浏览代码

Merge branch 'release/1.5.4'

Book Pauk 2 年之前
父节点
当前提交
c3810d1ff5

+ 7 - 0
CHANGELOG.md

@@ -1,3 +1,10 @@
+1.5.4 / 2023-04-12
+
+- Добавлена возможность поиска по типу файла
+- Улучшена работа с inpx, теперь понимает файлы в каталогах (без zip-архива)
+- В readme добавлен раздел "Запуск без сборки релиза" для запуска inpx-web на любых платформах
+- Исправления мелких багов
+
 1.5.3 / 2023-03-02
 
 - OPDS: исправление проблемы поиска для koreader

+ 28 - 6
README.md

@@ -29,7 +29,8 @@ OPDS-сервер доступен по адресу [http://127.0.0.1:12380/opd
     * [Удаленная библиотека](#remotelib)
     * [Фильтр по авторам и книгам](#filter)
     * [Настройка https с помощью nginx](#https)
-* [Сборка проекта](#build)
+* [Сборка релизов](#build)
+* [Запуск без сборки релиза](#native_run)
 * [Разработка](#development)
 
 <a id="capabilities" />
@@ -321,7 +322,7 @@ sudo service nginx reload
 
 <a id="build" />
 
-### Сборка проекта
+### Сборка релизов
 Сборка только в среде Linux.
 Необходима версия node.js не ниже 16.
 
@@ -331,15 +332,36 @@ sudo service nginx reload
 git clone https://github.com/bookpauk/inpx-web
 cd inpx-web
 npm i
-```
-
-#### Релизы
-```sh
 npm run release
 ```
 
 Результат сборки будет доступен в каталоге `dist/release`
 
+<a id="native_run" />
+
+### Запуск без сборки релиза
+Т.к. сборщик pkg поддерживает не все платформы, то не всегда удается собрать релиз.
+Однако, можно скачать и запустить inpx-web нативным путем, с помощью nodejs.
+Ниже пример для Ubuntu, для других линуксов различия не принципиальны:
+
+```sh
+# установка nodejs v16 и выше:
+curl -s https://deb.nodesource.com/setup_16.x | sudo bash
+sudo apt install nodejs -y
+
+# подготовка
+git clone https://github.com/bookpauk/inpx-web
+cd inpx-web
+npm i
+npm run build:client && node build/prepkg.js linux
+
+# удалим файл development-среды, чтобы запускался в production-режиме
+rm ./server/config/application_env
+
+# запуск inpx-web, тут же будет создан каталог .inpx-web
+node server --app-dir=.inpx-web
+```
+
 <a id="development" />
 
 ### Разработка

+ 9 - 3
client/components/Search/AuthorList/AuthorList.vue

@@ -126,9 +126,9 @@
         </div>
         <!-- Формирование списка конец ------------------------------------------------------------------>
 
-        <div v-if="!refreshing && !tableData.length" class="row items-center q-ml-md" style="font-size: 120%">
+        <div v-if="!refreshing && (!tableData.length || error)" class="row items-center q-ml-md" style="font-size: 120%">
             <q-icon class="la la-meh q-mr-xs" size="28px" />
-            Поиск не дал результатов
+            {{ (error ? error : 'Поиск не дал результатов') }}
         </div>
     </div>
 </template>
@@ -438,6 +438,7 @@ class AuthorList extends BaseList {
         if (this.refreshing)
             return;
 
+        this.error = '';
         this.refreshing = true;
 
         (async() => {
@@ -467,7 +468,12 @@ class AuthorList extends BaseList {
                         this.highlightPageScroller(query);
                     }
                 } catch (e) {
-                    this.$root.stdDialog.alert(e.message, 'Ошибка');
+                    this.list.queryFound = 0;
+                    this.list.totalFound = 0;
+                    this.searchResult = {found: []};
+                    await this.updateTableData();
+                    //this.$root.stdDialog.alert(e.message, 'Ошибка');
+                    this.error = `Ошибка: ${e.message}`;
                 }
             }
         } finally {

+ 11 - 1
client/components/Search/BaseList.js

@@ -48,6 +48,7 @@ export default class BaseList {
         genreMap: Object,
     };
     
+    error = '';
     loadingMessage = '';
     loadingMessage2 = '';
 
@@ -371,7 +372,8 @@ export default class BaseList {
                 bookValue = emptyFieldValue;
 
             bookValue = bookValue.toLowerCase();
-            searchValue = searchValue.toLowerCase();
+            if (searchValue[0] !== '~')
+                searchValue = searchValue.toLowerCase();
 
             //особая обработка префиксов
             if (searchValue[0] == '=') {
@@ -450,6 +452,13 @@ export default class BaseList {
                 librateFound = searchLibrate.has(book.librate);
             }
 
+            //ext
+            let extFound = !s.ext;
+            if (!extFound) {
+                const searchExt = new Set(s.ext.split('|'));
+                extFound = searchExt.has(book.ext.toLowerCase() || emptyFieldValue);
+            }
+
             return (this.showDeleted || !book.del)
                 && authorFound
                 && filterBySearch(book.series, s.series)
@@ -458,6 +467,7 @@ export default class BaseList {
                 && langFound
                 && dateFound
                 && librateFound
+                && extFound
             ;
         });
     }

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

@@ -20,7 +20,7 @@
                 <div class="poster-size">
                     <div class="column justify-center items-center" :class="{'poster': coverSrc, 'no-poster': !coverSrc}" @click.stop.prevent="posterClick">
                         <img v-if="coverSrc" :src="coverSrc" class="fit row justify-center items-center" style="object-fit: contain" @error="coverSrc = ''" />
-                        <div v-if="!coverSrc" class="fit row justify-center items-center text-grey-5" style="border: 1px solid #ccc; font-size: 300%">
+                        <div v-if="!coverSrc" class="fit row justify-center items-center text-grey-5 overflow-hidden" style="border: 1px solid #ccc; font-size: 300%">
                             <i>{{ book.ext }}</i>
                         </div>
                     </div>

+ 9 - 3
client/components/Search/ExtendedList/ExtendedList.vue

@@ -14,9 +14,9 @@
         </div>
         <!-- Формирование списка конец ------------------------------------------------------------------>
 
-        <div v-if="!refreshing && !tableData.length" class="row items-center q-ml-md" style="font-size: 120%">
+        <div v-if="!refreshing && (!tableData.length || error)" class="row items-center q-ml-md" style="font-size: 120%">
             <q-icon class="la la-meh q-mr-xs" size="28px" />
-            Поиск не дал результатов
+            {{ (error ? error : 'Поиск не дал результатов') }}
         </div>
     </div>
 </template>
@@ -74,6 +74,7 @@ class ExtendedList extends BaseList {
         if (this.refreshing)
             return;
 
+        this.error = '';
         this.refreshing = true;
 
         (async() => {
@@ -103,7 +104,12 @@ class ExtendedList extends BaseList {
                         this.highlightPageScroller(query);
                     }
                 } catch (e) {
-                    this.$root.stdDialog.alert(e.message, 'Ошибка');
+                    this.list.queryFound = 0;
+                    this.list.totalFound = 0;
+                    this.searchResult = {found: []};
+                    await this.updateTableData();
+                    //this.$root.stdDialog.alert(e.message, 'Ошибка');
+                    this.error = `Ошибка: ${e.message}`;
                 }
             }
         } finally {

+ 30 - 2
client/components/Search/Search.vue

@@ -160,7 +160,7 @@
                             <div class="q-mx-xs" />
                             <q-input
                                 v-model="librateNames" :maxlength="inputMaxLength" :debounce="inputDebounce"
-                                class="q-mt-xs col-1" :bg-color="inputBgColor()" input-style="cursor: pointer" style="min-width: 90px;" label="Оценка" stack-label outlined dense clearable readonly
+                                class="q-mt-xs col-2" :bg-color="inputBgColor()" input-style="cursor: pointer" style="min-width: 140px;" label="Оценка" stack-label outlined dense clearable readonly
                                 @click.stop.prevent="selectLibRate"
                             >
                                 <template v-if="librateNames" #append>
@@ -171,6 +171,21 @@
                                     {{ librateNames }}
                                 </q-tooltip>
                             </q-input>
+
+                            <div class="q-mx-xs" />
+                            <q-input
+                                v-model="search.ext" :maxlength="inputMaxLength" :debounce="inputDebounce"
+                                class="q-mt-xs col-2" :bg-color="inputBgColor()" input-style="cursor: pointer" style="min-width: 140px;" label="Тип файла" stack-label outlined dense clearable readonly
+                                @click.stop.prevent="selectExt"
+                            >
+                                <template v-if="search.ext" #append>
+                                    <q-icon name="la la-times-circle" class="q-field__focusable-action" @click.stop.prevent="search.ext = ''" />
+                                </template>
+
+                                <q-tooltip v-if="search.ext && showTooltips" :delay="500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
+                                    {{ search.ext }}
+                                </q-tooltip>
+                            </q-input>                            
                         </div>
                         <div v-show="!isExtendedSearch && !extendedParams && extendedParamsMessage" class="row q-mx-sm items-center clickable" @click.stop.prevent="extendedParams = true">
                             +{{ extendedParamsMessage }}
@@ -331,6 +346,7 @@
         <SelectLangDialog v-model="selectLangDialogVisible" v-model:lang="search.lang" :lang-list="langList" :lang-default="langDefault" />        
         <SelectLibRateDialog v-model="selectLibRateDialogVisible" v-model:librate="search.librate" />
         <SelectDateDialog v-model="selectDateDialogVisible" v-model:date="search.date" />
+        <SelectExtDialog v-model="selectExtDialogVisible" v-model:ext="search.ext" :ext-list="extList" />        
         <BookInfoDialog v-model="bookInfoDialogVisible" :book-info="bookInfo" />
         <SelectExtSearchDialog v-model="selectExtSearchDialogVisible" v-model:ext-search="extSearch" />        
     </div>
@@ -351,6 +367,7 @@ import SelectGenreDialog from './SelectGenreDialog/SelectGenreDialog.vue';
 import SelectLangDialog from './SelectLangDialog/SelectLangDialog.vue';
 import SelectLibRateDialog from './SelectLibRateDialog/SelectLibRateDialog.vue';
 import SelectDateDialog from './SelectDateDialog/SelectDateDialog.vue';
+import SelectExtDialog from './SelectExtDialog/SelectExtDialog.vue';
 import BookInfoDialog from './BookInfoDialog/BookInfoDialog.vue';
 import SelectExtSearchDialog from './SelectExtSearchDialog/SelectExtSearchDialog.vue';
 
@@ -384,6 +401,7 @@ const componentOptions = {
         SelectLangDialog,
         SelectLibRateDialog,
         SelectDateDialog,
+        SelectExtDialog,
         BookInfoDialog,
         SelectExtSearchDialog,
         Dialog,
@@ -495,6 +513,7 @@ class Search {
     selectLangDialogVisible = false;
     selectLibRateDialogVisible = false;
     selectDateDialogVisible = false;
+    selectExtDialogVisible = false;
     bookInfoDialogVisible = false;
     selectExtSearchDialogVisible = false;
 
@@ -531,6 +550,7 @@ class Search {
     genreTree = [];
     genreMap = new Map();
     langList = [];
+    extList = [];
     genreTreeInpxHash = '';
     showTooltips = true;
 
@@ -561,7 +581,7 @@ class Search {
         this.commit = this.$store.commit;
         this.api = this.$root.api;
 
-        this.generateDefaults(this.search, ['author', 'series', 'title', 'genre', 'lang', 'date', 'librate']);
+        this.generateDefaults(this.search, ['author', 'series', 'title', 'genre', 'lang', 'date', 'librate', 'ext']);
         this.search.setDefaults(this.search);
 
         this.loadSettings();
@@ -705,6 +725,7 @@ class Search {
         result.push(s.genre ? 'Жанр' : '');
         result.push(s.date ? 'Дата поступления' : '');
         result.push(s.librate ? 'Оценка' : '');
+        result.push(s.ext ? 'Тип файла' : '');
 
         return result.filter(s => s).join(', ');
     }
@@ -941,6 +962,11 @@ class Search {
         this.selectLibRateDialogVisible = true;
     }
 
+    selectExt() {
+        this.hideTooltip();
+        this.selectExtDialogVisible = true;
+    }
+
     selectExtSearch() {
         this.hideTooltip();
         this.selectExtSearchDialogVisible = true;
@@ -1079,6 +1105,7 @@ class Search {
             lang: (typeof(query.lang) == 'string' ? query.lang : this.langDefault),
             date: query.date,
             librate: query.librate,
+            ext: query.ext,
 
             page: parseInt(query.page, 10),
             limit: parseInt(query.limit, 10) || this.search.limit,
@@ -1170,6 +1197,7 @@ class Search {
                 }
 
                 this.langList = result.langList;
+                this.extList = result.extList;
                 this.genreTreeInpxHash = result.inpxHash;
             }
         } catch (e) {

+ 187 - 0
client/components/Search/SelectExtDialog/SelectExtDialog.vue

@@ -0,0 +1,187 @@
+<template>
+    <Dialog ref="dialog" v-model="dialogVisible">
+        <template #header>
+            <div class="row items-center">
+                <div style="font-size: 110%">
+                    Выбрать типы файлов
+                </div>
+            </div>
+        </template>
+
+        <div ref="box" class="column q-mt-xs overflow-auto no-wrap" style="width: 370px; padding: 0px 10px 10px 10px;">
+            <div v-show="extList.length" class="checkbox-tick-all">
+                <div class="row items-center">
+                    <q-option-group
+                        v-model="ticked"
+                        :options="optionsPre"
+                        type="checkbox"
+                        inline
+                    >
+                        <template #label="opt">
+                            <div class="row items-center" style="width: 35px">
+                                <span>{{ opt.label }}</span>
+                            </div>
+                        </template>
+                    </q-option-group>
+                </div>
+
+                <q-checkbox v-model="tickAll" label="Выбрать/снять все" toggle-order="ft" @update:model-value="makeTickAll" />
+            </div>
+
+            <q-option-group
+                v-model="ticked"
+                :options="options"
+                type="checkbox"
+            >
+            </q-option-group>
+        </div>
+
+        <template #footer>
+            <q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="okClick">
+                OK
+            </q-btn>
+        </template>
+    </Dialog>
+</template>
+
+<script>
+//-----------------------------------------------------------------------------
+import vueComponent from '../../vueComponent.js';
+
+import Dialog from '../../share/Dialog.vue';
+
+const componentOptions = {
+    components: {
+        Dialog
+    },
+    watch: {
+        modelValue(newValue) {
+            this.dialogVisible = newValue;
+            if (newValue)
+                this.init();//no await
+        },
+        dialogVisible(newValue) {
+            this.$emit('update:modelValue', newValue);
+        },
+        ext() {
+            this.updateTicked();
+        },
+        ticked() {
+            this.checkAllTicked();
+            this.updateExt();
+        },
+    }
+};
+class SelectExtDialog {
+    _options = componentOptions;
+    _props = {
+        modelValue: Boolean,
+        ext: {type: String, value: ''},
+        extList: Array,
+    };
+
+    dialogVisible = false;
+
+    ticked = [];
+    tickAll = false;
+
+    created() {
+        this.commit = this.$store.commit;
+    }
+
+    mounted() {
+        this.updateTicked();
+    }
+
+    async init() {
+        //await this.$refs.dialog.waitShown();
+    }
+
+    get options() {
+        const result = [];
+
+        for (const ext of this.extList) {
+            if (ext.length <= 4)
+                result.push({label: ext, value: ext});
+        }
+
+        for (const ext of this.extList) {
+            if (ext.length > 4)
+                result.push({label: ext, value: ext});
+        }
+
+        return result;
+    }
+
+    get optionsPre() {
+        const result = [];
+
+        for (const ext of ['fb2', 'epub', 'mobi', 'pdf', 'djvu', 'doc', 'docx', 'rtf', 'xml', 'html', 'txt', 'zip']) {
+            if (this.extList.includes(ext)) {
+                result.push({label: ext, value: ext});
+            }
+        }
+
+        return result;
+    }
+
+    makeTickAll() {
+        if (this.tickAll) {
+            const newTicked = [];
+            for (const ext of this.extList) {
+                newTicked.push(ext);
+            }
+            this.ticked = newTicked;
+        } else {
+            this.ticked = [];
+            this.tickAll = false;
+        }
+    }
+
+    checkAllTicked() {
+        const ticked = new Set(this.ticked);
+
+        let newTickAll = !!(this.extList.length);
+        for (const ext of this.extList) {
+            if (!ticked.has(ext)) {
+                newTickAll = false;
+                break;
+            }
+        }
+
+        if (this.ticked.length && !newTickAll) {
+            this.tickAll = undefined;
+        } else {
+            this.tickAll = newTickAll;
+        }
+    }
+
+    updateTicked() {
+        this.ticked = this.ext.split('|').filter(s => s);
+    }
+
+    updateExt() {
+        this.$emit('update:ext', this.ticked.join('|'));
+    }
+
+    okClick() {
+        this.dialogVisible = false;
+    }
+}
+
+export default vueComponent(SelectExtDialog);
+//-----------------------------------------------------------------------------
+</script>
+
+<style scoped>
+.checkbox-tick-all {
+    border-bottom: 1px solid #bbbbbb;
+    margin-bottom: 7px;
+    padding: 5px 5px 2px 0px;
+}
+
+.clickable {
+    color: blue;
+    cursor: pointer;
+}
+</style>

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

@@ -145,7 +145,7 @@ class SelectExtSearchDialog {
     Атрибуты можно увидеть, если включить опцию "Показывать JSON".
     Названия атрибутов (кроме "_uid" и "id") соответствуют названиям полей струкутры записей из inpx-файла.
     На поисковые значения действуют те же правила, что и для разделов "Авторы", "Серии", "Книги".
-    <br>
+    <br><br>
     Для строковых значений (S):
     <ul>
         <li>

+ 9 - 3
client/components/Search/SeriesList/SeriesList.vue

@@ -96,9 +96,9 @@
         </div>
         <!-- Формирование списка конец ------------------------------------------------------------------>
 
-        <div v-if="!refreshing && !tableData.length" class="row items-center q-ml-md" style="font-size: 120%">
+        <div v-if="!refreshing && (!tableData.length || error)" class="row items-center q-ml-md" style="font-size: 120%">
             <q-icon class="la la-meh q-mr-xs" size="28px" />
-            Поиск не дал результатов
+            {{ (error ? error : 'Поиск не дал результатов') }}
         </div>
     </div>
 </template>
@@ -234,6 +234,7 @@ class SeriesList extends BaseList {
         if (this.refreshing)
             return;
 
+        this.error = '';
         this.refreshing = true;
 
         (async() => {
@@ -263,7 +264,12 @@ class SeriesList extends BaseList {
                         this.highlightPageScroller(query);
                     }
                 } catch (e) {
-                    this.$root.stdDialog.alert(e.message, 'Ошибка');
+                    this.list.queryFound = 0;
+                    this.list.totalFound = 0;
+                    this.searchResult = {found: []};
+                    await this.updateTableData();
+                    //this.$root.stdDialog.alert(e.message, 'Ошибка');
+                    this.error = `Ошибка: ${e.message}`;
                 }
             }
         } finally {

+ 9 - 3
client/components/Search/TitleList/TitleList.vue

@@ -22,9 +22,9 @@
         </div>
         <!-- Формирование списка конец ------------------------------------------------------------------>
 
-        <div v-if="!refreshing && !tableData.length" class="row items-center q-ml-md" style="font-size: 120%">
+        <div v-if="!refreshing && (!tableData.length || error)" class="row items-center q-ml-md" style="font-size: 120%">
             <q-icon class="la la-meh q-mr-xs" size="28px" />
-            Поиск не дал результатов
+            {{ (error ? error : 'Поиск не дал результатов') }}
         </div>
     </div>
 </template>
@@ -95,6 +95,7 @@ class TitleList extends BaseList {
         if (this.refreshing)
             return;
 
+        this.error = '';
         this.refreshing = true;
 
         (async() => {
@@ -124,7 +125,12 @@ class TitleList extends BaseList {
                         this.highlightPageScroller(query);
                     }
                 } catch (e) {
-                    this.$root.stdDialog.alert(e.message, 'Ошибка');
+                    this.list.queryFound = 0;
+                    this.list.totalFound = 0;
+                    this.searchResult = {found: []};
+                    await this.updateTableData();
+                    //this.$root.stdDialog.alert(e.message, 'Ошибка');
+                    this.error = `Ошибка: ${e.message}`;
                 }
             }
         } finally {

+ 0 - 29
client/share/utils.js

@@ -87,35 +87,6 @@ export async function copyTextToClipboard(text) {
     return result;
 }
 
-/*
-export function formatDate(d, format = 'normal') {
-    switch (format) {
-        case 'normal':
-            return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.${d.getFullYear()} ` + 
-                `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
-        case 'coDate':
-            return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
-        case 'coMonth':
-            return `${(d.getMonth() + 1).toString().padStart(2, '0')}`;
-        case 'noDate':
-            return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.${d.getFullYear()}`;
-
-        default:
-            throw new Error('formatDate: unknown date format');
-    }
-}
-
-export function parseDate(sqlDate) {
-    const d = sqlDate.split('-');
-    const result = new Date();
-    result.setDate(parseInt(d[2], 10));
-    result.setMonth(parseInt(d[1], 10) - 1);
-    result.setYear(parseInt(d[0], 10));
-        
-    return result;
-}
-*/
-
 export function isDigit(c) {
     return !isNaN(parseInt(c, 10));
 }

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "inpx-web",
-  "version": "1.5.3",
+  "version": "1.5.4",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "inpx-web",
-      "version": "1.5.3",
+      "version": "1.5.4",
       "hasInstallScript": true,
       "license": "CC0-1.0",
       "dependencies": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "inpx-web",
-  "version": "1.5.3",
+  "version": "1.5.4",
   "author": "Book Pauk <bookpauk@gmail.com>",
   "license": "CC0-1.0",
   "repository": "bookpauk/inpx-web",

+ 1 - 1
server/config/base.js

@@ -21,7 +21,7 @@ module.exports = {
 
     //поправить в случае, если были критические изменения в DbCreator или InpxParser
     //иначе будет рассинхронизация по кешу между сервером и клиентом на уровне БД
-    dbVersion: '11',
+    dbVersion: '12',
     dbCacheSize: 5,
 
     maxPayloadSize: 500,//in MB

+ 10 - 0
server/core/DbCreator.js

@@ -64,6 +64,8 @@ class DbCreator {
         let dateArr = [];
         let librateMap = new Map();//оценка
         let librateArr = [];
+        let extMap = new Map();//тип файла
+        let extArr = [];
 
         let uidSet = new Set();//уникальные идентификаторы
 
@@ -215,6 +217,9 @@ class DbCreator {
 
             //оценка
             parseField(rec.librate, librateMap, librateArr, rec.id);
+
+            //тип файла
+            parseField(rec.ext, extMap, extArr, rec.id);
         };
 
         //основная процедура парсинга
@@ -272,6 +277,8 @@ class DbCreator {
         delMap = null;
         dateMap = null;
         librateMap = null;
+        extMap = null;
+
         uidSet = null;
 
         await db.close({table: 'book'});
@@ -408,6 +415,9 @@ class DbCreator {
         //librate
         await saveTable('librate', librateArr, () => {librateArr = null}, 'number');
 
+        //ext
+        await saveTable('ext', extArr, () => {extArr = null});
+
         //кэш-таблицы запросов
         await db.create({table: 'query_cache'});
         await db.create({table: 'query_time'});

+ 38 - 1
server/core/DbSearcher.js

@@ -49,7 +49,8 @@ class DbSearcher {
     getWhere(a) {
         const db = this.db;
 
-        a = a.toLowerCase();
+        if (a[0] !== '~')
+            a = a.toLowerCase();
         let where;
 
         //особая обработка префиксов
@@ -288,6 +289,42 @@ class DbSearcher {
             idsArr.push(ids);
         }
 
+        //тип файла
+        if (query.ext) {
+            const key = `book-ids-ext-${query.ext}`;
+            let ids = await this.getCached(key);
+
+            if (ids === null) {
+                const extRows = await db.select({
+                    table: 'ext',
+                    rawResult: true,
+                    where: `
+                        const exts = ${db.esc(query.ext.split('|'))};
+
+                        const ids = new Set();
+                        for (const l of exts) {
+                            for (const id of @indexLR('value', l, l))
+                                ids.add(id);
+                        }
+                        
+                        const result = new Set();
+                        for (const id of ids) {
+                            const row = @unsafeRow(id);
+                            for (const bookId of row.bookIds)
+                                result.add(bookId);
+                        }
+
+                        return new Uint32Array(result);
+                    `
+                });
+
+                ids = extRows[0].rawResult;
+                await this.putCached(key, ids);
+            }
+
+            idsArr.push(ids);
+        }
+
         if (idsArr.length > 1) {
             //ищем пересечение множеств
             let proc = 0;

+ 25 - 9
server/core/WebWorker.js

@@ -30,7 +30,7 @@ const stateToText = {
 };
 
 const cleanDirInterval = 60*60*1000;//каждый час
-const checkReleaseInterval = 2*60*60*1000;//каждые 2 часа
+const checkReleaseInterval = 7*60*60*1000;//каждые 7 часов
 
 //singleton
 let instance = null;
@@ -350,9 +350,14 @@ class WebWorker {
             rows = await db.select({table: 'lang', map: `(r) => ({value: r.value})`});
             const langs = rows.map(r => r.value);            
 
+            // exts
+            rows = await db.select({table: 'ext', map: `(r) => ({value: r.value})`});
+            const exts = rows.map(r => r.value);            
+
             result = {
                 genreTree: genres,
                 langList: langs,
+                extList: exts,
                 inpxHash: (config.inpxHash ? config.inpxHash : ''),
             };
 
@@ -367,17 +372,28 @@ class WebWorker {
     async extractBook(bookPath) {
         const outFile = `${this.config.tempDir}/${utils.randomHexString(30)}`;
 
-        const folder = `${this.config.libDir}/${path.dirname(bookPath)}`;
-        const file = path.basename(bookPath);
+        bookPath = bookPath.replace(/\\/g, '/').replace(/\/\//g, '/');
 
-        const zipReader = new ZipReader();
-        await zipReader.open(folder);
+        const i = bookPath.indexOf('/');
+        const folder = `${this.config.libDir}/${(i >= 0 ? bookPath.substring(0, i) : bookPath )}`;
+        const file = (i >= 0 ? bookPath.substring(i + 1) : '' );
 
-        try {
-            await zipReader.extractToFile(file, outFile);
+        const fullPath = `${folder}/${file}`;
+
+        if (!file || await fs.pathExists(fullPath)) {// файл есть на диске
+            
+            await fs.copy(fullPath, outFile);
             return outFile;
-        } finally {
-            await zipReader.close();
+        } else {// файл в zip-архиве
+            const zipReader = new ZipReader();
+            await zipReader.open(folder);
+
+            try {
+                await zipReader.extractToFile(file, outFile);
+                return outFile;
+            } finally {
+                await zipReader.close();
+            }
         }
     }
 

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

@@ -35,7 +35,7 @@ class Fb2Helper {
                 if (m) {
                     let enc = m[1].toLowerCase();
                     if (enc != 'utf-8') {
-                        //enc может не соответсвовать реальной кодировке файла, поэтому:
+                        //если кодировка не определена в getEncoding, используем enc
                         if (encoding.indexOf('ISO-8859') >= 0) {
                             encoding = enc;
                         }

+ 2 - 11
server/core/fb2/textUtils.js

@@ -4,7 +4,7 @@ function getEncoding(buf) {
     let selected = getEncodingLite(buf);
 
     if (selected == 'ISO-8859-5' && buf.length > 10) {
-        const charsetAll = chardet.analyse(buf.slice(0, 20000));
+        const charsetAll = chardet.analyse(buf.slice(0, 100000));
         for (const charset of charsetAll) {
             if (charset.name.indexOf('ISO-8859') < 0) {
                 selected = charset.name;
@@ -39,9 +39,7 @@ function getEncodingLite(buf, returnAll) {
         'u': 0,
     };
 
-    const len = buf.length;
-    const blockSize = (len > 5*3000 ? 3000 : len);
-    let counter = 0;
+    const len = (buf.length > 100000 ? 100000 : buf.length);
     let i = 0;
     let totalChecked = 0;
     while (i < len) {
@@ -76,13 +74,6 @@ function getEncodingLite(buf, returnAll) {
             if (char > 207 && char < 240) charsets['i'] += lowerCase;
             if (char > 175 && char < 208) charsets['i'] += upperCase;
         }
-
-        counter++;
-
-        if (counter > blockSize) {
-            counter = 0;
-            i += Math.round(len/2 - 2*blockSize);
-        }
     }
 
     let sorted = Object.keys(charsets).map(function(key) {

+ 2 - 1
server/core/opds/BasePage.js

@@ -243,7 +243,8 @@ class BasePage {
                 bookValue = emptyFieldValue;
 
             bookValue = bookValue.toLowerCase();
-            searchValue = searchValue.toLowerCase();
+            if (searchValue[0] !== '~')
+                searchValue = searchValue.toLowerCase();
 
             //особая обработка префиксов
             if (searchValue[0] == '=') {