Browse Source

Merge branch 'release/1.4.0'

Book Pauk 2 years ago
parent
commit
7fe0bcee2f
48 changed files with 1649 additions and 592 deletions
  1. 10 0
      CHANGELOG.md
  2. 18 4
      README.md
  3. 1 0
      build/appdir.js
  4. 2 2
      build/prepkg.js
  5. 1 0
      build/release.js
  6. 3 1
      build/webpack.base.config.js
  7. 1 2
      build/webpack.dev.config.js
  8. 1 2
      build/webpack.prod.config.js
  9. 4 0
      client/components/Api/Api.vue
  10. 5 1
      client/components/Api/webSocketConnection.js
  11. 35 17
      client/components/Search/BaseList.js
  12. 7 5
      client/components/Search/BookView/BookView.vue
  13. 128 0
      client/components/Search/ExtendedList/ExtendedList.vue
  14. 254 71
      client/components/Search/Search.vue
  15. 202 0
      client/components/Search/SelectExtSearchDialog/SelectExtSearchDialog.vue
  16. 7 1
      client/components/Search/SettingsDialog/SettingsDialog.vue
  17. 9 3
      client/components/share/DivBtn.vue
  18. 4 2
      client/components/share/NumInput.vue
  19. 1 0
      client/router.js
  20. 8 8
      client/share/utils.js
  21. 2 0
      client/store/root.js
  22. 261 256
      package-lock.json
  23. 30 28
      package.json
  24. 5 2
      server/config/base.js
  25. 1 0
      server/config/index.js
  26. 1 0
      server/config/production.js
  27. 13 0
      server/controllers/WebSocketController.js
  28. 10 2
      server/core/DbCreator.js
  29. 168 32
      server/core/DbSearcher.js
  30. 1 0
      server/core/FileDownloader.js
  31. 40 5
      server/core/InpxParser.js
  32. 2 1
      server/core/RemoteLib.js
  33. 19 7
      server/core/WebWorker.js
  34. 4 3
      server/core/fb2/Fb2Helper.js
  35. 1 1
      server/core/fb2/Fb2Parser.js
  36. 48 26
      server/core/opds/AuthorPage.js
  37. 15 3
      server/core/opds/BasePage.js
  38. 14 2
      server/core/opds/BookPage.js
  39. 59 0
      server/core/opds/SearchHelpPage.js
  40. 29 4
      server/core/opds/SearchPage.js
  41. 31 22
      server/core/opds/SeriesPage.js
  42. 22 8
      server/core/opds/TitlePage.js
  43. 4 1
      server/core/opds/index.js
  44. 29 6
      server/core/utils.js
  45. 4 2
      server/core/xml/XmlParser.js
  46. 2 2
      server/createWebApp.js
  47. 6 60
      server/index.js
  48. 127 0
      server/static.js

+ 10 - 0
CHANGELOG.md

@@ -1,3 +1,13 @@
+1.4.0 / 2022-12-07
+------------------
+- Добавлена возможность расширенного поиска (раздел "</>"). Поиск не оптимизирован и может сильно нагружать сервер.
+Отключить можно в конфиге, параметр extendedSearch
+- Улучшение поддержки reverse-proxy, в конфиг добавлены параметры server.root и opds.root для встраивания inpx-web в уже существующий веб-сервер
+- В настройки веб-интерфейса добавлена опция "Скачивать книги в виде zip-архива"
+- Исправлен баг "Android-читалки не очень хорошо работают с OPDS" (#4)
+- Добавлена сборка релизов для Linux arm64
+- В readme добавлена ссылка для донатов: [отблагодарить автора проекта](https://donatty.com/liberama)
+
 1.3.3 / 2022-11-28
 ------------------
 

+ 18 - 4
README.md

@@ -19,6 +19,8 @@ OPDS-сервер доступен по адресу [http://127.0.0.1:12380/opd
 Для указания местоположения .inpx-файла или папки с файлами библиотеки, воспользуйтесь [параметрами командной строки](#cli).
 Дополнительные параметры сервера настраиваются в [конфигурационном файле](#config).
 
+[Отблагодарить автора проекта](https://donatty.com/liberama)
+
 ## 
 * [Возможности программы](#capabilities)
 * [Использование](#usage)
@@ -87,6 +89,11 @@ Options:
     // 0 - отключить таймаут, время доступа по паролю не ограничено
     "accessTimeout": 0,
 
+    // включить(true)/выключить(false) возможность расширенного поиска (раздел "</>")
+    // расширенный поиск не оптимизирован, поэтому может сильно нагружать сервер
+    // чтобы ускорить поиск, увеличьте параметр dbCacheSize
+    "extendedSearch": true,
+
     // содержимое кнопки-ссылки "(читать)", если не задано - кнопка "(читать)" не показывается
     // пример: "https://omnireader.ru/#/reader?url=${DOWNLOAD_LINK}"
     // на место ${DOWNLOAD_LINK} будет подставлена ссылка на скачивание файла книги
@@ -99,7 +106,7 @@ Options:
     // если надо кешировать всю БД, можно поставить значение от 1000 и больше
     "dbCacheSize": 5,
 
-    // максимальный размер в байтах директории закешированных файлов в <раб.дир>/public/files
+    // максимальный размер в байтах директории закешированных файлов в <раб.дир>/public-files
     // чистка каждый час
     "maxFilesDirSize": 1073741824,
     
@@ -140,17 +147,23 @@ Options:
     "remoteLib": false,
 
     // настройки веб-сервера
+    // парамертр root указывает путь для кореневой страницы inpx-web
+    // например для "root": "/library", веб-интерфейс будет доступен по адресу http://127.0.0.1:12380/library
+    // root необходим при настройке reverse-proxy и встраивании inpx-web в уже существующий сервер
     "server": {
         "host": "0.0.0.0",
-        "port": "12380"
+        "port": "12380",
+        "root": ""
     },
 
     // настройки opds-сервера
     // user, password используются для Basic HTTP authentication
+    // параметр root задает путь для доступа к opds-серверу
     "opds": {
         "enabled": true,
         "user": "",
-        "password": ""
+        "password": "",
+        "root": "/opds"
     }
 }
 ```
@@ -198,7 +211,6 @@ Options:
 {
   "info": {
     "collection": "Новое название коллекции",
-    "structure": "",
     "version": "1.0.0"
   },
   "filter": "(r) => r.del == 0",
@@ -280,6 +292,8 @@ sudo service nginx reload
 Сборка только в среде Linux.
 Необходима версия node.js не ниже 16.
 
+Для сборки linux-arm64 необходимо предварительно установить [QEMU](https://wiki.debian.org/QemuUserEmulation).
+
 ```sh
 git clone https://github.com/bookpauk/inpx-web
 cd inpx-web

+ 1 - 0
build/appdir.js

@@ -0,0 +1 @@
+module.exports = 'app0b58f8bd9fbfa95504ba';

+ 2 - 2
build/prepkg.js

@@ -12,8 +12,8 @@ const publicDir = `${tmpDir}/public`;
 const outDir = `${distDir}/${platform}`;
 
 async function build() {
-    if (platform != 'linux' && platform != 'win' && platform != 'macos')
-        throw new Error(`Unknown platform: ${platform}`);
+    if (!platform)
+        throw new Error(`Please set platform`);
 
     await fs.emptyDir(outDir);
 

+ 1 - 0
build/release.js

@@ -22,6 +22,7 @@ async function main() {
         await fs.emptyDir(outDir);
         await makeRelease('win');
         await makeRelease('linux');
+        await makeRelease('linux-arm64');
         await makeRelease('macos');
     } catch(e) {
         console.error(e);

+ 3 - 1
build/webpack.base.config.js

@@ -2,6 +2,8 @@ const path = require('path');
 const DefinePlugin = require('webpack').DefinePlugin;
 const { VueLoaderPlugin } = require('vue-loader');
 
+const appdir = require('./appdir');
+
 const clientDir = path.resolve(__dirname, '../client');
 
 module.exports = {
@@ -12,7 +14,7 @@ module.exports = {
     },
     entry: [`${clientDir}/main.js`],
     output: {
-        publicPath: '/app/',
+        publicPath: `/${appdir}/`,
         clean: true
     },
 

+ 1 - 2
build/webpack.dev.config.js

@@ -16,9 +16,8 @@ module.exports = merge(baseWpConfig, {
     mode: 'development',
     devtool: 'inline-source-map',
     output: {
-        path: `${publicDir}/app`,
+        path: `${publicDir}${baseWpConfig.output.publicPath}`,
         filename: 'bundle.js',
-        clean: true
     },
 
     module: {

+ 1 - 2
build/webpack.prod.config.js

@@ -18,9 +18,8 @@ fs.emptyDirSync(publicDir);
 module.exports = merge(baseWpConfig, {
     mode: 'production',
     output: {
-        path: `${publicDir}/app`,
+        path: `${publicDir}${baseWpConfig.output.publicPath}`,
         filename: 'bundle.[contenthash].js',
-        clean: true
     },
     module: {
         rules: [

+ 4 - 0
client/components/Api/Api.vue

@@ -235,6 +235,10 @@ class Api {
         return await this.request({action: 'search', from, query}, 30);
     }
 
+    async bookSearch(query) {
+        return await this.request({action: 'bookSearch', query}, 30);
+    }
+
     async getAuthorBookList(authorId) {
         return await this.request({action: 'get-author-book-list', authorId});
     }

+ 5 - 1
client/components/Api/webSocketConnection.js

@@ -1,3 +1,7 @@
 import WebSocketConnection from '../../../server/core/WebSocketConnection';
 
-export default new WebSocketConnection();
+const protocol = (window.location.protocol == 'https:' ? 'wss:' : 'ws:');
+let url = `${protocol}//${window.location.host}${window.location.pathname}`;
+url += (url[url.length - 1] === '/' ? 'ws' : '/ws');
+
+export default new WebSocketConnection(url);

+ 35 - 17
client/components/Search/BaseList.js

@@ -1,3 +1,4 @@
+import axios from 'axios';
 import dayjs from 'dayjs';
 import _ from 'lodash';
 
@@ -20,13 +21,16 @@ const componentOptions = {
             this.loadSettings();
         },
         search: {
-            handler(newValue) {
-                this.limit = newValue.limit;
-
-                if (this.pageCount > 1)
-                    this.prevPage = this.search.page;
-
-                this.refresh();
+            handler() {
+                if (!this.isExtendedSearch)
+                    this.refresh();
+            },
+            deep: true,
+        },
+        extSearch: {
+            handler() {
+                if (this.isExtendedSearch)
+                    this.refresh();
             },
             deep: true,
         },
@@ -40,6 +44,7 @@ export default class BaseList {
     _props = {
         list: Object,
         search: Object,
+        extSearch: Object,
         genreMap: Object,
     };
     
@@ -50,6 +55,7 @@ export default class BaseList {
     expandedAuthor = [];
     expandedSeries = [];
 
+    downloadAsZip = false;
     showCounts = true;
     showRates = true;
     showGenres = true;    
@@ -66,6 +72,7 @@ export default class BaseList {
     tableData = [];
 
     created() {
+        this.isExtendedSearch = false;
         this.commit = this.$store.commit;
         this.api = this.$root.api;
 
@@ -81,6 +88,7 @@ export default class BaseList {
 
         this.expandedAuthor = _.cloneDeep(settings.expandedAuthor);
         this.expandedSeries = _.cloneDeep(settings.expandedSeries);
+        this.downloadAsZip = settings.downloadAsZip;
         this.showCounts = settings.showCounts;
         this.showRates = settings.showRates;
         this.showGenres = settings.showGenres;
@@ -105,16 +113,19 @@ export default class BaseList {
     }
 
     selectAuthor(author) {
-        this.search.author = `=${author}`;
+        const search = (this.isExtendedSearch ? this.extSearch : this.search);
+        search.author = `=${author}`;
         this.scrollToTop();
     }
 
     selectSeries(series) {
-        this.search.series = `=${series}`;
+        const search = (this.isExtendedSearch ? this.extSearch : this.search);
+        search.series = `=${series}`;
     }
 
     selectTitle(title) {
-        this.search.title = `=${title}`;
+        const search = (this.isExtendedSearch ? this.extSearch : this.search);
+        search.title = `=${title}`;
     }
 
     async download(book, action) {
@@ -133,13 +144,20 @@ export default class BaseList {
             const response = await this.api.getBookLink(book._uid);
             
             const link = response.link;
-            const href = `${window.location.origin}${link}`;
+            let href = `${window.location.origin}${link}`;
+
+            //downloadAsZip
+            if (this.downloadAsZip && (action == 'download' || action == 'copyLink')) {
+                href += '/zip';
+                //подожлем формирования zip-файла
+                await axios.head(href);
+            }
 
+            //action
             if (action == 'download') {
                 //скачивание
                 const d = this.$refs.download;
                 d.href = href;
-                d.download = response.downFileName;
 
                 d.click();
             } else if (action == 'copyLink') {
@@ -506,9 +524,9 @@ export default class BaseList {
     }
 
     getQuery() {
-        let newQuery = _.cloneDeep(this.search);
-        newQuery = newQuery.setDefaults(newQuery);
-        delete newQuery.setDefaults;
+        const search = (this.isExtendedSearch ? this.extSearch : this.search);
+        const newQuery = {};
+        search.setDefaults(newQuery, search);
 
         //дата
         if (newQuery.date) {
@@ -519,8 +537,8 @@ export default class BaseList {
         newQuery.offset = (newQuery.page - 1)*newQuery.limit;
 
         //del
-        if (!this.showDeleted)
-            newQuery.del = 0;
+        if (!newQuery.del && !this.showDeleted)
+            newQuery.del = '0';
 
         return newQuery;
     }

+ 7 - 5
client/components/Search/BookView/BookView.vue

@@ -33,7 +33,7 @@
         </div>
 
         <div class="q-ml-sm column">
-            <div v-if="(mode == 'series' || mode == 'title') && bookAuthor" class="row">
+            <div v-if="(mode == 'series' || mode == 'title' || mode == 'extended') && bookAuthor" class="row">
                 <div class="clickable2 text-green-10" @click.stop.prevent="emit('authorClick')">
                     {{ bookAuthor }}
                 </div>
@@ -46,7 +46,7 @@
                 <div class="clickable2" :class="titleColor" @click.stop.prevent="emit('titleClick')">
                     {{ book.title }}
                 </div>
-                <div v-if="mode == 'title' && bookSeries" class="q-ml-xs clickable2" @click.stop.prevent="emit('seriesClick')">
+                <div v-if="(mode == 'title' || mode == 'extended') && bookSeries" class="q-ml-xs clickable2" @click.stop.prevent="emit('seriesClick')">
                     {{ bookSeries }}
                 </div>
 
@@ -79,10 +79,10 @@
                     {{ bookDate }}
                 </div>
             </div>
-        </div>
 
-        <div v-show="false">
-            {{ book }}
+            <div v-show="showJson && mode == 'extended'">
+                <pre style="font-size: 80%; white-space: pre-wrap;">{{ book }}</pre>
+            </div>
         </div>
     </div>
 </template>
@@ -117,6 +117,7 @@ class BookView {
     showGenres = true;
     showDeleted = false;
     showDates = false;
+    showJson = false;
 
     created() {
         this.loadSettings();
@@ -130,6 +131,7 @@ class BookView {
         this.showGenres = settings.showGenres;
         this.showDates = settings.showDates;
         this.showDeleted = settings.showDeleted;
+        this.showJson = settings.showJson;
     }
 
     get settings() {

+ 128 - 0
client/components/Search/ExtendedList/ExtendedList.vue

@@ -0,0 +1,128 @@
+<template>
+    <div>
+        <a ref="download" style="display: none;"></a>
+
+        <LoadingMessage :message="loadingMessage" z-index="2" />
+        <LoadingMessage :message="loadingMessage2" z-index="1" />
+
+        <!-- Формирование списка ------------------------------------------------------------------------>
+        <div v-for="item in tableData" :key="item.key" class="column" :class="{'odd-item': item.num % 2}" style="font-size: 120%">
+            <BookView
+                class="q-ml-md"
+                :book="item.book" mode="extended" :genre-map="genreMap" :show-read-link="showReadLink" @book-event="bookEvent"
+            />
+        </div>
+        <!-- Формирование списка конец ------------------------------------------------------------------>
+
+        <div v-if="!refreshing && !tableData.length" class="row items-center q-ml-md" style="font-size: 120%">
+            <q-icon class="la la-meh q-mr-xs" size="28px" />
+            Поиск не дал результатов
+        </div>
+    </div>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../vueComponent.js';
+import { reactive } from 'vue';
+
+import BaseList from '../BaseList';
+
+import * as utils from '../../../share/utils';
+
+import _ from 'lodash';
+
+class ExtendedList extends BaseList {
+    created() {
+        super.created();
+        this.isExtendedSearch = true;
+    }
+
+    get foundCountMessage() {
+        return `${this.list.totalFound} ссыл${utils.wordEnding(this.list.totalFound, 5)} на файл(ы)`;
+    }
+
+    async updateTableData() {
+        let result = [];
+
+        const books = this.searchResult.found;
+        if (!books)
+            return;
+
+        let num = 0;
+        for (const book of books) {
+            const item = reactive({
+                num: num++,
+                book,
+            });
+
+            result.push(item);
+        }
+
+        this.tableData = result;
+    }
+
+    async refresh() {
+        //параметры запроса
+        const newQuery = this.getQuery();
+        if (_.isEqual(newQuery, this.prevQuery))
+            return;
+        this.prevQuery = newQuery;
+
+        this.queryExecute = newQuery;
+
+        if (this.refreshing)
+            return;
+
+        this.refreshing = true;
+
+        (async() => {
+            await utils.sleep(500);
+            if (this.refreshing)
+                this.loadingMessage = 'Поиск книг...';
+        })();
+
+        try {
+            while (this.queryExecute) {
+                const query = this.queryExecute;
+                this.queryExecute = null;
+
+                try {
+                    const response = await this.api.bookSearch(query);
+
+                    this.list.queryFound = response.found.length;
+                    this.list.totalFound = response.totalFound;
+                    this.list.inpxHash = response.inpxHash;
+
+                    this.searchResult = response;
+
+                    await utils.sleep(1);
+                    if (!this.queryExecute) {
+                        await this.updateTableData();
+                        this.scrollToTop();
+                        this.highlightPageScroller(query);
+                    }
+                } catch (e) {
+                    this.$root.stdDialog.alert(e.message, 'Ошибка');
+                }
+            }
+        } finally {
+            this.refreshing = false;
+            this.loadingMessage = '';
+        }
+    }
+}
+
+export default vueComponent(ExtendedList);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.clickable2 {
+    cursor: pointer;
+}
+
+.odd-item {
+    background-color: #e8e8e8;
+}
+</style>

+ 254 - 71
client/components/Search/Search.vue

@@ -55,9 +55,9 @@
                         </template>
                     </DivBtn>
                 </div>
-                <div class="row q-mx-md q-mb-xs items-center">
+                <div v-show="!isExtendedSearch" 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
+                        class="text-grey-5 bg-yellow-1 q-mt-xs" :size="30" :icon-size="24" round
                         :icon="(extendedParams ? 'la la-angle-double-up' : 'la la-angle-double-down')"
                         @click.stop.prevent="extendedParams = !extendedParams"
                     >
@@ -110,7 +110,7 @@
                     </q-input>
                     <div class="q-mx-xs" />
                     <DivBtn
-                        class="text-grey-8 bg-yellow-1 q-mt-xs" :size="34" :icon-size="24" round
+                        class="text-grey-8 bg-yellow-1 q-mt-xs" :size="30" :icon-size="24" round
                         icon="la la-level-up-alt"
                         @click.stop.prevent="cloneSearch"
                     >
@@ -121,8 +121,8 @@
                         </template>
                     </DivBtn>
                 </div>
-                <div v-show="extendedParams" class="row q-mx-md q-mb-xs items-center">
-                    <div style="width: 34px" />
+                <div v-show="!isExtendedSearch && extendedParams" class="row q-mx-md q-mb-xs items-center">
+                    <div style="width: 30px" />
                     <div class="q-mx-xs" />
                     <q-input
                         v-model="genreNames" :maxlength="inputMaxLength" :debounce="inputDebounce"
@@ -184,9 +184,87 @@
                         </q-tooltip>
                     </q-input>
                 </div>
-                <div v-show="!extendedParams && extendedParamsMessage" class="row q-mx-md items-center clickable" @click.stop.prevent="extendedParams = true">
+                <div v-show="!isExtendedSearch && !extendedParams && extendedParamsMessage" class="row q-mx-md items-center clickable" @click.stop.prevent="extendedParams = true">
                     +{{ extendedParamsMessage }}
                 </div>
+
+                <div v-show="isExtendedSearch" class="row q-mx-md q-mb-xs items-center">
+                    <q-input
+                        v-model="extSearchNames"
+                        class="col q-mt-xs" :bg-color="inputBgColor('extended')" input-style="cursor: pointer"
+                        style="min-width: 200px; max-width: 638px;" label="Расширенный поиск" stack-label outlined dense clearable readonly
+                        @click.stop.prevent="selectExtSearch"
+                    >
+                        <template v-if="extSearchNames" #append>
+                            <q-icon name="la la-times-circle" class="q-field__focusable-action" @click.stop.prevent="clearExtSearch" />
+                        </template>
+
+                        <q-tooltip v-if="extSearchNames && showTooltips" :delay="500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
+                            {{ extSearchNames }}
+                        </q-tooltip>
+                    </q-input>
+
+                    <div class="q-mx-xs" />
+                    <DivBtn
+                        class="text-grey-8 bg-yellow-1 q-mt-xs" :size="30" round
+                        :disabled="!extSearch.author"
+                        @me-click="extToList('author')"
+                    >
+                        <div style="font-size: 130%">
+                            <b>А</b>
+                        </div>
+                        <template #tooltip>
+                            <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
+                                В раздел "Авторы" с переносом значения author={{ extSearch.author }}
+                            </q-tooltip>
+                        </template>
+                    </DivBtn>
+
+                    <div class="q-mx-xs" />
+                    <DivBtn
+                        class="text-grey-8 bg-yellow-1 q-mt-xs" :size="30" round
+                        :disabled="!extSearch.series"
+                        @me-click="extToList('series')"
+                    >
+                        <div style="font-size: 130%">
+                            <b>С</b>
+                        </div>
+                        <template #tooltip>
+                            <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
+                                В раздел "Серии" с переносом значения series={{ extSearch.series }}
+                            </q-tooltip>
+                        </template>
+                    </DivBtn>
+
+                    <div class="q-mx-xs" />
+                    <DivBtn
+                        class="text-grey-8 bg-yellow-1 q-mt-xs" :size="30" round
+                        :disabled="!extSearch.title"
+                        @me-click="extToList('title')"
+                    >
+                        <div style="font-size: 130%">
+                            <b>К</b>
+                        </div>
+                        <template #tooltip>
+                            <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
+                                В раздел "Книги" с переносом значения title={{ extSearch.title }}
+                            </q-tooltip>
+                        </template>
+                    </DivBtn>
+
+                    <div class="q-mx-xs" />
+                    <DivBtn
+                        class="text-grey-8 bg-yellow-1 q-mt-xs" :size="30" :icon-size="24" round
+                        icon="la la-level-up-alt"
+                        @click.stop.prevent="cloneSearch"
+                    >
+                        <template #tooltip>
+                            <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
+                                Клонировать поиск
+                            </q-tooltip>
+                        </template>
+                    </DivBtn>
+                </div>
             </div>
 
             <div class="row items-center q-ml-lg q-mt-sm">
@@ -197,12 +275,16 @@
                 <div v-show="list.totalFound > 0" class="text-bold" style="font-size: 120%; padding-bottom: 2px">
                     {{ foundCountMessage }}
                 </div>
+
+                <div v-show="list.totalFound > 0 && isExtendedSearch" class="q-ml-md">
+                    <q-checkbox v-model="showJson" size="36px" label="Показывать JSON" />
+                </div>
             </div>
 
             <!-- Формирование списка ------------------------------------------------------------------------>
             <div v-if="selectedListComponent">
                 <div class="separator" />
-                <component :is="selectedListComponent" ref="list" :list="list" :search="search" :genre-map="genreMap" @list-event="listEvent" />
+                <component :is="selectedListComponent" ref="list" :list="list" :search="search" :ext-search="extSearch" :genre-map="genreMap" @list-event="listEvent" />
                 <div class="separator" />
             </div>
             <!-- Формирование списка конец ------------------------------------------------------------------>
@@ -224,6 +306,7 @@
         <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" />
+        <SelectExtSearchDialog v-model="selectExtSearchDialogVisible" v-model:ext-search="extSearch" />        
     </div>
 </template>
 
@@ -234,6 +317,7 @@ import vueComponent from '../vueComponent.js';
 import AuthorList from './AuthorList/AuthorList.vue';
 import SeriesList from './SeriesList/SeriesList.vue';
 import TitleList from './TitleList/TitleList.vue';
+import ExtendedList from './ExtendedList/ExtendedList.vue';
 
 import PageScroller from './PageScroller/PageScroller.vue';
 import SettingsDialog from './SettingsDialog/SettingsDialog.vue';
@@ -242,6 +326,7 @@ 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 SelectExtSearchDialog from './SelectExtSearchDialog/SelectExtSearchDialog.vue';
 
 import authorBooksStorage from './authorBooksStorage';
 import DivBtn from '../share/DivBtn.vue';
@@ -252,10 +337,13 @@ import diffUtils from '../../share/diffUtils';
 
 import _ from 'lodash';
 
+const maxLimit = 1000;
+
 const route2component = {
     'author': {component: 'AuthorList', label: 'Авторы'},
     'series': {component: 'SeriesList', label: 'Серии'},
     'title': {component: 'TitleList', label: 'Книги'},
+    'extended': {component: 'ExtendedList', label: 'Расширенный поиск'},
 };
 
 const componentOptions = {
@@ -263,6 +351,7 @@ const componentOptions = {
         AuthorList,
         SeriesList,
         TitleList,
+        ExtendedList,
         PageScroller,
         SettingsDialog,
         SelectGenreDialog,
@@ -270,6 +359,7 @@ const componentOptions = {
         SelectLibRateDialog,
         SelectDateDialog,
         BookInfoDialog,
+        SelectExtSearchDialog,
         Dialog,
         DivBtn
     },
@@ -292,6 +382,19 @@ const componentOptions = {
                 this.makeTitle();
                 this.updateRouteQueryFromSearch();
                 this.updateSearchDate(true);
+
+                //extSearch
+                if (this.isExtendedSearch) {
+                    this.extSearch.page = newValue.page;
+                    this.extSearch.limit = newValue.limit;
+                }
+            },
+            deep: true,
+        },
+        extSearch: {
+            handler() {
+                this.makeTitle();
+                this.updateRouteQueryFromSearch();
             },
             deep: true,
         },
@@ -310,6 +413,9 @@ const componentOptions = {
         langDefault() {
             this.updateSearchFromRouteQuery(this.$route);
         },
+        showJson(newValue) {
+            this.setSetting('showJson', newValue);
+        },
         list: {
             handler(newValue) {
                 this.updateGenreTreeIfNeeded();
@@ -337,6 +443,8 @@ const componentOptions = {
             if (this.getListRoute() != newValue) {
                 this.updateRouteQueryFromSearch();
             }
+
+            this.makeTitle();
         },
         searchDate() {
             this.updateSearchDate(false);
@@ -362,6 +470,7 @@ class Search {
     selectLibRateDialogVisible = false;
     selectDateDialogVisible = false;
     bookInfoDialogVisible = false;
+    selectExtSearchDialogVisible = false;
 
     pageCount = 1;    
 
@@ -370,21 +479,8 @@ class Search {
     inputDebounce = 200;
 
     //search fields
-    search = {
-        setDefaults(search) {
-            return Object.assign({}, search, {
-                author: search.author || '',
-                series: search.series || '',
-                title: search.title || '',
-                genre: search.genre || '',
-                lang: search.lang || '',
-                date: search.date || '',
-                librate: search.librate || '',
-                page: search.page || 1,
-                limit: search.limit || 50,
-            });
-        },
-    };
+    search = {};
+    extSearch = {};
 
     searchDate = '';
     prevManualDate = '';
@@ -394,6 +490,7 @@ class Search {
     langDefault = '';
     limit = 20;
     extendedParams = false;
+    showJson = false;
 
     //stuff
     prevList = {};
@@ -423,12 +520,22 @@ class Search {
         {label: 'выбрать даты', value: 'manual'},
     ];
 
+    generateDefaults(obj, fields) {
+        obj.setDefaults = (self, value = {}) => {
+            for (const f of fields)
+                self[f] = value[f] || '';
+
+            self.page = value.page || 1;
+            self.limit = value.limit || 50;
+        };
+    }
+
     created() {
         this.commit = this.$store.commit;
         this.api = this.$root.api;
 
-        this.search = this.search.setDefaults(this.search);
-        this.search.lang = this.langDefault;
+        this.generateDefaults(this.search, ['author', 'series', 'title', 'genre', 'lang', 'date', 'librate']);
+        this.search.setDefaults(this.search);
 
         this.loadSettings();
     }
@@ -437,6 +544,10 @@ class Search {
         (async() => {
             await this.api.updateConfig();
 
+            this.generateDefaults(this.extSearch, this.recStruct.map(f => f.field));
+            this.extSearch.setDefaults(this.extSearch);
+            this.search.lang = this.langDefault;
+
             //для встраивания в liberama
             window.addEventListener('message', (event) => {
                 if (!_.isObject(event.data) || event.data.from != 'ExternalLibs')
@@ -454,11 +565,11 @@ class Search {
                 this.$refs.authorInput.focus();
 
             this.updateListFromRoute(this.$route);
-            this.updateSearchFromRouteQuery(this.$route);
-
-            this.sendMessage({type: 'mes', data: 'hello-from-inpx-web'});
 
             this.ready = true;
+
+            this.sendMessage({type: 'mes', data: 'hello-from-inpx-web'});
+            this.updateSearchFromRouteQuery(this.$route);
         })();
     }
 
@@ -472,6 +583,7 @@ class Search {
         this.expandedSeries = _.cloneDeep(settings.expandedSeries);
         this.abCacheEnabled = settings.abCacheEnabled;
         this.langDefault = settings.langDefault;
+        this.showJson = settings.showJson;
     }
 
     recvMessage(d) {
@@ -498,6 +610,13 @@ class Search {
         return this.$store.state.config;
     }
 
+    get recStruct() {
+        if (this.config.dbConfig && this.config.dbConfig.inpxInfo.recStruct)
+            return this.config.dbConfig.inpxInfo.recStruct;
+        else
+            return [];
+    }
+
     get settings() {
         return this.$store.state.settings;
     }
@@ -529,7 +648,13 @@ class Search {
     get listOptions() {
         const result = [];
         for (const [route, rec] of Object.entries(route2component))
-            result.push({label: rec.label, value: route});
+            if (route == 'extended') {
+                if (this.config.extendedSearch) {
+                    result.push({value: route, icon: 'la la-code', size: '10px'});
+                }
+            } else {
+                result.push({label: rec.label, value: route, icon: rec.icon});
+            }
         return result;
     }
 
@@ -543,6 +668,19 @@ class Search {
         return result.filter(s => s).join(', ');
     }
 
+    get isExtendedSearch() {
+        return this.selectedList === 'extended';
+    }
+
+    get extSearchNames() {
+        let result = [];
+        for (const f of this.recStruct) {
+            if (this.extSearch[f.field])
+                result.push(`${f.field}=${this.extSearch[f.field]}`);
+        }
+        return result.join(', ');
+    }
+
     inputBgColor(inp) {
         if (inp === this.selectedList)
             return 'white';
@@ -552,8 +690,12 @@ class Search {
 
     async updateListFromRoute(to) {
         const newPath = to.path;
+
         let newList = this.getListRoute(newPath);
+        if (newList == 'extended' && !this.config.extendedSearch)
+            newList = '';
         newList = (newList ? newList : 'author');
+
         if (this.selectedList != newList)
             this.selectedList = newList;
     }
@@ -582,30 +724,35 @@ class Search {
 
         let result = `Коллекция ${this.collection}`;
 
-        const search = this.search;
-        const specSym = new Set(['*', '#']);
-        const correctValue = (v) => {
-            if (v) {
-                if (v[0] === '=')
-                    v = v.substring(1);
-                else if (!specSym.has(v[0]))
-                    v = '^' + v;
-            }
-            return v || '';
-        };
+        if (!this.isExtendedSearch) {
+            const search = this.search;
+            const specSym = new Set(['*', '#']);
+            const correctValue = (v) => {
+                if (v) {
+                    if (v[0] === '=')
+                        v = v.substring(1);
+                    else if (!specSym.has(v[0]))
+                        v = '^' + v;
+                }
+                return v || '';
+            };
 
-        if (search.author || search.series || search.title) {
-            const as = (search.author ? search.author.split(',') : []);
-            const author = (as.length ? as[0] : '') + (as.length > 1 ? ' и др.' : '');
+            if (search.author || search.series || search.title) {
+                const as = (search.author ? search.author.split(',') : []);
+                const author = (as.length ? as[0] : '') + (as.length > 1 ? ' и др.' : '');
 
-            const a = correctValue(author);
-            let s = correctValue(search.series);
-            s = (s ? `(Серия: ${s})` : '');
-            let t = correctValue(search.title);
-            t = (t ? `"${t}"` : '');
+                const a = correctValue(author);
+                let s = correctValue(search.series);
+                s = (s ? `(Серия: ${s})` : '');
+                let t = correctValue(search.title);
+                t = (t ? `"${t}"` : '');
 
-            result = [s, t].filter(v => v).join(' ');
-            result = [a, result].filter(v => v).join(' ');
+                result = [s, t].filter(v => v).join(' ');
+                result = [a, result].filter(v => v).join(' ');
+            }
+        } else {
+            if (this.extSearchNames)
+                result = this.extSearchNames;
         }
 
         this.$root.setAppTitle(result);
@@ -614,8 +761,7 @@ class Search {
     }
 
     showSearchHelp() {
-        let info = '';  
-        info += `<div style="min-width: 250px" />`;
+        let info = `<div style="min-width: 250px" />`;
         info += `
 <p>
     Для раздела <b>Авторы</b>, работу поискового движка можно описать простой фразой: найти авторов по указанным критериям.
@@ -654,7 +800,7 @@ class Search {
     <br><br>
     Для разделов <b>Серии</b>, <b>Книги</b> все аналогично разделу <b>Авторы</b>.
 </p>
-`;        
+`;
 
         this.$root.stdDialog.alert(info, 'Памятка', {iconName: 'la la-info-circle'});
     }
@@ -743,6 +889,16 @@ class Search {
         this.hideTooltip();
         this.selectLibRateDialogVisible = true;
     }
+
+    selectExtSearch() {
+        this.hideTooltip();
+        this.selectExtSearchDialogVisible = true;
+    }
+
+    clearExtSearch() {
+        const self = this.extSearch;
+        self.setDefaults(self, {page: self.page, limit: self.limit});
+    }
     
     onScroll() {
         const curScrollTop = this.$refs.scroller.scrollTop;
@@ -847,6 +1003,8 @@ class Search {
     }
 
     updateSearchFromRouteQuery(to) {
+        if (!this.ready)
+            return;
         if (this.list.liberamaReady)
             this.sendCurrentUrl();
 
@@ -855,22 +1013,34 @@ class Search {
 
         const query = to.query;
 
-        this.search = this.search.setDefaults(
-            Object.assign({}, this.search, {
-                author: query.author,
-                series: query.series,
-                title: query.title,
-                genre: query.genre,
-                lang: (typeof(query.lang) == 'string' ? query.lang : this.langDefault),
-                date: query.date,
-                librate: query.librate,
-                page: parseInt(query.page, 10),
-                limit: parseInt(query.limit, 10) || this.search.limit,
-            })
-        );
-
-        if (this.search.limit > 1000)
-            this.search.limit = 1000;
+        this.search.setDefaults(this.search, {
+            author: query.author,
+            series: query.series,
+            title: query.title,
+            genre: query.genre,
+            lang: (typeof(query.lang) == 'string' ? query.lang : this.langDefault),
+            date: query.date,
+            librate: query.librate,
+
+            page: parseInt(query.page, 10),
+            limit: parseInt(query.limit, 10) || this.search.limit,
+        });
+
+        if (this.search.limit > maxLimit)
+            this.search.limit = maxLimit;
+
+        const queryExtSearch = {
+            page: this.search.page,
+            limit: this.search.limit,
+        };
+
+        for (const f of this.recStruct) {
+            const field = `ex_${f.field}`;
+            if (query[field])
+                queryExtSearch[f.field] = query[field];
+        }
+
+        this.extSearch.setDefaults(this.extSearch, queryExtSearch);
     }
 
     updateRouteQueryFromSearch() {
@@ -880,11 +1050,12 @@ class Search {
         this.routeUpdating = true;
         try {
             const oldQuery = this.$route.query;
-            const cloned = _.cloneDeep(this.search);
+            let query = {};
 
-            delete cloned.setDefaults;
+            const cloned = {};
+            this.search.setDefaults(cloned, this.search);
 
-            const query = _.pickBy(cloned);
+            query = _.pickBy(cloned);
 
             if (this.search.lang == this.langDefault) {
                 delete query.lang;
@@ -892,6 +1063,12 @@ class Search {
                 query.lang = this.search.lang;
             }
 
+            for (const f of this.recStruct) {
+                const field = `ex_${f.field}`;
+                if (this.extSearch[f.field])
+                    query[field] = this.extSearch[f.field];
+            }
+
             const diff = diffUtils.getObjDiff(oldQuery, query);
             if (!diffUtils.isEmptyObjDiff(diff)) {
                 this.$router.replace({path: this.selectedList, query});
@@ -990,6 +1167,12 @@ class Search {
         window.open(window.location.href, '_blank');
     }
 
+    extToList(list) {
+        if (this.extSearch[list])
+            this.search[list] = this.extSearch[list];
+        this.selectedList = list;
+    }
+
     async logout() {
         await this.api.logout();
     }

+ 202 - 0
client/components/Search/SelectExtSearchDialog/SelectExtSearchDialog.vue

@@ -0,0 +1,202 @@
+<template>
+    <Dialog ref="dialog" v-model="dialogVisible">
+        <template #header>
+            <div class="row items-center">
+                <div style="font-size: 110%">
+                    Расширенный поиск
+                </div>
+
+                <DivBtn class="q-ml-md text-white bg-secondary" :size="30" :icon-size="24" icon="la la-question" round @click.stop.prevent="showSearchHelp">
+                    <template #tooltip>
+                        <q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
+                            Памятка
+                        </q-tooltip>
+                    </template>
+                </DivBtn>
+            </div>
+        </template>
+
+        <div ref="box" class="column q-mt-xs overflow-auto" style="max-width: 660px; padding: 0px 10px 10px 10px;">
+            <div class="row">
+                <div v-for="f in recStruct" :key="f.field" class="row">
+                    <div class="q-mx-xs" />
+                    <q-input
+                        v-model="search[f.field]" :maxlength="5000"
+                        class="q-mt-xs" style="width: 150px;" :label="`(${f.type}) ${f.field}`"
+                        :bg-color="bgColor[f.field] || 'white'"
+                        stack-label outlined dense clearable
+                        @keydown="onKeyDown"
+                    >
+                        <q-tooltip v-if="search[f.field]" :delay="500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
+                            {{ search[f.field] }}
+                        </q-tooltip>
+                    </q-input>
+                </div>
+            </div>
+            <div class="row q-mt-xs q-ml-sm" style="color: red" v-html="error" />
+        </div>
+
+        <template #footer>
+            <q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps :disabled="error !== ''" @click="apply">
+                Применить
+            </q-btn>
+        </template>
+    </Dialog>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../vueComponent.js';
+
+import Dialog from '../../share/Dialog.vue';
+import DivBtn from '../../share/DivBtn.vue';
+
+import _ from 'lodash';
+
+const componentOptions = {
+    components: {
+        Dialog,
+        DivBtn,
+    },
+    watch: {
+        modelValue(newValue) {
+            this.dialogVisible = newValue;
+        },
+        dialogVisible(newValue) {
+            this.$emit('update:modelValue', newValue);
+        },
+        extSearch: {
+            handler(newValue) {
+                this.search = _.cloneDeep(newValue);
+            },
+            deep: true,
+        },
+        search: {
+            handler() {
+                this.validate();
+            },
+            deep: true,
+        },
+    }
+};
+class SelectExtSearchDialog {
+    _options = componentOptions;
+    _props = {
+        modelValue: Boolean,
+        extSearch: Object,
+    };
+
+    dialogVisible = false;
+    search = {};
+    bgColor = {};
+    error = '';
+
+    created() {
+        this.commit = this.$store.commit;
+    }
+
+    mounted() {
+    }
+
+    get config() {
+        return this.$store.state.config;
+    }
+
+    get recStruct() {
+        if (this.config.dbConfig && this.config.dbConfig.inpxInfo.recStruct)
+            return this.config.dbConfig.inpxInfo.recStruct;
+        else
+            return [];
+    }
+
+    validate() {
+        const validNumValue = (n) => {
+            const validChars = new Set('0123456789.'.split(''));
+            for (const c of n.split(''))
+                if (!validChars.has(c))
+                    return false;
+
+            const v = n.split('..');
+            if ( isNaN(parseInt(v[0] || '0', 10)) || isNaN(parseInt(v[1] || '0', 10)) )
+                return false;
+
+            return true;
+        };
+
+        let error = [];
+        const s = this.search;
+        for (const f of this.recStruct) {
+            if (f.type == 'N' && s[f.field] && !validNumValue(s[f.field])) {
+                error.push(`Недопустимое значение поля ${f.field}`);
+                this.bgColor[f.field] = 'red-2';
+            } else {
+                this.bgColor[f.field] = '';//default
+            }
+        }
+
+        this.error = error.join('<br>');
+    }
+
+    showSearchHelp() {
+        let info = `<div style="min-width: 250px" />`;
+        info += `
+<p>
+    Расширенный поиск ведется непосредственно по значениям атрибутов записей описания книг.
+    Атрибуты можно увидеть, если включить опцию "Показывать JSON".
+    Названия атрибутов (кроме "_uid" и "id") соответствуют названиям полей струкутры записей из inpx-файла.
+    На поисковые значения действуют те же правила, что и для разделов "Авторы", "Серии", "Книги".
+    <br>
+    Для строковых значений (S):
+    <ul>
+        <li>
+            без префикса: значение трактуется, как "начинается с"
+        </li>
+        <li>
+            префикс "=": поиск по точному совпадению
+        </li>
+        <li>
+            префикс "*": поиск подстроки в строке
+        </li>
+        <li>
+            префикс "#": поиск подстроки в строке, но только среди начинающихся не с латинского или кириллического символа
+        </li>
+        <li>
+            префикс "?": поиск пустых значений или тех, что начинаются с этого символа
+        </li>
+    </ul>
+    Для числовых значений (N):
+    <ul>
+        <li>
+            число N: поиск по точному совпадению
+        </li>
+        <li>
+            диапазон N..M: поиск по диапазону числовых значений, включая N и M. Например, поисковое значение 1024..2048 в поле "size"
+            найдет все ссылки на файлы размером от 1КБ до 2КБ.
+        </li>
+    </ul>
+</p>
+`;
+
+        this.$root.stdDialog.alert(info, 'Памятка', {iconName: 'la la-info-circle'});
+    }
+
+    onKeyDown(event) {
+        if (event.code == 'Enter')
+            this.apply();
+    }
+
+    apply() {
+        this.validate();
+        if (!this.error) {
+            this.$emit('update:extSearch', _.cloneDeep(this.search));
+            this.dialogVisible = false;
+        }
+    }
+}
+
+export default vueComponent(SelectExtSearchDialog);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+</style>

+ 7 - 1
client/components/Search/SettingsDialog/SettingsDialog.vue

@@ -19,7 +19,8 @@
                 />
             </div>
 
-            <q-checkbox v-model="showCounts" size="36px" label="Показывать количество" />                
+            <q-checkbox v-model="downloadAsZip" size="36px" label="Скачивать книги в виде zip-архива" />
+            <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="Показывать жанры" />
@@ -60,6 +61,9 @@ const componentOptions = {
         limit(newValue) {
             this.commit('setSettings', {'limit': newValue});
         },
+        downloadAsZip(newValue) {
+            this.commit('setSettings', {'downloadAsZip': newValue});
+        },
         showCounts(newValue) {
             this.commit('setSettings', {'showCounts': newValue});
         },
@@ -93,6 +97,7 @@ class SettingsDialog {
 
     //settings
     limit = 20;
+    downloadAsZip = false;
     showCounts = true;
     showRates = true;
     showInfo = true;
@@ -129,6 +134,7 @@ class SettingsDialog {
 
         this.limit = settings.limit;
 
+        this.downloadAsZip = settings.downloadAsZip;
         this.showCounts = settings.showCounts;
         this.showRates = settings.showRates;
         this.showInfo = settings.showInfo;

+ 9 - 3
client/components/share/DivBtn.vue

@@ -1,5 +1,5 @@
 <template>
-    <div ref="btn" class="button clickable row justify-center items-center" @click="clickEffect">
+    <div ref="btn" class="button clickable row justify-center items-center" :class="{disabled}" @click.stop.prevent="clickEffect">
         <div class="row justify-center items-center no-wrap" :class="{'button-pressed': pressed}">
             <i :class="icon" :style="`font-size: ${iconSize}px; margin-top: ${imt}px`" />
             <slot></slot>
@@ -29,8 +29,9 @@ class DivBtn {
         height: { type: Number, default: 0 },
         icon: { type: String, default: '' },
         iconSize: { type: Number, default: 14 },
-        round: { type: Boolean },
+        round: Boolean,
         imt:  { type: Number, default: 0 },// icon margin top
+        disabled: Boolean,
     };
 
     pressed = false;
@@ -57,7 +58,12 @@ class DivBtn {
             style.borderRadius = `${this.size/10}px`;
     }
 
-    async clickEffect() {
+    async clickEffect(event) {
+        if (this.disabled) {
+            return;
+        }
+
+        this.$emit('meClick', event);
         this.pressed = true;
         await utils.sleep(100);
         this.pressed = false;

+ 4 - 2
client/components/share/NumInput.vue

@@ -74,7 +74,8 @@ const componentOptions = {
             this.checkErrorAndEmit(true);
         },
         modelValue(newValue) {
-            this.filteredValue = newValue;
+            if (this.ready)//исправление бага TypeError: Cannot read properties of null (reading 'emitsOptions')
+                this.filteredValue = newValue;
         },
         min() {
             this.checkErrorAndEmit();
@@ -102,7 +103,8 @@ class NumInput {
     filteredValue = 0;
     error = false;
 
-    created() {
+    mounted() {
+        this.ready = true;
         this.filteredValue = this.modelValue;
     }
 

+ 1 - 0
client/router.js

@@ -8,6 +8,7 @@ const myRoutes = [
     ['/author', Search],
     ['/series', Search],
     ['/title', Search],
+    ['/extended', Search],
     ['/:pathMatch(.*)*', null, null, '/'],
 ];
 

+ 8 - 8
client/share/utils.js

@@ -36,14 +36,14 @@ export function keyEventToCode(event) {
 
 export function wordEnding(num, type = 0) {
     const endings = [
-        ['ов', '', 'а', 'а', 'а', 'ов', 'ов', 'ов', 'ов', 'ов'],
-        ['й', 'я', 'и', 'и', 'и', 'й', 'й', 'й', 'й', 'й'],
-        ['о', '', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],
-        ['ий', 'ие', 'ия', 'ия', 'ия', 'ий', 'ий', 'ий', 'ий', 'ий'],
-        ['о', 'а', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],
-        ['ок', 'ка', 'ки', 'ки', 'ки', 'ок', 'ок', 'ок', 'ок', 'ок'],
-        ['ых', 'ое', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых'],
-        ['о', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],
+        ['ов', '', 'а', 'а', 'а', 'ов', 'ов', 'ов', 'ов', 'ов'],//0
+        ['й', 'я', 'и', 'и', 'и', 'й', 'й', 'й', 'й', 'й'],//1
+        ['о', '', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],//2
+        ['ий', 'ие', 'ия', 'ия', 'ия', 'ий', 'ий', 'ий', 'ий', 'ий'],//3
+        ['о', 'а', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],//4
+        ['ок', 'ка', 'ки', 'ки', 'ки', 'ок', 'ок', 'ок', 'ок', 'ок'],//5
+        ['ых', 'ое', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых'],//6
+        ['о', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],//7
     ];
     const deci = num % 100;
     if (deci > 10 && deci < 20) {

+ 2 - 0
client/store/root.js

@@ -7,6 +7,7 @@ const state = {
         limit: 20,
         expandedAuthor: [],
         expandedSeries: [],
+        downloadAsZip: false,
         showCounts: true,
         showRates: true,
         showInfo: true,
@@ -15,6 +16,7 @@ const state = {
         showDeleted: false,
         abCacheEnabled: true,
         langDefault: '',
+        showJson: false,
     },    
 };
 

File diff suppressed because it is too large
+ 261 - 256
package-lock.json


+ 30 - 28
package.json

@@ -1,6 +1,6 @@
 {
   "name": "inpx-web",
-  "version": "1.3.3",
+  "version": "1.4.0",
   "author": "Book Pauk <bookpauk@gmail.com>",
   "license": "CC0-1.0",
   "repository": "bookpauk/inpx-web",
@@ -11,10 +11,11 @@
     "dev": "nodemon --inspect --ignore server/.inpx-web --ignore client --exec 'node --expose-gc server --lib-dir=server/.inpx-web/lib'",
     "build:client": "webpack --config build/webpack.prod.config.js",
     "build:linux": "npm run build:client && node build/prepkg.js linux && pkg -t node16-linux-x64 -C GZip --options max-old-space-size=4096,expose-gc -o dist/linux/inpx-web .",
+    "build:linux-arm64": "npm run build:client && node build/prepkg.js linux-arm64 && pkg -t node16-linuxstatic-arm64 -C GZip --options max-old-space-size=4096,expose-gc -o dist/linux-arm64/inpx-web .",
     "build:win": "npm run build:client && node build/prepkg.js win && pkg -t node16-win-x64 -C GZip --options max-old-space-size=4096,expose-gc -o dist/win/inpx-web .",
     "build:macos": "npm run build:client && node build/prepkg.js macos && pkg -t node16-macos-x64 -C GZip --options max-old-space-size=4096,expose-gc -o dist/macos/inpx-web .",
     "build:client-dev": "webpack --config build/webpack.dev.config.js",
-    "build:all": "npm run build:linux && npm run build:win && npm run build:macos",
+    "build:all": "npm run build:linux && npm run build:win && npm run build:macos && npm run build:linux-arm64",
     "release": "npm run build:all && node build/release.js",
     "postinstall": "npm run build:client-dev"
   },
@@ -24,53 +25,54 @@
     "assets": "dist/public.json"
   },
   "devDependencies": {
-    "@babel/core": "^7.18.9",
-    "@babel/eslint-parser": "^7.18.9",
-    "@babel/eslint-plugin": "^7.17.7",
-    "@babel/plugin-proposal-decorators": "^7.18.9",
-    "@babel/preset-env": "^7.18.9",
+    "@babel/core": "^7.20.5",
+    "@babel/eslint-parser": "^7.19.1",
+    "@babel/eslint-plugin": "^7.19.1",
+    "@babel/plugin-proposal-decorators": "^7.20.5",
+    "@babel/preset-env": "^7.20.2",
     "@vue/compiler-sfc": "^3.2.22",
-    "babel-loader": "^8.2.5",
+    "babel-loader": "^9.1.0",
     "copy-webpack-plugin": "^11.0.0",
-    "css-loader": "^6.7.1",
-    "css-minimizer-webpack-plugin": "^4.0.0",
-    "eslint": "^8.20.0",
-    "eslint-plugin-vue": "^9.3.0",
+    "css-loader": "^6.7.2",
+    "css-minimizer-webpack-plugin": "^4.2.2",
+    "eslint": "^8.28.0",
+    "eslint-plugin-vue": "^9.8.0",
     "html-webpack-plugin": "^5.5.0",
-    "mini-css-extract-plugin": "^2.6.1",
+    "mini-css-extract-plugin": "^2.7.1",
     "pkg": "^5.8.0",
     "showdown": "^2.1.0",
-    "terser-webpack-plugin": "^5.3.3",
-    "vue-eslint-parser": "^9.0.3",
-    "vue-loader": "^17.0.0",
+    "terser-webpack-plugin": "^5.3.6",
+    "vue-eslint-parser": "^9.1.0",
+    "vue-loader": "^17.0.1",
     "vue-style-loader": "^4.1.3",
-    "webpack": "^5.74.0",
-    "webpack-cli": "^4.10.0",
-    "webpack-dev-middleware": "^5.3.3",
-    "webpack-hot-middleware": "^2.25.1",
+    "webpack": "^5.75.0",
+    "webpack-cli": "^5.0.0",
+    "webpack-dev-middleware": "^6.0.1",
+    "webpack-hot-middleware": "^2.25.3",
     "webpack-merge": "^5.8.0"
   },
   "dependencies": {
-    "@quasar/extras": "^1.15.0",
+    "@quasar/extras": "^1.15.6",
     "axios": "^0.27.2",
     "chardet": "^1.5.0",
     "dayjs": "^1.11.6",
-    "express": "^4.18.1",
+    "express": "^4.18.2",
     "express-basic-auth": "^1.2.1",
     "fs-extra": "^10.1.0",
     "he": "^1.2.0",
     "iconv-lite": "^0.6.3",
-    "jembadb": "^5.1.4",
+    "jembadb": "^5.1.5",
     "localforage": "^1.10.0",
     "lodash": "^4.17.21",
-    "minimist": "^1.2.6",
+    "minimist": "^1.2.7",
     "node-stream-zip": "^1.15.0",
-    "quasar": "^2.7.5",
+    "quasar": "^2.10.2",
     "safe-buffer": "^5.2.1",
     "vue": "^3.2.37",
-    "vue-router": "^4.1.2",
-    "vuex": "^4.0.2",
+    "vue-router": "^4.1.6",
+    "vuex": "^4.1.0",
     "vuex-persist": "^3.1.3",
-    "ws": "^8.8.1"
+    "ws": "^8.11.0",
+    "yazl": "^2.5.1"
   }
 }

+ 5 - 2
server/config/base.js

@@ -12,12 +12,13 @@ module.exports = {
 
     accessPassword: '',
     accessTimeout: 0,
+    extendedSearch: true,
     bookReadLink: '',
     loggingEnabled: true,
 
     //поправить в случае, если были критические изменения в DbCreator или InpxParser
     //иначе будет рассинхронизация между сервером и клиентом на уровне БД
-    dbVersion: '8',
+    dbVersion: '10',
     dbCacheSize: 5,
 
     maxPayloadSize: 500,//in MB
@@ -30,7 +31,7 @@ module.exports = {
     lowMemoryMode: false,
     fullOptimization: false,
 
-    webConfigParams: ['name', 'version', 'branch', 'bookReadLink', 'dbVersion'],
+    webConfigParams: ['name', 'version', 'branch', 'bookReadLink', 'dbVersion', 'extendedSearch'],
 
     allowRemoteLib: false,
     remoteLib: false,
@@ -45,12 +46,14 @@ module.exports = {
     server: {
         host: '0.0.0.0',
         port: '22380',
+        root: '',
     },
     //opds: false,
     opds: {
         enabled: true,
         user: '',
         password: '',
+        root: '/opds',
     },
 };
 

+ 1 - 0
server/config/index.js

@@ -7,6 +7,7 @@ const branchFilename = __dirname + '/application_env';
 const propsToSave = [
     'accessPassword',
     'accessTimeout',
+    'extendedSearch',
     'bookReadLink',
     'loggingEnabled',
     'dbCacheSize',

+ 1 - 0
server/config/production.js

@@ -11,6 +11,7 @@ module.exports = Object.assign({}, base, {
     server: {
         host: '0.0.0.0',
         port: '12380',
+        root: '',
     },
 
 });

+ 13 - 0
server/controllers/WebSocketController.js

@@ -87,6 +87,8 @@ class WebSocketController {
                     await this.getWorkerState(req, ws); break;
                 case 'search':
                     await this.search(req, ws); break;
+                case 'bookSearch':
+                    await this.bookSearch(req, ws); break;
                 case 'get-author-book-list':
                     await this.getAuthorBookList(req, ws); break;
                 case 'get-author-series-list':
@@ -165,6 +167,17 @@ class WebSocketController {
         this.send(result, req, ws);
     }
 
+    async bookSearch(req, ws) {
+        if (!this.config.extendedSearch)
+            throw new Error('config.extendedSearch disabled');
+        if (!req.query)
+            throw new Error(`query is empty`);
+
+        const result = await this.webWorker.bookSearch(req.query);
+
+        this.send(result, req, ws);
+    }
+
     async getAuthorBookList(req, ws) {
         const result = await this.webWorker.getAuthorBookList(req.authorId);
 

+ 10 - 2
server/core/DbCreator.js

@@ -261,7 +261,7 @@ class DbCreator {
 
         //парсинг
         const parser = new InpxParser();
-        await parser.parse(config.inpxFile, readFileCallback, parsedCallback);
+        await parser.parse(config.inpxFile, readFileCallback, parsedCallback);        
 
         //чистка памяти, ибо жрет как не в себя
         authorMap = null;
@@ -446,8 +446,16 @@ class DbCreator {
             table: 'config'
         });
 
+        const inpxInfo = parser.info;
+        if (inpxFilter && inpxFilter.info) {
+            if (inpxFilter.info.collection)
+                inpxInfo.collection = inpxFilter.info.collection;
+            if (inpxFilter.info.version)
+                inpxInfo.version = inpxFilter.info.version;
+        }
+
         await db.insert({table: 'config', rows: [
-            {id: 'inpxInfo', value: (inpxFilter && inpxFilter.info ? inpxFilter.info : parser.info)},
+            {id: 'inpxInfo', value: inpxInfo},
             {id: 'stats', value: stats},
             {id: 'inpxHash', value: await inpxHashCreator.getHash()},
         ]});

+ 168 - 32
server/core/DbSearcher.js

@@ -1,6 +1,5 @@
 const fs = require('fs-extra');
 //const _ = require('lodash');
-const LockQueue = require('./LockQueue');
 const utils = require('./utils');
 
 const maxLimit = 1000;
@@ -21,7 +20,6 @@ class DbSearcher {
 
         this.db = db;
 
-        this.lock = new LockQueue();
         this.searchFlag = 0;
         this.timer = null;
         this.closed = false;
@@ -30,11 +28,22 @@ class DbSearcher {
         this.bookIdMap = {};
 
         this.periodicCleanCache();//no await
-        this.fillBookIdMapAll();//no await
+    }
+
+    async init() {
+        await this.fillBookIdMap('author');
+        await this.fillBookIdMap('series');
+        await this.fillBookIdMap('title');
+        await this.fillDbConfig();
     }
 
     queryKey(q) {
-        return JSON.stringify([q.author, q.series, q.title, q.genre, q.lang, q.del, q.date, q.librate]);
+        const result = [];
+        for (const f of this.recStruct) {
+            result.push(q[f.field]);
+        }
+
+        return JSON.stringify(result);
     }
 
     getWhere(a) {
@@ -204,12 +213,13 @@ class DbSearcher {
         }
 
         //удаленные
-        if (query.del !== undefined) {
-            const key = `book-ids-del-${query.del}`;
+        if (query.del) {
+            const del = parseInt(query.del, 10) || 0;
+            const key = `book-ids-del-${del}`;
             let ids = await this.getCached(key);
 
             if (ids === null) {
-                ids = await tableBookIds('del', `@indexLR('value', ${db.esc(query.del)}, ${db.esc(query.del)})`);
+                ids = await tableBookIds('del', `@indexLR('value', ${db.esc(del)}, ${db.esc(del)})`);
 
                 await this.putCached(key, ids);
             }
@@ -298,34 +308,28 @@ class DbSearcher {
         }
     }
 
-    async fillBookIdMap(from) {
-        if (this.bookIdMap[from])
-            return this.bookIdMap[from];
+    async fillDbConfig() {
+        if (!this.dbConfig) {
+            const rows = await this.db.select({table: 'config'});
+            const config = {};
 
-        await this.lock.get();
-        try {
-            const data = await fs.readFile(`${this.config.dataDir}/db/${from}_id.map`, 'utf-8');
-
-            const idMap = JSON.parse(data);
-            idMap.arr = new Uint32Array(idMap.arr);
-            idMap.map = new Map(idMap.map);
-
-            this.bookIdMap[from] = idMap;
+            for (const row of rows) {
+                config[row.id] = row.value;
+            }
 
-            return this.bookIdMap[from];
-        } finally {
-            this.lock.ret();
+            this.dbConfig = config;
+            this.recStruct = config.inpxInfo.recStruct;
         }
     }
 
-    async fillBookIdMapAll() {
-        try {
-            await this.fillBookIdMap('author');
-            await this.fillBookIdMap('series');
-            await this.fillBookIdMap('title');
-        } catch (e) {
-            throw new Error(`DbSearcher.fillBookIdMapAll error: ${e.message}`)
-        }
+    async fillBookIdMap(from) {
+        const data = await fs.readFile(`${this.config.dataDir}/db/${from}_id.map`, 'utf-8');
+
+        const idMap = JSON.parse(data);
+        idMap.arr = new Uint32Array(idMap.arr);
+        idMap.map = new Map(idMap.map);
+
+        this.bookIdMap[from] = idMap;
     }
 
     async tableIdsFilter(from, query) {
@@ -376,7 +380,7 @@ class DbSearcher {
                 const filter = await this.tableIdsFilter(from, query);
 
                 const tableIdsSet = new Set();
-                const idMap = await this.fillBookIdMap(from);
+                const idMap = this.bookIdMap[from];
                 let proc = 0;
                 let nextProc = 0;
                 for (const bookId of bookIds) {
@@ -534,6 +538,137 @@ class DbSearcher {
         }
     }
 
+    async bookSearchIds(query) {
+        const queryKey = this.queryKey(query);
+        const bookKey = `book-search-ids-${queryKey}`;
+        let bookIds = await this.getCached(bookKey);
+
+        if (bookIds === null) {
+            const db = this.db;
+            const filterBySearch = (bookField, searchValue) => {
+                searchValue = searchValue.toLowerCase();
+                //особая обработка префиксов
+                if (searchValue == emptyFieldValue) {
+                    return `(row.${bookField} === '' || row.${bookField}.indexOf(${db.esc(emptyFieldValue)}) === 0)`;
+                } else if (searchValue[0] == '=') {
+
+                    searchValue = searchValue.substring(1);
+                    return `(row.${bookField}.toLowerCase().localeCompare(${db.esc(searchValue)}) === 0)`;
+                } else if (searchValue[0] == '*') {
+
+                    searchValue = searchValue.substring(1);
+                    return `(row.${bookField} && row.${bookField}.toLowerCase().indexOf(${db.esc(searchValue)}) >= 0)`;
+                } else if (searchValue[0] == '#') {
+
+                    searchValue = searchValue.substring(1);
+                    return `(row.${bookField} === '' || (!enru.has(row.${bookField}.toLowerCase()[0]) && row.${bookField}.toLowerCase().indexOf(${db.esc(searchValue)}) >= 0))`;
+                } else {
+
+                    return `(row.${bookField}.toLowerCase().localeCompare(${db.esc(searchValue)}) >= 0 ` +
+                        `&& row.${bookField}.toLowerCase().localeCompare(${db.esc(searchValue)} + ${db.esc(maxUtf8Char)}) <= 0)`;
+                }
+            };
+
+            const checks = ['true'];
+            for (const f of this.recStruct) {
+                if (query[f.field]) {
+                    let searchValue = query[f.field];
+                    if (f.type === 'S') {
+                        checks.push(filterBySearch(f.field, searchValue));
+                    } if (f.type === 'N') {
+                        const v = searchValue.split('..');
+
+                        if (v.length == 1) {
+                            searchValue = parseInt(searchValue, 10);
+                            if (isNaN(searchValue))
+                                throw new Error(`Wrong query param, ${f.field}=${searchValue}`);
+
+                            checks.push(`row.${f.field} === ${searchValue}`);
+                        } else {
+                            const from = parseInt(v[0] || '0', 10);
+                            const to = parseInt(v[1] || '0', 10);
+                            if (isNaN(from) || isNaN(to))
+                                throw new Error(`Wrong query param, ${f.field}=${searchValue}`);
+
+                            checks.push(`row.${f.field} >= ${from} && row.${f.field} <= ${to}`);
+                        }
+                    }
+                }
+            }
+
+            const rows = await db.select({
+                table: 'book',
+                rawResult: true,
+                where: `
+                    const enru = new Set(${db.esc(enruArr)});
+
+                    const checkBook = (row) => {
+                        return ${checks.join(' && ')};
+                    };
+
+                    const result = [];
+                    for (const id of @all()) {
+                        const row = @unsafeRow(id);
+                        if (checkBook(row))
+                            result.push(row);
+                    }
+
+                    result.sort((a, b) => {
+                        let cmp = a.author.localeCompare(b.author);
+                        if (cmp === 0 && (a.series || b.series)) {
+                            cmp = (a.series && b.series ? a.series.localeCompare(b.series) : (a.series ? -1 : 1));
+                        }
+                        if (cmp === 0)
+                            cmp = a.serno - b.serno;
+                        if (cmp === 0)
+                            cmp = a.title.localeCompare(b.title);
+
+                        return cmp;
+                    });
+
+                    return new Uint32Array(result.map(row => row.id));
+                `
+            });
+
+            bookIds = rows[0].rawResult;
+    
+            await this.putCached(bookKey, bookIds);
+        }
+
+        return bookIds;
+    }
+
+    //неоптимизированный поиск по всем книгам, по всем полям
+    async bookSearch(query) {
+        if (this.closed)
+            throw new Error('DbSearcher closed');
+
+        this.searchFlag++;
+
+        try {
+            const db = this.db;
+
+            const ids = await this.bookSearchIds(query);
+
+            const totalFound = ids.length;            
+            let limit = (query.limit ? query.limit : 100);
+            limit = (limit > maxLimit ? maxLimit : limit);
+            const offset = (query.offset ? query.offset : 0);
+
+            const slice = ids.slice(offset, offset + limit);
+
+            //выборка найденных значений
+            const found = await db.select({
+                table: 'book',
+                where: `@@id(${db.esc(Array.from(slice))})`
+            });
+
+            return {found, totalFound};
+        } finally {
+            this.searchFlag--;
+        }
+    }
+
     async opdsQuery(from, query) {
         if (this.closed)
             throw new Error('DbSearcher closed');
@@ -570,10 +705,11 @@ class DbSearcher {
                             const s = row.value.substring(0, depth);
                             let g = group.get(s);
                             if (!g) {
-                                g = {id: row.id, name: row.name, value: s, count: 0};
+                                g = {id: row.id, name: row.name, value: s, count: 0, bookCount: 0};
                                 group.set(s, g);
                             }
                             g.count++;
+                            g.bookCount += row.bookCount;
                         }
 
                         const result = Array.from(group.values());

+ 1 - 0
server/core/FileDownloader.js

@@ -14,6 +14,7 @@ class FileDownloader {
 
         let options = {
             headers: {
+                'accept-encoding': 'gzip, compress, deflate',
                 'user-agent': userAgent,
                 timeout: 300*1000,
             },

+ 40 - 5
server/core/InpxParser.js

@@ -1,12 +1,32 @@
 const path = require('path');
 const crypto = require('crypto');
 const ZipReader = require('./ZipReader');
+const utils = require('./utils');
 
 const collectionInfo = 'collection.info';
 const structureInfo = 'structure.info';
 const versionInfo = 'version.info';
 
 const defaultStructure = 'AUTHOR;GENRE;TITLE;SERIES;SERNO;FILE;SIZE;LIBID;DEL;EXT;DATE;LANG;LIBRATE;KEYWORDS';
+//'AUTHOR;GENRE;TITLE;SERIES;SERNO;FILE;SIZE;LIBID;DEL;EXT;DATE;INSNO;FOLDER;LANG;LIBRATE;KEYWORDS;'
+const recStructType = {
+    author: 'S',
+    genre: 'S',
+    title: 'S',
+    series: 'S',
+    serno: 'N',
+    file: 'S',
+    size: 'N',
+    libid: 'S',
+    del: 'N',
+    ext: 'S',
+    date: 'S',
+    insno: 'N',
+    folder: 'S',
+    lang: 'S',
+    librate: 'N',
+    keywords: 'S',
+}
 
 class InpxParser {
     constructor() {
@@ -24,6 +44,21 @@ class InpxParser {
         return result;
     }
 
+    getRecStruct(structure) {
+        const result = [];
+        let struct = structure;
+        //folder есть всегда
+        if (!struct.includes('folder'))
+            struct = struct.concat(['folder']);
+
+        for (const field of struct) {
+            if (utils.hasProp(recStructType, field))
+                result.push({field, type: recStructType[field]});
+        }
+
+        return result;
+    }
+
     async parse(inpxFile, readFileCallback, parsedCallback) {
         if (!readFileCallback)
             readFileCallback = async() => {};
@@ -61,11 +96,11 @@ class InpxParser {
             info.version = await this.safeExtractToString(zipReader, versionInfo);
 
             //структура
-            let inpxStructure = info.structure;
-            if (!inpxStructure)
-                inpxStructure = defaultStructure;
-            inpxStructure = inpxStructure.toLowerCase();
-            const structure = inpxStructure.split(';');
+            if (!info.structure)
+                info.structure = defaultStructure;
+            const structure = info.structure.toLowerCase().split(';');
+
+            info.recStruct = this.getRecStruct(structure);
 
             //парсим inp-файлы
             this.chunk = [];

+ 2 - 1
server/core/RemoteLib.js

@@ -68,7 +68,8 @@ class RemoteLib {
 
             const buf = await this.down.load(`${this.remoteHost}${link}`, {decompress: false});
 
-            const publicPath = `${this.config.publicFilesDir}${link}`;
+            const hash = path.basename(link);
+            const publicPath = `${this.config.bookDir}/${hash}`;
             
             await fs.writeFile(publicPath, buf);
 

+ 19 - 7
server/core/WebWorker.js

@@ -60,7 +60,7 @@ class WebWorker {
 
             const dirConfig = [
                 {
-                    dir: config.filesDir,
+                    dir: config.bookDir,
                     maxSize: config.maxFilesDirSize,
                 },
             ];
@@ -210,6 +210,7 @@ class WebWorker {
 
             //поисковый движок
             this.dbSearcher = new DbSearcher(config, db);
+            await this.dbSearcher.init();
 
             //stuff
             db.wwCache = {};            
@@ -267,6 +268,17 @@ class WebWorker {
         return result;
     }
 
+    async bookSearch(query) {
+        this.checkMyState();
+
+        const result = await this.dbSearcher.bookSearch(query);
+
+        const config = await this.dbConfig();
+        result.inpxHash = (config.inpxHash ? config.inpxHash : '');
+
+        return result;
+    }
+
     async opdsQuery(from, query) {
         this.checkMyState();
 
@@ -379,8 +391,8 @@ class WebWorker {
             hash = await this.remoteLib.downloadBook(bookUid);
         }
 
-        const link = `${this.config.filesPathStatic}/${hash}`;
-        const bookFile = `${this.config.filesDir}/${hash}`;
+        const link = `${this.config.bookPathStatic}/${hash}`;
+        const bookFile = `${this.config.bookDir}/${hash}`;
         const bookFileDesc = `${bookFile}.d.json`;
 
         if (!await fs.pathExists(bookFile) || !await fs.pathExists(bookFileDesc)) {
@@ -446,11 +458,11 @@ class WebWorker {
             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}`;
+                const bookFile = `${this.config.bookDir}/${hash}`;
                 const bookFileDesc = `${bookFile}.d.json`;
 
                 if (await fs.pathExists(bookFile) && await fs.pathExists(bookFileDesc)) {
-                    link = `${this.config.filesPathStatic}/${hash}`;
+                    link = `${this.config.bookPathStatic}/${hash}`;
                 }
             }
 
@@ -478,7 +490,7 @@ class WebWorker {
 
             let bookInfo = await this.getBookLink(bookUid);
             const hash = path.basename(bookInfo.link);
-            const bookFile = `${this.config.filesDir}/${hash}`;
+            const bookFile = `${this.config.bookDir}/${hash}`;
             const bookFileInfo = `${bookFile}.i.json`;
 
             let rows = await db.select({table: 'book', where: `@@hash('_uid', ${db.esc(bookUid)})`});
@@ -500,7 +512,7 @@ class WebWorker {
                     result.fb2 = fb2.rawNodes;
 
                     if (cover) {
-                        result.cover = `${this.config.filesPathStatic}/${hash}${coverExt}`;
+                        result.cover = `${this.config.bookPathStatic}/${hash}${coverExt}`;
                         await fs.writeFile(`${bookFile}${coverExt}`, cover);
                     }
                 }

+ 4 - 3
server/core/fb2/Fb2Helper.js

@@ -70,9 +70,6 @@ class Fb2Helper {
         if (coverImage.count) {
             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);
@@ -84,6 +81,10 @@ class Fb2Helper {
                         return;
 
                     if (attrs.id === binaryId) {
+                        let coverType = attrs['content-type'];
+                        coverType = (coverType == 'image/jpg' || coverType == 'application/octet-stream' ? 'image/jpeg' : coverType);
+                        coverExt = (coverType == 'image/png' ? '.png' : '.jpg');
+
                         const base64 = node.text();
                         cover = (base64 ? Buffer.from(base64, 'base64') : null);
                     }

+ 1 - 1
server/core/fb2/Fb2Parser.js

@@ -283,7 +283,7 @@ class Fb2Parser extends XmlParser {
         };
 
         for (const [tag, s] of Object.entries(substs)) {
-            const r = new RegExp(`${tag}`, 'g');
+            const r = new RegExp(tag, 'g');
             xmlString = xmlString.replace(r, s);
         }
 

+ 48 - 26
server/core/opds/AuthorPage.js

@@ -1,4 +1,5 @@
 const BasePage = require('./BasePage');
+const utils = require('../utils');
 
 class AuthorPage extends BasePage {
     constructor(config) {
@@ -11,17 +12,22 @@ class AuthorPage extends BasePage {
     sortBooks(bookList) {
         //схлопывание серий
         const books = [];
-        const seriesSet = new Set();
+        const seriesMap = new Map();
         for (const book of bookList) {
             if (book.series) {
-                if (!seriesSet.has(book.series)) {
+                let seriesIndex = seriesMap.get(book.series);
+                if (seriesIndex === undefined) {
+                    seriesIndex = books.length;
                     books.push({
                         type: 'series',
-                        book
+                        book,
+                        bookCount: 0,
                     });
 
-                    seriesSet.add(book.series);
+                    seriesMap.set(book.series, seriesIndex);
                 }
+
+                books[seriesIndex].bookCount++;
             } else {
                 books.push({
                     type: 'book',
@@ -99,21 +105,16 @@ class AuthorPage extends BasePage {
                 for (const book of sorted) {
                     const title = `${book.serno ? `${book.serno}. `: ''}${book.title || 'Без названия'} (${book.ext})`;
 
-                    const e = {
-                        id: book._uid,
-                        title,
-                        link: this.acqLink({href: `/book?uid=${encodeURIComponent(book._uid)}`}),
-                    };
-
-                    if (query.all) {
-                        e.content = {
-                            '*ATTRS': {type: 'text'},
-                            '*TEXT': this.bookAuthor(book.author),
-                        }
-                    }
-
                     entry.push(
-                        this.makeEntry(e)
+                        this.makeEntry({
+                            id: book._uid,
+                            title,
+                            link: this.acqLink({href: `/book?uid=${encodeURIComponent(book._uid)}`}),
+                            content: {
+                                '*ATTRS': {type: 'text'},
+                                '*TEXT': this.bookAuthor(book.author),
+                            },
+                        })
                     );
                 }
             }
@@ -134,6 +135,10 @@ class AuthorPage extends BasePage {
                                 link: this.navLink({
                                     href: `/${this.id}?author=${encodeURIComponent(query.author)}` +
                                         `&series=${encodeURIComponent(b.book.series)}&genre=${encodeURIComponent(query.genre)}`}),
+                                content: {
+                                    '*ATTRS': {type: 'text'},
+                                    '*TEXT': `${b.bookCount} книг${utils.wordEnding(b.bookCount, 8)} по автору${(query.genre ? ' (в выбранном жанре)' : '')}`,
+                                },
                             })
                         );
                     } else {
@@ -143,6 +148,10 @@ class AuthorPage extends BasePage {
                                 id: b.book._uid,
                                 title,
                                 link: this.acqLink({href: `/book?uid=${encodeURIComponent(b.book._uid)}`}),
+                                content: {
+                                    '*ATTRS': {type: 'text'},
+                                    '*TEXT': this.bookAuthor(b.book.author),
+                                },
                             })
                         );
                     }
@@ -162,14 +171,27 @@ class AuthorPage extends BasePage {
             //навигация по каталогу
             const queryRes = await this.opdsQuery('author', query, '[Остальные авторы]');
 
-            for (const rec of queryRes) {                
-                entry.push(
-                    this.makeEntry({
-                        id: rec.id,
-                        title: this.bookAuthor(rec.title),
-                        link: this.navLink({href: `/${this.id}?author=${rec.q}&genre=${encodeURIComponent(query.genre)}`}),
-                    })
-                );
+            for (const rec of queryRes) {
+                const e = {
+                    id: rec.id,
+                    title: this.bookAuthor(rec.title),
+                    link: this.navLink({href: `/${this.id}?author=${rec.q}&genre=${encodeURIComponent(query.genre)}`}),
+                };
+
+                let countStr = '';
+                if (rec.count)
+                    countStr = `${rec.count} автор${utils.wordEnding(rec.count, 0)}${(query.genre ? ' (в выбранном жанре)' : '')}`;
+                if (!countStr && rec.bookCount && !query.genre)
+                    countStr = `${rec.bookCount} книг${utils.wordEnding(rec.bookCount, 8)}`;
+
+                if (countStr) {
+                    e.content = {
+                        '*ATTRS': {type: 'text'},
+                        '*TEXT': countStr,
+                    };
+                }
+
+                entry.push(this.makeEntry(e));
             }
         }
 

+ 15 - 3
server/core/opds/BasePage.js

@@ -11,6 +11,7 @@ const ruAlphabet = 'абвгдеёжзийклмнопрстуфхцчшщъыь
 const enAlphabet = 'abcdefghijklmnopqrstuvwxyz';
 const enruArr = (ruAlphabet + enAlphabet).split('');
 const enru = new Set(enruArr);
+const ruOnly = new Set(ruAlphabet.split(''));
 
 class BasePage {
     constructor(config) {        
@@ -140,6 +141,7 @@ class BasePage {
                 id: row.id,
                 title: (row[from] || 'Без автора'),
                 q: `=${encodeURIComponent(row[from])}`,
+                bookCount: row.bookCount,
             };
 
             result.push(rec);
@@ -161,6 +163,7 @@ class BasePage {
             return await this.search(from, query);
         } else {
             let len = 0;
+            const enResult = [];
             for (const row of queryRes.found) {
                 const value = row.value;
                 len += value.length;
@@ -171,21 +174,30 @@ class BasePage {
                         id: row.id,
                         title: row.name,
                         q: `=${encodeURIComponent(row.name)}`,
+                        bookCount: row.bookCount,
                     };
                 } else {
                     rec = {
                         id: row.id,
                         title: `${value.toUpperCase().replace(/ /g, spaceChar)}~`,
                         q: encodeURIComponent(value),
+                        count: row.count,
                     };
                 }
-                if (query.depth > 1 || enru.has(value[0])) {
-                    result.push(rec);
+                if (query.depth > 1 || enru.has(value[0]) ) {
+                    //такой костыль из-за проблем с локалями в pkg
+                    //русский язык всегда идет первым!
+                    if (ruOnly.has(value[0]))
+                        result.push(rec)
+                    else
+                        enResult.push(rec);
                 } else {
                     others.push(rec);
                 }
             }
 
+            result = result.concat(enResult);
+
             if (query[from] && query.depth > 1 && result.length < 10 && len > prevLen) {
                 //рекурсия, с увеличением глубины, для облегчения навигации
                 const newQuery = _.cloneDeep(query);
@@ -195,7 +207,7 @@ class BasePage {
         }
 
         if (!query.others && others.length)
-            result.unshift({id: 'other', title: otherTitle, q: '___others'});
+            result.unshift({id: 'other', title: otherTitle, q: '___others', count: others.length});
 
         return (!query.others ? result : others);
     }

+ 14 - 2
server/core/opds/BookPage.js

@@ -128,7 +128,19 @@ class BookPage extends BasePage {
 
             if (bookInfo) {
                 const {genreMap} = await this.getGenres();
-                const fileFormat = `${bookInfo.book.ext}+zip`;
+
+                //format
+                const ext = bookInfo.book.ext;
+                let fileFormat = `${ext}+zip`;
+                let downHref = bookInfo.link;
+
+                if (ext === 'mobi') {
+                    fileFormat = 'x-mobipocket-ebook';
+                } else if (ext == 'epub') {
+                    //
+                } else {
+                    downHref = `${bookInfo.link}/zip`;
+                }
 
                 //entry
                 const e = this.makeEntry({
@@ -183,7 +195,7 @@ class BookPage extends BasePage {
                 }
 
                 //links
-                e.link = [ this.downLink({href: bookInfo.link, type: `application/${fileFormat}`}) ];
+                e.link = [ this.downLink({href: downHref, type: `application/${fileFormat}`}) ];
                 if (bookInfo.cover) {
                     let coverType = 'image/jpeg';
                     if (path.extname(bookInfo.cover) == '.png')

+ 59 - 0
server/core/opds/SearchHelpPage.js

@@ -0,0 +1,59 @@
+const he = require('he');
+
+const BasePage = require('./BasePage');
+
+class SearchHelpPage extends BasePage {
+    constructor(config) {
+        super(config);
+
+        this.id = 'search_help';
+        this.title = 'Памятка по поиску';
+
+    }
+
+    async body(req) {
+        const result = {};
+
+        result.link = this.baseLinks(req, true);
+
+        const content = `
+Формат поискового значения:
+<ul>
+    <li>
+        без префикса: значение трактуется, как "начинается с"
+    </li>
+    <li>
+        префикс "=": поиск по точному совпадению
+    </li>
+    <li>
+        префикс "*": поиск подстроки в строке
+    </li>
+    <li>
+        префикс "#": поиск подстроки в строке, но только среди значений, начинающихся не с латинского или кириллического символа
+    </li>
+    <li>
+        префикс "?": поиск пустых значений или тех, что начинаются с этого символа
+    </li>
+</ul>
+`;
+        const entry = [
+            this.makeEntry({
+                id: 'help',
+                title: this.title,
+                content: {
+                    '*ATTRS': {type: 'text/html'},
+                    '*TEXT': he.escape(content),
+                },
+                link: [
+                    this.downLink({href: '/book/fake-link', type: `application/fb2+zip`})
+                ],
+            })
+        ];
+
+        result.entry = entry;
+
+        return this.makeBody(result, req);
+    }
+}
+
+module.exports = SearchHelpPage;

+ 29 - 4
server/core/opds/SearchPage.js

@@ -1,4 +1,5 @@
 const BasePage = require('./BasePage');
+const utils = require('../utils');
 
 class SearchPage extends BasePage {
     constructor(config) {
@@ -30,16 +31,19 @@ class SearchPage extends BasePage {
                 const found = queryRes.found;
 
                 for (let i = 0; i < found.length; i++) {
-                    if (i >= limit)
-                        break;
-
                     const row = found[i];
+                    if (!row.bookCount)
+                        continue;
 
                     entry.push(
                         this.makeEntry({
                             id: row.id,
-                            title: row[from],
+                            title: `${(from === 'series' ? 'Серия: ': '')}${from === 'author' ? this.bookAuthor(row[from]) : row[from]}`,
                             link: this.navLink({href: `/${from}?${from}==${encodeURIComponent(row[from])}`}),
+                            content: {
+                                '*ATTRS': {type: 'text'},
+                                '*TEXT': `${row.bookCount} книг${utils.wordEnding(row.bookCount, 8)}`,
+                            },
                         }),
                     );
                 }
@@ -61,16 +65,37 @@ class SearchPage extends BasePage {
                     id: 'search_author',
                     title: 'Поиск авторов',
                     link: this.navLink({href: `/${this.id}?type=author&term=${encodeURIComponent(query.term)}`}),
+                    content: {
+                        '*ATTRS': {type: 'text'},
+                        '*TEXT': `Искать по именам авторов`,
+                    },
                 }),
                 this.makeEntry({
                     id: 'search_series',
                     title: 'Поиск серий',
                     link: this.navLink({href: `/${this.id}?type=series&term=${encodeURIComponent(query.term)}`}),
+                    content: {
+                        '*ATTRS': {type: 'text'},
+                        '*TEXT': `Искать по названиям серий`,
+                    },
                 }),
                 this.makeEntry({
                     id: 'search_title',
                     title: 'Поиск книг',
                     link: this.navLink({href: `/${this.id}?type=title&term=${encodeURIComponent(query.term)}`}),
+                    content: {
+                        '*ATTRS': {type: 'text'},
+                        '*TEXT': `Искать по названиям книг`,
+                    },
+                }),
+                this.makeEntry({
+                    id: 'search_help',
+                    title: '[Памятка по поиску]',
+                    link: this.acqLink({href: `/search-help`}),
+                    content: {
+                        '*ATTRS': {type: 'text'},
+                        '*TEXT': `Описание формата поискового значения`,
+                    },
                 }),
             ]
         }

+ 31 - 22
server/core/opds/SeriesPage.js

@@ -1,4 +1,5 @@
 const BasePage = require('./BasePage');
+const utils = require('../utils');
 
 class SeriesPage extends BasePage {
     constructor(config) {
@@ -63,21 +64,16 @@ class SeriesPage extends BasePage {
                 for (const book of sorted) {
                     const title = `${book.serno ? `${book.serno}. `: ''}${book.title || 'Без названия'} (${book.ext})`;
 
-                    const e = {
-                        id: book._uid,
-                        title,
-                        link: this.acqLink({href: `/book?uid=${encodeURIComponent(book._uid)}`}),
-                    };
-
-                    if (query.all) {
-                        e.content = {
-                            '*ATTRS': {type: 'text'},
-                            '*TEXT': this.bookAuthor(book.author),
-                        }
-                    }
-
                     entry.push(
-                        this.makeEntry(e)
+                        this.makeEntry({
+                            id: book._uid,
+                            title,
+                            link: this.acqLink({href: `/book?uid=${encodeURIComponent(book._uid)}`}),
+                            content: {
+                                '*ATTRS': {type: 'text'},
+                                '*TEXT': this.bookAuthor(book.author),
+                            },
+                        })
                     );
                 }
             }
@@ -95,14 +91,27 @@ class SeriesPage extends BasePage {
             //навигация по каталогу
             const queryRes = await this.opdsQuery('series', query, '[Остальные серии]');
 
-            for (const rec of queryRes) {                
-                entry.push(
-                    this.makeEntry({
-                        id: rec.id,
-                        title: rec.title,
-                        link: this.navLink({href: `/${this.id}?series=${rec.q}&genre=${encodeURIComponent(query.genre)}`}),
-                    })
-                );
+            for (const rec of queryRes) {
+                const e = {
+                    id: rec.id,
+                    title: (rec.count ? rec.title : `Серия: ${rec.title}`),
+                    link: this.navLink({href: `/${this.id}?series=${rec.q}&genre=${encodeURIComponent(query.genre)}`}),
+                };
+
+                let countStr = '';
+                if (rec.count)
+                    countStr = `${rec.count} сери${utils.wordEnding(rec.count, 1)}${(query.genre ? ' (в выбранном жанре)' : '')}`;
+                if (!countStr && rec.bookCount && !query.genre)
+                    countStr = `${rec.bookCount} книг${utils.wordEnding(rec.bookCount, 8)}`;
+
+                if (countStr) {
+                    e.content = {
+                        '*ATTRS': {type: 'text'},
+                        '*TEXT': countStr,
+                    };
+                }
+
+                entry.push(this.makeEntry(e));
             }
         }
 

+ 22 - 8
server/core/opds/TitlePage.js

@@ -1,4 +1,5 @@
 const BasePage = require('./BasePage');
+const utils = require('../utils');
 
 class TitlePage extends BasePage {
     constructor(config) {
@@ -65,14 +66,27 @@ class TitlePage extends BasePage {
             //навигация по каталогу
             const queryRes = await this.opdsQuery('title', query, '[Остальные названия]');
 
-            for (const rec of queryRes) {                
-                entry.push(
-                    this.makeEntry({
-                        id: rec.id,
-                        title: rec.title,
-                        link: this.navLink({href: `/${this.id}?title=${rec.q}&genre=${encodeURIComponent(query.genre)}`}),
-                    })
-                );
+            for (const rec of queryRes) {
+                const e = {
+                    id: rec.id,
+                    title: rec.title,
+                    link: this.navLink({href: `/${this.id}?title=${rec.q}&genre=${encodeURIComponent(query.genre)}`}),
+                };
+
+                let countStr = '';
+                if (rec.count)
+                    countStr = `${rec.count} назван${utils.wordEnding(rec.count, 3)}${(query.genre ? ' (в выбранном жанре)' : '')}`;
+                if (!countStr && rec.bookCount && !query.genre)
+                    countStr = `${rec.bookCount} книг${utils.wordEnding(rec.bookCount, 8)}`;
+
+                if (countStr) {
+                    e.content = {
+                        '*ATTRS': {type: 'text'},
+                        '*TEXT': countStr,
+                    };
+                }
+
+                entry.push(this.makeEntry(e));
             }
         }
 

+ 4 - 1
server/core/opds/index.js

@@ -9,12 +9,13 @@ const BookPage = require('./BookPage');
 
 const OpensearchPage = require('./OpensearchPage');
 const SearchPage = require('./SearchPage');
+const SearchHelpPage = require('./SearchHelpPage');
 
 module.exports = function(app, config) {
     if (!config.opds || !config.opds.enabled)
         return;
     
-    const opdsRoot = '/opds';
+    const opdsRoot = config.opds.root || '/opds';
     config.opdsRoot = opdsRoot;
 
     const root = new RootPage(config);
@@ -26,6 +27,7 @@ module.exports = function(app, config) {
 
     const opensearch = new OpensearchPage(config);
     const search = new SearchPage(config);
+    const searchHelp = new SearchHelpPage(config);
 
     const routes = [
         ['', root],
@@ -38,6 +40,7 @@ module.exports = function(app, config) {
 
         ['/opensearch', opensearch],
         ['/search', search],
+        ['/search-help', searchHelp],
     ];
 
     const pages = new Map();

+ 29 - 6
server/core/utils.js

@@ -109,9 +109,10 @@ function gzipFile(inputFile, outputFile, level = 1) {
             .pipe(gzip).on('error', reject)
             .pipe(output).on('error', reject)
             .on('finish', (err) => {
-            if (err) reject(err);
-            else resolve();
-        });
+                if (err) reject(err);
+                else resolve();
+            }
+        );
     });
 }
 
@@ -125,9 +126,10 @@ function gunzipFile(inputFile, outputFile) {
             .pipe(gzip).on('error', reject)
             .pipe(output).on('error', reject)
             .on('finish', (err) => {
-            if (err) reject(err);
-            else resolve();
-        });
+                if (err) reject(err);
+                else resolve();
+            }
+        );
     });
 }
 
@@ -174,6 +176,26 @@ function makeValidFileNameOrEmpty(fileName) {
     }
 }
 
+function wordEnding(num, type = 0) {
+    const endings = [
+        ['ов', '', 'а', 'а', 'а', 'ов', 'ов', 'ов', 'ов', 'ов'],//0
+        ['й', 'я', 'и', 'и', 'и', 'й', 'й', 'й', 'й', 'й'],//1
+        ['о', '', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],//2
+        ['ий', 'ие', 'ия', 'ия', 'ия', 'ий', 'ий', 'ий', 'ий', 'ий'],//3
+        ['о', 'а', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],//4
+        ['ок', 'ка', 'ки', 'ки', 'ки', 'ок', 'ок', 'ок', 'ок', 'ок'],//5
+        ['ых', 'ое', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых'],//6
+        ['о', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],//7
+        ['', 'а', 'и', 'и', 'и', '', '', '', '', ''],//8
+    ];
+    const deci = num % 100;
+    if (deci > 10 && deci < 20) {
+        return endings[type][0];
+    } else {
+        return endings[type][num % 10];
+    }
+}
+
 module.exports = {
     sleep,
     processLoop,
@@ -193,4 +215,5 @@ module.exports = {
     toUnixPath,
     makeValidFileName,
     makeValidFileNameOrEmpty,
+    wordEnding,
 };

+ 4 - 2
server/core/xml/XmlParser.js

@@ -734,8 +734,8 @@ class XmlParser extends NodeBase {
                 } else if (tag === '*ATTRS') {
                     //пропускаем
                 } else {
-                    if (typeof(objNode) === 'string') {
-                        result.push(this.createNode(tag, null, [this.createText(objNode).raw]).raw);
+                    if (typeof(objNode) === 'string' || typeof(objNode) === 'number') {
+                        result.push(this.createNode(tag, null, [this.createText(objNode.toString()).raw]).raw);
                     } else if (Array.isArray(objNode)) {
                         for (const n of objNode) {
                             if (typeof(n) === 'string') {
@@ -747,6 +747,8 @@ class XmlParser extends NodeBase {
 
                     } else if (typeof(objNode) === 'object') {
                         result.push(this.createNode(tag, (objNode['*ATTRS'] ? Object.entries(objNode['*ATTRS']) : null), objectToNodes(objNode)).raw);
+                    } else {
+                        throw new Error(`Unknown node type "${typeof(objNode)}" of node: ${objNode}`);
                     }
                 }
             }

+ 2 - 2
server/createWebApp.js

@@ -9,7 +9,7 @@ module.exports = async(config) => {
 
     if (await fs.pathExists(verFile)) {
         const curPublicVersion = await fs.readFile(verFile, 'utf8');
-        if (curPublicVersion == config.version)
+        if (curPublicVersion == config.version + config.rootPathStatic)
             return;
     }
 
@@ -26,6 +26,6 @@ module.exports = async(config) => {
         await zipReader.close();
     }
 
-    await fs.writeFile(verFile, config.version);
+    await fs.writeFile(verFile, config.version + config.rootPathStatic);
     await fs.remove(zipFile);
 };

+ 6 - 60
server/index.js

@@ -50,13 +50,14 @@ async function init() {
     config.logDir = `${config.dataDir}/log`;
     config.publicDir = `${config.dataDir}/public`;
     config.publicFilesDir = `${config.dataDir}/public-files`;
-    config.filesPathStatic = `/book`;
-    config.filesDir = `${config.publicFilesDir}${config.filesPathStatic}`;
+    config.rootPathStatic = config.server.root || '';
+    config.bookPathStatic = `${config.rootPathStatic}/book`;
+    config.bookDir = `${config.publicFilesDir}/book`;
 
     configManager.config = config;
 
     await fs.ensureDir(config.dataDir);
-    await fs.ensureDir(config.filesDir);
+    await fs.ensureDir(config.bookDir);
     await fs.ensureDir(config.tempDir);
     await fs.emptyDir(config.tempDir);
 
@@ -156,6 +157,8 @@ async function main() {
 
     const opds = require('./core/opds');
     opds(app, config);
+
+    const initStatic = require('./static');
     initStatic(app, config);
     
     const webAccess = new (require('./core/WebAccess'))(config);
@@ -179,63 +182,6 @@ async function main() {
     });
 }
 
-function initStatic(app, config) {
-    /*
-    publicFilesDir = `${config.dataDir}/public-files`;
-    filesPathStatic = `/book`;
-    filesDir = `${config.publicFilesDir}${config.filesPathStatic}`;
-    */
-    const filesPath = `${config.filesPathStatic}/`;
-    //загрузка или восстановление файлов в /files, при необходимости
-    app.use(async(req, res, next) => {
-        if ((req.method !== 'GET' && req.method !== 'HEAD') ||
-            !(req.path.indexOf(filesPath) === 0)
-            ) {
-            return next();
-        }
-
-        if (path.extname(req.path) == '') {
-            const bookFile = `${config.publicFilesDir}${req.path}`;
-            const bookFileDesc = `${bookFile}.d.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);
-            }
-
-            if (downFileName)
-                res.downFileName = downFileName;
-        }
-
-        return next();
-    });
-
-    //заголовки при отдаче
-    app.use(config.filesPathStatic, express.static(config.filesDir, {
-        setHeaders: (res) => {
-            if (res.downFileName) {
-                res.set('Content-Encoding', 'gzip');
-                res.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(res.downFileName)}`);
-            }
-        },
-    }));
-
-    app.use(express.static(config.publicDir));
-}
-
 (async() => {
     try {
         await init();

+ 127 - 0
server/static.js

@@ -0,0 +1,127 @@
+const fs = require('fs-extra');
+const path = require('path');
+const yazl = require('yazl');
+
+const express = require('express');
+const utils = require('./core/utils');
+const webAppDir = require('../build/appdir');
+
+const log = new (require('./core/AppLogger'))().log;//singleton
+
+function generateZip(zipFile, dataFile, dataFileInZip) {
+    return new Promise((resolve, reject) => {
+        const zip = new yazl.ZipFile();
+        zip.addFile(dataFile, dataFileInZip);
+        zip.outputStream
+            .pipe(fs.createWriteStream(zipFile)).on('error', reject)
+            .on('finish', (err) => {
+                if (err) reject(err);
+                else resolve();
+            }
+        );
+        zip.end();
+    });
+}
+
+module.exports = (app, config) => {
+    /*
+    config.bookPathStatic = `${config.rootPathStatic}/book`;
+    config.bookDir = `${config.publicFilesDir}/book`;
+    */
+    //загрузка или восстановление файлов в /public-files, при необходимости
+    app.use([`${config.bookPathStatic}/:fileName/:fileType`, `${config.bookPathStatic}/:fileName`], async(req, res, next) => {
+        if (req.method !== 'GET' && req.method !== 'HEAD') {
+            return next();
+        }
+
+        try {
+            const fileName = req.params.fileName;
+            const fileType = req.params.fileType;
+
+            if (path.extname(fileName) === '') {//восстановление файлов {hash}.raw, {hash}.zip
+                let bookFile = `${config.bookDir}/${fileName}`;
+                const bookFileDesc = `${bookFile}.d.json`;
+
+                //восстановим из json-файла описания
+                if (await fs.pathExists(bookFile) && await fs.pathExists(bookFileDesc)) {
+                    await utils.touchFile(bookFile);
+                    await utils.touchFile(bookFileDesc);
+
+                    let desc = await fs.readFile(bookFileDesc, 'utf8');
+                    let downFileName = (JSON.parse(desc)).downFileName;
+                    let gzipped = true;
+
+                    if (!req.acceptsEncodings('gzip') || fileType) {
+                        const rawFile = `${bookFile}.raw`;
+                        //не принимает gzip, тогда распакуем
+                        if (!await fs.pathExists(rawFile))
+                            await utils.gunzipFile(bookFile, rawFile);
+
+                        gzipped = false;
+
+                        if (fileType === undefined || fileType === 'raw') {
+                            bookFile = rawFile;
+                        } else if (fileType === 'zip') {
+                            //создаем zip-файл
+                            bookFile += '.zip';
+                            if (!await fs.pathExists(bookFile))
+                                await generateZip(bookFile, rawFile, downFileName);
+                            downFileName += '.zip';
+                        } else {
+                            throw new Error(`Unsupported file type: ${fileType}`);
+                        }
+                    }
+
+                    //отдача файла
+                    if (gzipped)
+                        res.set('Content-Encoding', 'gzip');
+                    res.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(downFileName)}`);
+                    res.sendFile(bookFile);
+                    return;
+                } else {
+                    await fs.remove(bookFile);
+                    await fs.remove(bookFileDesc);
+                }
+            }
+        } catch(e) {
+            log(LM_ERR, e.message);
+        }
+
+        return next();
+    });
+
+    //иначе просто отдаем запрошенный файл из /public-files
+    app.use(config.bookPathStatic, express.static(config.bookDir));
+
+    if (config.rootPathStatic) {
+        //подмена rootPath в файлах статики WebApp при необходимости
+        app.use(config.rootPathStatic, async(req, res, next) => {
+            if (req.method !== 'GET' && req.method !== 'HEAD') {
+                return next();
+            }
+
+            try {
+                const reqPath = (req.path == '/' ? '/index.html' : req.path);
+                const ext = path.extname(reqPath);
+                if (ext == '.html' || ext == '.js' || ext == '.css') {
+                    const reqFile = `${config.publicDir}${reqPath}`;
+                    const flagFile = `${reqFile}.replaced`;
+
+                    if (!await fs.pathExists(flagFile) && await fs.pathExists(reqFile)) {
+                        const content = await fs.readFile(reqFile, 'utf8');
+                        const re = new RegExp(`/${webAppDir}`, 'g');
+                        await fs.writeFile(reqFile, content.replace(re, `${config.rootPathStatic}/${webAppDir}`));
+                        await fs.writeFile(flagFile, '');
+                    }
+                }
+            } catch(e) {
+                log(LM_ERR, e.message);
+            }
+
+            return next();
+        });
+    }
+
+    //статика файлов WebApp
+    app.use(config.rootPathStatic, express.static(config.publicDir));
+};

Some files were not shown because too many files changed in this diff