Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc729218ba | ||
|
|
15778eb3e4 | ||
|
|
ad1a6560fa | ||
|
|
7fa203eaae | ||
|
|
74d8cd3f94 | ||
|
|
fd29532cf1 | ||
|
|
1dc169d14b | ||
|
|
870f95a51f | ||
|
|
72ab94291c | ||
|
|
8de33fbd9a | ||
|
|
fd9bc45fb1 | ||
|
|
e356b87494 | ||
|
|
4371e1a641 | ||
|
|
95da605cb9 | ||
|
|
6dfa551b97 | ||
|
|
6a3b919f5f | ||
|
|
cac8e7c721 | ||
|
|
410aa01ac9 | ||
|
|
a8ed8b29e5 | ||
|
|
5a04e4f0c7 | ||
|
|
a6d9df7dec | ||
|
|
8cf370c79d | ||
|
|
35925dbc6e | ||
|
|
d0e79b0abb | ||
|
|
aba0c206f8 | ||
|
|
037b42a5b4 | ||
|
|
8a71c4040c | ||
|
|
e685f136e1 | ||
|
|
b8b40e8cb0 | ||
|
|
7e9f446079 | ||
|
|
13c3c98c63 | ||
|
|
1b70259ea7 | ||
|
|
a840fb7233 | ||
|
|
1ba54c1237 | ||
|
|
412335c0f1 | ||
|
|
6b91c43655 | ||
|
|
4b4865b6ed | ||
|
|
d5931138e3 | ||
|
|
3d1385da6e | ||
|
|
5630feba36 | ||
|
|
64a301eda1 | ||
|
|
044ab1ab26 | ||
|
|
d6260e3433 | ||
|
|
fb2eb62a98 | ||
|
|
d7c6b0e7ab | ||
|
|
94922f3926 | ||
|
|
a580b1eb6d | ||
|
|
cd7b8afb29 | ||
|
|
e634893ff3 | ||
|
|
fadc7ddc34 | ||
|
|
ed5dc25d94 | ||
|
|
dd11e8c5ad | ||
|
|
2db2b8cff4 | ||
|
|
4d3661b758 | ||
|
|
891b1e4fe8 | ||
|
|
d588b16885 |
53
README.md
53
README.md
@@ -2,19 +2,24 @@ inpx-web
|
||||
========
|
||||
|
||||
Веб-сервер для поиска по .inpx-коллекции.
|
||||
Выглядит это так: https://lib.omnireader.ru
|
||||
|
||||
Выглядит следующим образом: [https://lib.omnireader.ru](https://lib.omnireader.ru)
|
||||
|
||||
.inpx - индексный файл для импорта\экспорта информации из базы данных сетевых библиотек
|
||||
в базу каталогизатора [MyHomeLib](https://alex80.github.io/mhl/)
|
||||
или [freeLib](http://sourceforge.net/projects/freelibdesign)
|
||||
или [LightLib](https://lightlib.azurewebsites.net)
|
||||
|
||||
Просто поместите приложение `inpx-web` в папку с .inpx-файлом и файлами библиотеки (zip-архивами) и запустите.
|
||||
Сервер будет доступен по адресу http://127.0.0.1:12380
|
||||
[Установка](#usage): просто поместить приложение `inpx-web` в папку с .inpx-файлом и файлами библиотеки (zip-архивами) и запустить.
|
||||
|
||||
После открытия веб-приложения в бразуере, для быстрого понимания того, как работает поиск, воспользуйтесь памяткой (кнопка со знаком вопроса).
|
||||
По умолчанию, веб-сервер будет доступен по адресу [http://127.0.0.1:12380](http://127.0.0.1:12380)
|
||||
|
||||
##
|
||||
OPDS-сервер доступен по адресу [http://127.0.0.1:12380/opds](http://127.0.0.1:12380/opds)
|
||||
|
||||
Для указания местоположения .inpx-файла или папки с файлами библиотеки, воспользуйтесь [параметрами командной строки](#cli).
|
||||
Дополнительные параметры сервера настраиваются в [конфигурационном файле](#config).
|
||||
|
||||
##
|
||||
* [Возможности программы](#capabilities)
|
||||
* [Использование](#usage)
|
||||
* [Параметры командной строки](#cli)
|
||||
@@ -28,6 +33,7 @@ inpx-web
|
||||
<a id="capabilities" />
|
||||
|
||||
## Возможности программы
|
||||
- веб-интерфейс и OPDS-сервер
|
||||
- поиск по автору, серии, названию и пр.
|
||||
- скачивание книги, копирование ссылки или открытие в читалке
|
||||
- возможность указать рабочий каталог при запуске, а также расположение .inpx и файлов библиотеки
|
||||
@@ -45,7 +51,9 @@ inpx-web
|
||||
Там же, при первом запуске, будет создана рабочая директория `.inpx-web`, в которой хранится
|
||||
конфигурационный файл `config.json`, файлы базы данных, журналы и прочее.
|
||||
|
||||
По умолчанию сервер будет доступен по адресу http://127.0.0.1:12380
|
||||
По умолчанию веб-интерфейс будет доступен по адресу [http://127.0.0.1:12380](http://127.0.0.1:12380)
|
||||
|
||||
OPDS-сервер доступен по адресу [http://127.0.0.1:12380/opds](http://127.0.0.1:12380/opds)
|
||||
|
||||
<a id="cli" />
|
||||
|
||||
@@ -89,9 +97,17 @@ Options:
|
||||
// чистка каждый час
|
||||
"maxFilesDirSize": 1073741824,
|
||||
|
||||
// включить(true)/выключить(false) кеширование запросов на сервере
|
||||
// включить(true)/выключить(false) серверное кеширование запросов на диске и в памяти
|
||||
"queryCacheEnabled": true,
|
||||
|
||||
// размер кеша запросов в оперативной памяти (количество)
|
||||
// 0 - отключить кеширование запросов в оперативной памяти
|
||||
"queryCacheMemSize": 50,
|
||||
|
||||
// размер кеша запросов на диске (количество)
|
||||
// 0 - отключить кеширование запросов на диске
|
||||
"queryCacheDiskSize": 500,
|
||||
|
||||
// периодичность чистки кеша запросов на сервере, в минутах
|
||||
// 0 - отключить чистку
|
||||
"cacheCleanInterval": 60,
|
||||
@@ -121,6 +137,14 @@ Options:
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": "12380"
|
||||
},
|
||||
|
||||
// настройки opds-сервера
|
||||
// user, password используются для Basic HTTP authentication
|
||||
"opds": {
|
||||
"enabled": true,
|
||||
"user": "",
|
||||
"password": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -161,7 +185,7 @@ Options:
|
||||
|
||||
### Фильтр по авторам и книгам
|
||||
|
||||
При создании поисковой БД во время загрузки и парсинга .inpx-файла, имеется возможность
|
||||
При создании поисковой БД, во время загрузки и парсинга .inpx-файла, имеется возможность
|
||||
отфильтровать авторов и книги, задав определенные критерии. Для этого небходимо создать
|
||||
в рабочей директории (там же, где `config.json`) файл `filter.json` следующего вида:
|
||||
```json
|
||||
@@ -176,7 +200,7 @@ Options:
|
||||
"excludeAuthors": ["Имя автора"]
|
||||
}
|
||||
```
|
||||
При создании поисковой БД, авторы и книги из `includeAuthors` будут добавлены, а из `excludeAuthors` исключены.
|
||||
При фильтрации, авторы и их книги из `includeAuthors` будут оставлены, а из `excludeAuthors` исключены.
|
||||
Использование совместно `includeAuthors` и `excludeAuthors` имеет мало смысла, поэтому для включения
|
||||
определенных авторов можно использовать только `includeAuthors`:
|
||||
```json
|
||||
@@ -256,17 +280,12 @@ cd inpx-web
|
||||
npm i
|
||||
```
|
||||
|
||||
#### Для платформы Windows
|
||||
#### Релизы
|
||||
```sh
|
||||
npm run build:win
|
||||
npm run release
|
||||
```
|
||||
|
||||
#### Для платформы Linux
|
||||
```sh
|
||||
npm run build:linux
|
||||
```
|
||||
|
||||
Результат сборки будет доступен в каталоге `dist/linux|win` в виде исполнимого (standalone) файла.
|
||||
Результат сборки будет доступен в каталоге `dist/release`
|
||||
|
||||
<a id="development" />
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const showdown = require('showdown');
|
||||
|
||||
const platform = process.argv[2];
|
||||
|
||||
const distDir = path.resolve(__dirname, '../dist');
|
||||
@@ -10,11 +12,17 @@ const publicDir = `${tmpDir}/public`;
|
||||
const outDir = `${distDir}/${platform}`;
|
||||
|
||||
async function build() {
|
||||
if (platform != 'linux' && platform != 'win')
|
||||
if (platform != 'linux' && platform != 'win' && platform != 'macos')
|
||||
throw new Error(`Unknown platform: ${platform}`);
|
||||
|
||||
await fs.emptyDir(outDir);
|
||||
|
||||
//добавляем readme в релиз
|
||||
let readme = await fs.readFile(path.resolve(__dirname, '../README.md'), 'utf-8');
|
||||
const converter = new showdown.Converter();
|
||||
readme = converter.makeHtml(readme);
|
||||
await fs.writeFile(`${outDir}/readme.html`, readme);
|
||||
|
||||
// перемещаем public на место
|
||||
if (await fs.pathExists(publicDir)) {
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ async function main() {
|
||||
await fs.emptyDir(outDir);
|
||||
await makeRelease('win');
|
||||
await makeRelease('linux');
|
||||
await makeRelease('macos');
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
|
||||
@@ -231,12 +231,12 @@ class Api {
|
||||
return await this.request({action: 'get-genre-tree'});
|
||||
}
|
||||
|
||||
async getBookLink(bookId) {
|
||||
return await this.request({action: 'get-book-link', bookId}, 120);
|
||||
async getBookLink(bookUid) {
|
||||
return await this.request({action: 'get-book-link', bookUid}, 120);
|
||||
}
|
||||
|
||||
async getBookInfo(bookId) {
|
||||
return await this.request({action: 'get-book-info', bookId}, 120);
|
||||
async getBookInfo(bookUid) {
|
||||
return await this.request({action: 'get-book-info', bookUid}, 120);
|
||||
}
|
||||
|
||||
async getConfig() {
|
||||
|
||||
@@ -238,6 +238,13 @@ class AuthorList extends BaseList {
|
||||
const booksToFilter = await this.loadAuthorBooks(item.key);
|
||||
const filtered = this.filterBooks(booksToFilter);
|
||||
|
||||
if (!filtered.length && this.list.totalFound == 1) {
|
||||
this.list.queryFound = 0;
|
||||
this.list.totalFound = 0;
|
||||
this.searchResult.found = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
const prepareBook = (book) => {
|
||||
return Object.assign(
|
||||
{
|
||||
@@ -345,7 +352,10 @@ class AuthorList extends BaseList {
|
||||
if (authors.length > 1 || item.count > this.maxItemCount)
|
||||
this.getAuthorBooks(item);//no await
|
||||
else
|
||||
await this.getAuthorBooks(item);
|
||||
if (await this.getAuthorBooks(item) === false) {
|
||||
this.tableData = [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(item);
|
||||
|
||||
@@ -130,7 +130,7 @@ export default class BaseList {
|
||||
|
||||
try {
|
||||
//подготовка
|
||||
const response = await this.api.getBookLink(book.id);
|
||||
const response = await this.api.getBookLink(book._uid);
|
||||
|
||||
const link = response.link;
|
||||
const href = `${window.location.origin}${link}`;
|
||||
@@ -164,7 +164,7 @@ export default class BaseList {
|
||||
}
|
||||
} else if (action == 'bookInfo') {
|
||||
//информация о книге
|
||||
const response = await this.api.getBookInfo(book.id);
|
||||
const response = await this.api.getBookInfo(book._uid);
|
||||
this.$emit('listEvent', {action: 'bookInfo', data: response.bookInfo});
|
||||
}
|
||||
} catch(e) {
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-sm no-wrap">
|
||||
<div class="column justify-center" style="height: 300px; width: 200px; min-width: 100px">
|
||||
<img v-if="coverSrc" :src="coverSrc" class="fit row justify-center items-center" style="object-fit: contain" @error="coverSrc = ''" />
|
||||
<div v-if="!coverSrc" class="fit row justify-center items-center text-grey-5" style="border: 1px solid #ccc; font-size: 300%">
|
||||
<i>{{ book.ext }}</i>
|
||||
<div class="poster-size">
|
||||
<div class="poster-size column justify-center items-center" :class="{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%">
|
||||
<i>{{ book.ext }}</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,6 +73,19 @@
|
||||
OK
|
||||
</q-btn>
|
||||
</template>
|
||||
|
||||
|
||||
<Dialog v-model="posterDialogVisible">
|
||||
<template #header>
|
||||
<div class="row items-center">
|
||||
<div style="font-size: 110%">
|
||||
Обложка
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<img :src="coverSrc" class="fit q-pb-sm" style="height: 100%; max-height: calc(100vh - 140px); object-fit: contain" />
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -106,6 +121,7 @@ class BookInfoDialog {
|
||||
};
|
||||
|
||||
dialogVisible = false;
|
||||
posterDialogVisible = false;
|
||||
selectedTab = 'fb2';
|
||||
|
||||
//info props
|
||||
@@ -158,7 +174,6 @@ class BookInfoDialog {
|
||||
{name: 'fileInfo', label: 'Информация о файле', value: [
|
||||
{name: 'folder', label: 'Папка'},
|
||||
{name: 'file', label: 'Файл'},
|
||||
{name: 'ext', label: 'Тип'},
|
||||
{name: 'size', label: 'Размер'},
|
||||
{name: 'date', label: 'Добавлен'},
|
||||
{name: 'del', label: 'Удален'},
|
||||
@@ -177,7 +192,10 @@ class BookInfoDialog {
|
||||
]},
|
||||
];
|
||||
|
||||
const valueToString = (value, nodePath) => {//eslint-disable-line no-unused-vars
|
||||
const valueToString = (value, nodePath, b) => {//eslint-disable-line no-unused-vars
|
||||
if (nodePath == 'fileInfo/file')
|
||||
return `${value}.${b.ext}`;
|
||||
|
||||
if (nodePath == 'fileInfo/size')
|
||||
return `${this.formatSize(value)} (${value.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1 ')} Bytes)`;
|
||||
|
||||
@@ -185,7 +203,10 @@ class BookInfoDialog {
|
||||
return utils.sqlDateFormat(value);
|
||||
|
||||
if (nodePath == 'fileInfo/del')
|
||||
return (value ? 'Да' : '');
|
||||
return (value ? 'Да' : null);
|
||||
|
||||
if (nodePath == 'fileInfo/insno')
|
||||
return (value ? value : null);
|
||||
|
||||
if (nodePath == 'titleInfo/author')
|
||||
return value.split(',').join(', ');
|
||||
@@ -211,7 +232,7 @@ class BookInfoDialog {
|
||||
const subItemOut = {
|
||||
name: subItem.name,
|
||||
label: subItem.label,
|
||||
value: valueToString(book[subItem.name], `${item.name}/${subItem.name}`)
|
||||
value: valueToString(book[subItem.name], `${item.name}/${subItem.name}`, book)
|
||||
};
|
||||
if (subItemOut.value)
|
||||
itemOut.value.push(subItemOut);
|
||||
@@ -273,6 +294,13 @@ class BookInfoDialog {
|
||||
this.book = bookInfo.book;
|
||||
}
|
||||
|
||||
posterClick() {
|
||||
if (!this.coverSrc)
|
||||
return;
|
||||
|
||||
this.posterDialogVisible = true;
|
||||
}
|
||||
|
||||
okClick() {
|
||||
this.dialogVisible = false;
|
||||
}
|
||||
@@ -283,6 +311,26 @@ export default vueComponent(BookInfoDialog);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.poster-size {
|
||||
height: 300px;
|
||||
width: 200px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.poster {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.poster:hover {
|
||||
position: relative;
|
||||
top: -1%;
|
||||
left: -1%;
|
||||
width: 102%;
|
||||
height: 102%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<div class="q-ml-sm column">
|
||||
<div v-if="(mode == 'series' || mode == 'title') && bookAuthor" class="row">
|
||||
<div class="clickable2 text-green-10" @click="emit('authorClick')">
|
||||
<div class="clickable2 text-green-10" @click.stop.prevent="emit('authorClick')">
|
||||
{{ bookAuthor }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,10 +43,10 @@
|
||||
<div v-if="book.serno" class="q-mr-xs">
|
||||
{{ book.serno }}.
|
||||
</div>
|
||||
<div class="clickable2" :class="titleColor" @click="emit('titleClick')">
|
||||
<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="emit('seriesClick')">
|
||||
<div v-if="mode == 'title' && bookSeries" class="q-ml-xs clickable2" @click.stop.prevent="emit('seriesClick')">
|
||||
{{ bookSeries }}
|
||||
</div>
|
||||
|
||||
@@ -55,19 +55,19 @@
|
||||
{{ bookSize }}, {{ book.ext }}
|
||||
</div>
|
||||
|
||||
<div v-if="showInfo" class="q-ml-sm clickable" @click="emit('bookInfo')">
|
||||
<div v-if="showInfo" class="q-ml-sm clickable" @click.stop.prevent="emit('bookInfo')">
|
||||
(инфо)
|
||||
</div>
|
||||
|
||||
<div class="q-ml-sm clickable" @click="emit('download')">
|
||||
<div class="q-ml-sm clickable" @click.stop.prevent="emit('download')">
|
||||
(скачать)
|
||||
</div>
|
||||
|
||||
<div class="q-ml-sm clickable" @click="emit('copyLink')">
|
||||
<div class="q-ml-sm clickable" @click.stop.prevent="emit('copyLink')">
|
||||
<q-icon name="la la-copy" size="20px" />
|
||||
</div>
|
||||
|
||||
<div v-if="showReadLink" class="q-ml-sm clickable" @click="emit('readBook')">
|
||||
<div v-if="showReadLink" class="q-ml-sm clickable" @click.stop.prevent="emit('readBook')">
|
||||
(читать)
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,14 +24,14 @@
|
||||
<div class="q-mr-xs">
|
||||
Коллекция
|
||||
</div>
|
||||
<div class="clickable" @click="showCollectionInfo">
|
||||
<div class="clickable" @click.stop.prevent="showCollectionInfo">
|
||||
{{ collection }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col"></div>
|
||||
|
||||
<DivBtn class="q-ml-md text-white bg-secondary" :size="30" :icon-size="24" icon="la la-question" round @click="showSearchHelp">
|
||||
<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">
|
||||
Памятка
|
||||
@@ -39,7 +39,7 @@
|
||||
</template>
|
||||
</DivBtn>
|
||||
|
||||
<DivBtn class="q-ml-sm text-white bg-secondary" :size="30" :icon-size="24" :imt="1" icon="la la-cog" round @click="settingsDialogVisible = true">
|
||||
<DivBtn class="q-ml-sm text-white bg-secondary" :size="30" :icon-size="24" :imt="1" icon="la la-cog" round @click.stop.prevent="settingsDialogVisible = true">
|
||||
<template #tooltip>
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
|
||||
Настройки
|
||||
@@ -51,7 +51,7 @@
|
||||
<DivBtn
|
||||
class="text-grey-5 bg-yellow-1 q-mt-xs" :size="34" :icon-size="24" round
|
||||
:icon="(extendedParams ? 'la la-angle-double-up' : 'la la-angle-double-down')"
|
||||
@click="extendedParams = !extendedParams"
|
||||
@click.stop.prevent="extendedParams = !extendedParams"
|
||||
>
|
||||
<template #tooltip>
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
|
||||
@@ -90,7 +90,7 @@
|
||||
<q-input
|
||||
v-model="search.lang" :maxlength="inputMaxLength" :debounce="inputDebounce"
|
||||
class="q-mt-xs" :bg-color="inputBgColor()" input-style="cursor: pointer" style="width: 90px;" label="Язык" stack-label outlined dense clearable readonly
|
||||
@click="selectLang"
|
||||
@click.stop.prevent="selectLang"
|
||||
>
|
||||
<template v-if="search.lang" #append>
|
||||
<q-icon name="la la-times-circle" class="q-field__focusable-action" @click.stop.prevent="search.lang = ''" />
|
||||
@@ -104,7 +104,7 @@
|
||||
<DivBtn
|
||||
class="text-grey-8 bg-yellow-1 q-mt-xs" :size="34" :icon-size="24" round
|
||||
icon="la la-level-up-alt"
|
||||
@click="cloneSearch"
|
||||
@click.stop.prevent="cloneSearch"
|
||||
>
|
||||
<template #tooltip>
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%" max-width="400px">
|
||||
@@ -119,7 +119,7 @@
|
||||
<q-input
|
||||
v-model="genreNames" :maxlength="inputMaxLength" :debounce="inputDebounce"
|
||||
class="q-mt-xs" :bg-color="inputBgColor()" input-style="cursor: pointer" style="width: 200px;" label="Жанр" stack-label outlined dense clearable readonly
|
||||
@click="selectGenre"
|
||||
@click.stop.prevent="selectGenre"
|
||||
>
|
||||
<template v-if="genreNames" #append>
|
||||
<q-icon name="la la-times-circle" class="q-field__focusable-action" @click.stop.prevent="search.genre = ''" />
|
||||
@@ -151,7 +151,7 @@
|
||||
</template>
|
||||
|
||||
<template #option="scope">
|
||||
<q-item v-bind="scope.itemProps" @click="dateSelectItemClick(scope.opt.value)">
|
||||
<q-item v-bind="scope.itemProps" @click.stop.prevent="dateSelectItemClick(scope.opt.value)">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
{{ scope.opt.label }}
|
||||
@@ -165,7 +165,7 @@
|
||||
<q-input
|
||||
v-model="librateNames" :maxlength="inputMaxLength" :debounce="inputDebounce"
|
||||
class="q-mt-xs" :bg-color="inputBgColor()" input-style="cursor: pointer" style="width: 90px;" label="Оценка" stack-label outlined dense clearable readonly
|
||||
@click="selectLibRate"
|
||||
@click.stop.prevent="selectLibRate"
|
||||
>
|
||||
<template v-if="librateNames" #append>
|
||||
<q-icon name="la la-times-circle" class="q-field__focusable-action" @click.stop.prevent="search.librate = ''" />
|
||||
@@ -176,7 +176,7 @@
|
||||
</q-tooltip>
|
||||
</q-input>
|
||||
</div>
|
||||
<div v-show="!extendedParams && extendedParamsMessage" class="row q-mx-md items-center clickable" @click="extendedParams = true">
|
||||
<div v-show="!extendedParams && extendedParamsMessage" class="row q-mx-md items-center clickable" @click.stop.prevent="extendedParams = true">
|
||||
+{{ extendedParamsMessage }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,48 +204,13 @@
|
||||
</div>
|
||||
|
||||
<div class="row justify-center">
|
||||
<div class="q-mb-lg q-px-sm q-py-xs bg-cyan-2 clickable2" style="border: 1px solid #aaaaaa; border-radius: 6px; white-space: nowrap;" @click="openReleasePage">
|
||||
<div class="q-mb-lg q-px-sm q-py-xs bg-cyan-2 clickable2" style="border: 1px solid #aaaaaa; border-radius: 6px; white-space: nowrap;" @click.stop.prevent="openReleasePage">
|
||||
{{ projectName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog v-model="settingsDialogVisible">
|
||||
<template #header>
|
||||
<div class="row items-center" style="font-size: 110%">
|
||||
<q-icon class="q-mr-sm text-green" name="la la-cog" size="28px"></q-icon>
|
||||
Настройки
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="q-mx-md column" style="min-width: 300px; font-size: 120%;">
|
||||
<div class="row items-center q-ml-sm">
|
||||
<div class="q-mr-sm">
|
||||
Результатов на странице
|
||||
</div>
|
||||
<q-select
|
||||
v-model="limit" :options="limitOptions" class="bg-white"
|
||||
dropdown-icon="la la-angle-down la-sm"
|
||||
outlined dense emit-value map-options
|
||||
/>
|
||||
</div>
|
||||
|
||||
<q-checkbox v-model="showCounts" size="36px" label="Показывать количество" />
|
||||
<q-checkbox v-model="showRates" size="36px" label="Показывать оценки" />
|
||||
<q-checkbox v-model="showInfo" size="36px" label="Показывать кнопку (инфо)" />
|
||||
<q-checkbox v-model="showGenres" size="36px" label="Показывать жанры" />
|
||||
<q-checkbox v-model="showDates" size="36px" label="Показывать даты поступления" />
|
||||
<q-checkbox v-model="showDeleted" size="36px" label="Показывать удаленные" />
|
||||
<q-checkbox v-model="abCacheEnabled" size="36px" label="Кешировать запросы" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="settingsDialogVisible = false">
|
||||
OK
|
||||
</q-btn>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<SettingsDialog v-model="settingsDialogVisible" />
|
||||
<SelectGenreDialog v-model="selectGenreDialogVisible" v-model:genre="search.genre" :genre-tree="genreTree" />
|
||||
<SelectLangDialog v-model="selectLangDialogVisible" v-model:lang="search.lang" :lang-list="langList" :lang-default="langDefault" />
|
||||
<SelectLibRateDialog v-model="selectLibRateDialogVisible" v-model:librate="search.librate" />
|
||||
@@ -263,6 +228,7 @@ import SeriesList from './SeriesList/SeriesList.vue';
|
||||
import TitleList from './TitleList/TitleList.vue';
|
||||
|
||||
import PageScroller from './PageScroller/PageScroller.vue';
|
||||
import SettingsDialog from './SettingsDialog/SettingsDialog.vue';
|
||||
import SelectGenreDialog from './SelectGenreDialog/SelectGenreDialog.vue';
|
||||
import SelectLangDialog from './SelectLangDialog/SelectLangDialog.vue';
|
||||
import SelectLibRateDialog from './SelectLibRateDialog/SelectLibRateDialog.vue';
|
||||
@@ -290,6 +256,7 @@ const componentOptions = {
|
||||
SeriesList,
|
||||
TitleList,
|
||||
PageScroller,
|
||||
SettingsDialog,
|
||||
SelectGenreDialog,
|
||||
SelectLangDialog,
|
||||
SelectLibRateDialog,
|
||||
@@ -328,27 +295,6 @@ const componentOptions = {
|
||||
|
||||
this.updatePageCount();
|
||||
},
|
||||
showCounts(newValue) {
|
||||
this.setSetting('showCounts', newValue);
|
||||
},
|
||||
showRates(newValue) {
|
||||
this.setSetting('showRates', newValue);
|
||||
},
|
||||
showInfo(newValue) {
|
||||
this.setSetting('showInfo', newValue);
|
||||
},
|
||||
showGenres(newValue) {
|
||||
this.setSetting('showGenres', newValue);
|
||||
},
|
||||
showDates(newValue) {
|
||||
this.setSetting('showDates', newValue);
|
||||
},
|
||||
showDeleted(newValue) {
|
||||
this.setSetting('showDeleted', newValue);
|
||||
},
|
||||
abCacheEnabled(newValue) {
|
||||
this.setSetting('abCacheEnabled', newValue);
|
||||
},
|
||||
$route(to) {
|
||||
this.updateListFromRoute(to);
|
||||
this.updateSearchFromRouteQuery(to);
|
||||
@@ -436,12 +382,6 @@ class Search {
|
||||
prevManualDate = '';
|
||||
|
||||
//settings
|
||||
showCounts = true;
|
||||
showRates = true;
|
||||
showInfo = true;
|
||||
showGenres = true;
|
||||
showDates = true;
|
||||
showDeleted = false;
|
||||
abCacheEnabled = true;
|
||||
langDefault = '';
|
||||
limit = 20;
|
||||
@@ -464,16 +404,6 @@ class Search {
|
||||
|
||||
bookInfo = {};
|
||||
|
||||
limitOptions = [
|
||||
{label: '10', value: 10},
|
||||
{label: '20', value: 20},
|
||||
{label: '50', value: 50},
|
||||
{label: '100', value: 100},
|
||||
{label: '200', value: 200},
|
||||
{label: '500', value: 500},
|
||||
{label: '1000', value: 1000},
|
||||
];
|
||||
|
||||
searchDateOptions = [
|
||||
{label: 'сегодня', value: 'today'},
|
||||
{label: 'за 3 дня', value: '3days'},
|
||||
@@ -530,12 +460,6 @@ class Search {
|
||||
this.extendedParams = settings.extendedParams;
|
||||
this.expanded = _.cloneDeep(settings.expanded);
|
||||
this.expandedSeries = _.cloneDeep(settings.expandedSeries);
|
||||
this.showCounts = settings.showCounts;
|
||||
this.showRates = settings.showRates;
|
||||
this.showInfo = settings.showInfo;
|
||||
this.showGenres = settings.showGenres;
|
||||
this.showDates = settings.showDates;
|
||||
this.showDeleted = settings.showDeleted;
|
||||
this.abCacheEnabled = settings.abCacheEnabled;
|
||||
this.langDefault = settings.langDefault;
|
||||
}
|
||||
|
||||
151
client/components/Search/SettingsDialog/SettingsDialog.vue
Normal file
151
client/components/Search/SettingsDialog/SettingsDialog.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<Dialog ref="dialog" v-model="dialogVisible">
|
||||
<template #header>
|
||||
<div class="row items-center" style="font-size: 110%">
|
||||
<q-icon class="q-mr-sm text-green" name="la la-cog" size="28px"></q-icon>
|
||||
Настройки
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="q-mx-md column" style="min-width: 300px; font-size: 120%;">
|
||||
<div class="row items-center q-ml-sm">
|
||||
<div class="q-mr-sm">
|
||||
Результатов на странице
|
||||
</div>
|
||||
<q-select
|
||||
v-model="limit" :options="limitOptions" class="bg-white"
|
||||
dropdown-icon="la la-angle-down la-sm"
|
||||
outlined dense emit-value map-options
|
||||
/>
|
||||
</div>
|
||||
|
||||
<q-checkbox v-model="showCounts" size="36px" label="Показывать количество" />
|
||||
<q-checkbox v-model="showRates" size="36px" label="Показывать оценки" />
|
||||
<q-checkbox v-model="showInfo" size="36px" label="Показывать кнопку (инфо)" />
|
||||
<q-checkbox v-model="showGenres" size="36px" label="Показывать жанры" />
|
||||
<q-checkbox v-model="showDates" size="36px" label="Показывать даты поступления" />
|
||||
<q-checkbox v-model="showDeleted" size="36px" label="Показывать удаленные" />
|
||||
<q-checkbox v-model="abCacheEnabled" size="36px" label="Кешировать запросы" />
|
||||
</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;
|
||||
},
|
||||
dialogVisible(newValue) {
|
||||
this.$emit('update:modelValue', newValue);
|
||||
},
|
||||
|
||||
settings() {
|
||||
this.loadSettings();
|
||||
},
|
||||
limit(newValue) {
|
||||
this.commit('setSettings', {'limit': newValue});
|
||||
},
|
||||
showCounts(newValue) {
|
||||
this.commit('setSettings', {'showCounts': newValue});
|
||||
},
|
||||
showRates(newValue) {
|
||||
this.commit('setSettings', {'showRates': newValue});
|
||||
},
|
||||
showInfo(newValue) {
|
||||
this.commit('setSettings', {'showInfo': newValue});
|
||||
},
|
||||
showGenres(newValue) {
|
||||
this.commit('setSettings', {'showGenres': newValue});
|
||||
},
|
||||
showDates(newValue) {
|
||||
this.commit('setSettings', {'showDates': newValue});
|
||||
},
|
||||
showDeleted(newValue) {
|
||||
this.commit('setSettings', {'showDeleted': newValue});
|
||||
},
|
||||
abCacheEnabled(newValue) {
|
||||
this.commit('setSettings', {'abCacheEnabled': newValue});
|
||||
},
|
||||
}
|
||||
};
|
||||
class SettingsDialog {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
modelValue: Boolean,
|
||||
};
|
||||
|
||||
dialogVisible = false;
|
||||
|
||||
//settings
|
||||
limit = 20;
|
||||
showCounts = true;
|
||||
showRates = true;
|
||||
showInfo = true;
|
||||
showGenres = true;
|
||||
showDates = true;
|
||||
showDeleted = false;
|
||||
abCacheEnabled = true;
|
||||
|
||||
limitOptions = [
|
||||
{label: '10', value: 10},
|
||||
{label: '20', value: 20},
|
||||
{label: '50', value: 50},
|
||||
{label: '100', value: 100},
|
||||
{label: '200', value: 200},
|
||||
{label: '500', value: 500},
|
||||
{label: '1000', value: 1000},
|
||||
];
|
||||
|
||||
created() {
|
||||
this.commit = this.$store.commit;
|
||||
|
||||
this.loadSettings();
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
get settings() {
|
||||
return this.$store.state.settings;
|
||||
}
|
||||
|
||||
loadSettings() {
|
||||
const settings = this.settings;
|
||||
|
||||
this.limit = settings.limit;
|
||||
|
||||
this.showCounts = settings.showCounts;
|
||||
this.showRates = settings.showRates;
|
||||
this.showInfo = settings.showInfo;
|
||||
this.showGenres = settings.showGenres;
|
||||
this.showDates = settings.showDates;
|
||||
this.showDeleted = settings.showDeleted;
|
||||
this.abCacheEnabled = settings.abCacheEnabled;
|
||||
}
|
||||
|
||||
okClick() {
|
||||
this.dialogVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(SettingsDialog);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
114
package-lock.json
generated
114
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "inpx-web",
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "inpx-web",
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "CC0-1.0",
|
||||
"dependencies": {
|
||||
@@ -15,9 +15,11 @@
|
||||
"chardet": "^1.5.0",
|
||||
"dayjs": "^1.11.6",
|
||||
"express": "^4.18.1",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"fs-extra": "^10.1.0",
|
||||
"he": "^1.2.0",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"jembadb": "^5.0.2",
|
||||
"jembadb": "^5.1.3",
|
||||
"localforage": "^1.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"minimist": "^1.2.6",
|
||||
@@ -49,6 +51,7 @@
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"mini-css-extract-plugin": "^2.6.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",
|
||||
@@ -2574,6 +2577,22 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/basic-auth": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
|
||||
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/basic-auth/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"node_modules/big.js": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
|
||||
@@ -4209,6 +4228,14 @@
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express-basic-auth": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-basic-auth/-/express-basic-auth-1.2.1.tgz",
|
||||
"integrity": "sha512-L6YQ1wQ/mNjVLAmK3AG1RK6VkokA1BIY6wmiH304Xtt/cLTps40EusZsU1Uop+v9lTDPxdtzbFmdXfFO3KEnwA==",
|
||||
"dependencies": {
|
||||
"basic-auth": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
@@ -4701,7 +4728,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
@@ -5046,9 +5072,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jembadb": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jembadb/-/jembadb-5.0.2.tgz",
|
||||
"integrity": "sha512-0309Qo4wSkyf154xTokxNl0DuBP5f2Q2MzWGUNX1JmMzlRypFsPY/9VDYV/htkxhT53f2prlQ2NUguQjG2lCRA==",
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/jembadb/-/jembadb-5.1.3.tgz",
|
||||
"integrity": "sha512-HGl9d3/fcNNahOqEsb3ocpXRWEfmDwV2zgWvKXERwlsxOHqoEId2fHXPkjv97qRywEyE/n9U8WimIWsP2Evf4w==",
|
||||
"engines": {
|
||||
"node": ">=16.16.0"
|
||||
}
|
||||
@@ -7477,6 +7503,31 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/showdown": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz",
|
||||
"integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"showdown": "bin/showdown.js"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://www.paypal.me/tiviesantos"
|
||||
}
|
||||
},
|
||||
"node_modules/showdown/node_modules/commander": {
|
||||
"version": "9.4.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz",
|
||||
"integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
|
||||
@@ -10682,6 +10733,21 @@
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true
|
||||
},
|
||||
"basic-auth": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
|
||||
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
|
||||
"requires": {
|
||||
"safe-buffer": "5.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"big.js": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
|
||||
@@ -11906,6 +11972,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"express-basic-auth": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-basic-auth/-/express-basic-auth-1.2.1.tgz",
|
||||
"integrity": "sha512-L6YQ1wQ/mNjVLAmK3AG1RK6VkokA1BIY6wmiH304Xtt/cLTps40EusZsU1Uop+v9lTDPxdtzbFmdXfFO3KEnwA==",
|
||||
"requires": {
|
||||
"basic-auth": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -12283,8 +12357,7 @@
|
||||
"he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
|
||||
},
|
||||
"html-entities": {
|
||||
"version": "2.3.3",
|
||||
@@ -12521,9 +12594,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"jembadb": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jembadb/-/jembadb-5.0.2.tgz",
|
||||
"integrity": "sha512-0309Qo4wSkyf154xTokxNl0DuBP5f2Q2MzWGUNX1JmMzlRypFsPY/9VDYV/htkxhT53f2prlQ2NUguQjG2lCRA=="
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/jembadb/-/jembadb-5.1.3.tgz",
|
||||
"integrity": "sha512-HGl9d3/fcNNahOqEsb3ocpXRWEfmDwV2zgWvKXERwlsxOHqoEId2fHXPkjv97qRywEyE/n9U8WimIWsP2Evf4w=="
|
||||
},
|
||||
"jest-worker": {
|
||||
"version": "27.5.1",
|
||||
@@ -14257,6 +14330,23 @@
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true
|
||||
},
|
||||
"showdown": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz",
|
||||
"integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"commander": "^9.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": {
|
||||
"version": "9.4.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz",
|
||||
"integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"side-channel": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
|
||||
|
||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inpx-web",
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.0",
|
||||
"author": "Book Pauk <bookpauk@gmail.com>",
|
||||
"license": "CC0-1.0",
|
||||
"repository": "bookpauk/inpx-web",
|
||||
@@ -12,8 +12,9 @@
|
||||
"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: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",
|
||||
"build:all": "npm run build:linux && npm run build:win && npm run build:macos",
|
||||
"release": "npm run build:all && node build/release.js",
|
||||
"postinstall": "npm run build:client-dev"
|
||||
},
|
||||
@@ -38,6 +39,7 @@
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"mini-css-extract-plugin": "^2.6.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",
|
||||
@@ -54,9 +56,11 @@
|
||||
"chardet": "^1.5.0",
|
||||
"dayjs": "^1.11.6",
|
||||
"express": "^4.18.1",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"fs-extra": "^10.1.0",
|
||||
"he": "^1.2.0",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"jembadb": "^5.0.2",
|
||||
"jembadb": "^5.1.3",
|
||||
"localforage": "^1.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"minimist": "^1.2.6",
|
||||
|
||||
@@ -16,12 +16,14 @@ module.exports = {
|
||||
|
||||
//поправить в случае, если были критические изменения в DbCreator или InpxParser
|
||||
//иначе будет рассинхронизация между сервером и клиентом на уровне БД
|
||||
dbVersion: '6',
|
||||
dbVersion: '8',
|
||||
dbCacheSize: 5,
|
||||
|
||||
maxPayloadSize: 500,//in MB
|
||||
maxFilesDirSize: 1024*1024*1024,//1Gb
|
||||
queryCacheEnabled: true,
|
||||
queryCacheMemSize: 50,
|
||||
queryCacheDiskSize: 500,
|
||||
cacheCleanInterval: 60,//minutes
|
||||
inpxCheckInterval: 60,//minutes
|
||||
lowMemoryMode: false,
|
||||
@@ -43,5 +45,11 @@ module.exports = {
|
||||
host: '0.0.0.0',
|
||||
port: '22380',
|
||||
},
|
||||
//opds: false,
|
||||
opds: {
|
||||
enabled: true,
|
||||
user: '',
|
||||
password: '',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ const propsToSave = [
|
||||
'dbCacheSize',
|
||||
'maxFilesDirSize',
|
||||
'queryCacheEnabled',
|
||||
'queryCacheMemSize',
|
||||
'queryCacheDiskSize',
|
||||
'cacheCleanInterval',
|
||||
'inpxCheckInterval',
|
||||
'lowMemoryMode',
|
||||
@@ -18,6 +20,7 @@ const propsToSave = [
|
||||
'allowRemoteLib',
|
||||
'remoteLib',
|
||||
'server',
|
||||
'opds',
|
||||
];
|
||||
|
||||
let instance = null;
|
||||
|
||||
@@ -165,19 +165,19 @@ class WebSocketController {
|
||||
}
|
||||
|
||||
async getBookLink(req, ws) {
|
||||
if (!utils.hasProp(req, 'bookId'))
|
||||
throw new Error(`bookId is empty`);
|
||||
if (!utils.hasProp(req, 'bookUid'))
|
||||
throw new Error(`bookUid is empty`);
|
||||
|
||||
const result = await this.webWorker.getBookLink(req.bookId);
|
||||
const result = await this.webWorker.getBookLink(req.bookUid);
|
||||
|
||||
this.send(result, req, ws);
|
||||
}
|
||||
|
||||
async getBookInfo(req, ws) {
|
||||
if (!utils.hasProp(req, 'bookId'))
|
||||
throw new Error(`bookId is empty`);
|
||||
if (!utils.hasProp(req, 'bookUid'))
|
||||
throw new Error(`bookUid is empty`);
|
||||
|
||||
const result = await this.webWorker.getBookInfo(req.bookId);
|
||||
const result = await this.webWorker.getBookInfo(req.bookUid);
|
||||
|
||||
this.send(result, req, ws);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ class DbCreator {
|
||||
let librateMap = new Map();//оценка
|
||||
let librateArr = [];
|
||||
|
||||
let uidSet = new Set();//уникальные идентификаторы
|
||||
|
||||
//stats
|
||||
let authorCount = 0;
|
||||
let bookCount = 0;
|
||||
@@ -221,13 +223,14 @@ class DbCreator {
|
||||
let filtered = false;
|
||||
for (const rec of chunk) {
|
||||
//сначала фильтр
|
||||
if (!filter(rec)) {
|
||||
if (!filter(rec) || uidSet.has(rec._uid)) {
|
||||
rec.id = 0;
|
||||
filtered = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
rec.id = ++id;
|
||||
uidSet.add(rec._uid);
|
||||
|
||||
if (!rec.del) {
|
||||
bookCount++;
|
||||
@@ -269,6 +272,7 @@ class DbCreator {
|
||||
delMap = null;
|
||||
dateMap = null;
|
||||
librateMap = null;
|
||||
uidSet = null;
|
||||
|
||||
await db.close({table: 'book'});
|
||||
await db.freeMemory();
|
||||
@@ -455,7 +459,6 @@ class DbCreator {
|
||||
const config = this.config;
|
||||
|
||||
const to = `${from}_book`;
|
||||
const toId = `${from}_id`;
|
||||
|
||||
await db.open({table: from});
|
||||
await db.create({table: to});
|
||||
@@ -544,7 +547,7 @@ class DbCreator {
|
||||
await saveChunk(chunk);
|
||||
|
||||
processed += chunk.length;
|
||||
callback({progress: 0.5*processed/fromLength});
|
||||
callback({progress: 0.9*processed/fromLength});
|
||||
} else
|
||||
break;
|
||||
|
||||
@@ -558,24 +561,18 @@ class DbCreator {
|
||||
await db.close({table: to});
|
||||
await db.close({table: from});
|
||||
|
||||
await db.create({table: toId});
|
||||
|
||||
const chunkSize = 50000;
|
||||
let idRows = [];
|
||||
let proc = 0;
|
||||
const idMap = {arr: [], map: []};
|
||||
for (const [id, value] of bookId2RecId) {
|
||||
idRows.push({id, value});
|
||||
if (idRows.length >= chunkSize) {
|
||||
await db.insert({table: toId, rows: idRows});
|
||||
idRows = [];
|
||||
|
||||
proc += chunkSize;
|
||||
callback({progress: 0.5 + 0.5*proc/bookId2RecId.size});
|
||||
if (value.length > 1) {
|
||||
idMap.map.push([id, value]);
|
||||
idMap.arr[id] = 0;
|
||||
} else {
|
||||
idMap.arr[id] = value[0];
|
||||
}
|
||||
}
|
||||
if (idRows.length)
|
||||
await db.insert({table: toId, rows: idRows});
|
||||
await db.close({table: toId});
|
||||
|
||||
callback({progress: 1});
|
||||
await fs.writeFile(`${this.config.dataDir}/db/${from}_id.map`, JSON.stringify(idMap));
|
||||
|
||||
bookId2RecId = null;
|
||||
utils.freeMemory();
|
||||
@@ -624,6 +621,12 @@ class DbCreator {
|
||||
stats.filesDelCount = res.filesDelCount;
|
||||
}
|
||||
|
||||
//заодно добавим нужный индекс
|
||||
await db.create({
|
||||
in: 'book',
|
||||
hash: {field: '_uid', type: 'string', depth: 100, unique: true},
|
||||
});
|
||||
|
||||
countDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const fs = require('fs-extra');
|
||||
//const _ = require('lodash');
|
||||
const LockQueue = require('./LockQueue');
|
||||
const utils = require('./utils');
|
||||
|
||||
const maxMemCacheSize = 100;
|
||||
const maxLimit = 1000;
|
||||
|
||||
const emptyFieldValue = '?';
|
||||
@@ -14,6 +14,11 @@ const enruArr = (ruAlphabet + enAlphabet).split('');
|
||||
class DbSearcher {
|
||||
constructor(config, db) {
|
||||
this.config = config;
|
||||
this.queryCacheMemSize = this.config.queryCacheMemSize;
|
||||
this.queryCacheDiskSize = this.config.queryCacheDiskSize;
|
||||
this.queryCacheEnabled = this.config.queryCacheEnabled
|
||||
&& (this.queryCacheMemSize > 0 || this.queryCacheDiskSize > 0);
|
||||
|
||||
this.db = db;
|
||||
|
||||
this.lock = new LockQueue();
|
||||
@@ -77,7 +82,7 @@ class DbSearcher {
|
||||
result.add(bookId);
|
||||
}
|
||||
|
||||
return Array.from(result);
|
||||
return new Uint32Array(result);
|
||||
`
|
||||
});
|
||||
|
||||
@@ -151,7 +156,7 @@ class DbSearcher {
|
||||
result.add(bookId);
|
||||
}
|
||||
|
||||
return Array.from(result);
|
||||
return new Uint32Array(result);
|
||||
`
|
||||
});
|
||||
|
||||
@@ -187,7 +192,7 @@ class DbSearcher {
|
||||
result.add(bookId);
|
||||
}
|
||||
|
||||
return Array.from(result);
|
||||
return new Uint32Array(result);
|
||||
`
|
||||
});
|
||||
|
||||
@@ -252,7 +257,7 @@ class DbSearcher {
|
||||
result.add(bookId);
|
||||
}
|
||||
|
||||
return Array.from(result);
|
||||
return new Uint32Array(result);
|
||||
`
|
||||
});
|
||||
|
||||
@@ -285,7 +290,7 @@ class DbSearcher {
|
||||
inter = newInter;
|
||||
}
|
||||
|
||||
return Array.from(inter);
|
||||
return new Uint32Array(inter);
|
||||
} else if (idsArr.length == 1) {
|
||||
return idsArr[0];
|
||||
} else {
|
||||
@@ -299,29 +304,13 @@ class DbSearcher {
|
||||
|
||||
await this.lock.get();
|
||||
try {
|
||||
const db = this.db;
|
||||
const map = new Map();
|
||||
const table = `${from}_id`;
|
||||
const data = await fs.readFile(`${this.config.dataDir}/db/${from}_id.map`, 'utf-8');
|
||||
|
||||
await db.open({table});
|
||||
let rows = await db.select({table});
|
||||
await db.close({table});
|
||||
const idMap = JSON.parse(data);
|
||||
idMap.arr = new Uint32Array(idMap.arr);
|
||||
idMap.map = new Map(idMap.map);
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.value.length)
|
||||
continue;
|
||||
|
||||
if (row.value.length > 1)
|
||||
map.set(row.id, row.value);
|
||||
else
|
||||
map.set(row.id, row.value[0]);
|
||||
}
|
||||
|
||||
this.bookIdMap[from] = map;
|
||||
|
||||
rows = null;
|
||||
await db.freeMemory();
|
||||
utils.freeMemory();
|
||||
this.bookIdMap[from] = idMap;
|
||||
|
||||
return this.bookIdMap[from];
|
||||
} finally {
|
||||
@@ -330,15 +319,20 @@ class DbSearcher {
|
||||
}
|
||||
|
||||
async fillBookIdMapAll() {
|
||||
await this.fillBookIdMap('author');
|
||||
await this.fillBookIdMap('series');
|
||||
await this.fillBookIdMap('title');
|
||||
try {
|
||||
await this.fillBookIdMap('author');
|
||||
await this.fillBookIdMap('series');
|
||||
await this.fillBookIdMap('title');
|
||||
} catch (e) {
|
||||
throw new Error(`DbSearcher.fillBookIdMapAll error: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async filterTableIds(tableIds, from, query) {
|
||||
let result = tableIds;
|
||||
|
||||
//т.к. авторы у книги идут списком, то дополнительно фильтруем
|
||||
async tableIdsFilter(from, query) {
|
||||
//т.к. авторы у книги идут списком (т.е. одна книга относиться сразу к нескольким авторам),
|
||||
//то в выборку по bookId могут попасть авторы, которые отсутствуют в критерии query.author,
|
||||
//поэтому дополнительно фильтруем
|
||||
let result = null;
|
||||
if (from == 'author' && query.author && query.author !== '*') {
|
||||
const key = `filter-ids-author-${query.author}`;
|
||||
let authorIds = await this.getCached(key);
|
||||
@@ -347,7 +341,7 @@ class DbSearcher {
|
||||
const rows = await this.db.select({
|
||||
table: 'author',
|
||||
rawResult: true,
|
||||
where: `return Array.from(${this.getWhere(query.author)})`
|
||||
where: `return new Uint32Array(${this.getWhere(query.author)})`
|
||||
});
|
||||
|
||||
authorIds = rows[0].rawResult;
|
||||
@@ -355,12 +349,7 @@ class DbSearcher {
|
||||
await this.putCached(key, authorIds);
|
||||
}
|
||||
|
||||
//пересечение tableIds и authorIds
|
||||
result = [];
|
||||
const authorIdsSet = new Set(authorIds);
|
||||
for (const id of tableIds)
|
||||
if (authorIdsSet.has(id))
|
||||
result.push(id);
|
||||
result = new Set(authorIds);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -381,24 +370,30 @@ class DbSearcher {
|
||||
await this.putCached(bookKey, bookIds);
|
||||
}
|
||||
|
||||
//id книг (bookIds) нашли, теперь надо их смаппировать в id таблицы from (авторов, серий, названий)
|
||||
if (bookIds) {
|
||||
//т.к. авторы у книги идут списком, то дополнительно фильтруем
|
||||
const filter = await this.tableIdsFilter(from, query);
|
||||
|
||||
const tableIdsSet = new Set();
|
||||
const bookIdMap = await this.fillBookIdMap(from);
|
||||
const idMap = await this.fillBookIdMap(from);
|
||||
let proc = 0;
|
||||
let nextProc = 0;
|
||||
for (const bookId of bookIds) {
|
||||
const tableIdValue = bookIdMap.get(bookId);
|
||||
if (!tableIdValue)
|
||||
continue;
|
||||
|
||||
if (Array.isArray(tableIdValue)) {
|
||||
for (const tableId of tableIdValue) {
|
||||
const tableId = idMap.arr[bookId];
|
||||
if (tableId) {
|
||||
if (!filter || filter.has(tableId))
|
||||
tableIdsSet.add(tableId);
|
||||
proc++;
|
||||
}
|
||||
} else {
|
||||
tableIdsSet.add(tableIdValue);
|
||||
proc++;
|
||||
} else {
|
||||
const tableIdArr = idMap.map.get(bookId);
|
||||
if (tableIdArr) {
|
||||
for (const tableId of tableIdArr) {
|
||||
if (!filter || filter.has(tableId))
|
||||
tableIdsSet.add(tableId);
|
||||
proc++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//прерываемся иногда, чтобы не блокировать Event Loop
|
||||
@@ -408,19 +403,19 @@ class DbSearcher {
|
||||
}
|
||||
}
|
||||
|
||||
tableIds = Array.from(tableIdsSet);
|
||||
} else {
|
||||
tableIds = new Uint32Array(tableIdsSet);
|
||||
} else {//bookIds пустой - критерии не заданы, значит берем все id из from
|
||||
const rows = await db.select({
|
||||
table: from,
|
||||
rawResult: true,
|
||||
where: `return Array.from(@all())`
|
||||
where: `return new Uint32Array(@all())`
|
||||
});
|
||||
|
||||
tableIds = rows[0].rawResult;
|
||||
}
|
||||
|
||||
tableIds = await this.filterTableIds(tableIds, from, query);
|
||||
|
||||
//сортируем по id
|
||||
//порядок id соответствует ASC-сортировке по строковому значению из from (имя автора, назание серии, название книги)
|
||||
tableIds.sort((a, b) => a - b);
|
||||
|
||||
await this.putCached(tableKey, tableIds);
|
||||
@@ -509,11 +504,13 @@ class DbSearcher {
|
||||
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: from,
|
||||
map: `(r) => ({id: r.id, ${from}: r.name, bookCount: r.bookCount, bookDelCount: r.bookDelCount})`,
|
||||
where: `@@id(${db.esc(ids.slice(offset, offset + limit))})`
|
||||
where: `@@id(${db.esc(Array.from(slice))})`
|
||||
});
|
||||
|
||||
//для title восстановим books
|
||||
@@ -537,28 +534,105 @@ class DbSearcher {
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthorBookList(authorId) {
|
||||
async opdsQuery(from, query) {
|
||||
if (this.closed)
|
||||
throw new Error('DbSearcher closed');
|
||||
|
||||
if (!authorId)
|
||||
if (!['author', 'series', 'title'].includes(from))
|
||||
throw new Error(`Unknown value for param 'from'`);
|
||||
|
||||
this.searchFlag++;
|
||||
|
||||
try {
|
||||
const db = this.db;
|
||||
|
||||
const depth = query.depth || 1;
|
||||
const queryKey = this.queryKey(query);
|
||||
const opdsKey = `${from}-opds-d${depth}-${queryKey}`;
|
||||
let result = await this.getCached(opdsKey);
|
||||
|
||||
if (result === null) {
|
||||
const ids = await this.selectTableIds(from, query);
|
||||
|
||||
const totalFound = ids.length;
|
||||
|
||||
//группировка по name длиной depth
|
||||
const found = await db.select({
|
||||
table: from,
|
||||
rawResult: true,
|
||||
where: `
|
||||
const depth = ${db.esc(depth)};
|
||||
const group = new Map();
|
||||
|
||||
const ids = ${db.esc(Array.from(ids))};
|
||||
for (const id of ids) {
|
||||
const row = @unsafeRow(id);
|
||||
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};
|
||||
group.set(s, g);
|
||||
}
|
||||
g.count++;
|
||||
}
|
||||
|
||||
const result = Array.from(group.values());
|
||||
result.sort((a, b) => a.value.localeCompare(b.value));
|
||||
|
||||
return result;
|
||||
`
|
||||
});
|
||||
|
||||
result = {found: found[0].rawResult, totalFound};
|
||||
|
||||
await this.putCached(opdsKey, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
this.searchFlag--;
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthorBookList(authorId, author) {
|
||||
if (this.closed)
|
||||
throw new Error('DbSearcher closed');
|
||||
|
||||
if (!authorId && !author)
|
||||
return {author: '', books: ''};
|
||||
|
||||
this.searchFlag++;
|
||||
|
||||
try {
|
||||
//выборка книг автора по authorId
|
||||
const rows = await this.restoreBooks('author', [authorId])
|
||||
const db = this.db;
|
||||
|
||||
let author = '';
|
||||
if (!authorId) {
|
||||
//восстановим authorId
|
||||
authorId = 0;
|
||||
author = author.toLowerCase();
|
||||
|
||||
const rows = await db.select({
|
||||
table: 'author',
|
||||
rawResult: true,
|
||||
where: `return Array.from(@dirtyIndexLR('value', ${db.esc(author)}, ${db.esc(author)}))`
|
||||
});
|
||||
|
||||
if (rows.length && rows[0].rawResult.length)
|
||||
authorId = rows[0].rawResult[0];
|
||||
}
|
||||
|
||||
//выборка книг автора по authorId
|
||||
const rows = await this.restoreBooks('author', [authorId]);
|
||||
|
||||
let authorName = '';
|
||||
let books = '';
|
||||
|
||||
if (rows.length) {
|
||||
author = rows[0].name;
|
||||
authorName = rows[0].name;
|
||||
books = rows[0].books;
|
||||
}
|
||||
|
||||
return {author, books: (books && books.length ? JSON.stringify(books) : '')};
|
||||
return {author: authorName, books: (books && books.length ? JSON.stringify(books) : '')};
|
||||
} finally {
|
||||
this.searchFlag--;
|
||||
}
|
||||
@@ -601,7 +675,7 @@ class DbSearcher {
|
||||
}
|
||||
|
||||
async getCached(key) {
|
||||
if (!this.config.queryCacheEnabled)
|
||||
if (!this.queryCacheEnabled)
|
||||
return null;
|
||||
|
||||
let result = null;
|
||||
@@ -609,13 +683,13 @@ class DbSearcher {
|
||||
const db = this.db;
|
||||
const memCache = this.memCache;
|
||||
|
||||
if (memCache.has(key)) {//есть в недавних
|
||||
if (this.queryCacheMemSize > 0 && memCache.has(key)) {//есть в недавних
|
||||
result = memCache.get(key);
|
||||
|
||||
//изменим порядок ключей, для последующей правильной чистки старых
|
||||
memCache.delete(key);
|
||||
memCache.set(key, result);
|
||||
} else {//смотрим в таблице
|
||||
} else if (this.queryCacheDiskSize > 0) {//смотрим в таблице
|
||||
const rows = await db.select({table: 'query_cache', where: `@@id(${db.esc(key)})`});
|
||||
|
||||
if (rows.length) {//нашли в кеше
|
||||
@@ -626,13 +700,17 @@ class DbSearcher {
|
||||
});
|
||||
|
||||
result = rows[0].value;
|
||||
memCache.set(key, result);
|
||||
|
||||
if (memCache.size > maxMemCacheSize) {
|
||||
//удаляем самый старый ключ-значение
|
||||
for (const k of memCache.keys()) {
|
||||
memCache.delete(k);
|
||||
break;
|
||||
//заполняем кеш в памяти
|
||||
if (this.queryCacheMemSize > 0) {
|
||||
memCache.set(key, result);
|
||||
|
||||
if (memCache.size > this.queryCacheMemSize) {
|
||||
//удаляем самый старый ключ-значение
|
||||
for (const k of memCache.keys()) {
|
||||
memCache.delete(k);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -642,40 +720,44 @@ class DbSearcher {
|
||||
}
|
||||
|
||||
async putCached(key, value) {
|
||||
if (!this.config.queryCacheEnabled)
|
||||
if (!this.queryCacheEnabled)
|
||||
return;
|
||||
|
||||
const db = this.db;
|
||||
|
||||
const memCache = this.memCache;
|
||||
memCache.set(key, value);
|
||||
if (this.queryCacheMemSize > 0) {
|
||||
const memCache = this.memCache;
|
||||
memCache.set(key, value);
|
||||
|
||||
if (memCache.size > maxMemCacheSize) {
|
||||
//удаляем самый старый ключ-значение
|
||||
for (const k of memCache.keys()) {
|
||||
memCache.delete(k);
|
||||
break;
|
||||
if (memCache.size > this.queryCacheMemSize) {
|
||||
//удаляем самый старый ключ-значение
|
||||
for (const k of memCache.keys()) {
|
||||
memCache.delete(k);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//кладем в таблицу асинхронно
|
||||
(async() => {
|
||||
try {
|
||||
await db.insert({
|
||||
table: 'query_cache',
|
||||
replace: true,
|
||||
rows: [{id: key, value}],
|
||||
});
|
||||
if (this.queryCacheDiskSize > 0) {
|
||||
//кладем в таблицу асинхронно
|
||||
(async() => {
|
||||
try {
|
||||
await db.insert({
|
||||
table: 'query_cache',
|
||||
replace: true,
|
||||
rows: [{id: key, value}],
|
||||
});
|
||||
|
||||
await db.insert({
|
||||
table: 'query_time',
|
||||
replace: true,
|
||||
rows: [{id: key, time: Date.now()}],
|
||||
});
|
||||
} catch(e) {
|
||||
console.error(`putCached: ${e.message}`);
|
||||
}
|
||||
})();
|
||||
await db.insert({
|
||||
table: 'query_time',
|
||||
replace: true,
|
||||
rows: [{id: key, time: Date.now()}],
|
||||
});
|
||||
} catch(e) {
|
||||
console.error(`putCached: ${e.message}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
async periodicCleanCache() {
|
||||
@@ -685,21 +767,37 @@ class DbSearcher {
|
||||
return;
|
||||
|
||||
try {
|
||||
if (!this.queryCacheEnabled || this.queryCacheDiskSize <= 0)
|
||||
return;
|
||||
|
||||
const db = this.db;
|
||||
|
||||
const oldThres = Date.now() - cleanInterval;
|
||||
let rows = await db.select({table: 'query_time', count: true});
|
||||
const delCount = rows[0].count - this.queryCacheDiskSize;
|
||||
|
||||
//выберем всех кандидатов на удаление
|
||||
const rows = await db.select({
|
||||
if (delCount < 1)
|
||||
return;
|
||||
|
||||
//выберем delCount кандидатов на удаление
|
||||
rows = await db.select({
|
||||
table: 'query_time',
|
||||
rawResult: true,
|
||||
where: `
|
||||
@@iter(@all(), (r) => (r.time < ${db.esc(oldThres)}));
|
||||
const delCount = ${delCount};
|
||||
const rows = [];
|
||||
|
||||
@unsafeIter(@all(), (r) => {
|
||||
rows.push(r);
|
||||
return false;
|
||||
});
|
||||
|
||||
rows.sort((a, b) => a.time - b.time);
|
||||
|
||||
return rows.slice(0, delCount).map(r => r.id);
|
||||
`
|
||||
});
|
||||
|
||||
const ids = [];
|
||||
for (const row of rows)
|
||||
ids.push(row.id);
|
||||
const ids = rows[0].rawResult;
|
||||
|
||||
//удаляем
|
||||
await db.delete({table: 'query_cache', where: `@@id(${db.esc(ids)})`});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const ZipReader = require('./ZipReader');
|
||||
|
||||
const collectionInfo = 'collection.info';
|
||||
@@ -98,9 +99,13 @@ class InpxParser {
|
||||
if (line[line.length - 1] == '\x0D')
|
||||
line = line.substring(0, line.length - 1);
|
||||
|
||||
const rec = {};
|
||||
//уникальный идентификатор записи
|
||||
const sha256 = crypto.createHash('sha256');
|
||||
rec._uid = sha256.update(line).digest('base64');
|
||||
|
||||
//парсим запись
|
||||
const parts = line.split('\x04');
|
||||
const rec = {};
|
||||
|
||||
const len = (parts.length > structLen ? structLen : parts.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
|
||||
@@ -58,9 +58,9 @@ class RemoteLib {
|
||||
}
|
||||
}
|
||||
|
||||
async downloadBook(bookId) {
|
||||
async downloadBook(bookUid) {
|
||||
try {
|
||||
const response = await await this.wsRequest({action: 'get-book-link', bookId});
|
||||
const response = await await this.wsRequest({action: 'get-book-link', bookUid});
|
||||
const link = response.link;
|
||||
|
||||
const buf = await this.down.load(`${this.remoteHost}${link}`, {decompress: false});
|
||||
|
||||
@@ -267,10 +267,16 @@ class WebWorker {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getAuthorBookList(authorId) {
|
||||
async opdsQuery(from, query) {
|
||||
this.checkMyState();
|
||||
|
||||
return await this.dbSearcher.getAuthorBookList(authorId);
|
||||
return await this.dbSearcher.opdsQuery(from, query);
|
||||
}
|
||||
|
||||
async getAuthorBookList(authorId, author) {
|
||||
this.checkMyState();
|
||||
|
||||
return await this.dbSearcher.getAuthorBookList(authorId, author);
|
||||
}
|
||||
|
||||
async getSeriesBookList(series) {
|
||||
@@ -354,7 +360,7 @@ class WebWorker {
|
||||
}
|
||||
}
|
||||
|
||||
async restoreBook(bookId, bookPath, downFileName) {
|
||||
async restoreBook(bookUid, bookPath, downFileName) {
|
||||
const db = this.db;
|
||||
|
||||
let extractedFile = '';
|
||||
@@ -364,7 +370,7 @@ class WebWorker {
|
||||
extractedFile = await this.extractBook(bookPath);
|
||||
hash = await utils.getFileHash(extractedFile, 'sha256', 'hex');
|
||||
} else {
|
||||
hash = await this.remoteLib.downloadBook(bookId);
|
||||
hash = await this.remoteLib.downloadBook(bookUid);
|
||||
}
|
||||
|
||||
const link = `${this.config.filesPathStatic}/${hash}`;
|
||||
@@ -402,7 +408,7 @@ class WebWorker {
|
||||
return link;
|
||||
}
|
||||
|
||||
async getBookLink(bookId) {
|
||||
async getBookLink(bookUid) {
|
||||
this.checkMyState();
|
||||
|
||||
try {
|
||||
@@ -410,11 +416,11 @@ class WebWorker {
|
||||
let link = '';
|
||||
|
||||
//найдем bookPath и downFileName
|
||||
let rows = await db.select({table: 'book', where: `@@id(${db.esc(bookId)})`});
|
||||
let rows = await db.select({table: 'book', where: `@@hash('_uid', ${db.esc(bookUid)})`});
|
||||
if (!rows.length)
|
||||
throw new Error('404 Файл не найден');
|
||||
|
||||
const book = rows[0];
|
||||
const book = rows[0];
|
||||
let downFileName = book.file;
|
||||
const author = book.author.split(',');
|
||||
const at = [author[0], book.title];
|
||||
@@ -443,7 +449,7 @@ class WebWorker {
|
||||
}
|
||||
|
||||
if (!link) {
|
||||
link = await this.restoreBook(bookId, bookPath, downFileName)
|
||||
link = await this.restoreBook(bookUid, bookPath, downFileName)
|
||||
}
|
||||
|
||||
if (!link)
|
||||
@@ -458,23 +464,25 @@ class WebWorker {
|
||||
}
|
||||
}
|
||||
|
||||
async getBookInfo(bookId) {
|
||||
async getBookInfo(bookUid) {
|
||||
this.checkMyState();
|
||||
|
||||
try {
|
||||
const db = this.db;
|
||||
|
||||
let bookInfo = await this.getBookLink(bookId);
|
||||
let bookInfo = await this.getBookLink(bookUid);
|
||||
const hash = path.basename(bookInfo.link);
|
||||
const bookFile = `${this.config.filesDir}/${hash}`;
|
||||
const bookFileInfo = `${bookFile}.i.json`;
|
||||
|
||||
let rows = await db.select({table: 'book', where: `@@hash('_uid', ${db.esc(bookUid)})`});
|
||||
if (!rows.length)
|
||||
throw new Error('404 Файл не найден');
|
||||
const book = rows[0];
|
||||
|
||||
const restoreBookInfo = async(info) => {
|
||||
const result = {};
|
||||
|
||||
const rows = await db.select({table: 'book', where: `@@id(${db.esc(bookId)})`});
|
||||
const book = rows[0];
|
||||
|
||||
result.book = book;
|
||||
result.cover = '';
|
||||
result.fb2 = false;
|
||||
@@ -491,7 +499,8 @@ class WebWorker {
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(info ,result);
|
||||
Object.assign(info, result);
|
||||
|
||||
await fs.writeFile(bookFileInfo, JSON.stringify(info));
|
||||
|
||||
if (this.config.branch === 'development') {
|
||||
@@ -511,7 +520,7 @@ class WebWorker {
|
||||
if (tmpInfo.cover)
|
||||
coverFile = `${this.config.publicFilesDir}${tmpInfo.cover}`;
|
||||
|
||||
if (coverFile && !await fs.pathExists(coverFile)) {
|
||||
if (book.id != tmpInfo.book.id || (coverFile && !await fs.pathExists(coverFile))) {
|
||||
await restoreBookInfo(bookInfo);
|
||||
} else {
|
||||
bookInfo = tmpInfo;
|
||||
|
||||
181
server/core/opds/AuthorPage.js
Normal file
181
server/core/opds/AuthorPage.js
Normal file
@@ -0,0 +1,181 @@
|
||||
const BasePage = require('./BasePage');
|
||||
|
||||
class AuthorPage extends BasePage {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
|
||||
this.id = 'author';
|
||||
this.title = 'Авторы';
|
||||
}
|
||||
|
||||
sortBooks(bookList) {
|
||||
//схлопывание серий
|
||||
const books = [];
|
||||
const seriesSet = new Set();
|
||||
for (const book of bookList) {
|
||||
if (book.series) {
|
||||
if (!seriesSet.has(book.series)) {
|
||||
books.push({
|
||||
type: 'series',
|
||||
book
|
||||
});
|
||||
|
||||
seriesSet.add(book.series);
|
||||
}
|
||||
} else {
|
||||
books.push({
|
||||
type: 'book',
|
||||
book
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//сортировка
|
||||
books.sort((a, b) => {
|
||||
if (a.type == 'series') {
|
||||
return (b.type == 'series' ? a.book.series.localeCompare(b.book.series) : -1);
|
||||
} else {
|
||||
return (b.type == 'book' ? a.book.title.localeCompare(b.book.title) : 1);
|
||||
}
|
||||
});
|
||||
|
||||
return books;
|
||||
}
|
||||
|
||||
sortSeriesBooks(seriesBooks) {
|
||||
seriesBooks.sort((a, b) => {
|
||||
const dserno = (a.serno || Number.MAX_VALUE) - (b.serno || Number.MAX_VALUE);
|
||||
const dtitle = a.title.localeCompare(b.title);
|
||||
const dext = a.ext.localeCompare(b.ext);
|
||||
return (dserno ? dserno : (dtitle ? dtitle : dext));
|
||||
});
|
||||
|
||||
return seriesBooks;
|
||||
}
|
||||
|
||||
async body(req) {
|
||||
const result = {};
|
||||
|
||||
const query = {
|
||||
author: req.query.author || '',
|
||||
series: req.query.series || '',
|
||||
genre: req.query.genre || '',
|
||||
del: 0,
|
||||
|
||||
all: req.query.all || '',
|
||||
depth: 0,
|
||||
};
|
||||
query.depth = query.author.length + 1;
|
||||
|
||||
if (query.author == '___others') {
|
||||
query.author = '';
|
||||
query.depth = 1;
|
||||
query.others = true;
|
||||
}
|
||||
|
||||
const entry = [];
|
||||
if (query.series) {
|
||||
//книги по серии
|
||||
const bookList = await this.webWorker.getSeriesBookList(query.series);
|
||||
|
||||
if (bookList.books) {
|
||||
let books = JSON.parse(bookList.books);
|
||||
const booksAll = this.filterBooks(books, {del: 0});
|
||||
const filtered = (query.all ? booksAll : this.filterBooks(books, query));
|
||||
const sorted = this.sortSeriesBooks(filtered);
|
||||
|
||||
if (booksAll.length > filtered.length) {
|
||||
entry.push(
|
||||
this.makeEntry({
|
||||
id: 'all_series_books',
|
||||
title: '[Все книги серии]',
|
||||
link: this.navLink({
|
||||
href: `/${this.id}?author=${encodeURIComponent(query.author)}` +
|
||||
`&series=${encodeURIComponent(query.series)}&all=1`}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (query.author && query.author[0] == '=') {
|
||||
//книги по автору
|
||||
const bookList = await this.webWorker.getAuthorBookList(0, query.author.substring(1));
|
||||
|
||||
if (bookList.books) {
|
||||
let books = JSON.parse(bookList.books);
|
||||
books = this.sortBooks(this.filterBooks(books, query));
|
||||
|
||||
for (const b of books) {
|
||||
if (b.type == 'series') {
|
||||
entry.push(
|
||||
this.makeEntry({
|
||||
id: b.book._uid,
|
||||
title: `Серия: ${b.book.series}`,
|
||||
link: this.navLink({
|
||||
href: `/${this.id}?author=${encodeURIComponent(query.author)}` +
|
||||
`&series=${encodeURIComponent(b.book.series)}&genre=${encodeURIComponent(query.genre)}`}),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
const title = `${b.book.title || 'Без названия'} (${b.book.ext})`;
|
||||
entry.push(
|
||||
this.makeEntry({
|
||||
id: b.book._uid,
|
||||
title,
|
||||
link: this.acqLink({href: `/book?uid=${encodeURIComponent(b.book._uid)}`}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (query.depth == 1 && !query.genre && !query.others) {
|
||||
entry.push(
|
||||
this.makeEntry({
|
||||
id: 'select_genre',
|
||||
title: '[Выбрать жанр]',
|
||||
link: this.navLink({href: `/genre?from=${this.id}`}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
//навигация по каталогу
|
||||
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)}`}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
result.entry = entry;
|
||||
return this.makeBody(result, req);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AuthorPage;
|
||||
347
server/core/opds/BasePage.js
Normal file
347
server/core/opds/BasePage.js
Normal file
@@ -0,0 +1,347 @@
|
||||
const _ = require('lodash');
|
||||
const he = require('he');
|
||||
|
||||
const WebWorker = require('../WebWorker');//singleton
|
||||
const XmlParser = require('../xml/XmlParser');
|
||||
|
||||
const spaceChar = String.fromCodePoint(0x00B7);
|
||||
const emptyFieldValue = '?';
|
||||
const maxUtf8Char = String.fromCodePoint(0xFFFFF);
|
||||
const ruAlphabet = 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя';
|
||||
const enAlphabet = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const enruArr = (ruAlphabet + enAlphabet).split('');
|
||||
const enru = new Set(enruArr);
|
||||
|
||||
class BasePage {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
|
||||
this.webWorker = new WebWorker(config);
|
||||
this.rootTag = 'feed';
|
||||
this.opdsRoot = config.opdsRoot;
|
||||
}
|
||||
|
||||
makeEntry(entry = {}) {
|
||||
if (!entry.id)
|
||||
throw new Error('makeEntry: no id');
|
||||
if (!entry.title)
|
||||
throw new Error('makeEntry: no title');
|
||||
|
||||
entry.title = he.escape(entry.title);
|
||||
|
||||
const result = {
|
||||
updated: (new Date()).toISOString().substring(0, 19) + 'Z',
|
||||
};
|
||||
|
||||
return Object.assign(result, entry);
|
||||
}
|
||||
|
||||
myEntry() {
|
||||
return this.makeEntry({
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
link: this.navLink({href: `/${this.id}`}),
|
||||
});
|
||||
}
|
||||
|
||||
makeLink(attrs) {
|
||||
attrs.href = he.escape(attrs.href);
|
||||
return {'*ATTRS': attrs};
|
||||
}
|
||||
|
||||
navLink(attrs) {
|
||||
return this.makeLink({
|
||||
href: (attrs.hrefAsIs ? attrs.href : `${this.opdsRoot}${attrs.href || ''}`),
|
||||
rel: attrs.rel || 'subsection',
|
||||
type: 'application/atom+xml;profile=opds-catalog;kind=navigation',
|
||||
});
|
||||
}
|
||||
|
||||
acqLink(attrs) {
|
||||
return this.makeLink({
|
||||
href: (attrs.hrefAsIs ? attrs.href : `${this.opdsRoot}${attrs.href || ''}`),
|
||||
rel: attrs.rel || 'subsection',
|
||||
type: 'application/atom+xml;profile=opds-catalog;kind=acquisition',
|
||||
});
|
||||
}
|
||||
|
||||
downLink(attrs) {
|
||||
if (!attrs.href)
|
||||
throw new Error('downLink: no href');
|
||||
if (!attrs.type)
|
||||
throw new Error('downLink: no type');
|
||||
|
||||
return this.makeLink({
|
||||
href: attrs.href,
|
||||
rel: 'http://opds-spec.org/acquisition',
|
||||
type: attrs.type,
|
||||
});
|
||||
}
|
||||
|
||||
imgLink(attrs) {
|
||||
if (!attrs.href)
|
||||
throw new Error('imgLink: no href');
|
||||
|
||||
return this.makeLink({
|
||||
href: attrs.href,
|
||||
rel: `http://opds-spec.org/image${attrs.thumb ? '/thumbnail' : ''}`,
|
||||
type: attrs.type || 'image/jpeg',
|
||||
});
|
||||
}
|
||||
|
||||
baseLinks(req, selfAcq = false) {
|
||||
const result = [
|
||||
this.makeLink({href: `${this.opdsRoot}/opensearch`, rel: 'search', type: 'application/opensearchdescription+xml'}),
|
||||
this.makeLink({href: `${this.opdsRoot}/search?term={searchTerms}`, rel: 'search', type: 'application/atom+xml'}),
|
||||
|
||||
this.navLink({rel: 'start'}),
|
||||
];
|
||||
|
||||
if (selfAcq) {
|
||||
result.push(this.acqLink({rel: 'self', href: req.originalUrl, hrefAsIs: true}));
|
||||
} else {
|
||||
result.push(this.navLink({rel: 'self', href: req.originalUrl, hrefAsIs: true}));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
makeBody(content, req) {
|
||||
const base = this.makeEntry({id: this.id, title: this.title});
|
||||
base['*ATTRS'] = {
|
||||
'xmlns': 'http://www.w3.org/2005/Atom',
|
||||
'xmlns:dc': 'http://purl.org/dc/terms/',
|
||||
'xmlns:opds': 'http://opds-spec.org/2010/catalog',
|
||||
};
|
||||
|
||||
if (!content.link)
|
||||
base.link = this.baseLinks(req);
|
||||
|
||||
const xml = new XmlParser();
|
||||
const xmlObject = {};
|
||||
xmlObject[this.rootTag] = Object.assign(base, content);
|
||||
|
||||
xml.fromObject(xmlObject);
|
||||
|
||||
return xml.toString({format: true});
|
||||
}
|
||||
|
||||
async body() {
|
||||
throw new Error('Body not implemented');
|
||||
}
|
||||
|
||||
// -- stuff -------------------------------------------
|
||||
async search(from, query) {
|
||||
const result = [];
|
||||
const queryRes = await this.webWorker.search(from, query);
|
||||
|
||||
for (const row of queryRes.found) {
|
||||
const rec = {
|
||||
id: row.id,
|
||||
title: (row[from] || 'Без автора'),
|
||||
q: `=${encodeURIComponent(row[from])}`,
|
||||
};
|
||||
|
||||
result.push(rec);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async opdsQuery(from, query, otherTitle = '[Другие]', prevLen = 0) {
|
||||
const queryRes = await this.webWorker.opdsQuery(from, query);
|
||||
let count = 0;
|
||||
for (const row of queryRes.found)
|
||||
count += row.count;
|
||||
|
||||
const others = [];
|
||||
let result = [];
|
||||
if (count <= 50) {
|
||||
//конец навигации
|
||||
return await this.search(from, query);
|
||||
} else {
|
||||
let len = 0;
|
||||
for (const row of queryRes.found) {
|
||||
const value = row.value;
|
||||
len += value.length;
|
||||
|
||||
let rec;
|
||||
if (row.count == 1) {
|
||||
rec = {
|
||||
id: row.id,
|
||||
title: row.name,
|
||||
q: `=${encodeURIComponent(row.name)}`,
|
||||
};
|
||||
} else {
|
||||
rec = {
|
||||
id: row.id,
|
||||
title: `${value.toUpperCase().replace(/ /g, spaceChar)}~`,
|
||||
q: encodeURIComponent(value),
|
||||
};
|
||||
}
|
||||
if (query.depth > 1 || enru.has(value[0])) {
|
||||
result.push(rec);
|
||||
} else {
|
||||
others.push(rec);
|
||||
}
|
||||
}
|
||||
|
||||
if (query[from] && query.depth > 1 && result.length < 10 && len > prevLen) {
|
||||
//рекурсия, с увеличением глубины, для облегчения навигации
|
||||
const newQuery = _.cloneDeep(query);
|
||||
newQuery.depth++;
|
||||
return await this.opdsQuery(from, newQuery, otherTitle, len);
|
||||
}
|
||||
}
|
||||
|
||||
if (!query.others && others.length)
|
||||
result.unshift({id: 'other', title: otherTitle, q: '___others'});
|
||||
|
||||
return (!query.others ? result : others);
|
||||
}
|
||||
|
||||
//скопировано из BaseList.js, часть функционала не используется
|
||||
filterBooks(books, query) {
|
||||
const s = query;
|
||||
|
||||
const splitAuthor = (author) => {
|
||||
if (!author) {
|
||||
author = emptyFieldValue;
|
||||
}
|
||||
|
||||
const result = author.split(',');
|
||||
if (result.length > 1)
|
||||
result.push(author);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const filterBySearch = (bookValue, searchValue) => {
|
||||
if (!searchValue)
|
||||
return true;
|
||||
|
||||
if (!bookValue)
|
||||
bookValue = emptyFieldValue;
|
||||
|
||||
bookValue = bookValue.toLowerCase();
|
||||
searchValue = searchValue.toLowerCase();
|
||||
|
||||
//особая обработка префиксов
|
||||
if (searchValue[0] == '=') {
|
||||
|
||||
searchValue = searchValue.substring(1);
|
||||
return bookValue.localeCompare(searchValue) == 0;
|
||||
} else if (searchValue[0] == '*') {
|
||||
|
||||
searchValue = searchValue.substring(1);
|
||||
return bookValue !== emptyFieldValue && bookValue.indexOf(searchValue) >= 0;
|
||||
} else if (searchValue[0] == '#') {
|
||||
|
||||
searchValue = searchValue.substring(1);
|
||||
return !bookValue || (bookValue !== emptyFieldValue && !enru.has(bookValue[0]) && bookValue.indexOf(searchValue) >= 0);
|
||||
} else {
|
||||
//where = `@dirtyIndexLR('value', ${db.esc(a)}, ${db.esc(a + maxUtf8Char)})`;
|
||||
return bookValue.localeCompare(searchValue) >= 0 && bookValue.localeCompare(searchValue + maxUtf8Char) <= 0;
|
||||
}
|
||||
};
|
||||
|
||||
return books.filter((book) => {
|
||||
//author
|
||||
let authorFound = false;
|
||||
const authors = splitAuthor(book.author);
|
||||
for (const a of authors) {
|
||||
if (filterBySearch(a, s.author)) {
|
||||
authorFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
//genre
|
||||
let genreFound = !s.genre;
|
||||
if (!genreFound) {
|
||||
const searchGenres = new Set(s.genre.split(','));
|
||||
const bookGenres = book.genre.split(',');
|
||||
|
||||
for (let g of bookGenres) {
|
||||
if (!g)
|
||||
g = emptyFieldValue;
|
||||
|
||||
if (searchGenres.has(g)) {
|
||||
genreFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//lang
|
||||
let langFound = !s.lang;
|
||||
if (!langFound) {
|
||||
const searchLang = new Set(s.lang.split(','));
|
||||
langFound = searchLang.has(book.lang || emptyFieldValue);
|
||||
}
|
||||
|
||||
//date
|
||||
let dateFound = !s.date;
|
||||
if (!dateFound) {
|
||||
const date = this.queryDate(s.date).split(',');
|
||||
let [from = '0000-00-00', to = '9999-99-99'] = date;
|
||||
|
||||
dateFound = (book.date >= from && book.date <= to);
|
||||
}
|
||||
|
||||
//librate
|
||||
let librateFound = !s.librate;
|
||||
if (!librateFound) {
|
||||
const searchLibrate = new Set(s.librate.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n)));
|
||||
librateFound = searchLibrate.has(book.librate);
|
||||
}
|
||||
|
||||
return (this.showDeleted || !book.del)
|
||||
&& authorFound
|
||||
&& filterBySearch(book.series, s.series)
|
||||
&& filterBySearch(book.title, s.title)
|
||||
&& genreFound
|
||||
&& langFound
|
||||
&& dateFound
|
||||
&& librateFound
|
||||
;
|
||||
});
|
||||
}
|
||||
|
||||
bookAuthor(author) {
|
||||
if (author) {
|
||||
let a = author.split(',');
|
||||
return a.slice(0, 3).join(', ') + (a.length > 3 ? ' и др.' : '');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async getGenres() {
|
||||
let result;
|
||||
if (!this.genres) {
|
||||
const res = await this.webWorker.getGenreTree();
|
||||
|
||||
result = {
|
||||
genreTree: res.genreTree,
|
||||
genreMap: new Map(),
|
||||
genreSection: new Map(),
|
||||
};
|
||||
|
||||
for (const section of result.genreTree) {
|
||||
result.genreSection.set(section.name, section.value);
|
||||
|
||||
for (const g of section.value)
|
||||
result.genreMap.set(g.value, g.name);
|
||||
}
|
||||
|
||||
this.genres = result;
|
||||
} else {
|
||||
result = this.genres;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BasePage;
|
||||
206
server/core/opds/BookPage.js
Normal file
206
server/core/opds/BookPage.js
Normal file
@@ -0,0 +1,206 @@
|
||||
const path = require('path');
|
||||
const _ = require('lodash');
|
||||
const he = require('he');
|
||||
const dayjs = require('dayjs');
|
||||
|
||||
const BasePage = require('./BasePage');
|
||||
const Fb2Parser = require('../fb2/Fb2Parser');
|
||||
|
||||
class BookPage extends BasePage {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
|
||||
this.id = 'book';
|
||||
this.title = 'Книга';
|
||||
|
||||
}
|
||||
|
||||
formatSize(size) {
|
||||
size = size/1024;
|
||||
let unit = 'KB';
|
||||
if (size > 1024) {
|
||||
size = size/1024;
|
||||
unit = 'MB';
|
||||
}
|
||||
return `${size.toFixed(1)} ${unit}`;
|
||||
}
|
||||
|
||||
inpxInfo(bookRec) {
|
||||
const mapping = [
|
||||
{name: 'fileInfo', label: 'Информация о файле', value: [
|
||||
{name: 'folder', label: 'Папка'},
|
||||
{name: 'file', label: 'Файл'},
|
||||
{name: 'size', label: 'Размер'},
|
||||
{name: 'date', label: 'Добавлен'},
|
||||
{name: 'del', label: 'Удален'},
|
||||
{name: 'libid', label: 'LibId'},
|
||||
{name: 'insno', label: 'InsideNo'},
|
||||
]},
|
||||
|
||||
{name: 'titleInfo', label: 'Общая информация', value: [
|
||||
{name: 'author', label: 'Автор(ы)'},
|
||||
{name: 'title', label: 'Название'},
|
||||
{name: 'series', label: 'Серия'},
|
||||
{name: 'genre', label: 'Жанр'},
|
||||
{name: 'librate', label: 'Оценка'},
|
||||
{name: 'lang', label: 'Язык книги'},
|
||||
{name: 'keywords', label: 'Ключевые слова'},
|
||||
]},
|
||||
];
|
||||
|
||||
const valueToString = (value, nodePath, b) => {//eslint-disable-line no-unused-vars
|
||||
if (nodePath == 'fileInfo/file')
|
||||
return `${value}.${b.ext}`;
|
||||
|
||||
if (nodePath == 'fileInfo/size')
|
||||
return `${this.formatSize(value)} (${value.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1 ')} Bytes)`;
|
||||
|
||||
if (nodePath == 'fileInfo/date')
|
||||
return dayjs(value, 'YYYY-MM-DD').format('DD.MM.YYYY');
|
||||
|
||||
if (nodePath == 'fileInfo/del')
|
||||
return (value ? 'Да' : null);
|
||||
|
||||
if (nodePath == 'fileInfo/insno')
|
||||
return (value ? value : null);
|
||||
|
||||
if (nodePath == 'titleInfo/author')
|
||||
return value.split(',').join(', ');
|
||||
|
||||
if (nodePath == 'titleInfo/librate' && !value)
|
||||
return null;
|
||||
|
||||
if (typeof(value) === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return (value.toString ? value.toString() : '');
|
||||
};
|
||||
|
||||
let result = [];
|
||||
const book = _.cloneDeep(bookRec);
|
||||
book.series = [book.series, book.serno].filter(v => v).join(' #');
|
||||
|
||||
for (const item of mapping) {
|
||||
const itemOut = {name: item.name, label: item.label, value: []};
|
||||
|
||||
for (const subItem of item.value) {
|
||||
const subItemOut = {
|
||||
name: subItem.name,
|
||||
label: subItem.label,
|
||||
value: valueToString(book[subItem.name], `${item.name}/${subItem.name}`, book)
|
||||
};
|
||||
if (subItemOut.value)
|
||||
itemOut.value.push(subItemOut);
|
||||
}
|
||||
|
||||
if (itemOut.value.length)
|
||||
result.push(itemOut);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
htmlInfo(title, infoList) {
|
||||
let info = '';
|
||||
for (const part of infoList) {
|
||||
if (part.value.length)
|
||||
info += `<h3>${part.label}</h3>`;
|
||||
for (const rec of part.value)
|
||||
info += `<p>${rec.label}: ${rec.value}</p>`;
|
||||
}
|
||||
|
||||
if (info)
|
||||
info = `<h2>${title}</h2>${info}`;
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
async body(req) {
|
||||
const result = {};
|
||||
|
||||
result.link = this.baseLinks(req, true);
|
||||
|
||||
const bookUid = req.query.uid;
|
||||
const entry = [];
|
||||
if (bookUid) {
|
||||
const {bookInfo} = await this.webWorker.getBookInfo(bookUid);
|
||||
|
||||
if (bookInfo) {
|
||||
const {genreMap} = await this.getGenres();
|
||||
const fileFormat = `${bookInfo.book.ext}+zip`;
|
||||
|
||||
//entry
|
||||
const e = this.makeEntry({
|
||||
id: bookUid,
|
||||
title: bookInfo.book.title || 'Без названия',
|
||||
});
|
||||
|
||||
e['dc:language'] = bookInfo.book.lang;
|
||||
e['dc:format'] = fileFormat;
|
||||
|
||||
//genre
|
||||
const genre = bookInfo.book.genre.split(',');
|
||||
for (const g of genre) {
|
||||
const genreName = genreMap.get(g);
|
||||
if (genreName) {
|
||||
if (!e.category)
|
||||
e.category = [];
|
||||
e.category.push({
|
||||
'*ATTRS': {term: genreName, label: genreName},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let content = '';
|
||||
let ann = '';
|
||||
let info = '';
|
||||
//fb2 info
|
||||
if (bookInfo.fb2) {
|
||||
const parser = new Fb2Parser(bookInfo.fb2);
|
||||
const infoObj = parser.bookInfo();
|
||||
|
||||
if (infoObj.titleInfo) {
|
||||
if (infoObj.titleInfo.author.length) {
|
||||
e.author = infoObj.titleInfo.author.map(a => ({name: a}));
|
||||
}
|
||||
|
||||
ann = infoObj.titleInfo.annotationHtml || '';
|
||||
const infoList = parser.bookInfoList(infoObj);
|
||||
info += this.htmlInfo('Fb2 инфо', infoList);
|
||||
}
|
||||
}
|
||||
|
||||
//content
|
||||
info += this.htmlInfo('Inpx инфо', this.inpxInfo(bookInfo.book));
|
||||
|
||||
content = `${ann}${info}`;
|
||||
if (content) {
|
||||
e.content = {
|
||||
'*ATTRS': {type: 'text/html'},
|
||||
'*TEXT': he.escape(content),
|
||||
};
|
||||
}
|
||||
|
||||
//links
|
||||
e.link = [ this.downLink({href: bookInfo.link, type: `application/${fileFormat}`}) ];
|
||||
if (bookInfo.cover) {
|
||||
let coverType = 'image/jpeg';
|
||||
if (path.extname(bookInfo.cover) == '.png')
|
||||
coverType = 'image/png';
|
||||
|
||||
e.link.push(this.imgLink({href: bookInfo.cover, type: coverType}));
|
||||
e.link.push(this.imgLink({href: bookInfo.cover, type: coverType, thumb: true}));
|
||||
}
|
||||
|
||||
entry.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
result.entry = entry;
|
||||
|
||||
return this.makeBody(result, req);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BookPage;
|
||||
72
server/core/opds/GenrePage.js
Normal file
72
server/core/opds/GenrePage.js
Normal file
@@ -0,0 +1,72 @@
|
||||
const BasePage = require('./BasePage');
|
||||
|
||||
class GenrePage extends BasePage {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
|
||||
this.id = 'genre';
|
||||
this.title = 'Жанры';
|
||||
|
||||
}
|
||||
|
||||
async body(req) {
|
||||
const result = {};
|
||||
|
||||
const query = {
|
||||
from: req.query.from || '',
|
||||
section: req.query.section || '',
|
||||
};
|
||||
|
||||
const entry = [];
|
||||
if (query.from) {
|
||||
|
||||
if (query.section) {
|
||||
//выбираем подразделы
|
||||
const {genreSection} = await this.getGenres();
|
||||
const section = genreSection.get(query.section);
|
||||
|
||||
if (section) {
|
||||
let id = 0;
|
||||
const all = [];
|
||||
for (const g of section) {
|
||||
all.push(g.value);
|
||||
entry.push(
|
||||
this.makeEntry({
|
||||
id: ++id,
|
||||
title: g.name,
|
||||
link: this.navLink({href: `/${encodeURIComponent(query.from)}?genre=${encodeURIComponent(g.value)}`}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
entry.unshift(
|
||||
this.makeEntry({
|
||||
id: 'whole_section',
|
||||
title: '[Весь раздел]',
|
||||
link: this.navLink({href: `/${encodeURIComponent(query.from)}?genre=${encodeURIComponent(all.join(','))}`}),
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
//выбираем разделы
|
||||
const {genreTree} = await this.getGenres();
|
||||
let id = 0;
|
||||
for (const section of genreTree) {
|
||||
entry.push(
|
||||
this.makeEntry({
|
||||
id: ++id,
|
||||
title: section.name,
|
||||
link: this.navLink({href: `/genre?from=${encodeURIComponent(query.from)}§ion=${encodeURIComponent(section.name)}`}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.entry = entry;
|
||||
|
||||
return this.makeBody(result, req);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GenrePage;
|
||||
45
server/core/opds/OpensearchPage.js
Normal file
45
server/core/opds/OpensearchPage.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const BasePage = require('./BasePage');
|
||||
const XmlParser = require('../xml/XmlParser');
|
||||
|
||||
class OpensearchPage extends BasePage {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
|
||||
this.id = 'opensearch';
|
||||
this.title = 'opensearch';
|
||||
}
|
||||
|
||||
async body() {
|
||||
const xml = new XmlParser();
|
||||
const xmlObject = {};
|
||||
/*
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<ShortName>inpx-web</ShortName>
|
||||
<Description>Поиск по каталогу</Description>
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
<OutputEncoding>UTF-8</OutputEncoding>
|
||||
<Url type="application/atom+xml;profile=opds-catalog;kind=navigation" template="/opds/search?term={searchTerms}"/>
|
||||
</OpenSearchDescription>
|
||||
*/
|
||||
xmlObject['OpenSearchDescription'] = {
|
||||
'*ATTRS': {xmlns: 'http://a9.com/-/spec/opensearch/1.1/'},
|
||||
ShortName: 'inpx-web',
|
||||
Description: 'Поиск по каталогу',
|
||||
InputEncoding: 'UTF-8',
|
||||
OutputEncoding: 'UTF-8',
|
||||
Url: {
|
||||
'*ATTRS': {
|
||||
type: 'application/atom+xml;profile=opds-catalog;kind=navigation',
|
||||
template: `${this.opdsRoot}/search?term={searchTerms}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
xml.fromObject(xmlObject);
|
||||
|
||||
return xml.toString({format: true});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OpensearchPage;
|
||||
39
server/core/opds/RootPage.js
Normal file
39
server/core/opds/RootPage.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const BasePage = require('./BasePage');
|
||||
const AuthorPage = require('./AuthorPage');
|
||||
const SeriesPage = require('./SeriesPage');
|
||||
const TitlePage = require('./TitlePage');
|
||||
|
||||
class RootPage extends BasePage {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
|
||||
this.id = 'root';
|
||||
this.title = '';
|
||||
|
||||
this.authorPage = new AuthorPage(config);
|
||||
this.seriesPage = new SeriesPage(config);
|
||||
this.titlePage = new TitlePage(config);
|
||||
}
|
||||
|
||||
async body(req) {
|
||||
const result = {};
|
||||
|
||||
if (!this.title) {
|
||||
const dbConfig = await this.webWorker.dbConfig();
|
||||
const collection = dbConfig.inpxInfo.collection.split('\n');
|
||||
this.title = collection[0].trim();
|
||||
if (!this.title)
|
||||
this.title = 'Неизвестная коллекция';
|
||||
}
|
||||
|
||||
result.entry = [
|
||||
this.authorPage.myEntry(),
|
||||
this.seriesPage.myEntry(),
|
||||
this.titlePage.myEntry(),
|
||||
];
|
||||
|
||||
return this.makeBody(result, req);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RootPage;
|
||||
83
server/core/opds/SearchPage.js
Normal file
83
server/core/opds/SearchPage.js
Normal file
@@ -0,0 +1,83 @@
|
||||
const BasePage = require('./BasePage');
|
||||
|
||||
class SearchPage extends BasePage {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
|
||||
this.id = 'search';
|
||||
this.title = 'Поиск';
|
||||
}
|
||||
|
||||
async body(req) {
|
||||
const result = {};
|
||||
|
||||
const query = {
|
||||
type: req.query.type || '',
|
||||
term: req.query.term || '',
|
||||
page: parseInt(req.query.page, 10) || 1,
|
||||
};
|
||||
|
||||
let entry = [];
|
||||
if (query.type) {
|
||||
if (['author', 'series', 'title'].includes(query.type)) {
|
||||
const from = query.type;
|
||||
const page = query.page;
|
||||
|
||||
const limit = 100;
|
||||
const offset = (page - 1)*limit;
|
||||
const queryRes = await this.webWorker.search(from, {[from]: query.term, del: 0, offset, limit});
|
||||
|
||||
const found = queryRes.found;
|
||||
|
||||
for (let i = 0; i < found.length; i++) {
|
||||
if (i >= limit)
|
||||
break;
|
||||
|
||||
const row = found[i];
|
||||
|
||||
entry.push(
|
||||
this.makeEntry({
|
||||
id: row.id,
|
||||
title: row[from],
|
||||
link: this.navLink({href: `/${from}?${from}==${encodeURIComponent(row[from])}`}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (queryRes.totalFound > offset + found.length) {
|
||||
entry.push(
|
||||
this.makeEntry({
|
||||
id: 'next_page',
|
||||
title: '[Следующая страница]',
|
||||
link: this.navLink({href: `/${this.id}?type=${from}&term=${encodeURIComponent(query.term)}&page=${page + 1}`}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//корневой раздел
|
||||
entry = [
|
||||
this.makeEntry({
|
||||
id: 'search_author',
|
||||
title: 'Поиск авторов',
|
||||
link: this.navLink({href: `/${this.id}?type=author&term=${encodeURIComponent(query.term)}`}),
|
||||
}),
|
||||
this.makeEntry({
|
||||
id: 'search_series',
|
||||
title: 'Поиск серий',
|
||||
link: this.navLink({href: `/${this.id}?type=series&term=${encodeURIComponent(query.term)}`}),
|
||||
}),
|
||||
this.makeEntry({
|
||||
id: 'search_title',
|
||||
title: 'Поиск книг',
|
||||
link: this.navLink({href: `/${this.id}?type=title&term=${encodeURIComponent(query.term)}`}),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
result.entry = entry;
|
||||
return this.makeBody(result, req);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SearchPage;
|
||||
114
server/core/opds/SeriesPage.js
Normal file
114
server/core/opds/SeriesPage.js
Normal file
@@ -0,0 +1,114 @@
|
||||
const BasePage = require('./BasePage');
|
||||
|
||||
class SeriesPage extends BasePage {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
|
||||
this.id = 'series';
|
||||
this.title = 'Серии';
|
||||
}
|
||||
|
||||
sortSeriesBooks(seriesBooks) {
|
||||
seriesBooks.sort((a, b) => {
|
||||
const dserno = (a.serno || Number.MAX_VALUE) - (b.serno || Number.MAX_VALUE);
|
||||
const dtitle = a.title.localeCompare(b.title);
|
||||
const dext = a.ext.localeCompare(b.ext);
|
||||
return (dserno ? dserno : (dtitle ? dtitle : dext));
|
||||
});
|
||||
|
||||
return seriesBooks;
|
||||
}
|
||||
|
||||
async body(req) {
|
||||
const result = {};
|
||||
|
||||
const query = {
|
||||
series: req.query.series || '',
|
||||
genre: req.query.genre || '',
|
||||
del: 0,
|
||||
|
||||
all: req.query.all || '',
|
||||
depth: 0,
|
||||
};
|
||||
query.depth = query.series.length + 1;
|
||||
|
||||
if (query.series == '___others') {
|
||||
query.series = '';
|
||||
query.depth = 1;
|
||||
query.others = true;
|
||||
}
|
||||
|
||||
const entry = [];
|
||||
if (query.series && query.series[0] == '=') {
|
||||
//книги по серии
|
||||
const bookList = await this.webWorker.getSeriesBookList(query.series.substring(1));
|
||||
|
||||
if (bookList.books) {
|
||||
let books = JSON.parse(bookList.books);
|
||||
const booksAll = this.filterBooks(books, {del: 0});
|
||||
const filtered = (query.all ? booksAll : this.filterBooks(books, query));
|
||||
const sorted = this.sortSeriesBooks(filtered);
|
||||
|
||||
if (booksAll.length > filtered.length) {
|
||||
entry.push(
|
||||
this.makeEntry({
|
||||
id: 'all_series_books',
|
||||
title: '[Все книги серии]',
|
||||
link: this.navLink({
|
||||
href: `/${this.id}?series=${encodeURIComponent(query.series)}&all=1`}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (query.depth == 1 && !query.genre && !query.others) {
|
||||
entry.push(
|
||||
this.makeEntry({
|
||||
id: 'select_genre',
|
||||
title: '[Выбрать жанр]',
|
||||
link: this.navLink({href: `/genre?from=${this.id}`}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
//навигация по каталогу
|
||||
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)}`}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
result.entry = entry;
|
||||
return this.makeBody(result, req);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SeriesPage;
|
||||
84
server/core/opds/TitlePage.js
Normal file
84
server/core/opds/TitlePage.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const BasePage = require('./BasePage');
|
||||
|
||||
class TitlePage extends BasePage {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
|
||||
this.id = 'title';
|
||||
this.title = 'Книги';
|
||||
}
|
||||
|
||||
async body(req) {
|
||||
const result = {};
|
||||
|
||||
const query = {
|
||||
title: req.query.title || '',
|
||||
genre: req.query.genre || '',
|
||||
del: 0,
|
||||
|
||||
depth: 0,
|
||||
};
|
||||
query.depth = query.title.length + 1;
|
||||
|
||||
if (query.title == '___others') {
|
||||
query.title = '';
|
||||
query.depth = 1;
|
||||
query.others = true;
|
||||
}
|
||||
|
||||
const entry = [];
|
||||
if (query.title && query.title[0] == '=') {
|
||||
//книги по названию
|
||||
const res = await this.webWorker.search('title', query);
|
||||
|
||||
if (res.found.length) {
|
||||
const books = res.found[0].books || [];
|
||||
const filtered = this.filterBooks(books, query);
|
||||
|
||||
for (const book of filtered) {
|
||||
const title = `${book.serno ? `${book.serno}. `: ''}${book.title || 'Без названия'} (${book.ext})`;
|
||||
|
||||
entry.push(
|
||||
this.makeEntry({
|
||||
id: book._uid,
|
||||
title,
|
||||
link: this.acqLink({href: `/book?uid=${encodeURIComponent(book._uid)}`}),
|
||||
content: {
|
||||
'*ATTRS': {type: 'text'},
|
||||
'*TEXT': this.bookAuthor(book.author),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (query.depth == 1 && !query.genre && !query.others) {
|
||||
entry.push(
|
||||
this.makeEntry({
|
||||
id: 'select_genre',
|
||||
title: '[Выбрать жанр]',
|
||||
link: this.navLink({href: `/genre?from=${this.id}`}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
//навигация по каталогу
|
||||
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)}`}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
result.entry = entry;
|
||||
return this.makeBody(result, req);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TitlePage;
|
||||
82
server/core/opds/index.js
Normal file
82
server/core/opds/index.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const basicAuth = require('express-basic-auth');
|
||||
|
||||
const RootPage = require('./RootPage');
|
||||
const AuthorPage = require('./AuthorPage');
|
||||
const SeriesPage = require('./SeriesPage');
|
||||
const TitlePage = require('./TitlePage');
|
||||
const GenrePage = require('./GenrePage');
|
||||
const BookPage = require('./BookPage');
|
||||
|
||||
const OpensearchPage = require('./OpensearchPage');
|
||||
const SearchPage = require('./SearchPage');
|
||||
|
||||
module.exports = function(app, config) {
|
||||
if (!config.opds || !config.opds.enabled)
|
||||
return;
|
||||
|
||||
const opdsRoot = '/opds';
|
||||
config.opdsRoot = opdsRoot;
|
||||
|
||||
const root = new RootPage(config);
|
||||
const author = new AuthorPage(config);
|
||||
const series = new SeriesPage(config);
|
||||
const title = new TitlePage(config);
|
||||
const genre = new GenrePage(config);
|
||||
const book = new BookPage(config);
|
||||
|
||||
const opensearch = new OpensearchPage(config);
|
||||
const search = new SearchPage(config);
|
||||
|
||||
const routes = [
|
||||
['', root],
|
||||
['/root', root],
|
||||
['/author', author],
|
||||
['/series', series],
|
||||
['/title', title],
|
||||
['/genre', genre],
|
||||
['/book', book],
|
||||
|
||||
['/opensearch', opensearch],
|
||||
['/search', search],
|
||||
];
|
||||
|
||||
const pages = new Map();
|
||||
for (const r of routes) {
|
||||
pages.set(`${opdsRoot}${r[0]}`, r[1]);
|
||||
}
|
||||
|
||||
const opds = async(req, res, next) => {
|
||||
try {
|
||||
const page = pages.get(req.path);
|
||||
|
||||
if (page) {
|
||||
res.set('Content-Type', 'application/atom+xml; charset=utf-8');
|
||||
|
||||
const result = await page.body(req, res);
|
||||
|
||||
if (result !== false)
|
||||
res.send(result);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).send({error: e.message});
|
||||
if (config.branch == 'development') {
|
||||
console.error({error: e.message, url: req.originalUrl});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const opdsPaths = [opdsRoot, `${opdsRoot}/*`];
|
||||
if (config.opds.password) {
|
||||
if (!config.opds.user)
|
||||
throw new Error('User must not be empty if password set');
|
||||
|
||||
app.use(opdsPaths, basicAuth({
|
||||
users: {[config.opds.user]: config.opds.password},
|
||||
challenge: true,
|
||||
}));
|
||||
}
|
||||
app.get(opdsPaths, opds);
|
||||
};
|
||||
|
||||
@@ -154,6 +154,8 @@ async function main() {
|
||||
if (devModule)
|
||||
devModule.logQueries(app);
|
||||
|
||||
const opds = require('./core/opds');
|
||||
opds(app, config);
|
||||
initStatic(app, config);
|
||||
|
||||
const { WebSocketController } = require('./controllers');
|
||||
|
||||
Reference in New Issue
Block a user