Parcourir la source

Merge branch 'release/1.2.0'

Book Pauk il y a 2 ans
Parent
commit
c11e949316

+ 6 - 2
client/components/Api/Api.vue

@@ -231,8 +231,12 @@ class Api {
         return await this.request({action: 'get-genre-tree'});
     }    
 
-    async getBookLink(params) {
-        return await this.request(Object.assign({action: 'get-book-link'}, params), 120);
+    async getBookLink(bookId) {
+        return await this.request({action: 'get-book-link', bookId}, 120);
+    }
+
+    async getBookInfo(bookId) {
+        return await this.request({action: 'get-book-info', bookId}, 120);
     }
 
     async getConfig() {

+ 9 - 0
client/components/App.vue

@@ -133,6 +133,15 @@ body, html, #app {
     animation: rotating 2s linear infinite;
 }
 
+.q-dialog__inner--minimized {
+    padding: 10px !important;
+}
+
+.q-dialog__inner--minimized > div {
+    max-height: 100% !important;
+    max-width: 800px !important;
+}
+
 @keyframes rotating { 
     from { 
         transform: rotate(0deg); 

+ 7 - 25
client/components/Search/BaseList.js

@@ -129,31 +129,8 @@ export default class BaseList {
         })();
 
         try {
-            const makeValidFilenameOrEmpty = (s) => {
-                try {
-                    return utils.makeValidFilename(s);
-                } catch(e) {
-                    return '';
-                }
-            };
-
-            //имя файла
-            let downFileName = 'default-name';
-            const author = book.author.split(',');
-            const at = [author[0], book.title];
-            downFileName = makeValidFilenameOrEmpty(at.filter(r => r).join(' - '))
-                || makeValidFilenameOrEmpty(at[0])
-                || makeValidFilenameOrEmpty(at[1])
-                || downFileName;
-            downFileName = downFileName.substring(0, 100);
-
-            const ext = `.${book.ext}`;
-            if (downFileName.substring(downFileName.length - ext.length) != ext)
-                downFileName += ext;
-
-            const bookPath = `${book.folder}/${book.file}${ext}`;
             //подготовка
-            const response = await this.api.getBookLink({bookPath, downFileName});
+            const response = await this.api.getBookLink(book.id);
             
             const link = response.link;
             const href = `${window.location.origin}${link}`;
@@ -162,7 +139,7 @@ export default class BaseList {
                 //скачивание
                 const d = this.$refs.download;
                 d.href = href;
-                d.download = downFileName;
+                d.download = response.downFileName;
 
                 d.click();
             } else if (action == 'copyLink') {
@@ -185,6 +162,10 @@ export default class BaseList {
                     const url = this.config.bookReadLink.replace('${DOWNLOAD_LINK}', href);
                     window.open(url, '_blank');
                 }
+            } else if (action == 'bookInfo') {
+                //информация о книге
+                const response = await this.api.getBookInfo(book.id);
+                this.$emit('listEvent', {action: 'bookInfo', data: response.bookInfo});
             }
         } catch(e) {
             this.$root.stdDialog.alert(e.message, 'Ошибка');
@@ -208,6 +189,7 @@ export default class BaseList {
             case 'download':
             case 'copyLink':
             case 'readBook':
+            case 'bookInfo':
                 this.download(event.book, event.action);//no await
                 break;
         }

+ 298 - 0
client/components/Search/BookInfoDialog/BookInfoDialog.vue

@@ -0,0 +1,298 @@
+<template>
+    <Dialog ref="dialog" v-model="dialogVisible">
+        <template #header>
+            <div class="row items-center">
+                <div style="font-size: 110%">
+                    Информация о книге
+                </div>
+            </div>
+        </template>
+
+        <div ref="box" class="fit column q-mt-xs overflow-auto no-wrap" style="padding: 0px 10px 10px 10px;">
+            <div class="text-green-10">
+                {{ bookAuthor }}
+            </div>
+            <div>
+                <b>{{ book.title }}</b>
+            </div>
+
+            <div class="row q-mt-sm no-wrap">
+                <div class="column justify-center" style="height: 300px; width: 200px; min-width: 100px">
+                    <img v-if="coverSrc" :src="coverSrc" class="fit row justify-center items-center" style="object-fit: contain" @error="coverSrc = ''" />
+                    <div v-if="!coverSrc" class="fit row justify-center items-center text-grey-5" style="border: 1px solid #ccc; font-size: 300%">
+                        <i>{{ book.ext }}</i>
+                    </div>
+                </div>
+
+                <div class="col column q-ml-sm" style="min-width: 400px; border: 1px solid #ccc">
+                    <div class="bg-grey-3 row">
+                        <q-tabs
+                            v-model="selectedTab"
+                            active-color="black"
+                            active-bg-color="white"
+                            indicator-color="white"
+                            dense
+                            no-caps
+                            inline-label
+                            class="bg-grey-4 text-grey-7"
+                        >
+                            <q-tab v-if="fb2.length" name="fb2" label="Fb2 инфо" />
+                            <q-tab name="inpx" label="Inpx инфо" />
+                        </q-tabs>
+                    </div>
+
+                    <div class="overflow-auto full-width" style="height: 262px">
+                        <div v-for="item in info" :key="item.name">
+                            <div class="row q-ml-sm q-mt-sm items-center">
+                                <div class="text-blue" style="font-size: 90%">
+                                    {{ item.label }}
+                                </div>
+                                <div class="col q-mx-xs" style="height: 0px; border-top: 1px solid #ccc"></div>
+                            </div>
+
+                            <div v-for="subItem in item.value" :key="subItem.name" class="row q-ml-md">
+                                <div style="width: 100px">
+                                    {{ subItem.label }}
+                                </div>
+                                <div class="q-ml-sm" v-html="subItem.value" />
+                            </div>
+                        </div>
+
+                        <div class="q-mt-xs"></div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="q-mt-md" v-html="annotation" />
+        </div>
+
+        <template #footer>
+            <q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="okClick">
+                OK
+            </q-btn>
+        </template>
+    </Dialog>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../vueComponent.js';
+
+import Dialog from '../../share/Dialog.vue';
+import Fb2Parser from '../../../../server/core/fb2/Fb2Parser';
+import * as utils from '../../../share/utils';
+import _ from 'lodash';
+
+const componentOptions = {
+    components: {
+        Dialog
+    },
+    watch: {
+        modelValue(newValue) {
+            this.dialogVisible = newValue;
+            if (newValue)
+                this.init();
+        },
+        dialogVisible(newValue) {
+            this.$emit('update:modelValue', newValue);
+        },
+    }
+};
+class BookInfoDialog {
+    _options = componentOptions;
+    _props = {
+        modelValue: Boolean,
+        bookInfo: Object,
+    };
+
+    dialogVisible = false;
+    selectedTab = 'fb2';
+
+    //info props
+    coverSrc = '';
+    annotation = '';
+    fb2 = [];
+    book = {};
+
+    created() {
+        this.commit = this.$store.commit;
+    }
+
+    mounted() {
+    }
+
+    init() {
+        //defaults
+        this.coverSrc = '';
+        this.annotation = '';
+        this.fb2 = [];
+        this.book = {};
+
+        this.parseBookInfo();
+
+        if (!this.fb2.length)
+            this.selectedTab = 'inpx';
+    }
+
+    get bookAuthor() {
+        if (this.book.author) {
+            let a = this.book.author.split(',');
+            return a.slice(0, 3).join(', ') + (a.length > 3 ? ' и др.' : '');
+        }
+
+        return '';
+    }
+
+    formatSize(size) {
+        size = size/1024;
+        let unit = 'KB';
+        if (size > 1024) {
+            size = size/1024;
+            unit = 'MB';
+        }
+        return `${size.toFixed(1)} ${unit}`;
+    }
+
+    get inpx() {
+        const mapping = [
+            {name: 'fileInfo', label: 'Информация о файле', value: [
+                {name: 'folder', label: 'Папка'},
+                {name: 'file', label: 'Файл'},
+                {name: 'ext', label: 'Тип'},
+                {name: 'size', label: 'Размер'},
+                {name: 'date', label: 'Добавлен'},
+                {name: 'del', label: 'Удален'},
+                {name: 'libid', label: 'LibId'},
+                {name: 'insno', label: 'InsideNo'},
+            ]},
+
+            {name: 'titleInfo', label: 'Общая информация', value: [
+                {name: 'author', label: 'Автор(ы)'},
+                {name: 'title', label: 'Название'},
+                {name: 'series', label: 'Серия'},
+                {name: 'genre', label: 'Жанр'},
+                {name: 'librate', label: 'Оценка'},
+                {name: 'lang', label: 'Язык книги'},
+                {name: 'keywords', label: 'Ключевые слова'},
+            ]},
+        ];
+
+        const valueToString = (value, nodePath) => {//eslint-disable-line no-unused-vars
+            if (nodePath == 'fileInfo/size')
+                return `${this.formatSize(value)} (${value.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1 ')} Bytes)`;
+
+            if (nodePath == 'fileInfo/date')
+                return utils.sqlDateFormat(value);
+
+            if (nodePath == 'fileInfo/del')
+                return (value ? 'Да' : 'Нет');
+
+            if (nodePath == 'titleInfo/author')
+                return value.split(',').join(', ');
+
+            if (nodePath == 'titleInfo/librate' && !value)
+                return null;
+
+            if (typeof(value) === 'string') {
+                return value;
+            }
+
+            return (value.toString ? value.toString() : '');
+        };
+
+        let result = [];
+        const book = _.cloneDeep(this.book);
+        book.series = [book.series, book.serno].filter(v => v).join(' #');
+
+        for (const item of mapping) {
+            const itemOut = {name: item.name, label: item.label, value: []};
+
+            for (const subItem of item.value) {
+                const subItemOut = {
+                    name: subItem.name,
+                    label: subItem.label,
+                    value: valueToString(book[subItem.name], `${item.name}/${subItem.name}`)
+                };
+                if (subItemOut.value)
+                    itemOut.value.push(subItemOut);
+            }
+
+            if (itemOut.value.length)
+                result.push(itemOut);
+        }
+
+        return result;
+    }
+
+    get info() {
+        let result = [];
+
+        switch (this.selectedTab) {
+            case 'fb2':
+                return this.fb2;
+            case 'inpx':
+                return this.inpx;
+        }
+
+        return result;
+    }
+
+    parseBookInfo() {
+        const bookInfo = this.bookInfo;
+        const parser = new Fb2Parser();
+
+        //cover
+        if (bookInfo.cover)
+            this.coverSrc = bookInfo.cover;
+
+        //fb2
+        if (bookInfo.fb2) {
+            this.fb2 = parser.bookInfoList(bookInfo.fb2, {
+                valueToString(value, nodePath, origVTS) {//eslint-disable-line no-unused-vars
+                    if (nodePath == 'documentInfo/historyHtml' && value)
+                        return value.replace(/<p>/g, `<p class="p-history">`);
+
+                    return origVTS(value, nodePath);
+                },
+            });
+            
+            const infoObj = parser.bookInfo(bookInfo.fb2);
+            if (infoObj.titleInfo) {
+                let ann = infoObj.titleInfo.annotationHtml;
+                if (ann) {
+                    ann = ann.replace(/<p>/g, `<p class="p-annotation">`);
+                    this.annotation = ann;
+                }
+            }
+        }
+
+        //book
+        if (bookInfo.book)
+            this.book = bookInfo.book;
+    }
+
+    okClick() {
+        this.dialogVisible = false;
+    }
+}
+
+export default vueComponent(BookInfoDialog);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+</style>
+
+<style>
+.p-annotation {
+    text-indent: 20px;
+    text-align: justify;
+    padding: 0;
+    margin: 0;
+}
+
+.p-history {
+    padding: 0;
+    margin: 0;
+}
+</style>

+ 17 - 29
client/components/Search/BookView/BookView.vue

@@ -33,18 +33,20 @@
         </div>
 
         <div class="q-ml-sm column">
-            <div v-if="(mode == 'series' || mode == 'title') && bookAuthor" class="row items-center clickable2 text-green-10" @click="selectAuthor">
-                {{ bookAuthor }}
+            <div v-if="(mode == 'series' || mode == 'title') && bookAuthor" class="row">
+                <div class="clickable2 text-green-10" @click="emit('authorClick')">
+                    {{ bookAuthor }}
+                </div>
             </div>
 
             <div class="row items-center">
                 <div v-if="book.serno" class="q-mr-xs">
                     {{ book.serno }}.
                 </div>
-                <div class="clickable2" :class="titleColor" @click="selectTitle">
+                <div class="clickable2" :class="titleColor" @click="emit('titleClick')">
                     {{ book.title }}
                 </div>
-                <div v-if="mode == 'title' && bookSeries" class="q-ml-xs clickable2" @click="selectSeries">
+                <div v-if="mode == 'title' && bookSeries" class="q-ml-xs clickable2" @click="emit('seriesClick')">
                     {{ bookSeries }}
                 </div>
 
@@ -53,15 +55,19 @@
                     {{ bookSize }}, {{ book.ext }}
                 </div>
 
-                <div class="q-ml-sm clickable" @click="download">
+                <div v-if="showInfo" class="row items-center q-ml-sm clickable" @click="emit('bookInfo')">
+                    [ . . . ]
+                </div>
+
+                <div class="q-ml-sm clickable" @click="emit('download')">
                     (скачать)
                 </div>
 
-                <div class="q-ml-sm clickable" @click="copyLink">
+                <div class="q-ml-sm clickable" @click="emit('copyLink')">
                     <q-icon name="la la-copy" size="20px" />
                 </div>
 
-                <div v-if="showReadLink" class="q-ml-sm clickable" @click="readBook">
+                <div v-if="showReadLink" class="q-ml-sm clickable" @click="emit('readBook')">
                     (читать)
                 </div>
 
@@ -107,6 +113,7 @@ class BookView {
     };
 
     showRates = true;
+    showInfo = true;
     showGenres = true;
     showDeleted = false;
     showDates = false;
@@ -119,6 +126,7 @@ class BookView {
         const settings = this.settings;
 
         this.showRates = settings.showRates;
+        this.showInfo = settings.showInfo;
         this.showGenres = settings.showGenres;
         this.showDates = settings.showDates;
         this.showDeleted = settings.showDeleted;
@@ -183,28 +191,8 @@ class BookView {
         return utils.sqlDateFormat(this.book.date);
     }
 
-    selectAuthor() {
-        this.$emit('bookEvent', {action: 'authorClick', book: this.book});
-    }
-
-    selectSeries() {
-        this.$emit('bookEvent', {action: 'seriesClick', book: this.book});
-    }
-
-    selectTitle() {
-        this.$emit('bookEvent', {action: 'titleClick', book: this.book});
-    }
-
-    download() {
-        this.$emit('bookEvent', {action: 'download', book: this.book});
-    }
-
-    copyLink() {
-        this.$emit('bookEvent', {action: 'copyLink', book: this.book});
-    }
-
-    readBook() {
-        this.$emit('bookEvent', {action: 'readBook', book: this.book});
+    emit(action) {
+        this.$emit('bookEvent', {action, book: this.book});
     }
 }
 

+ 68 - 29
client/components/Search/Search.vue

@@ -3,24 +3,16 @@
         <div ref="scroller" class="col fit column no-wrap" style="overflow: auto; position: relative" @scroll="onScroll">
             <div ref="toolPanel" class="tool-panel q-pb-xs column bg-cyan-2" style="position: sticky; top: 0; z-index: 10;">
                 <div class="header q-mx-md q-mb-xs q-mt-sm row items-center">
-                    <a :href="newSearchLink" style="height: 33px">
+                    <a :href="newSearchLink" style="height: 33px; width: 34px">
                         <img src="./assets/logo.png" />
                         <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
                             Новый поиск
                         </q-tooltip>
                     </a>
-                    <div class="row items-center q-ml-sm" style="font-size: 150%;">
-                        <div class="q-mr-xs">
-                            Коллекция
-                        </div>
-                        <div class="clickable" @click="showCollectionInfo">
-                            {{ collection }}
-                        </div>
-                    </div>
-                    
+
                     <q-btn-toggle
                         v-model="selectedList"
-                        class="q-ml-md"
+                        class="q-ml-sm"
                         toggle-color="primary"
                         :options="listOptions"
                         push
@@ -28,28 +20,46 @@
                         rounded
                     />
 
-                    <DivBtn class="q-ml-md text-white bg-secondary" :size="30" :icon-size="24" :imt="1" icon="la la-cog" round @click="settingsDialogVisible = true">
+                    <div class="row items-center q-ml-sm" style="font-size: 150%;">
+                        <div class="q-mr-xs">
+                            Коллекция
+                        </div>
+                        <div class="clickable" @click="showCollectionInfo">
+                            {{ collection }}
+                        </div>
+                    </div>
+
+                    <div class="col"></div>
+
+                    <DivBtn class="q-ml-md text-white bg-secondary" :size="30" :icon-size="24" icon="la la-question" round @click="showSearchHelp">
                         <template #tooltip>
                             <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
-                                Настройки
+                                Памятка
                             </q-tooltip>
                         </template>
                     </DivBtn>
 
-                    <DivBtn class="q-ml-sm text-white bg-secondary" :size="30" :icon-size="24" icon="la la-question" round @click="showSearchHelp">
+                    <DivBtn class="q-ml-sm text-white bg-secondary" :size="30" :icon-size="24" :imt="1" icon="la la-cog" round @click="settingsDialogVisible = true">
                         <template #tooltip>
                             <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
-                                Памятка
+                                Настройки
                             </q-tooltip>
                         </template>
                     </DivBtn>
-
-                    <div class="col"></div>
-                    <div class="q-px-sm q-py-xs bg-green-12 clickable2" style="border: 1px solid #aaaaaa; border-radius: 6px" @click="openReleasePage">
-                        {{ projectName }}
-                    </div>
                 </div>
                 <div class="row q-mx-md q-mb-xs items-center">
+                    <DivBtn
+                        class="text-grey-5 bg-yellow-1 q-mt-xs" :size="34" :icon-size="24" round
+                        :icon="(extendedParams ? 'la la-angle-double-up' : 'la la-angle-double-down')"
+                        @click="extendedParams = !extendedParams"
+                    >
+                        <template #tooltip>
+                            <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
+                                {{ `${(extendedParams ? 'Скрыть' : 'Показать')} дополнительные критерии поиска` }}
+                            </q-tooltip>
+                        </template>
+                    </DivBtn>
+                    <div class="q-mx-xs" />
                     <q-input
                         ref="authorInput" v-model="search.author" :maxlength="5000" :debounce="inputDebounce"
                         class="q-mt-xs" :bg-color="inputBgColor('author')" style="width: 200px;" label="Автор" stack-label outlined dense clearable
@@ -79,7 +89,7 @@
                     <div class="q-mx-xs" />
                     <q-input
                         v-model="search.lang" :maxlength="inputMaxLength" :debounce="inputDebounce"
-                        class="q-mt-xs" :bg-color="inputBgColor()" input-style="cursor: pointer" style="width: 100px;" label="Язык" stack-label outlined dense clearable readonly
+                        class="q-mt-xs" :bg-color="inputBgColor()" input-style="cursor: pointer" style="width: 90px;" label="Язык" stack-label outlined dense clearable readonly
                         @click="selectLang"
                     >
                         <template v-if="search.lang" #append>
@@ -90,21 +100,22 @@
                             {{ search.lang }}
                         </q-tooltip>
                     </q-input>
-
                     <div class="q-mx-xs" />
                     <DivBtn
-                        class="text-grey-5 bg-yellow-1 q-mt-xs" :size="34" :icon-size="24" round
-                        :icon="(extendedParams ? 'la la-angle-double-up' : 'la la-angle-double-down')"
-                        @click="extendedParams = !extendedParams"
+                        class="text-grey-8 bg-yellow-1 q-mt-xs" :size="34" :icon-size="24" round
+                        icon="la la-level-up-alt"
+                        @click="cloneSearch"
                     >
                         <template #tooltip>
                             <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
-                                {{ `${(extendedParams ? 'Скрыть' : 'Показать')} дополнительные критерии поиска` }}
+                                Клонировать поиск
                             </q-tooltip>
                         </template>
                     </DivBtn>
                 </div>
                 <div v-show="extendedParams" class="row q-mx-md q-mb-xs items-center">
+                    <div style="width: 34px" />
+                    <div class="q-mx-xs" />
                     <q-input
                         v-model="genreNames" :maxlength="inputMaxLength" :debounce="inputDebounce"
                         class="q-mt-xs" :bg-color="inputBgColor()" input-style="cursor: pointer" style="width: 200px;" label="Жанр" stack-label outlined dense clearable readonly
@@ -153,7 +164,7 @@
                     <div class="q-mx-xs" />
                     <q-input
                         v-model="librateNames" :maxlength="inputMaxLength" :debounce="inputDebounce"
-                        class="q-mt-xs" :bg-color="inputBgColor()" input-style="cursor: pointer" style="width: 100px;" label="Оценка" stack-label outlined dense clearable readonly
+                        class="q-mt-xs" :bg-color="inputBgColor()" input-style="cursor: pointer" style="width: 90px;" label="Оценка" stack-label outlined dense clearable readonly
                         @click="selectLibRate"
                     >
                         <template v-if="librateNames" #append>
@@ -191,12 +202,18 @@
             <div class="row q-ml-lg q-mb-sm">
                 <PageScroller v-show="pageCount > 1" v-model="search.page" :page-count="pageCount" />
             </div>
+
+            <div class="row justify-center">
+                <div class="q-mb-sm q-px-sm q-py-xs bg-cyan-2 clickable2" style="border: 1px solid #aaaaaa; border-radius: 6px; white-space: nowrap;" @click="openReleasePage">
+                    {{ projectName }}
+                </div>
+            </div>
         </div>
 
         <Dialog v-model="settingsDialogVisible">
             <template #header>
-                <div class="row items-center" style="font-size: 130%">
-                    <q-icon class="q-mr-sm" name="la la-cog" size="28px"></q-icon>
+                <div class="row items-center" style="font-size: 110%">
+                    <q-icon class="q-mr-sm text-green" name="la la-cog" size="28px"></q-icon>
                     Настройки
                 </div>
             </template>
@@ -215,6 +232,7 @@
 
                 <q-checkbox v-model="showCounts" size="36px" label="Показывать количество" />                
                 <q-checkbox v-model="showRates" size="36px" label="Показывать оценки" />
+                <q-checkbox v-model="showInfo" size="36px" label="Показывать кнопку 'инфо'" />
                 <q-checkbox v-model="showGenres" size="36px" label="Показывать жанры" />
                 <q-checkbox v-model="showDates" size="36px" label="Показывать даты поступления" />
                 <q-checkbox v-model="showDeleted" size="36px" label="Показывать удаленные" />
@@ -232,6 +250,7 @@
         <SelectLangDialog v-model="selectLangDialogVisible" v-model:lang="search.lang" :lang-list="langList" :lang-default="langDefault" />        
         <SelectLibRateDialog v-model="selectLibRateDialogVisible" v-model:librate="search.librate" />
         <SelectDateDialog v-model="selectDateDialogVisible" v-model:date="search.date" />
+        <BookInfoDialog v-model="bookInfoDialogVisible" :book-info="bookInfo" />
     </div>
 </template>
 
@@ -248,6 +267,7 @@ import SelectGenreDialog from './SelectGenreDialog/SelectGenreDialog.vue';
 import SelectLangDialog from './SelectLangDialog/SelectLangDialog.vue';
 import SelectLibRateDialog from './SelectLibRateDialog/SelectLibRateDialog.vue';
 import SelectDateDialog from './SelectDateDialog/SelectDateDialog.vue';
+import BookInfoDialog from './BookInfoDialog/BookInfoDialog.vue';
 
 import authorBooksStorage from './authorBooksStorage';
 import DivBtn from '../share/DivBtn.vue';
@@ -274,6 +294,7 @@ const componentOptions = {
         SelectLangDialog,
         SelectLibRateDialog,
         SelectDateDialog,
+        BookInfoDialog,
         Dialog,
         DivBtn
     },
@@ -313,6 +334,9 @@ const componentOptions = {
         showRates(newValue) {
             this.setSetting('showRates', newValue);
         },
+        showInfo(newValue) {
+            this.setSetting('showInfo', newValue);
+        },
         showGenres(newValue) {
             this.setSetting('showGenres', newValue);
         },
@@ -383,6 +407,7 @@ class Search {
     selectLangDialogVisible = false;
     selectLibRateDialogVisible = false;
     selectDateDialogVisible = false;
+    bookInfoDialogVisible = false;
 
     pageCount = 1;    
 
@@ -413,6 +438,7 @@ class Search {
     //settings
     showCounts = true;
     showRates = true;
+    showInfo = true;
     showGenres = true;
     showDates = true;
     showDeleted = false;
@@ -436,6 +462,8 @@ class Search {
     genreTreeInpxHash = '';
     showTooltips = true;
 
+    bookInfo = {};
+
     limitOptions = [
         {label: '10', value: 10},
         {label: '20', value: 20},
@@ -504,6 +532,7 @@ class Search {
         this.expandedSeries = _.cloneDeep(settings.expandedSeries);
         this.showCounts = settings.showCounts;
         this.showRates = settings.showRates;
+        this.showInfo = settings.showInfo;
         this.showGenres = settings.showGenres;
         this.showDates = settings.showDates;
         this.showDeleted = settings.showDeleted;
@@ -786,6 +815,8 @@ class Search {
 
         if (this.ignoreScrolling) {
             this.lastScrollTop = curScrollTop;
+            if (this.$refs.toolPanel.offsetTop > curScrollTop)
+                this.$refs.toolPanel.style.top = `${curScrollTop}px`;
             return;
         }
 
@@ -854,6 +885,10 @@ class Search {
             case 'submitUrl':
                 this.sendMessage({type: 'submitUrl', data: event.data});
                 break;
+            case 'bookInfo':
+                this.bookInfo = event.data;
+                this.bookInfoDialogVisible = true;
+                break;
         }
     }
 
@@ -1016,6 +1051,10 @@ class Search {
             this.selectDateDialogVisible = true
         }
     }
+
+    cloneSearch() {
+        window.open(window.location.href, '_blank');
+    }
 }
 
 export default vueComponent(Search);

+ 1 - 1
client/components/Search/SelectDateDialog/SelectDateDialog.vue

@@ -2,7 +2,7 @@
     <Dialog ref="dialog" v-model="dialogVisible">
         <template #header>
             <div class="row items-center">
-                <div style="font-size: 130%">
+                <div style="font-size: 110%">
                     Выбрать даты
                 </div>
             </div>

+ 1 - 1
client/components/Search/SelectGenreDialog/SelectGenreDialog.vue

@@ -2,7 +2,7 @@
     <Dialog ref="dialog" v-model="dialogVisible">
         <template #header>
             <div class="row items-center">
-                <div style="font-size: 130%">
+                <div style="font-size: 110%">
                     Выбрать жанры
                 </div>
             </div>

+ 1 - 1
client/components/Search/SelectLangDialog/SelectLangDialog.vue

@@ -2,7 +2,7 @@
     <Dialog ref="dialog" v-model="dialogVisible">
         <template #header>
             <div class="row items-center">
-                <div style="font-size: 130%">
+                <div style="font-size: 110%">
                     Выбрать языки
                 </div>
             </div>

+ 1 - 1
client/components/Search/SelectLibRateDialog/SelectLibRateDialog.vue

@@ -2,7 +2,7 @@
     <Dialog ref="dialog" v-model="dialogVisible">
         <template #header>
             <div class="row items-center">
-                <div style="font-size: 130%">
+                <div style="font-size: 110%">
                     Выбрать оценки
                 </div>
             </div>

+ 2 - 2
client/quasar.js

@@ -17,7 +17,7 @@ import {QBtn} from 'quasar/src/components/btn';
 import {QBtnToggle} from 'quasar/src/components/btn-toggle';
 import {QIcon} from 'quasar/src/components/icon';
 //import {QSlider} from 'quasar/src/components/slider';
-//import {QTabs, QTab} from 'quasar/src/components/tabs';
+import {QTabs, QTab} from 'quasar/src/components/tabs';
 //import {QTabPanels, QTabPanel} from 'quasar/src/components/tab-panels';
 //import {QSeparator} from 'quasar/src/components/separator';
 //import {QList} from 'quasar/src/components/item';
@@ -52,7 +52,7 @@ const components = {
     QBtnToggle,
     QIcon,
     //QSlider,
-    //QTabs, QTab,
+    QTabs, QTab,
     //QTabPanels, QTabPanel,
     //QSeparator,
     //QList,

+ 0 - 12
client/share/utils.js

@@ -87,18 +87,6 @@ export async function copyTextToClipboard(text) {
     return result;
 }
 
-export function makeValidFilename(filename, repl = '_') {
-    let f = filename.replace(/[\x00\\/:*"<>|]/g, repl); // eslint-disable-line no-control-regex
-    f = f.trim();
-    while (f.length && (f[f.length - 1] == '.' || f[f.length - 1] == '_')) {
-        f = f.substring(0, f.length - 1);
-    }
-
-    if (f)
-        return f;
-    else
-        throw new Error('Invalid filename');
-}
 /*
 export function formatDate(d, format = 'normal') {
     switch (format) {

+ 1 - 0
client/store/root.js

@@ -9,6 +9,7 @@ const state = {
         expandedSeries: [],
         showCounts: true,
         showRates: true,
+        showInfo: true,
         showGenres: true,
         showDates: false,
         showDeleted: false,

+ 60 - 10
package-lock.json

@@ -1,19 +1,21 @@
 {
   "name": "inpx-web",
-  "version": "1.1.4",
+  "version": "1.2.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "inpx-web",
-      "version": "1.1.4",
+      "version": "1.2.0",
       "hasInstallScript": true,
       "license": "CC0-1.0",
       "dependencies": {
         "@quasar/extras": "^1.15.0",
         "axios": "^0.27.2",
+        "chardet": "^1.5.0",
         "express": "^4.18.1",
         "fs-extra": "^10.1.0",
+        "iconv-lite": "^0.6.3",
         "jembadb": "^5.0.2",
         "localforage": "^1.10.0",
         "lodash": "^4.17.21",
@@ -2645,6 +2647,17 @@
         "ms": "2.0.0"
       }
     },
+    "node_modules/body-parser/node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/body-parser/node_modules/ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -2809,6 +2822,11 @@
         "node": ">=4"
       }
     },
+    "node_modules/chardet": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/chardet/-/chardet-1.5.0.tgz",
+      "integrity": "sha512-Nj3VehbbFs/1ZnJJJaL3ztEf3Nu5Fs6YV/NBs6lyz/iDDHUU+X9QNk5QgPy1/5Rjtb/cGVf+NyazP7kVEJqKRg=="
+    },
     "node_modules/chownr": {
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
@@ -4781,11 +4799,11 @@
       }
     },
     "node_modules/iconv-lite": {
-      "version": "0.4.24",
-      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
-      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
       "dependencies": {
-        "safer-buffer": ">= 2.1.2 < 3"
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
       },
       "engines": {
         "node": ">=0.10.0"
@@ -7011,6 +7029,17 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/raw-body/node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/rc": {
       "version": "1.2.8",
       "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -10718,6 +10747,14 @@
             "ms": "2.0.0"
           }
         },
+        "iconv-lite": {
+          "version": "0.4.24",
+          "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+          "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+          "requires": {
+            "safer-buffer": ">= 2.1.2 < 3"
+          }
+        },
         "ms": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -10832,6 +10869,11 @@
         "supports-color": "^5.3.0"
       }
     },
+    "chardet": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/chardet/-/chardet-1.5.0.tgz",
+      "integrity": "sha512-Nj3VehbbFs/1ZnJJJaL3ztEf3Nu5Fs6YV/NBs6lyz/iDDHUU+X9QNk5QgPy1/5Rjtb/cGVf+NyazP7kVEJqKRg=="
+    },
     "chownr": {
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
@@ -12311,11 +12353,11 @@
       }
     },
     "iconv-lite": {
-      "version": "0.4.24",
-      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
-      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
       "requires": {
-        "safer-buffer": ">= 2.1.2 < 3"
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
       }
     },
     "icss-utils": {
@@ -13869,6 +13911,14 @@
           "version": "3.1.2",
           "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
           "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="
+        },
+        "iconv-lite": {
+          "version": "0.4.24",
+          "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+          "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+          "requires": {
+            "safer-buffer": ">= 2.1.2 < 3"
+          }
         }
       }
     },

+ 3 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "inpx-web",
-  "version": "1.1.4",
+  "version": "1.2.0",
   "author": "Book Pauk <bookpauk@gmail.com>",
   "license": "CC0-1.0",
   "repository": "bookpauk/inpx-web",
@@ -51,8 +51,10 @@
   "dependencies": {
     "@quasar/extras": "^1.15.0",
     "axios": "^0.27.2",
+    "chardet": "^1.5.0",
     "express": "^4.18.1",
     "fs-extra": "^10.1.0",
+    "iconv-lite": "^0.6.3",
     "jembadb": "^5.0.2",
     "localforage": "^1.10.0",
     "lodash": "^4.17.21",

+ 14 - 5
server/controllers/WebSocketController.js

@@ -84,6 +84,8 @@ class WebSocketController {
                     await this.getGenreTree(req, ws); break;
                 case 'get-book-link':
                     await this.getBookLink(req, ws); break;
+                case 'get-book-info':
+                    await this.getBookInfo(req, ws); break;
 
                 case 'get-inpx-file':
                     await this.getInpxFile(req, ws); break;
@@ -163,12 +165,19 @@ class WebSocketController {
     }
 
     async getBookLink(req, ws) {
-        if (!utils.hasProp(req, 'bookPath'))
-            throw new Error(`bookPath is empty`);
-        if (!utils.hasProp(req, 'downFileName'))
-            throw new Error(`downFileName is empty`);    
+        if (!utils.hasProp(req, 'bookId'))
+            throw new Error(`bookId is empty`);
 
-        const result = await this.webWorker.getBookLink({bookPath: req.bookPath, downFileName: req.downFileName});
+        const result = await this.webWorker.getBookLink(req.bookId);
+
+        this.send(result, req, ws);
+    }
+
+    async getBookInfo(req, ws) {
+        if (!utils.hasProp(req, 'bookId'))
+            throw new Error(`bookId is empty`);
+
+        const result = await this.webWorker.getBookInfo(req.bookId);
 
         this.send(result, req, ws);
     }

+ 76 - 33
server/core/WebWorker.js

@@ -15,6 +15,7 @@ const ayncExit = new (require('./AsyncExit'))();
 const log = new (require('./AppLogger'))().log;//singleton
 const utils = require('./utils');
 const genreTree = require('./genres');
+const Fb2Helper = require('./fb2/Fb2Helper');
 
 //server states
 const ssNormal = 'normal';
@@ -44,6 +45,7 @@ class WebWorker {
             }
             
             this.inpxHashCreator = new InpxHashCreator(config);
+            this.fb2Helper = new Fb2Helper();
             this.inpxFileHash = '';
 
             this.wState = this.workerState.getControl('server_state');
@@ -400,17 +402,36 @@ class WebWorker {
         return link;
     }
 
-    async getBookLink(params) {
+    async getBookLink(bookId) {
         this.checkMyState();
 
-        const {bookPath, downFileName} = params;
-
         try {
             const db = this.db;
             let link = '';
 
+            //найдем bookPath и downFileName
+            let rows = await db.select({table: 'book', where: `@@id(${db.esc(bookId)})`});
+            if (!rows.length)
+                throw new Error('404 Файл не найден');
+
+            const book = rows[0];            
+            let downFileName = book.file;
+            const author = book.author.split(',');
+            const at = [author[0], book.title];
+            downFileName = utils.makeValidFileNameOrEmpty(at.filter(r => r).join(' - '))
+                || utils.makeValidFileNameOrEmpty(at[0])
+                || utils.makeValidFileNameOrEmpty(at[1])
+                || downFileName;
+            downFileName = downFileName.substring(0, 100);
+
+            const ext = `.${book.ext}`;
+            if (downFileName.substring(downFileName.length - ext.length) != ext)
+                downFileName += ext;
+
+            const bookPath = `${book.folder}/${book.file}${ext}`;
+
             //найдем хеш
-            const rows = await db.select({table: 'file_hash', where: `@@id(${db.esc(bookPath)})`});
+            rows = await db.select({table: 'file_hash', where: `@@id(${db.esc(bookPath)})`});
             if (rows.length) {//хеш найден по bookPath
                 const hash = rows[0].hash;
                 const bookFile = `${this.config.filesDir}/${hash}`;
@@ -428,7 +449,7 @@ class WebWorker {
             if (!link)
                 throw new Error('404 Файл не найден');
 
-            return {link};
+            return {link, bookPath, downFileName};
         } catch(e) {
             log(LM_ERR, `getBookLink error: ${e.message}`);
             if (e.message.indexOf('ENOENT') >= 0)
@@ -437,48 +458,70 @@ class WebWorker {
         }
     }
 
-    /*
-    async restoreBookFile(publicPath) {
+    async getBookInfo(bookId) {
         this.checkMyState();
 
         try {
             const db = this.db;
-            const hash = path.basename(publicPath);
 
-            //найдем bookPath и downFileName
-            const rows = await db.select({table: 'file_hash', where: `@@id(${db.esc(hash)})`});        
-            if (rows.length) {//нашли по хешу
-                const rec = rows[0];
-                await this.restoreBook(rec.bookPath, rec.downFileName);
+            let bookInfo = await this.getBookLink(bookId);
+            const hash = path.basename(bookInfo.link);
+            const bookFile = `${this.config.filesDir}/${hash}`;
+            const bookFileInfo = `${bookFile}.info`;
 
-                return rec.downFileName;
-            } else {//bookPath не найден
-                throw new Error('404 Файл не найден');
+            const restoreBookInfo = async() => {
+                const result = {};
+
+                const rows = await db.select({table: 'book', where: `@@id(${db.esc(bookId)})`});
+                const book = rows[0];
+
+                result.book = book;
+                result.cover = '';
+                result.fb2 = false;
+
+                if (book.ext == 'fb2') {
+                    const {fb2, cover, coverExt} = await this.fb2Helper.getDescAndCover(bookFile);
+                    result.fb2 = fb2;
+
+                    if (cover) {
+                        result.cover = `${this.config.filesPathStatic}/${hash}${coverExt}`;
+                        await fs.writeFile(`${bookFile}${coverExt}`, cover);
+                    }
+                }
+
+                return result;
+            };
+
+            if (!await fs.pathExists(bookFileInfo)) {
+                Object.assign(bookInfo, await restoreBookInfo());
+                await fs.writeFile(bookFileInfo, JSON.stringify(bookInfo, null, 2));
+            } else {
+                await utils.touchFile(bookFileInfo);
+                const info = await fs.readFile(bookFileInfo, 'utf-8');
+                const tmpInfo = JSON.parse(info);
+
+                //проверим существование файла обложки, восстановим если нету
+                let coverFile = '';
+                if (tmpInfo.cover)
+                    coverFile = `${this.config.publicFilesDir}${tmpInfo.cover}`;
+
+                if (coverFile && !await fs.pathExists(coverFile)) {
+                    Object.assign(bookInfo, await restoreBookInfo());
+                    await fs.writeFile(bookFileInfo, JSON.stringify(bookInfo, null, 2));
+                } else {
+                    bookInfo = tmpInfo;
+                }
             }
+
+            return {bookInfo};
         } catch(e) {
-            log(LM_ERR, `restoreBookFile error: ${e.message}`);
+            log(LM_ERR, `getBookInfo error: ${e.message}`);
             if (e.message.indexOf('ENOENT') >= 0)
                 throw new Error('404 Файл не найден');
             throw e;
         }
     }
 
-    async getDownFileName(publicPath) {
-        this.checkMyState();
-
-        const db = this.db;
-        const hash = path.basename(publicPath);
-
-        //найдем downFileName
-        const rows = await db.select({table: 'file_hash', where: `@@id(${db.esc(hash)})`});        
-        if (rows.length) {//downFileName найден по хешу
-            return rows[0].downFileName;
-        } else {//bookPath не найден
-            throw new Error('404 Файл не найден');
-        }
-    }
-    */
-
     async getInpxFile(params) {
         let data = null;
         if (params.inpxFileHash && this.inpxFileHash && params.inpxFileHash === this.inpxFileHash) {

+ 103 - 0
server/core/fb2/Fb2Helper.js

@@ -0,0 +1,103 @@
+const fs = require('fs-extra');
+const iconv = require('iconv-lite');
+const textUtils = require('./textUtils');
+
+const Fb2Parser = require('../fb2/Fb2Parser');
+const utils = require('../utils');
+
+class Fb2Helper {
+    checkEncoding(data) {
+        //Корректируем кодировку UTF-16
+        let encoding = textUtils.getEncoding(data);
+        if (encoding.indexOf('UTF-16') == 0) {
+            data = Buffer.from(iconv.decode(data, encoding));
+            encoding = 'utf-8';
+        }
+
+        //Корректируем пробелы, всякие файлы попадаются :(
+        if (data[0] == 32) {
+            data = Buffer.from(data.toString().trim());
+        }
+
+        //Окончательно корректируем кодировку
+        let result = data;
+
+        let left = data.indexOf('<?xml version="1.0"');
+        if (left < 0) {
+            left = data.indexOf('<?xml version=\'1.0\'');
+        }
+
+        if (left >= 0) {
+            const right = data.indexOf('?>', left);
+            if (right >= 0) {
+                const head = data.slice(left, right + 2).toString();
+                const m = head.match(/encoding=['"](.*?)['"]/);
+                if (m) {
+                    let enc = m[1].toLowerCase();
+                    if (enc != 'utf-8') {
+                        //enc может не соответсвовать реальной кодировке файла, поэтому:
+                        if (encoding.indexOf('ISO-8859') >= 0) {
+                            encoding = enc;
+                        }
+
+                        result = iconv.decode(data, encoding);
+                        result = Buffer.from(result.toString().replace(m[0], `encoding="utf-8"`));
+                    }
+                }
+            }
+        }
+
+        return result;
+    }
+
+    async getDescAndCover(bookFile) {
+        let data = await fs.readFile(bookFile);
+        data = await utils.gunzipBuffer(data);
+
+        data = this.checkEncoding(data);
+
+        const parser = new Fb2Parser();
+
+        parser.fromString(data.toString(), {
+            lowerCase: true,
+            pickNode: route => route.indexOf('fictionbook/body') !== 0,
+        });
+
+        const desc = parser.$$('description').toObject();
+        const coverImage = parser.inspector(desc).$('description/title-info/coverpage/image');
+
+        let cover = null;
+        let coverExt = '';
+        if (coverImage) {
+            const coverAttrs = coverImage.attrs();
+            const href = coverAttrs[`${parser.xlinkNS}:href`];
+            let coverType = coverAttrs['content-type'];
+            coverType = (coverType == 'image/jpg' || coverType == 'application/octet-stream' ? 'image/jpeg' : coverType);
+            coverExt = (coverType == 'image/png' ? '.png' : '.jpg');
+
+            if (href) {
+                const binaryId = (href[0] == '#' ? href.substring(1) : href);
+
+                //найдем нужный image
+                parser.$$('binary').eachSelf(node => {
+                    let attrs = node.attrs();
+                    if (!attrs)
+                        return;
+                    attrs = Object.fromEntries(attrs);
+
+                    if (attrs.id === binaryId) {
+                        const textNode = new Fb2Parser(node.value);
+                        const base64 = textNode.$self('*TEXT').value;
+
+                        cover = (base64 ? Buffer.from(base64, 'base64') : null);
+                    }
+                });
+            }
+        }
+
+        parser.remove('binary');
+        return {fb2: parser.toObject(), cover, coverExt};
+    }
+}
+
+module.exports = Fb2Helper;

+ 297 - 0
server/core/fb2/Fb2Parser.js

@@ -0,0 +1,297 @@
+const XmlParser = require('../xml/XmlParser');
+
+class Fb2Parser extends XmlParser {
+    get xlinkNS() {
+        if (!this._xlinkNS) {
+            const rootAttrs = this.$self().attrs();
+            let ns = 'l';
+            for (const [key, value] of rootAttrs) {
+                if (value == 'http://www.w3.org/1999/xlink') {
+                    ns = key.split(':')[1] || ns;
+                    break;
+                }
+            }
+
+            this._xlinkNS = ns;
+        }
+
+        return this._xlinkNS;
+    }
+
+    bookInfo(fb2Object) {
+        const result = {};
+
+        if (!fb2Object)
+            fb2Object = this.toObject();
+
+        const desc = this.inspector(fb2Object).$('fictionbook/description');
+
+        if (!desc)
+            return result;
+
+        const parseAuthors = (node, tagName) => {
+            const authors = [];
+            for (const a of node.$$(tagName)) {
+                let names = [];
+                names.push(a.text('last-name'));
+                names.push(a.text('first-name'));
+                names.push(a.text('middle-name'));
+                names = names.filter(n => n);
+                if (!names.length)
+                    names.push(a.text('nickname'));
+
+                authors.push(names.join(' '));
+            }
+
+            return authors;
+        }
+
+        const parseSequence = (node, tagName) => {
+            const sequence = [];
+            for (const s of node.$$(tagName)) {
+                const seqAttrs = s.attrs() || {};
+                const name = seqAttrs['name'] || null;
+                const num = seqAttrs['number'] || null;
+                const lang = seqAttrs['xml:lang'] || null;
+
+                sequence.push({name, num, lang});
+            }
+
+            return sequence;
+        }
+
+        const parseTitleInfo = (titleInfo) => {
+            const info = {};
+
+            info.genre = [];
+            for (const g of titleInfo.$$('genre'))
+                info.genre.push(g.text());
+
+            info.author = parseAuthors(titleInfo, 'author');
+
+            info.bookTitle = titleInfo.text('book-title');
+
+            //annotation как Object
+            info.annotation = titleInfo.$('annotation') && titleInfo.$('annotation').value;
+            info.annotationXml = null;
+            info.annotationHtml = null;
+            if (info.annotation) {
+                //annotation как кусок xml
+                info.annotationXml = (new XmlParser()).fromObject(info.annotation).toString({noHeader: true});
+
+                //annotation как html
+                info.annotationHtml = this.toHtml(info.annotationXml);
+            }
+
+            info.keywords = titleInfo.text('keywords');
+            info.date = titleInfo.text('date');
+            info.coverpage = titleInfo.$('coverpage') && titleInfo.$('coverpage').value;
+            info.lang = titleInfo.text('lang');
+            info.srcLang = titleInfo.text('src-lang');
+
+            info.translator = parseAuthors(titleInfo, 'translator');
+
+            info.sequence = parseSequence(titleInfo, 'sequence');
+
+            return info;
+        }
+
+        //title-info
+        const titleInfo = desc.$('title-info');
+        if (titleInfo) {
+            result.titleInfo = parseTitleInfo(titleInfo);
+        }
+
+        //src-title-info
+        const srcTitleInfo = desc.$('src-title-info');
+        if (srcTitleInfo) {
+            result.srcTitleInfo = parseTitleInfo(srcTitleInfo);
+        }
+
+        //document-info
+        const documentInfo = desc.$('document-info');
+        if (documentInfo) {
+            const info = {};
+
+            info.author = parseAuthors(documentInfo, 'author');
+            info.programUsed = documentInfo.text('program-used');
+            info.date = documentInfo.text('date');
+
+            info.srcUrl = [];
+            for (const url of documentInfo.$$('src-url'))
+                info.srcUrl.push(url.text());
+
+            info.srcOcr = documentInfo.text('src-ocr');
+            info.id = documentInfo.text('id');
+            info.version = documentInfo.text('version');
+            
+            //аналогично annotation
+            info.history = documentInfo.$('history') && documentInfo.$('history').value;
+            info.historyXml = null;
+            info.historyHtml = null;
+            if (info.history) {
+                //history как кусок xml
+                info.historyXml = (new XmlParser()).fromObject(info.history).toString({noHeader: true});
+
+                //history как html
+                info.historyHtml = this.toHtml(info.historyXml);
+            }
+
+            info.publisher = parseAuthors(documentInfo, 'publisher');
+
+            result.documentInfo = info;
+        }
+
+        //publish-info
+        const publishInfo = desc.$('publish-info');
+        if (publishInfo) {
+            const info = {};
+
+            info.bookName = publishInfo.text('book-name');
+            info.publisher = publishInfo.text('publisher');
+            info.city = publishInfo.text('city');
+            info.year = publishInfo.text('year');
+            info.isbn = publishInfo.text('isbn');
+            info.sequence = parseSequence(publishInfo, 'sequence');
+
+            result.publishInfo = info;
+        }
+
+        return result;
+    }
+
+    bookInfoList(fb2Object, options = {}) {
+        let {
+            correctMapping = false,
+            valueToString = false,
+        } = options;
+
+        if (!correctMapping)
+            correctMapping = mapping => mapping;
+
+        const myValueToString = (value, nodePath, origVTS) => {//eslint-disable-line no-unused-vars
+            if (nodePath == 'titleInfo/sequence' 
+                || nodePath == 'srcTitleInfo/sequence' 
+                || nodePath == 'publishInfo/sequence')
+                return value.map(v => [v.name, v.num].filter(s => s).join(' #')).join(', ');
+
+            if (typeof(value) === 'string') {
+                return value;
+            } else if (Array.isArray(value)) {
+                return value.join(', ');
+            } else if (typeof(value) === 'object') {
+                return JSON.stringify(value);
+            }
+
+            return value;
+        };
+
+        if (!valueToString)
+            valueToString = myValueToString;
+
+        let mapping = [
+            {name: 'titleInfo', label: 'Общая информация', value: [
+                {name: 'author', label: 'Автор(ы)'},
+                {name: 'bookTitle', label: 'Название'},
+                {name: 'sequence', label: 'Серия'},
+                {name: 'genre', label: 'Жанр'},
+
+                {name: 'date', label: 'Дата'},
+                {name: 'lang', label: 'Язык книги'},
+                {name: 'srcLang', label: 'Язык оригинала'},
+                {name: 'translator', label: 'Переводчик(и)'},
+                {name: 'keywords', label: 'Ключевые слова'},
+            ]},
+            {name: 'srcTitleInfo', label: 'Информация о произведении на языке оригинала', value: [
+                {name: 'author', label: 'Автор(ы)'},
+                {name: 'bookTitle', label: 'Название'},
+                {name: 'sequence', label: 'Серия'},
+                {name: 'genre', label: 'Жанр'},
+
+                {name: 'date', label: 'Дата'},
+                {name: 'lang', label: 'Язык книги'},
+                {name: 'srcLang', label: 'Язык оригинала'},
+                {name: 'translator', label: 'Переводчик(и)'},
+                {name: 'keywords', label: 'Ключевые слова'},
+            ]},
+            {name: 'publishInfo', label: 'Издательская информация', value: [
+                {name: 'bookName', label: 'Название'},
+                {name: 'publisher', label: 'Издательство'},
+                {name: 'city', label: 'Город'},
+                {name: 'year', label: 'Год'},
+                {name: 'isbn', label: 'ISBN'},
+                {name: 'sequence', label: 'Серия'},
+            ]},
+            {name: 'documentInfo', label: 'Информация о документе (OCR)', value: [
+                {name: 'author', label: 'Автор(ы)'},
+                {name: 'programUsed', label: 'Программа'},
+                {name: 'date', label: 'Дата'},
+                //srcUrl = []
+                {name: 'id', label: 'ID'},
+                {name: 'version', label: 'Версия'},
+                {name: 'srcOcr', label: 'Автор источника'},
+                {name: 'historyHtml', label: 'История'},
+                {name: 'publisher', label: 'Правообладатели'},
+            ]},
+        ];
+
+        mapping = correctMapping(mapping);
+        const bookInfo = this.bookInfo(fb2Object);
+
+        //заполняем mapping
+        let result = [];
+        for (const item of mapping) {
+            const itemOut = {name: item.name, label: item.label, value: []};
+            const info = bookInfo[item.name];
+            if (!info)
+                continue;
+
+            for (const subItem of item.value) {
+                if (info[subItem.name] !== null) {
+                    const subItemOut = {
+                        name: subItem.name,
+                        label: subItem.label,
+                        value: valueToString(info[subItem.name], `${item.name}/${subItem.name}`, myValueToString),
+                    };
+
+                    if (subItemOut.value)
+                        itemOut.value.push(subItemOut);
+                }
+            }
+
+            if (itemOut.value.length)
+                result.push(itemOut);
+        }
+
+        return result;
+    }
+
+    toHtml(xmlString) {
+        const substs = {
+            '<subtitle>': '<p><b>',
+            '</subtitle>': '</b></p>',
+            '<empty-line/>': '<br>',
+            '<strong>': '<b>',
+            '</strong>': '</b>',
+            '<emphasis>': '<i>',
+            '</emphasis>': '</i>',
+            '<stanza>': '<br>',
+            '</stanza>': '',
+            '<poem>': '<br>',
+            '</poem>': '',
+            '<cite>': '<i>',
+            '</cite>': '</i>',
+            '<table>': '<br>',
+            '</table>': '',
+        };
+
+        for (const [tag, s] of Object.entries(substs)) {
+            const r = new RegExp(`${tag}`, 'g');
+            xmlString = xmlString.replace(r, s);
+        }
+
+        return xmlString;
+    }    
+}
+
+module.exports = Fb2Parser;

+ 130 - 0
server/core/fb2/textUtils.js

@@ -0,0 +1,130 @@
+const chardet = require('chardet');
+
+function getEncoding(buf) {
+    let selected = getEncodingLite(buf);
+
+    if (selected == 'ISO-8859-5' && buf.length > 10) {
+        const charsetAll = chardet.analyse(buf.slice(0, 20000));
+        for (const charset of charsetAll) {
+            if (charset.name.indexOf('ISO-8859') < 0) {
+                selected = charset.name;
+                break;
+            }
+        }
+    }
+
+    return selected;
+}
+
+
+function getEncodingLite(buf, returnAll) {
+    const lowerCase = 3;
+    const upperCase = 1;
+
+    const codePage = {
+        'k': 'koi8-r',
+        'w': 'Windows-1251',
+        'd': 'cp866',
+        'i': 'ISO-8859-5',
+        'm': 'maccyrillic',
+        'u': 'utf-8',
+    };
+
+    let charsets = {
+        'k': 0,
+        'w': 0,
+        'd': 0,
+        'i': 0,
+        'm': 0,
+        'u': 0,
+    };
+
+    const len = buf.length;
+    const blockSize = (len > 5*3000 ? 3000 : len);
+    let counter = 0;
+    let i = 0;
+    let totalChecked = 0;
+    while (i < len) {
+        const char = buf[i];
+        const nextChar = (i < len - 1 ? buf[i + 1] : 0);
+        totalChecked++;
+        i++;
+        //non-russian characters
+        if (char < 128 || char > 256)
+            continue;
+        //UTF-8
+        if ((char == 208 || char == 209) && nextChar >= 128 && nextChar <= 190)
+            charsets['u'] += lowerCase;
+        else {
+            //CP866
+            if ((char > 159 && char < 176) || (char > 223 && char < 242)) charsets['d'] += lowerCase;
+            if ((char > 127 && char < 160)) charsets['d'] += upperCase;
+
+            //KOI8-R
+            if ((char > 191 && char < 223)) charsets['k'] += lowerCase;
+            if ((char > 222 && char < 256)) charsets['k'] += upperCase;
+
+            //WIN-1251
+            if (char > 223 && char < 256) charsets['w'] += lowerCase;
+            if (char > 191 && char < 224) charsets['w'] += upperCase;
+
+            //MAC
+            if (char > 221 && char < 255) charsets['m'] += lowerCase;
+            if (char > 127 && char < 160) charsets['m'] += upperCase;
+
+            //ISO-8859-5
+            if (char > 207 && char < 240) charsets['i'] += lowerCase;
+            if (char > 175 && char < 208) charsets['i'] += upperCase;
+        }
+
+        counter++;
+
+        if (counter > blockSize) {
+            counter = 0;
+            i += Math.round(len/2 - 2*blockSize);
+        }
+    }
+
+    let sorted = Object.keys(charsets).map(function(key) {
+        return { codePage: codePage[key], c: charsets[key], totalChecked };
+    });
+
+    sorted.sort((a, b) => b.c - a.c);
+
+    if (returnAll)
+        return sorted;
+    else if (sorted[0].c > 0 && sorted[0].c > sorted[0].totalChecked/2)
+        return sorted[0].codePage;
+    else
+        return 'ISO-8859-5';
+}
+
+function checkIfText(buf) {
+    const enc = getEncodingLite(buf, true);
+    if (enc[0].c > enc[0].totalChecked*0.9)
+        return true;
+
+    let spaceCount = 0;
+    let crCount = 0;
+    let lfCount = 0;
+    for (let i = 0; i < buf.length; i++) {
+        if (buf[i] == 32)
+            spaceCount++;
+        if (buf[i] == 13)
+            crCount++;
+        if (buf[i] == 10)
+            lfCount++;
+    }
+
+    const spaceFreq = spaceCount/(buf.length + 1);
+    const crFreq = crCount/(buf.length + 1);
+    const lfFreq = lfCount/(buf.length + 1);
+
+    return (buf.length < 1000 || spaceFreq > 0.1 || crFreq > 0.03 || lfFreq > 0.03);
+}
+
+module.exports = {
+    getEncoding,
+    getEncodingLite,
+    checkIfText,
+}

+ 60 - 0
server/core/utils.js

@@ -115,10 +115,65 @@ function gzipFile(inputFile, outputFile, level = 1) {
     });
 }
 
+function gunzipFile(inputFile, outputFile) {
+    return new Promise((resolve, reject) => {
+        const gzip = zlib.createGunzip();
+        const input = fs.createReadStream(inputFile);
+        const output = fs.createWriteStream(outputFile);
+
+        input.on('error', reject)
+            .pipe(gzip).on('error', reject)
+            .pipe(output).on('error', reject)
+            .on('finish', (err) => {
+            if (err) reject(err);
+            else resolve();
+        });
+    });
+}
+
+function gzipBuffer(buf) {
+    return new Promise((resolve, reject) => {
+        zlib.gzip(buf, {level: 1}, (err, result) => {
+            if (err) reject(err);
+            resolve(result);
+        });
+    });
+}
+
+function gunzipBuffer(buf) {
+    return new Promise((resolve, reject) => {
+        zlib.gunzip(buf, (err, result) => {
+            if (err) reject(err);
+            resolve(result);
+        });
+    });
+}
+
 function toUnixPath(dir) {
     return dir.replace(/\\/g, '/');
 }
 
+function makeValidFileName(fileName, repl = '_') {
+    let f = fileName.replace(/[\x00\\/:*"<>|]/g, repl); // eslint-disable-line no-control-regex
+    f = f.trim();
+    while (f.length && (f[f.length - 1] == '.' || f[f.length - 1] == '_')) {
+        f = f.substring(0, f.length - 1);
+    }
+
+    if (f)
+        return f;
+    else
+        throw new Error('Invalid filename');
+}
+
+function makeValidFileNameOrEmpty(fileName) {
+    try {
+        return makeValidFileName(fileName);
+    } catch(e) {
+        return '';
+    }
+}
+
 module.exports = {
     sleep,
     processLoop,
@@ -132,5 +187,10 @@ module.exports = {
     intersectSet,
     randomHexString,
     gzipFile,
+    gunzipFile,
+    gzipBuffer,
+    gunzipBuffer,
     toUnixPath,
+    makeValidFileName,
+    makeValidFileNameOrEmpty,
 };

+ 109 - 0
server/core/xml/ObjectInspector.js

@@ -0,0 +1,109 @@
+class ObjectInspector {
+    constructor(raw = null) {
+        this.raw = raw;
+    }
+
+    makeSelector(selector) {
+        const result = [];
+        selector = selector.trim();
+        
+        //последний индекс не учитывется, только если не задан явно
+        if (selector && selector[selector.length - 1] == ']')
+            selector += '/';
+
+        const levels = selector.split('/');
+
+        for (const level of levels) {
+            const [name, indexPart] = level.split('[');
+            let index = 0;
+            if (indexPart) {
+                const i = indexPart.indexOf(']');
+                index = parseInt(indexPart.substring(0, i), 10) || 0;
+            }
+
+            result.push({name, index});
+        }
+
+        if (result.length);
+            result[result.length - 1].last = true;
+
+        return result;
+    }
+
+    select(selector = '') {
+        selector = this.makeSelector(selector);
+
+        let raw = this.raw;
+        for (const s of selector) {
+            if (s.name) {
+                if (typeof(raw) === 'object' && !Array.isArray(raw))
+                    raw = raw[s.name];
+                else
+                    raw = null;
+            }
+
+            if (raw !== null && !s.last) {
+                if (Array.isArray(raw))
+                    raw = raw[s.index];
+                else if (s.index > 0)
+                    raw = null;
+            }
+
+            if (raw === undefined || raw === null) {
+                raw = null;
+                break;
+            }
+        }
+
+        if (raw === null)
+            return [];
+
+        raw = (Array.isArray(raw) ? raw : [raw]);
+
+        const result = [];
+        for (const r of raw)
+            result.push(new ObjectInspector(r));
+
+        return result;
+    }
+
+    $$(selector) {
+        return this.select(selector);
+    }
+
+    $(selector) {
+        const res = this.select(selector);
+        return (res !== null && res.length ? res[0] : null);
+    }
+
+    get value() {
+        return this.raw;
+    }
+
+    v(selector = '') {
+        const res = this.$(selector);
+        return (res ? res.value : null);
+    }
+
+    text(selector = '') {
+        const res = this.$(`${selector}/*TEXT`);
+        return (res ? res.value : null);
+    }
+
+    comment(selector = '') {
+        const res = this.$(`${selector}/*COMMENT`);
+        return (res ? res.value : null);
+    }
+
+    cdata(selector = '') {
+        const res = this.$(`${selector}/*CDATA`);
+        return (res ? res.value : null);
+    }
+
+    attrs(selector = '') {
+        const res = this.$(`${selector}/*ATTRS`);
+        return (res ? res.value : null);
+    }
+}
+
+module.exports = ObjectInspector;

+ 771 - 0
server/core/xml/XmlParser.js

@@ -0,0 +1,771 @@
+const sax = require('./sax');
+const ObjectInspector = require('./ObjectInspector');
+
+//node types
+const NODE = 1;
+const TEXT = 2;
+const CDATA = 3;
+const COMMENT = 4;
+
+const name2type = {
+    'NODE': NODE,
+    'TEXT': TEXT,
+    'CDATA': CDATA,
+    'COMMENT': COMMENT,
+};
+
+const type2name = {
+    [NODE]: 'NODE',
+    [TEXT]: 'TEXT',
+    [CDATA]: 'CDATA',
+    [COMMENT]: 'COMMENT',
+};
+
+class NodeBase {
+    makeSelectorObj(selectorString) {
+        const result = {all: false, before: false, type: 0, name: ''};
+
+        if (selectorString === '') {
+            result.before = true;
+        } else if (selectorString === '*') {
+            result.all = true;        
+        } else if (selectorString[0] === '*') {
+            const typeName = selectorString.substring(1);
+            result.type = name2type[typeName];
+            if (!result.type)
+                throw new Error(`Unknown selector type: ${typeName}`);
+        } else {
+            result.name = selectorString;
+        }
+
+        return result;
+    }
+
+    checkNode(rawNode, selectorObj) {
+        return selectorObj.all || selectorObj.before
+            || (selectorObj.type && rawNode[0] === selectorObj.type)
+            || (rawNode[0] === NODE && rawNode[1] === selectorObj.name);
+    }
+
+    findNodeIndex(nodes, selectorObj) {
+        for (let i = 0; i < nodes.length; i++)
+            if (this.checkNode(nodes[i], selectorObj))
+                return i;
+    }
+
+    rawAdd(nodes, rawNode, selectorObj) {
+        if (selectorObj.all) {
+            nodes.push(rawNode);
+        } else if (selectorObj.before) {
+            nodes.unshift(rawNode);
+        } else {
+            const index = this.findNodeIndex(nodes, selectorObj);
+            if (index >= 0)
+                nodes.splice(index, 0, rawNode);
+            else 
+                nodes.push(rawNode);
+        }
+    }
+
+    rawRemove(nodes, selectorObj) {
+        if (selectorObj.before)
+            return;
+
+        for (let i = nodes.length - 1; i >= 0; i--) {
+            if (this.checkNode(nodes[i], selectorObj))
+                nodes.splice(i, 1);
+        }
+    }
+}
+
+class NodeObject extends NodeBase {
+    constructor(raw = null) {
+        super();
+
+        if (raw)
+            this.raw = raw;
+        else
+            this.raw = [];
+    }
+
+    get type() {
+        return this.raw[0] || null;
+    }
+
+    get name() {
+        if (this.type === NODE)
+            return this.raw[1] || null;
+
+        return null;
+    }
+
+    set name(value) {
+        if (this.type === NODE)
+            this.raw[1] = value;
+    }
+
+    attrs(key, value) {
+        if (this.type !== NODE)
+            return null;
+
+        let map = null;
+
+        if (key instanceof Map) {
+            map = key;
+            this.raw[2] = Array.from(map);
+        } else if (Array.isArray(this.raw[2])) {
+            map = new Map(this.raw[2]);
+            if (key) {
+                map.set(key, value);
+                this.raw[2] = Array.from(map);
+            }
+        }
+
+        return map;
+    }
+
+    get value() {
+        switch (this.type) {
+            case NODE:
+                return this.raw[3] || null;
+            case TEXT:
+            case CDATA:
+            case COMMENT:
+                return this.raw[1] || null;
+        }
+
+        return null;
+    }
+
+    set value(v) {
+        switch (this.type) {
+            case NODE:
+                this.raw[3] = v;
+                break;
+            case TEXT:
+            case CDATA:
+            case COMMENT:
+                this.raw[1] = v;
+        }
+    }
+
+    add(node, after = '*') {
+        if (this.type !== NODE)
+            return;
+
+        const selectorObj = this.makeSelectorObj(after);
+
+        if (!Array.isArray(this.raw[3]))
+            this.raw[3] = [];
+
+        if (Array.isArray(node)) {
+            for (const node_ of node)
+                this.rawAdd(this.raw[3], node_.raw, selectorObj);
+        } else {
+            this.rawAdd(this.raw[3], node.raw, selectorObj);
+        }
+
+        return this;
+    }
+
+    remove(selector = '') {
+        if (this.type !== NODE || !this.raw[3])
+            return;
+
+        const selectorObj = this.makeSelectorObj(selector);
+
+        this.rawRemove(this.raw[3], selectorObj);
+        if (!this.raw[3].length)
+            this.raw[3] = null;
+
+        return this;
+    }
+
+    each(callback) {
+        if (this.type !== NODE || !this.raw[3])
+            return;
+
+        for (const n of this.raw[3]) {
+            if (callback(new NodeObject(n)) === false)
+                break;
+        }
+
+        return this;
+    }
+
+    eachDeep(callback) {
+        if (this.type !== NODE || !this.raw[3])
+            return;
+
+        const deep = (nodes, route = '') => {
+            for (const n of nodes) {
+                const node = new NodeObject(n);
+
+                if (callback(node, route) === false)
+                    return false;
+
+                if (node.type === NODE && node.value) {
+                    if (deep(node.value, `${route}${route ? '/' : ''}${node.name}`) === false)
+                        return false;
+                }
+            }
+        }
+
+        deep(this.raw[3]);
+
+        return this;
+    }
+}
+
+class XmlParser extends NodeBase {
+    constructor(rawNodes = []) {
+        super();
+
+        this.NODE = NODE;
+        this.TEXT = TEXT;
+        this.CDATA = CDATA;
+        this.COMMENT = COMMENT;
+
+        this.rawNodes = rawNodes;
+    }
+
+    get count() {
+        return this.rawNodes.length;
+    }
+
+    nodeObject(node) {
+        return new NodeObject(node);
+    }
+
+    newParser(nodes) {
+        return new XmlParser(nodes);
+    }
+
+    checkType(type) {
+        if (!type2name[type])
+            throw new Error(`Invalid type: ${type}`);
+    }
+
+    createTypedNode(type, nameOrValue, attrs = null, value = null) {
+        this.checkType(type);
+        switch (type) {
+            case NODE:
+                if (!nameOrValue || typeof(nameOrValue) !== 'string')
+                    throw new Error('Node name must be non-empty string');
+                return new NodeObject([type, nameOrValue, attrs, value]);
+            case TEXT:
+            case CDATA:
+            case COMMENT:
+                if (typeof(nameOrValue) !== 'string')
+                    throw new Error('Node value must be of type string');
+                return new NodeObject([type, nameOrValue]);
+        }
+    }
+
+    createNode(name, attrs = null, value = null) {
+        return this.createTypedNode(NODE, name, attrs, value);
+    }
+
+    createText(value = null) {
+        return this.createTypedNode(TEXT, value);
+    }
+
+    createCdata(value = null) {
+        return this.createTypedNode(CDATA, value);
+    }
+
+    createComment(value = null) {
+        return this.createTypedNode(COMMENT, value);
+    }
+
+    add(node, after = '*') {
+        const selectorObj = this.makeSelectorObj(after);
+
+        for (const n of this.rawNodes) {
+            if (n && n[0] === NODE) {
+                if (!Array.isArray(n[3]))
+                    n[3] = [];
+                
+                if (Array.isArray(node)) {
+                    for (const node_ of node)
+                        this.rawAdd(n[3], node_.raw, selectorObj);
+                } else {
+                    this.rawAdd(n[3], node.raw, selectorObj);
+                }
+            }
+        }
+
+        return this;
+    }
+
+    addRoot(node, after = '*') {
+        const selectorObj = this.makeSelectorObj(after);
+
+        if (Array.isArray(node)) {
+            for (const node_ of node)
+                this.rawAdd(this.rawNodes, node_.raw, selectorObj);
+        } else {
+            this.rawAdd(this.rawNodes, node.raw, selectorObj);
+        }
+
+        return this;
+    }
+
+    remove(selector = '') {
+        const selectorObj = this.makeSelectorObj(selector);
+
+        for (const n of this.rawNodes) {
+            if (n && n[0] === NODE && Array.isArray(n[3])) {
+                this.rawRemove(n[3], selectorObj);
+                if (!n[3].length)
+                    n[3] = null;
+            }
+        }
+
+        return this;
+    }
+
+    removeRoot(selector = '') {
+        const selectorObj = this.makeSelectorObj(selector);
+
+        this.rawRemove(this.rawNodes, selectorObj);
+
+        return this;
+    }
+
+    each(callback, self = false) {
+        if (self) {
+            for (const n of this.rawNodes) {
+                if (callback(new NodeObject(n)) === false)
+                    return this;
+            }
+        } else {
+            for (const n of this.rawNodes) {
+                if (n[0] === NODE && n[3]) {
+                    for (const nn of n[3])
+                        if (callback(new NodeObject(nn)) === false)
+                            return this;
+                }
+            }
+        }
+
+        return this;
+    }
+
+    eachSelf(callback) {
+        return this.each(callback, true);
+    }
+
+    eachDeep(callback, self = false) {
+        const deep = (nodes, route = '') => {
+            for (const n of nodes) {
+                const node = new NodeObject(n);
+
+                if (callback(node, route) === false)
+                    return false;
+
+                if (node.type === NODE && node.value) {
+                    if (deep(node.value, `${route}${route ? '/' : ''}${node.name}`) === false)
+                        return false;
+                }
+            }
+        }
+
+        if (self) {
+            deep(this.rawNodes);
+        } else {
+            for (const n of this.rawNodes) {
+                if (n[0] === NODE && n[3])
+                    if (deep(n[3]) === false)
+                        break;
+            }
+        }
+
+        return this;
+    }
+
+    eachDeepSelf(callback) {
+        return this.eachDeep(callback, true);
+    }
+
+    rawSelect(nodes, selectorObj, callback) {
+        for (const n of nodes)
+            if (this.checkNode(n, selectorObj))
+                callback(n);
+
+        return this;
+    }
+
+    select(selector = '', self = false) {
+        let newRawNodes = [];
+
+        if (selector.indexOf('/') >= 0) {
+            const selectors = selector.split('/');
+            let res = this;
+            for (const sel of selectors) {
+                res = res.select(sel, self);
+                self = false;
+            }
+
+            newRawNodes = res.rawNodes;
+        } else {
+            const selectorObj = this.makeSelectorObj(selector);
+
+            if (self) {
+                this.rawSelect(this.rawNodes, selectorObj, (node) => {
+                    newRawNodes.push(node);
+                })
+            } else {
+                for (const n of this.rawNodes) {
+                    if (n && n[0] === NODE && Array.isArray(n[3])) {
+                        this.rawSelect(n[3], selectorObj, (node) => {
+                            newRawNodes.push(node);
+                        })
+                    }
+                }
+            }
+        }
+
+        return new XmlParser(newRawNodes);
+    }
+
+    $$(selector, self) {
+        return this.select(selector, self);
+    }
+
+    $$self(selector) {
+        return this.select(selector, true);
+    }
+
+    selectFirst(selector, self) {
+        const result = this.select(selector, self);
+        const node = (result.count ? result.rawNodes[0] : null);
+        return new NodeObject(node);
+    }
+
+    $(selector, self) {
+        return this.selectFirst(selector, self);
+    }
+
+    $self(selector) {
+        return this.selectFirst(selector, true);
+    }
+
+    toJson(options = {}) {
+        const {format = false} = options;
+
+        if (format)
+            return JSON.stringify(this.rawNodes, null, 2);
+        else
+            return JSON.stringify(this.rawNodes);
+    }
+
+    fromJson(jsonString) {
+        const parsed = JSON.parse(jsonString);
+        if (!Array.isArray(parsed))
+            throw new Error('JSON parse error: root element must be array');
+
+        this.rawNodes = parsed;
+
+        return this;
+    }
+
+    toString(options = {}) {
+        const {
+            encoding = 'utf-8',
+            format = false,
+            noHeader = false,
+            expandEmpty = false
+        } = options;
+
+        let deepType = 0;
+        let out = '';
+        if (!noHeader)
+            out += `<?xml version="1.0" encoding="${encoding}"?>`;
+
+        const nodesToString = (nodes, depth = 0) => {
+            let result = '';
+
+            const indent = '\n' + ' '.repeat(depth);
+            let lastType = 0;
+
+            for (const n of nodes) {
+                const node = new NodeObject(n);
+
+                let open = '';
+                let body = '';
+                let close = '';
+
+                if (node.type === NODE) {
+                    if (!node.name)
+                        continue;
+
+                    let attrs = '';
+
+                    const nodeAttrs = node.attrs();
+                    if (nodeAttrs) {
+                        for (const [attrName, attrValue] of nodeAttrs) {
+                            if (typeof(attrValue) === 'string')
+                                attrs += ` ${attrName}="${attrValue}"`;
+                            else
+                                if (attrValue)
+                                    attrs += ` ${attrName}`;
+                        }
+                    }
+
+                    if (node.value)
+                        body = nodesToString(node.value, depth + 2);
+
+                    if (!body && !expandEmpty) {
+                        open = (format && lastType !== TEXT ? indent : '');
+                        open += `<${node.name}${attrs}/>`;
+                    } else {
+                        open = (format && lastType !== TEXT ? indent : '');
+                        open += `<${node.name}${attrs}>`;
+
+                        close = (format && deepType && deepType !== TEXT ? indent : '');
+                        close += `</${node.name}>`;
+                    }
+                } else if (node.type === TEXT) {
+                    body = node.value || '';
+                } else if (node.type === CDATA) {
+                    body = (format && lastType !== TEXT ? indent : '');
+                    body += `<![CDATA[${node.value || ''}]]>`;
+                } else if (node.type === COMMENT) {
+                    body = (format && lastType !== TEXT ? indent : '');
+                    body += `<!--${node.value || ''}-->`;
+                }
+
+                result += `${open}${body}${close}`;
+                lastType = node.type;
+            }
+
+            deepType = lastType;
+            return result;
+        }
+
+        out += nodesToString(this.rawNodes) + (format ? '\n' : '');
+
+        return out;
+    }
+
+    fromString(xmlString, options = {}) {
+        const {
+            lowerCase = false,
+            whiteSpace = false,
+            pickNode = false,
+        } = options;
+
+        const parsed = [];
+        const root = this.createNode('root', null, parsed);//fake node
+        let node = root;
+
+        let route = '';
+        let routeStack = [];
+        let ignoreNode = false;
+
+        const onStartNode = (tag, tail, singleTag, cutCounter, cutTag) => {// eslint-disable-line no-unused-vars
+            if (tag == '?xml')
+                return;
+
+            if (!ignoreNode && pickNode) {
+                route += `${route ? '/' : ''}${tag}`;
+                ignoreNode = !pickNode(route);
+            }
+
+            let newNode = node;
+            if (!ignoreNode)
+                newNode = this.createNode(tag);
+
+            routeStack.push({tag, route, ignoreNode, node: newNode});
+
+            if (ignoreNode)
+                return;
+
+            if (tail && tail.trim() !== '') {
+                const parsedAttrs = sax.getAttrsSync(tail, lowerCase);
+                const attrs = new Map();
+                for (const attr of parsedAttrs.values()) {
+                    attrs.set(attr.fn, attr.value);
+                }
+
+                if (attrs.size)
+                    newNode.attrs(attrs);
+            }
+
+            if (!node.value)
+                node.value = [];
+            node.value.push(newNode.raw);
+            node = newNode;
+        };
+
+        const onEndNode = (tag, tail, singleTag, cutCounter, cutTag) => {// eslint-disable-line no-unused-vars
+            if (routeStack.length && routeStack[routeStack.length - 1].tag === tag) {
+                routeStack.pop();
+
+                if (routeStack.length) {
+                    const last = routeStack[routeStack.length - 1];
+                    route = last.route;
+                    ignoreNode = last.ignoreNode;
+                    node = last.node;
+                } else {
+                    route = '';
+                    ignoreNode = false;
+                    node = root;
+                }
+            }
+        }
+
+        const onTextNode = (text, cutCounter, cutTag) => {// eslint-disable-line no-unused-vars
+            if (ignoreNode || (pickNode && !pickNode(`${route}/*TEXT`)))
+                return;
+
+            if (!whiteSpace && text.trim() == '')
+                return;
+
+            if (!node.value)
+                node.value = [];
+
+            node.value.push(this.createText(text).raw);
+        };
+
+        const onCdata = (tagData, cutCounter, cutTag) => {// eslint-disable-line no-unused-vars
+            if (ignoreNode || (pickNode && !pickNode(`${route}/*CDATA`)))
+                return;
+
+            if (!node.value)
+                node.value = [];
+
+            node.value.push(this.createCdata(tagData).raw);
+        }
+
+        const onComment = (tagData, cutCounter, cutTag) => {// eslint-disable-line no-unused-vars
+            if (ignoreNode || (pickNode && !pickNode(`${route}/*COMMENT`)))
+                return;
+
+            if (!node.value)
+                node.value = [];
+
+            node.value.push(this.createComment(tagData).raw);
+        }
+
+        sax.parseSync(xmlString, {
+            onStartNode, onEndNode, onTextNode, onCdata, onComment, lowerCase
+        });
+
+        this.rawNodes = parsed;
+
+        return this;
+    }
+
+    toObject(options = {}) {
+        const {
+            compactText = false
+        } = options;
+
+        const nodesToObject = (nodes) => {
+            const result = {};
+
+            for (const n of nodes) {
+                const node = new NodeObject(n);
+
+                if (node.type === NODE) {
+                    if (!node.name)
+                        continue;
+
+                    let newNode = {};
+
+                    const nodeAttrs = node.attrs();
+                    if (nodeAttrs)
+                        newNode['*ATTRS'] = Object.fromEntries(nodeAttrs);
+
+                    if (node.value) {
+                        Object.assign(newNode, nodesToObject(node.value));
+
+                        //схлопывание текстового узла до string
+                        if (compactText
+                            && !Array.isArray(newNode)
+                            && Object.prototype.hasOwnProperty.call(newNode, '*TEXT')
+                            && Object.keys(newNode).length === 1) {
+                            newNode = newNode['*TEXT'];
+                        }
+                    }
+
+                    if (!Object.prototype.hasOwnProperty.call(result, node.name)) {
+                        result[node.name] = newNode;
+                    } else {
+                        if (!Array.isArray(result[node.name])) {
+                            result[node.name] = [result[node.name]];
+                        }
+
+                        result[node.name].push(newNode);
+                    }
+                } else if (node.type === TEXT) {
+                    if (!result['*TEXT'])
+                        result['*TEXT'] = '';
+                    result['*TEXT'] += node.value || '';
+                } else if (node.type === CDATA) {
+                    if (!result['*CDATA'])
+                        result['*CDATA'] = '';
+                    result['*CDATA'] += node.value || '';
+                } else if (node.type === COMMENT) {
+                    if (!result['*COMMENT'])
+                        result['*COMMENT'] = '';
+                    result['*COMMENT'] += node.value || '';
+                }
+            }
+
+            return result;
+        }
+
+        return nodesToObject(this.rawNodes);
+    }
+
+    fromObject(xmlObject) {
+        const objectToNodes = (obj) => {
+            const result = [];
+
+            for (const [tag, objNode] of Object.entries(obj)) {
+                if (tag === '*TEXT') {
+                    result.push(this.createText(objNode).raw);
+                } else if (tag === '*CDATA') {
+                    result.push(this.createCdata(objNode).raw);
+                } else if (tag === '*COMMENT') {
+                    result.push(this.createComment(objNode).raw);
+                } else if (tag === '*ATTRS') {
+                    //пропускаем
+                } else {
+                    if (typeof(objNode) === 'string') {
+                        result.push(this.createNode(tag, null, [this.createText(objNode).raw]).raw);
+                    } else if (Array.isArray(objNode)) {
+                        for (const n of objNode) {
+                            if (typeof(n) === 'string') {
+                                result.push(this.createNode(tag, null, [this.createText(n).raw]).raw);
+                            } else if (typeof(n) === 'object') {
+                                result.push(this.createNode(tag, (n['*ATTRS'] ? Object.entries(n['*ATTRS']) : null), objectToNodes(n)).raw);
+                            }
+                        }
+
+                    } else if (typeof(objNode) === 'object') {
+                        result.push(this.createNode(tag, (objNode['*ATTRS'] ? Object.entries(objNode['*ATTRS']) : null), objectToNodes(objNode)).raw);
+                    }
+                }
+            }
+
+            return result;
+        };
+
+        this.rawNodes = objectToNodes(xmlObject);
+
+        return this;
+    }
+
+    inspector(obj) {
+        if (!obj)
+            obj = this.toObject();
+
+        return new ObjectInspector(obj);
+    }
+}
+
+module.exports = XmlParser;

+ 367 - 0
server/core/xml/sax.js

@@ -0,0 +1,367 @@
+function parseSync(xstr, options) {
+    const dummy = () => {};
+    let {onStartNode: _onStartNode = dummy,
+        onEndNode: _onEndNode = dummy,
+        onTextNode: _onTextNode = dummy,
+        onCdata: _onCdata = dummy,
+        onComment: _onComment = dummy,
+        onProgress: _onProgress = dummy,
+        innerCut = new Set(),
+        lowerCase = true,
+    } = options;
+
+    let i = 0;
+    const len = xstr.length;
+    const progStep = len/20;
+    let nextProg = 0;
+
+    let cutCounter = 0;
+    let cutTag = '';
+    let inCdata;
+    let inComment;
+    let leftData = 0;
+    while (i < len) {
+        inCdata = false;
+        inComment = false;
+        let singleTag = false;
+
+        let left = xstr.indexOf('<', i);
+        if (left < 0)
+            break;
+        leftData = left;
+
+        if (left < len - 2 && xstr[left + 1] == '!') {
+            if (xstr[left + 2] == '-') {
+                const leftComment = xstr.indexOf('<!--', left);
+                if (leftComment == left) {
+                    inComment = true;
+                    leftData = left + 3;
+                }
+            }
+
+            if (!inComment && xstr[left + 2] == '[') {
+                const leftCdata = xstr.indexOf('<![CDATA[', left);
+                if (leftCdata == left) {
+                    inCdata = true;
+                    leftData = left + 8;
+                }
+            }
+        }
+
+        if (left != i) {
+            const text = xstr.substr(i, left - i);
+            _onTextNode(text, cutCounter, cutTag);
+        }
+
+        let right = null;
+        let rightData = null;
+        if (inCdata) {
+            rightData = xstr.indexOf(']]>', leftData + 1);
+            if (rightData < 0)
+                break;
+            right = rightData + 2;
+        } else if (inComment) {
+            rightData = xstr.indexOf('-->', leftData + 1);
+            if (rightData < 0)
+                break;
+            right = rightData + 2;
+        } else {
+            rightData = xstr.indexOf('>', leftData + 1);
+            if (rightData < 0)
+                break;
+            right = rightData;
+            if (xstr[right - 1] === '/') {
+                singleTag = true;
+                rightData--;
+            }
+        }
+
+        let tagData = xstr.substr(leftData + 1, rightData - leftData - 1);
+
+        if (inCdata) {
+            _onCdata(tagData, cutCounter, cutTag);
+        } else if (inComment) {
+            _onComment(tagData, cutCounter, cutTag);
+        } else {
+            let tag = '';
+            let tail = '';
+            const firstSpace = tagData.indexOf(' ');
+            if (firstSpace >= 0) {
+                tail = tagData.substr(firstSpace);
+                tag = tagData.substr(0, firstSpace);
+            } else {
+                tag = tagData;
+            }
+            if (lowerCase)
+                tag = tag.toLowerCase();
+
+            if (innerCut.has(tag) && (!cutCounter || cutTag === tag)) {
+                if (!cutCounter)
+                    cutTag = tag;
+                cutCounter++;
+            }
+
+            let endTag = (singleTag ? tag : '');
+            if (tag === '' || tag[0] !== '/') {
+                _onStartNode(tag, tail, singleTag, cutCounter, cutTag);
+            } else {
+                endTag = tag.substr(1);
+            }
+
+            if (endTag)
+                _onEndNode(endTag, tail, singleTag, cutCounter, cutTag);
+
+            if (cutTag === endTag) {
+                cutCounter = (cutCounter > 0 ? cutCounter - 1 : 0);
+                if (!cutCounter)
+                    cutTag = '';
+            }
+        }
+
+        if (right >= nextProg) {
+            _onProgress(Math.round(right/(len + 1)*100));
+            nextProg += progStep;
+        }
+        i = right + 1;
+    }
+
+    if (i < len) {
+        if (inCdata) {
+            _onCdata(xstr.substr(leftData + 1, len - leftData - 1), cutCounter, cutTag);
+        } else if (inComment) {
+            _onComment(xstr.substr(leftData + 1, len - leftData - 1), cutCounter, cutTag);
+        } else {
+            _onTextNode(xstr.substr(i, len - i), cutCounter, cutTag);
+        }
+    }
+
+    _onProgress(100);
+}
+
+//асинхронная копия parseSync
+//делается заменой "_on" => "await _on" после while
+async function parse(xstr, options) {
+    const dummy = () => {};
+    let {onStartNode: _onStartNode = dummy,
+        onEndNode: _onEndNode = dummy,
+        onTextNode: _onTextNode = dummy,
+        onCdata: _onCdata = dummy,
+        onComment: _onComment = dummy,
+        onProgress: _onProgress = dummy,
+        innerCut = new Set(),
+        lowerCase = true,
+    } = options;
+
+    let i = 0;
+    const len = xstr.length;
+    const progStep = len/20;
+    let nextProg = 0;
+
+    let cutCounter = 0;
+    let cutTag = '';
+    let inCdata;
+    let inComment;
+    let leftData = 0;
+    while (i < len) {
+        inCdata = false;
+        inComment = false;
+        let singleTag = false;
+
+        let left = xstr.indexOf('<', i);
+        if (left < 0)
+            break;
+        leftData = left;
+
+        if (left < len - 2 && xstr[left + 1] == '!') {
+            if (xstr[left + 2] == '-') {
+                const leftComment = xstr.indexOf('<!--', left);
+                if (leftComment == left) {
+                    inComment = true;
+                    leftData = left + 3;
+                }
+            }
+
+            if (!inComment && xstr[left + 2] == '[') {
+                const leftCdata = xstr.indexOf('<![CDATA[', left);
+                if (leftCdata == left) {
+                    inCdata = true;
+                    leftData = left + 8;
+                }
+            }
+        }
+
+        if (left != i) {
+            const text = xstr.substr(i, left - i);
+            await _onTextNode(text, cutCounter, cutTag);
+        }
+
+        let right = null;
+        let rightData = null;
+        if (inCdata) {
+            rightData = xstr.indexOf(']]>', leftData + 1);
+            if (rightData < 0)
+                break;
+            right = rightData + 2;
+        } else if (inComment) {
+            rightData = xstr.indexOf('-->', leftData + 1);
+            if (rightData < 0)
+                break;
+            right = rightData + 2;
+        } else {
+            rightData = xstr.indexOf('>', leftData + 1);
+            if (rightData < 0)
+                break;
+            right = rightData;
+            if (xstr[right - 1] === '/') {
+                singleTag = true;
+                rightData--;
+            }
+        }
+
+        let tagData = xstr.substr(leftData + 1, rightData - leftData - 1);
+
+        if (inCdata) {
+            await _onCdata(tagData, cutCounter, cutTag);
+        } else if (inComment) {
+            await _onComment(tagData, cutCounter, cutTag);
+        } else {
+            let tag = '';
+            let tail = '';
+            const firstSpace = tagData.indexOf(' ');
+            if (firstSpace >= 0) {
+                tail = tagData.substr(firstSpace);
+                tag = tagData.substr(0, firstSpace);
+            } else {
+                tag = tagData;
+            }
+            if (lowerCase)
+                tag = tag.toLowerCase();
+
+            if (innerCut.has(tag) && (!cutCounter || cutTag === tag)) {
+                if (!cutCounter)
+                    cutTag = tag;
+                cutCounter++;
+            }
+
+            let endTag = (singleTag ? tag : '');
+            if (tag === '' || tag[0] !== '/') {
+                await _onStartNode(tag, tail, singleTag, cutCounter, cutTag);
+            } else {
+                endTag = tag.substr(1);
+            }
+
+            if (endTag)
+                await _onEndNode(endTag, tail, singleTag, cutCounter, cutTag);
+
+            if (cutTag === endTag) {
+                cutCounter = (cutCounter > 0 ? cutCounter - 1 : 0);
+                if (!cutCounter)
+                    cutTag = '';
+            }
+        }
+
+        if (right >= nextProg) {
+            await _onProgress(Math.round(right/(len + 1)*100));
+            nextProg += progStep;
+        }
+        i = right + 1;
+    }
+
+    if (i < len) {
+        if (inCdata) {
+            await _onCdata(xstr.substr(leftData + 1, len - leftData - 1), cutCounter, cutTag);
+        } else if (inComment) {
+            await _onComment(xstr.substr(leftData + 1, len - leftData - 1), cutCounter, cutTag);
+        } else {
+            await _onTextNode(xstr.substr(i, len - i), cutCounter, cutTag);
+        }
+    }
+
+    await _onProgress(100);
+}
+
+function getAttrsSync(tail, lowerCase = true) {
+    let result = new Map();
+    let name = '';    
+    let value = '';
+    let vOpen = '';
+    let inName = false;
+    let inValue = false;
+    let waitValue = false;
+    let waitEq = true;
+
+    const pushResult = () => {
+        if (waitEq)
+            value = true;
+        if (lowerCase)
+            name = name.toLowerCase();
+        if (name != '') {
+            const fn = name;
+            let ns = '';
+            if (fn.indexOf(':') >= 0) {
+                [ns, name] = fn.split(':');
+            }
+
+            result.set(fn, {value, ns, name, fn});
+        }
+        name = '';
+        value = '';
+        vOpen = '';
+        inName = false;
+        inValue = false;
+        waitValue = false;
+        waitEq = true;
+    };
+
+    tail = tail.replace(/[\t\n\r]/g, ' ');
+    for (let i = 0; i < tail.length; i++) {
+        const c = tail.charAt(i);
+        if (c == ' ') {
+            if (inValue) {
+                if (vOpen == '"')
+                    value += c;
+                else
+                    pushResult();
+            } else if (inName) {
+                inName = false;
+            }
+        } else if (!inValue && c == '=') {
+            waitEq = false;
+            waitValue = true;
+            inName = false;
+        } else if (c == '"') {
+            if (inValue) {
+                pushResult();
+            } else if (waitValue) {
+                inValue = true;
+                vOpen = '"';
+            }
+        } else if (inValue) {
+            value += c;
+        } else if (inName) {
+            name += c;
+        } else if (waitEq) {
+            pushResult();
+            inName = true;
+            name = c;
+        } else if (waitValue) {
+            waitValue = false;
+            inValue = true;
+            vOpen = ' ';
+            value = c;
+        } else {
+            inName = true;
+            name = c;
+        }
+    }
+    if (name != '')
+        pushResult();
+
+    return result;
+}
+
+module.exports = {
+    parseSync,
+    getAttrsSync,
+    parse
+}

+ 23 - 24
server/index.js

@@ -189,32 +189,31 @@ function initStatic(app, config) {
             return next();
         }
 
-        if (path.extname(req.path) == '.json')
-            return next();
-
-        const bookFile = `${config.publicFilesDir}${req.path}`;
-        const bookFileDesc = `${bookFile}.json`;
-
-        let downFileName = '';
-        //восстановим из json-файла описания
-        try {
-            if (await fs.pathExists(bookFile) && await fs.pathExists(bookFileDesc)) {
-                await utils.touchFile(bookFile);
-                await utils.touchFile(bookFileDesc);
-
-                let desc = await fs.readFile(bookFileDesc, 'utf8');
-                desc = JSON.parse(desc);
-                downFileName = desc.downFileName;
-            } else {
-                await fs.remove(bookFile);
-                await fs.remove(bookFileDesc);
+        if (path.extname(req.path) == '') {
+            const bookFile = `${config.publicFilesDir}${req.path}`;
+            const bookFileDesc = `${bookFile}.json`;
+
+            let downFileName = '';
+            //восстановим из json-файла описания
+            try {
+                if (await fs.pathExists(bookFile) && await fs.pathExists(bookFileDesc)) {
+                    await utils.touchFile(bookFile);
+                    await utils.touchFile(bookFileDesc);
+
+                    let desc = await fs.readFile(bookFileDesc, 'utf8');
+                    desc = JSON.parse(desc);
+                    downFileName = desc.downFileName;
+                } else {
+                    await fs.remove(bookFile);
+                    await fs.remove(bookFileDesc);
+                }
+            } catch(e) {
+                log(LM_ERR, e.message);
             }
-        } catch(e) {
-            log(LM_ERR, e.message);
-        }
 
-        if (downFileName)
-            res.downFileName = downFileName;
+            if (downFileName)
+                res.downFileName = downFileName;
+        }
 
         return next();
     });