486 Commits
1.0.3 ... 1.5.8

Author SHA1 Message Date
Book Pauk
9c0c83143f Merge branch 'release/1.5.8' 2024-06-04 20:08:44 +07:00
Book Pauk
ac074c4c00 v1.5.8 2024-06-04 20:08:04 +07:00
Book Pauk
9a3ce1e184 Исправлен баг: версия 1.5.7 не создает файл конфигурации (#32) 2024-06-04 20:07:08 +07:00
Book Pauk
e17053141d Небольшие переименования 2024-04-04 18:38:01 +07:00
Book Pauk
320e5aec2a Мелкая поправка 2024-04-04 18:27:11 +07:00
Book Pauk
2d8c1c655a Merge tag '1.5.7' into develop
1.5.7
2024-04-04 18:11:16 +07:00
Book Pauk
7e268fec0b Merge branch 'release/1.5.7' 2024-04-04 18:11:08 +07:00
Book Pauk
1de8af197e 1.5.7 2024-04-04 18:07:06 +07:00
Book Pauk
d01c0744e5 CHANGELOG 2024-04-04 18:06:42 +07:00
Book Pauk
175511ba45 В параметры командной строки добавлена возможность задавать путь к файлу
конфигурации, а все остальные настройки приложения можно указать в нем самом (#30)
2024-04-04 18:02:16 +07:00
Book Pauk
4edcc4c88d Добавлены цели сборки для отдельных платформ 2024-04-04 15:49:47 +07:00
Book Pauk
915716880c Поправки журналирования 2024-04-04 15:14:14 +07:00
Book Pauk
77a7678565 CHANGELOG 2024-04-04 14:08:23 +07:00
Book Pauk
49fd516d45 В параметр bookReadLink конфига добавлен вариант замены DOWNLOAD_URI на uri из ссылки для скачивания книги (#29) 2024-04-04 14:08:15 +07:00
Book Pauk
7e4b548583 CHANGELOG 2024-03-25 16:11:59 +07:00
Book Pauk
981d862fa7 Merge tag '1.5.6' into develop
1.5.6
2024-03-25 16:03:49 +07:00
Book Pauk
fc35a5aa97 Merge branch 'release/1.5.6' 2024-03-25 16:03:44 +07:00
Book Pauk
d52d9d7f20 1.5.6 2024-03-25 16:03:29 +07:00
Book Pauk
a3b83f93c3 Добавлена расшифровка имен жанров в информации о книге(#24) 2024-03-25 16:02:04 +07:00
Book Pauk
2efdc6b0aa CHANGELOG 2024-03-25 16:01:56 +07:00
Book Pauk
1e7d81b181 CHANGELOG 2024-03-25 14:44:05 +07:00
Book Pauk
bbf7b43aa3 В конфиг добавлен параметр logQueries для журналирования запросов и времени их выполнения 2024-03-25 14:42:52 +07:00
Book Pauk
5aefa9e558 Убрано (по умолчанию) ежеминутное журналирование статистики сервера. В конфиг добавлен параметр logServerStats 2024-03-25 14:11:58 +07:00
Book Pauk
605aba7a05 CHANGELOG 2024-03-25 14:11:24 +07:00
Book Pauk
5a43cd0e46 CHANGELOG 2024-03-25 14:09:36 +07:00
Book Pauk
307b9adfa3 Добавлен вывод version.info в статистику по коллекции (#27) 2024-03-25 13:33:58 +07:00
Book Pauk
85a23578e7 Актуализация пакетов 2024-03-25 13:14:52 +07:00
Book Pauk
db0b3c8c62 Добавил дебаг в комменте 2023-06-23 15:42:24 +07:00
Book Pauk
e820c82c56 caniuse-lite update 2023-06-23 13:40:43 +07:00
Book Pauk
ac29dbebba Merge tag '1.5.5' into develop
1.5.5
2023-04-25 16:20:41 +07:00
Book Pauk
84843fb6bd Merge branch 'release/1.5.5' 2023-04-25 16:20:34 +07:00
Book Pauk
74ece78175 CHANGELOG 2023-04-25 16:20:10 +07:00
Book Pauk
b2fcf8d043 Версия 1.5.5 2023-04-25 16:13:20 +07:00
Book Pauk
9ce33715d2 CHANGELOG 2023-04-25 16:10:43 +07:00
Book Pauk
76fc64c1b7 Улучшение работы с inpx: теперь понимает zip-архивы, вложенные в каталоги
Улучшение работы с zip-файлами: теперь понимает кодировку cp866
2023-04-25 16:08:47 +07:00
Book Pauk
b0edc3561e Merge tag '1.5.4' into develop
1.5.4
2023-04-12 18:31:14 +07:00
Book Pauk
c3810d1ff5 Merge branch 'release/1.5.4' 2023-04-12 18:31:10 +07:00
Book Pauk
deeac0be14 Поправлен readme 2023-04-12 18:24:53 +07:00
Book Pauk
70593b5d22 Версия 1.5.4 2023-04-12 18:15:34 +07:00
Book Pauk
4c90844f46 CHANGELOG 2023-04-12 18:14:55 +07:00
Book Pauk
3e2599f233 В readme добавлен раздел "Запуск без сборки релиза" для запуска inpx-web на любых платформах 2023-04-12 18:14:44 +07:00
Book Pauk
8687931274 Улучшена обработка ошибок 2023-04-12 17:53:51 +07:00
Book Pauk
50f7a7800d Исправлен баг с рег.выражениями - не понимал верхний регистр 2023-04-12 17:26:56 +07:00
Book Pauk
eb2fbd20ae CHANGELOG 2023-04-12 17:14:21 +07:00
Book Pauk
24d609d8f1 Улучшена работа с inpx, теперь понимает файлы в каталогах (без zip-архива) 2023-04-12 17:14:12 +07:00
Book Pauk
4b4f7bd697 Улучшено определение кодировки 2023-03-17 13:39:50 +07:00
Book Pauk
bf6cf4238a CHANGELOG 2023-03-17 12:57:51 +07:00
Book Pauk
1daf619a2d Мелкая поправка стилей 2023-03-17 12:56:22 +07:00
Book Pauk
7e8d616c65 CHANGELOG 2023-03-17 12:49:33 +07:00
Book Pauk
d9e19ff9b7 Добавлен поиск по типу файла 2023-03-17 12:45:13 +07:00
Book Pauk
6be1b94b96 Убрал старое 2023-03-17 11:56:02 +07:00
Book Pauk
d6aa43d01b Работа над поиском по типу файла 2023-03-17 11:44:50 +07:00
Book Pauk
14a877d6d1 Мелкая поправка разметки 2023-03-17 10:40:00 +07:00
Book Pauk
82eb78f433 Поправлено время проверки обновления 2023-03-17 10:26:29 +07:00
Book Pauk
8e5e0104a4 Merge tag '1.5.3' into develop
1.5.3
2023-03-02 18:15:23 +07:00
Book Pauk
636e34bdc1 Merge branch 'release/1.5.3' 2023-03-02 18:15:13 +07:00
Book Pauk
2c687c7af9 CHANGELOG 2023-03-02 18:14:54 +07:00
Book Pauk
27fc46c410 Версия 1.5.3 2023-03-02 18:14:38 +07:00
Book Pauk
a7cd019a97 CHANGELOG 2023-03-02 18:12:13 +07:00
Book Pauk
12304c13a1 Добавлена полоска уведомления о выходе новой версии (отключается в настройках веб-интерфейса). Проверка настраивается параметром checkReleaseLink в конфиге сревера (#15) 2023-03-02 18:10:15 +07:00
Book Pauk
d87e2ce632 CHANGELOG 2023-03-02 15:59:58 +07:00
Book Pauk
a2423fb704 opds: улучшено скачивание для не-fb2 форматов файлов (djvu, pdf и пр.) 2023-03-02 15:50:26 +07:00
Book Pauk
192b92cab8 Поправка бага (не показывалось имя автора для не-fb2 файлов) 2023-03-02 14:58:12 +07:00
Book Pauk
386a937239 CHANGELOG 2023-03-02 14:34:45 +07:00
Book Pauk
06300e30b4 Добавлен костыль - неверная кодировка от koreader при поиске в opds 2023-03-02 14:33:01 +07:00
Book Pauk
371d5646ae Merge tag '1.5.2' into develop
1.5.2
2023-02-05 17:50:59 +07:00
Book Pauk
0d52a9fd52 Merge branch 'release/1.5.2' 2023-02-05 17:50:52 +07:00
Book Pauk
08287deaa4 Версия 1.5.2 2023-02-05 17:50:26 +07:00
Book Pauk
c43c5520a4 Улучшена обработка ошибок 2023-02-05 17:48:02 +07:00
Book Pauk
4e2b7886a9 Мелкий рефакторинг 2023-02-05 17:43:24 +07:00
Book Pauk
05744e8472 CHANGELOG 2023-02-05 17:35:32 +07:00
Book Pauk
9126973378 Исправление проблемы чтения каталога opds для koreader 2023-02-05 17:35:24 +07:00
Book Pauk
018e1069d5 Merge tag '1.5.1' into develop
1.5.1
2023-01-28 21:21:18 +07:00
Book Pauk
92617b3263 Merge branch 'release/1.5.1' 2023-01-28 21:21:10 +07:00
Book Pauk
d0172f11c3 Версия 1.5.1 2023-01-28 21:20:54 +07:00
Book Pauk
638126b5f3 OPDS: исправлен баг - не учитывался жанр при переходе на следующую страницу 2023-01-28 21:20:35 +07:00
Book Pauk
ef938bff77 Merge tag '1.5.0' into develop
1.5.0
2023-01-28 18:24:35 +07:00
Book Pauk
50597f8d3a Merge branch 'release/1.5.0' 2023-01-28 18:24:30 +07:00
Book Pauk
b84e26fa3b Версия 1.5.0
"jembadb": "^5.1.7"
2023-01-28 18:23:53 +07:00
Book Pauk
d8ac700ee7 CHANGELOG 2023-01-28 18:19:50 +07:00
Book Pauk
5519e23e02 OPDS: добавлен раздел "Жанры", в поиск добавлен раздел "Поиск книг в жанре" (#9) 2023-01-28 18:18:46 +07:00
Book Pauk
4d6da6a9b7 Небольшие поправки формирования имени файла 2023-01-28 16:37:17 +07:00
Book Pauk
1385babea5 CHANGELOG 2023-01-28 16:35:25 +07:00
Book Pauk
ed916e1b28 Уменьшена длина имени файла при скачивании (#7) 2023-01-27 14:32:05 +07:00
Book Pauk
97c18a290a Мелкая поправка для единообразия интерфейса 2023-01-27 14:03:36 +07:00
Book Pauk
e53bb11566 Настройки веб-интерфейса и опции командной строки "--lib-dir", "--inpx" вынесены в конфиг (#6) 2023-01-27 13:57:29 +07:00
Book Pauk
1ecc19b8b5 Cli-параметры --lib-dir и --inpx вынесены в конфиг (#6) 2023-01-27 13:02:19 +07:00
Book Pauk
7ef1520756 Улучшение ui на мобильных устройствах 2023-01-24 15:23:59 +07:00
Book Pauk
5a5ea147b1 К предыдущему 2023-01-24 14:57:55 +07:00
Book Pauk
5a15241cc1 Улучшение ui для мобильных устройств 2023-01-24 14:54:59 +07:00
Book Pauk
4a8035949c Улучшение вида для мобильных 2023-01-13 19:39:51 +07:00
Book Pauk
ab57dbfbe7 Улучшение разметки для мобильных устройств 2023-01-13 19:21:38 +07:00
Book Pauk
302dd8eeb4 Кнопка раскрытия дополнительных параметров вынесена в нижнюю строку 2023-01-13 18:16:53 +07:00
Book Pauk
e983db84ba CHANGELOG 2023-01-13 17:35:36 +07:00
Book Pauk
51c026e9d2 Косметические поправки 2023-01-13 17:30:11 +07:00
Book Pauk
435b425ab6 Merge branch 'master' into develop 2023-01-13 16:53:10 +07:00
Book Pauk
0981a36fb5 Merge ssh://github.com/bookpauk/inpx-web 2023-01-13 16:52:44 +07:00
bookpauk
3292f35310 Merge pull request #12 from Weegley/master
Скрытие/показ шапки вместе со скроллом
2023-01-13 16:51:17 +07:00
Sergey Samsonov
f85c126d03 Фикс поведения шапки при смене страницы 2023-01-05 00:59:39 +03:00
Sergey Samsonov
2cc753f6d0 Скрытие/показ шапки вместе со скроллом 2023-01-04 21:52:30 +03:00
Book Pauk
cb26aff4b9 Merge tag '1.4.1' into develop
1.4.1
2022-12-21 13:45:36 +07:00
Book Pauk
ac1be219fb Merge branch 'release/1.4.1' 2022-12-21 13:45:31 +07:00
Book Pauk
b34d915597 Версия 1.4.1 2022-12-21 13:45:10 +07:00
Book Pauk
a2db43bbe1 CHANGELOG 2022-12-21 13:44:27 +07:00
Book Pauk
024a5f9b8c Заплатка для исправления (#10) 2022-12-21 13:42:54 +07:00
Book Pauk
070c667605 Улучшение встраивания в liberama 2022-12-15 16:26:22 +07:00
Book Pauk
6e2ff07a48 Мелкая поправка 2022-12-12 17:27:29 +07:00
Book Pauk
46f40d29be Мелкий рефакторинг 2022-12-11 18:08:09 +07:00
Book Pauk
71f2674b38 gitignore 2022-12-11 18:07:23 +07:00
Book Pauk
bd2551559b Исправления багов поиска по рег. выражению 2022-12-09 18:31:41 +07:00
Book Pauk
85bf7296ef Поправка формирования поисковой БД (из series удалена информация о книгах без серии) 2022-12-09 17:48:40 +07:00
Book Pauk
64254ccf5f CHANGELOG 2022-12-09 16:50:57 +07:00
Book Pauk
48b973d384 Улучшена обработка ошибок 2022-12-09 16:49:47 +07:00
Book Pauk
c17b696d61 Добавлен поиск по регулярным выражениям 2022-12-09 16:40:30 +07:00
Book Pauk
8837fea654 Merge tag '1.4.0' into develop
1.4.0
2022-12-07 21:11:00 +07:00
Book Pauk
7fe0bcee2f Merge branch 'release/1.4.0' 2022-12-07 21:10:56 +07:00
Book Pauk
b8589ea026 Версия 1.4.0 2022-12-07 21:10:08 +07:00
Book Pauk
de838ebb4d CHANGELOG 2022-12-07 21:09:06 +07:00
Book Pauk
74af82f34f Поправки мелких недочетов 2022-12-07 21:07:59 +07:00
Book Pauk
307b78f60d Небольшие доработки opds 2022-12-07 20:55:30 +07:00
Book Pauk
8ed52f62b8 Поправлен мелкий баг 2022-12-07 20:17:01 +07:00
Book Pauk
93fcbb9084 Мелкая поправка 2022-12-07 20:03:12 +07:00
Book Pauk
2b017a2ae3 Работа над расширенным поиском 2022-12-07 19:57:59 +07:00
Book Pauk
d32375d4e4 Работа над расширенным поиском 2022-12-07 19:44:51 +07:00
Book Pauk
a965032b08 Работа над расширенным поиском 2022-12-07 18:27:11 +07:00
Book Pauk
3a26a12807 Работа над расширенным поиском 2022-12-07 18:16:09 +07:00
Book Pauk
0867e4020f Работа над расширенным поиском 2022-12-07 18:02:34 +07:00
Book Pauk
f132cdfbdf Работа над расширенным поиском 2022-12-07 17:09:58 +07:00
Book Pauk
4927cdf6ce Работа над расширенным поиском 2022-12-07 16:43:46 +07:00
Book Pauk
cff6e5aab9 Работа над расширенным поиском 2022-12-07 15:52:43 +07:00
Book Pauk
85007f3e91 Работа над расширенным поиском 2022-12-07 15:36:54 +07:00
Book Pauk
5b6ef8202f Работа над расширенным поиском 2022-12-06 21:52:19 +07:00
Book Pauk
0c07f638f9 Работа над расширенным поиском 2022-12-06 21:03:46 +07:00
Book Pauk
5faa6a1e8f Работа над расширенным поиском 2022-12-06 19:03:49 +07:00
Book Pauk
51b325d63c CHANGELOG 2022-12-06 16:18:08 +07:00
Book Pauk
a4715be0ed Сборка для linux-arm64 2022-12-06 16:15:28 +07:00
Book Pauk
c2c88cb397 dbVersion: '10' 2022-12-06 15:06:39 +07:00
Book Pauk
a72a37ddfa В настройки веб-интерфейса добавлена опция "Скачивать книги в виде zip-архива" 2022-12-06 15:06:19 +07:00
Book Pauk
512d9fc8cb Поправка сортировки 2022-12-06 14:41:26 +07:00
Book Pauk
6284154a39 "jembadb": "^5.1.5" 2022-12-06 14:25:49 +07:00
Book Pauk
d78991dc03 CHANGELOG 2022-12-06 13:01:19 +07:00
Book Pauk
287edf3d12 Поправлен баг - неверно определялся тип обложки 2022-12-06 12:57:37 +07:00
Book Pauk
7b6bd25189 Замена JSZip на yazl 2022-12-06 12:33:14 +07:00
Book Pauk
b3ed9ea89c Добавлено формирование zip-файла (#4) 2022-12-05 18:06:20 +07:00
Book Pauk
de9471c2d5 Добавлен jszip 2022-12-05 18:04:15 +07:00
Book Pauk
4ad5aad64e Добавлено отображение имени автора 2022-12-04 20:15:17 +07:00
Book Pauk
28d68ca569 Включил webpackDevMiddleware 2022-12-04 20:14:52 +07:00
Book Pauk
db34b954c5 Поправки текста 2022-12-04 19:46:56 +07:00
Book Pauk
a22ec367a5 Добавлено отображение количества книг в поиске 2022-12-04 19:42:29 +07:00
Book Pauk
fd66034ba9 Улучшено отображение количества найденных результатов 2022-12-04 19:36:27 +07:00
Book Pauk
c2e1d062e8 Поправлено отображение количества найденных значений 2022-12-04 18:39:45 +07:00
Book Pauk
17e42d7088 1.3.4 2022-12-04 17:41:26 +07:00
Book Pauk
6858acd0b1 Незначительные поправки 2022-12-04 17:38:36 +07:00
Book Pauk
ea0845880b Добавлен server.root 2022-12-04 17:36:28 +07:00
Book Pauk
a9cc87f64f Поправка opds.root 2022-12-04 17:31:31 +07:00
Book Pauk
7bbe90a968 Добавлено описание параметров server.root и opds.root 2022-12-04 17:31:12 +07:00
Book Pauk
d970863a17 Улучшение поддержки reverse-proxy,
в конфиг добавлены параметры server.root и opds.root для встраивания inpx-web в уже существующий веб-сервер
2022-12-04 17:24:33 +07:00
Book Pauk
10b10f695a CHANGELOG 2022-12-04 17:24:21 +07:00
Book Pauk
8272f48cba CHANGELOG 2022-12-04 17:12:51 +07:00
Book Pauk
409befce5e Поправки для поддержки reverse-proxy, рефакторинг 2022-12-04 17:12:24 +07:00
Book Pauk
105680e38a Добавлен заголовок 'accept-encoding' 2022-12-04 15:52:14 +07:00
Book Pauk
0ddf18ec26 Поправлен баг 2022-12-02 21:29:17 +07:00
Book Pauk
46b69059e8 Мелкий рефакоринг 2022-12-02 20:12:00 +07:00
Book Pauk
09a4e70bb0 Поправки для поддержки reverse-proxy 2022-12-02 19:42:36 +07:00
Book Pauk
cf5de3ca89 Откат новой версии пакета axios, несовместимость с pkg 2022-12-02 19:19:32 +07:00
Book Pauk
8134527a7d Исправление бага (#4) 2022-12-02 18:52:25 +07:00
Book Pauk
33aea2a194 Добавлено отображение количества найденных значений 2022-12-02 18:00:09 +07:00
Book Pauk
772ea3ca0c Поправлен баг в fromObject 2022-12-02 16:52:48 +07:00
Book Pauk
5c77b1711b Работа над расширенным поиском 2022-12-01 19:05:34 +07:00
Book Pauk
602fe39c00 Поправка опечатки 2022-12-01 17:13:00 +07:00
Book Pauk
6160afa7f0 Работа над расширенным поиском 2022-12-01 17:09:21 +07:00
Book Pauk
68532e361e Работа над расширенным поиском 2022-12-01 16:28:39 +07:00
Book Pauk
e0a315f77c Мелкая поправка 2022-12-01 15:51:28 +07:00
Book Pauk
bcce467bcb Добавлена ссылка на донат 2022-12-01 15:37:13 +07:00
Book Pauk
f5cff52588 Обновление пакетов 2022-11-30 16:47:46 +07:00
Book Pauk
3472ca7844 Обновление пакетов 2022-11-30 16:40:28 +07:00
Book Pauk
5372507acd Поправлен баг 2022-11-30 16:36:07 +07:00
Book Pauk
01fb6479f3 Merge tag '1.3.3' into develop
1.3.3
2022-11-28 17:04:12 +07:00
Book Pauk
90c3faadcf Merge branch 'release/1.3.3' 2022-11-28 17:04:05 +07:00
Book Pauk
e594206759 Версия 1.3.3 2022-11-28 17:03:35 +07:00
Book Pauk
80b21371a4 Добавлено отображение общего количества книг в серии, без ее раскрытия 2022-11-28 16:58:32 +07:00
Book Pauk
af575a87a2 Рефакторинг, небольшие улучшения 2022-11-28 16:11:54 +07:00
Book Pauk
a6aa8d8e95 Добавлен CHANGELOG 2022-11-28 15:34:30 +07:00
Book Pauk
aa436feae7 Поправлен баг ограничения доступа в режиме "Удаленная библиотека" 2022-11-28 15:15:55 +07:00
Book Pauk
abce84999f Мелкая поправка 2022-11-28 15:07:11 +07:00
Book Pauk
74bb3f2362 Поправлен баг закрытия БД 2022-11-27 21:31:03 +07:00
Book Pauk
abcbbc86bc Мелкий рефакторинг 2022-11-27 21:28:48 +07:00
Book Pauk
d859202fb1 Merge tag '1.3.2' into develop
1.3.2
2022-11-27 21:05:14 +07:00
Book Pauk
d71c235ebc Merge branch 'release/1.3.2' 2022-11-27 21:05:09 +07:00
Book Pauk
49583d3407 Поправки багов 2022-11-27 21:03:12 +07:00
Book Pauk
a7f71562b4 Версия 1.3.2 2022-11-27 20:29:13 +07:00
Book Pauk
f93269ac8b К предыдущему 2022-11-27 20:25:56 +07:00
Book Pauk
79f85f362a Добавлено отображение количества книг в серии (раздел "Авторы") 2022-11-27 20:23:43 +07:00
Book Pauk
bf9ae0b9e1 Работа с токенами сессий вынесена в отдельный класс 2022-11-27 19:57:35 +07:00
Book Pauk
678562525f Добавлено описание параметра accessTimeout 2022-11-27 19:43:27 +07:00
Book Pauk
abb3baf94b Изменения механизма ограничения доступа 2022-11-27 18:34:01 +07:00
Book Pauk
59b4f48897 Изменения механизма ограничения доступа 2022-11-27 18:12:59 +07:00
Book Pauk
dca59e02dd Merge tag '1.3.1' into develop
1.3.1
2022-11-26 01:03:18 +07:00
Book Pauk
9bc92a7346 Merge branch 'release/1.3.1' 2022-11-26 01:03:13 +07:00
Book Pauk
53264da6bd Версия 1.3.1 2022-11-26 01:02:44 +07:00
Book Pauk
53e328ac2d Поправлен versionText 2022-11-26 00:05:35 +07:00
Book Pauk
59c2d73c05 Merge tag '1.3.0' into develop
1.3.0
2022-11-24 21:11:10 +07:00
Book Pauk
fc729218ba Merge branch 'release/1.3.0' 2022-11-24 21:11:05 +07:00
Book Pauk
15778eb3e4 Версия 1.3.0 2022-11-24 20:59:43 +07:00
Book Pauk
ad1a6560fa Добавлены цели для сборки релиза macos 2022-11-24 20:58:33 +07:00
Book Pauk
7fa203eaae Поправлен readme 2022-11-24 20:50:04 +07:00
Book Pauk
74d8cd3f94 Добавлен basic-auth для opds 2022-11-24 20:37:35 +07:00
Book Pauk
fd29532cf1 + "express-basic-auth": "^1.2.1" 2022-11-24 20:14:07 +07:00
Book Pauk
1dc169d14b Работа над opds 2022-11-24 19:52:51 +07:00
Book Pauk
870f95a51f Работа над opds 2022-11-24 19:08:34 +07:00
Book Pauk
72ab94291c Рефакторинг 2022-11-24 18:29:14 +07:00
Book Pauk
8de33fbd9a Работа над opds 2022-11-24 17:50:35 +07:00
Book Pauk
fd9bc45fb1 Работа над opds 2022-11-24 17:20:11 +07:00
Book Pauk
e356b87494 Работа над opds 2022-11-24 16:54:56 +07:00
Book Pauk
4371e1a641 Работа над opds 2022-11-24 16:36:40 +07:00
Book Pauk
95da605cb9 Рефакторинг 2022-11-24 16:04:27 +07:00
Book Pauk
6dfa551b97 Работа над opds 2022-11-23 20:57:41 +07:00
Book Pauk
6a3b919f5f Работа над opds 2022-11-23 19:17:08 +07:00
Book Pauk
cac8e7c721 Поправки багов 2022-11-23 18:08:31 +07:00
Book Pauk
410aa01ac9 Поправки багов 2022-11-23 17:31:47 +07:00
Book Pauk
a8ed8b29e5 Работа над opds 2022-11-23 17:03:33 +07:00
Book Pauk
5a04e4f0c7 Работа над opds 2022-11-23 14:59:29 +07:00
Book Pauk
a6d9df7dec Работа над opds 2022-11-23 14:38:23 +07:00
Book Pauk
8cf370c79d Работа над opds 2022-11-23 01:21:29 +07:00
Book Pauk
35925dbc6e Работа над opds 2022-11-22 20:09:00 +07:00
Book Pauk
d0e79b0abb + "he": "^1.2.0" 2022-11-22 19:55:54 +07:00
Book Pauk
aba0c206f8 Работа над opds 2022-11-20 19:52:10 +07:00
Book Pauk
037b42a5b4 Работа над opds 2022-11-20 19:22:54 +07:00
Book Pauk
8a71c4040c Начата работа над opds 2022-11-20 17:47:15 +07:00
Book Pauk
e685f136e1 Поправка мелкого бага 2022-11-18 20:45:38 +07:00
Book Pauk
b8b40e8cb0 Поправлен readme 2022-11-17 18:00:05 +07:00
Book Pauk
7e9f446079 В релиз добавлен readme.html 2022-11-17 17:56:46 +07:00
Book Pauk
13c3c98c63 Поправил readme 2022-11-17 17:53:47 +07:00
Book Pauk
1b70259ea7 "showdown": "^2.1.0" 2022-11-17 17:53:28 +07:00
Book Pauk
a840fb7233 Поправка отображения Inpx инфо 2022-11-17 17:08:06 +07:00
Book Pauk
1ba54c1237 Добавлено описание параметров конфига queryCacheMemSize, queryCacheDiskSize 2022-11-17 16:58:09 +07:00
Book Pauk
412335c0f1 В конфиг добавлены параметры queryCacheMemSize, queryCacheDiskSize 2022-11-17 16:51:12 +07:00
Book Pauk
6b91c43655 "jembadb": "^5.1.3" 2022-11-17 14:01:40 +07:00
Book Pauk
4b4865b6ed Переход на Uint32Array 2022-11-16 20:37:18 +07:00
Book Pauk
d5931138e3 Переход на Uint32Array 2022-11-16 20:27:53 +07:00
Book Pauk
3d1385da6e "jembadb": "^5.1.2" 2022-11-16 20:08:28 +07:00
Book Pauk
5630feba36 "jembadb": "^5.1.1" 2022-11-16 19:32:51 +07:00
Book Pauk
64a301eda1 "jembadb": "^5.1.0" 2022-11-16 18:56:46 +07:00
Book Pauk
044ab1ab26 Поправлена обработка ошибок 2022-11-15 00:16:45 +07:00
Book Pauk
d6260e3433 Оптимизация использования памяти при загрузке маппингов 2022-11-15 00:03:10 +07:00
Book Pauk
fb2eb62a98 Merge tag '1.2.4' into develop
1.2.4
2022-11-14 16:39:53 +07:00
Book Pauk
d7c6b0e7ab Merge branch 'release/1.2.4' 2022-11-14 16:39:48 +07:00
Book Pauk
94922f3926 Версия 1.2.4 2022-11-14 16:39:24 +07:00
Book Pauk
a580b1eb6d Добавлено отображение постера в отдельном окне 2022-11-14 16:37:11 +07:00
Book Pauk
cd7b8afb29 Рефакторинг 2022-11-14 15:09:13 +07:00
Book Pauk
e634893ff3 Добавил .stop.prevent для событий @click 2022-11-14 14:46:23 +07:00
Book Pauk
fadc7ddc34 Merge tag '1.2.3' into develop
1.2.3
2022-11-13 02:04:13 +07:00
Book Pauk
ed5dc25d94 Merge branch 'release/1.2.3' 2022-11-13 02:04:01 +07:00
Book Pauk
dd11e8c5ad Версия 1.2.3 2022-11-13 02:03:25 +07:00
Book Pauk
2db2b8cff4 Решение проблемы скачивания файлов в режиме "Удаленная библиотека"
(запрашивался не тот файл из-за несовпадения bookId)
2022-11-13 01:59:42 +07:00
Book Pauk
4d3661b758 Мелкая поправка 2022-11-13 00:11:26 +07:00
Book Pauk
891b1e4fe8 Поправка мелкого бага 2022-11-12 23:56:50 +07:00
Book Pauk
d588b16885 Merge tag '1.2.2' into develop
1.2.2

Исправлен баг при скачивании в режиме "Удаленная библиотека"
2022-11-12 17:24:52 +07:00
Book Pauk
a0e4651607 Merge branch 'release/1.2.2' 2022-11-12 17:24:41 +07:00
Book Pauk
c21b8ffa0e 1.2.2 2022-11-12 17:24:19 +07:00
Book Pauk
f174617f33 Исправлен баг при скачивании в режиме "Удаленная библиотека" 2022-11-12 17:23:21 +07:00
Book Pauk
2de9ad0edf Мелкая поправка разметки 2022-11-12 17:11:24 +07:00
Book Pauk
a6592f2f8d Merge tag '1.2.1' into develop
1.2.1

Добавлено диалоговое окно "Информация о книге".
Небольшие изменения интерфейса, добавлена кнопка "Клонировать поиск".
2022-11-12 16:56:49 +07:00
Book Pauk
b4da07e924 Merge branch 'release/1.2.1' 2022-11-12 16:56:36 +07:00
Book Pauk
110d145b91 Версия 1.2.1 2022-11-12 16:56:05 +07:00
Book Pauk
fc3d391aa0 Замена moment на dayjs 2022-11-12 16:54:03 +07:00
Book Pauk
4d01901463 Мелкие поправки разметки 2022-11-12 16:38:01 +07:00
Book Pauk
2d380bd98f Убрал дебаг 2022-11-11 22:16:13 +07:00
Book Pauk
2dd67487dc Работа над BookInfoDialog 2022-11-11 22:12:13 +07:00
Book Pauk
a3190e4af3 Добавлены методы XML Inspector 2022-11-11 20:31:41 +07:00
Book Pauk
3d28beddac Merge tag '1.2.0' into develop
1.2.0
2022-11-10 20:27:53 +07:00
Book Pauk
c11e949316 Merge branch 'release/1.2.0' 2022-11-10 20:27:45 +07:00
Book Pauk
4bbaf659b8 Небольшая поправка разметки 2022-11-10 20:26:30 +07:00
Book Pauk
caf3adf884 Версия 1.2.0 2022-11-10 20:14:49 +07:00
Book Pauk
6dfb3f6db9 Работа над BookInfoDialog 2022-11-10 20:10:43 +07:00
Book Pauk
e39611098a Работа над BookInfoDialog 2022-11-10 19:45:26 +07:00
Book Pauk
d7d04fcda8 Работа над BookInfoDialog 2022-11-10 18:57:20 +07:00
Book Pauk
7b2171c269 Работа над BookInfoDialog 2022-11-10 18:05:53 +07:00
Book Pauk
0bb434d415 Работа над BookInfoDialog 2022-11-10 17:55:16 +07:00
Book Pauk
391fb3aa70 Работа над BookInfoDialog 2022-11-10 17:17:56 +07:00
Book Pauk
81d8b476a5 Работа над BookInfoDialog 2022-11-10 14:56:25 +07:00
Book Pauk
79e6ca2d27 Работа над BookInfoDialog 2022-11-10 00:59:47 +07:00
Book Pauk
1d99472ca1 Реструктуризация 2022-11-10 00:51:18 +07:00
Book Pauk
ec6b72868b Переименования 2022-11-10 00:40:44 +07:00
Book Pauk
8ee1b98a12 Поправлена отдача статики 2022-11-09 23:58:08 +07:00
Book Pauk
ffc65ab944 Работа над BookInfoDialog 2022-11-09 19:25:59 +07:00
Book Pauk
7b5061df5f Работа над BookInfoDialog 2022-11-09 18:26:29 +07:00
Book Pauk
2fa48cdde6 Поправлен баг 2022-11-09 18:26:19 +07:00
Book Pauk
28963116c3 Работа над XmlParser 2022-11-09 17:22:18 +07:00
Book Pauk
18da23530b Рефакторинг 2022-11-09 16:47:36 +07:00
Book Pauk
b64c5de5a3 Работа над XmlParser 2022-11-09 16:37:10 +07:00
Book Pauk
f1db203027 Перемещение файлов 2022-11-09 14:47:55 +07:00
Book Pauk
927dade502 Работа над Fb2Parser 2022-11-09 14:45:40 +07:00
Book Pauk
04a8ba8426 Работа над XmlParser 2022-11-09 14:44:00 +07:00
Book Pauk
40f72d17e6 Работа над BookInfoDialog 2022-11-08 22:07:11 +07:00
Book Pauk
4b5949e3bc Работа над XmlParser 2022-11-08 22:02:35 +07:00
Book Pauk
f7994fd9e9 Работа над XmlParser 2022-11-08 16:12:46 +07:00
Book Pauk
9cf9530447 Работа над XmlParser 2022-11-08 15:01:52 +07:00
Book Pauk
98fac2bf11 Работа над XmlParser 2022-11-08 14:42:28 +07:00
Book Pauk
e755ddbbef Работа над XmlParser 2022-11-08 14:07:17 +07:00
Book Pauk
b5c7219e09 Работа над XmlParser 2022-11-08 03:52:00 +07:00
Book Pauk
6a640ba2cd Работа над XmlParser 2022-11-08 02:42:11 +07:00
Book Pauk
d2484659e7 Работа над XmlParser 2022-11-08 02:21:34 +07:00
Book Pauk
8f4dec510c Работа над XmlParser 2022-11-07 21:50:38 +07:00
Book Pauk
e4571faf39 Работа над XmlParser 2022-11-07 21:13:15 +07:00
Book Pauk
a40d9e25b0 Работа над XmlParser 2022-11-07 19:52:29 +07:00
Book Pauk
02f276ca6b Работа над карточкой "Информация о книге" 2022-11-07 16:24:52 +07:00
Book Pauk
2ae7f21bc8 Дополнительные пакеты 2022-11-07 16:24:07 +07:00
Book Pauk
55239159ba Перенос на сервер работы с именами файлов при скачивании 2022-11-06 18:03:03 +07:00
Book Pauk
d9f1912ea2 Работа над BookInfoDialog 2022-11-06 17:15:45 +07:00
Book Pauk
351abe9401 Поправка размера шрифта 2022-11-06 16:39:39 +07:00
Book Pauk
4cde00b337 Поправки интерфейса, работа над информацией о файле 2022-11-06 16:38:01 +07:00
Book Pauk
ba5d7b10b8 Поправлено положение элементов интерфейса,
добавлена кнопка "Клонировать поиск"
2022-11-06 15:47:42 +07:00
Book Pauk
0360098b53 Поправлен баг скроллинга 2022-11-06 14:49:27 +07:00
Book Pauk
32c2d6fef9 Merge tag '1.1.4' into develop
1.1.4
2022-11-03 22:32:13 +07:00
Book Pauk
2c3172d2a9 Merge branch 'release/1.1.4' 2022-11-03 22:32:07 +07:00
Book Pauk
f43a0bde45 1.1.4 2022-11-03 22:31:57 +07:00
Book Pauk
0f7ac5c387 Версия БД: '6' 2022-11-03 22:30:50 +07:00
Book Pauk
a4aa4ae2f0 Рефакторинг 2022-11-03 21:50:08 +07:00
Book Pauk
6e9ff3787e Merge tag '1.1.3' into develop
1.1.3
2022-11-03 21:27:14 +07:00
Book Pauk
cd35acb60e Merge branch 'release/1.1.3' 2022-11-03 21:27:08 +07:00
Book Pauk
94bec6ed42 Верия 1.1.3 2022-11-03 21:26:45 +07:00
Book Pauk
42436fabd3 Исправлен баг "Не качает книги #1", fixed #1 2022-11-03 21:25:10 +07:00
Book Pauk
36c50fd699 Merge tag '1.1.2' into develop
1.1.2
2022-11-01 02:13:49 +07:00
Book Pauk
1a2cad315f Merge branch 'release/1.1.2' 2022-11-01 02:13:43 +07:00
Book Pauk
1d22a129e5 Версия 1.1.2 2022-11-01 02:12:55 +07:00
Book Pauk
2fd18a93e5 Поправлен баг по клику на имени автора 2022-11-01 02:11:30 +07:00
Book Pauk
511f20e9bc Merge tag '1.1.1' into develop
1.1.1
2022-11-01 02:03:24 +07:00
Book Pauk
a7af7342b6 Merge branch 'release/1.1.1' 2022-11-01 02:03:20 +07:00
Book Pauk
2fa8be9cb0 Версия 1.1.1 2022-11-01 02:02:59 +07:00
Book Pauk
9f67b3bf28 Улучшение обработки ошибок в gzipFile 2022-11-01 02:01:15 +07:00
Book Pauk
037d1aa022 Исправления багов скачивания файла в режиме RemoteLib 2022-11-01 01:53:39 +07:00
Book Pauk
b56eeaa024 Merge tag '1.1.0' into develop
1.1.0
2022-11-01 01:11:44 +07:00
Book Pauk
75f5a50d20 Merge branch 'release/1.1.0' 2022-11-01 01:11:39 +07:00
Book Pauk
a38ba2fd9a Поправка бага 2022-11-01 00:34:30 +07:00
Book Pauk
d46d153416 Версия 1.1.0 2022-11-01 00:16:28 +07:00
Book Pauk
e7fe77ef6c Поправка makeTitle 2022-11-01 00:15:49 +07:00
Book Pauk
810920beed Поправки текста 2022-11-01 00:09:00 +07:00
Book Pauk
55984769ea Улучшение выбора даты поступления 2022-10-31 21:07:25 +07:00
Book Pauk
0e4344995c Исправления багов, мелкий рефакторинг 2022-10-31 21:04:36 +07:00
Book Pauk
548f18430d Мелкие доработки 2022-10-31 20:53:29 +07:00
Book Pauk
8982b3eaf0 Работа над диалогом выбора даты 2022-10-31 20:38:35 +07:00
Book Pauk
bcd0308641 "moment": "^2.29.4", 2022-10-31 18:45:49 +07:00
Book Pauk
21ec7df1e7 Добавлено пересоздание поисковой БД при обнаружении повреждений 2022-10-31 16:41:01 +07:00
Book Pauk
35d7e5296f Добавлено описание параметра fullOptimization 2022-10-31 16:29:02 +07:00
Book Pauk
ca812f0eb4 Удален параметр конфига extendedSearch 2022-10-31 16:25:30 +07:00
Book Pauk
e0c856ce8e Поправки багов скроллинга 2022-10-31 16:22:03 +07:00
Book Pauk
820769071d Поправки багов скроллинга 2022-10-31 15:33:58 +07:00
Book Pauk
4ea9b388f0 Улучшение вида списков 2022-10-31 15:00:32 +07:00
Book Pauk
26340bf061 Поправлен баг 2022-10-31 14:11:49 +07:00
Book Pauk
39dc7647bc Поправлен баг 2022-10-31 13:57:10 +07:00
Book Pauk
198a2b9d76 Поправлен баг 2022-10-31 13:47:26 +07:00
Book Pauk
6e9662b409 Небольшие улучшения 2022-10-31 13:35:59 +07:00
Book Pauk
c5bd31e514 Улучшена отзывчивость поисковика при старте 2022-10-31 13:21:10 +07:00
Book Pauk
ea5cf4e3bd Перемещение модуля 2022-10-31 13:11:58 +07:00
Book Pauk
c6080fa423 Мелкая поправка 2022-10-31 13:08:38 +07:00
Book Pauk
f2f22c362d Оптимизация 2022-10-31 01:16:23 +07:00
Book Pauk
55e05d3469 Оптимизация 2022-10-31 00:31:54 +07:00
Book Pauk
87f8567d93 Поправка таймаута 2022-10-31 00:10:34 +07:00
Book Pauk
1659f69aae Merge branch 'feature/new_search' into develop 2022-10-30 17:19:30 +07:00
Book Pauk
f310a70e49 Убрал дебаг 2022-10-30 17:10:58 +07:00
Book Pauk
b4cb08ea62 Работа над новым поиском 2022-10-30 16:57:23 +07:00
Book Pauk
fba7300131 Работа над новым поиском 2022-10-30 16:44:51 +07:00
Book Pauk
c904990eed Работа над новым поиском 2022-10-30 16:04:57 +07:00
Book Pauk
75cb5444b2 Работа над новым поиском 2022-10-30 15:21:10 +07:00
Book Pauk
1210e5bd8a Работа над новым поиском 2022-10-30 15:03:29 +07:00
Book Pauk
70de9e2ab4 dbVersion: '5' 2022-10-30 13:56:43 +07:00
Book Pauk
3f39b55ca0 Работа над новым поиском 2022-10-30 13:55:54 +07:00
Book Pauk
2ba8297594 Начало переделки структуры БД и поиска 2022-10-29 23:40:29 +07:00
Book Pauk
c3724feba0 Добавлено отображение даты поступления 2022-10-28 21:08:32 +07:00
Book Pauk
0b9a25aff0 Мелкие поправки 2022-10-28 20:25:02 +07:00
Book Pauk
96576307c8 Работа над поиском по оценкам 2022-10-28 20:18:30 +07:00
Book Pauk
f51166822b Поправки разметки 2022-10-28 19:56:07 +07:00
Book Pauk
fee3231d10 Работа над поиском по дате поступления и оценке 2022-10-28 19:47:00 +07:00
Book Pauk
52ed97c8de Поправки метки 2022-10-28 18:21:57 +07:00
Book Pauk
52f3938077 Настройка шрифта 2022-10-28 18:11:45 +07:00
Book Pauk
c00754c4ca Удалил ненужное 2022-10-27 19:12:43 +07:00
Book Pauk
78602a082b Поправки разметки 2022-10-27 18:56:27 +07:00
Book Pauk
c33fc407d2 Поправки разметки 2022-10-27 18:48:02 +07:00
Book Pauk
529095e014 Поправки разметки 2022-10-27 18:01:45 +07:00
Book Pauk
8a6852c4ae Поправлен баг 2022-10-27 17:24:47 +07:00
Book Pauk
ecb372ec70 Добавлена возможность сокрытия дополнительных параметров поиска 2022-10-27 16:59:38 +07:00
Book Pauk
87fc08e3bc Добавлен фильтр по удаленным 2022-10-27 15:46:32 +07:00
Book Pauk
b7555fe55d Исправление обработки ошибок вебсокета при запросах 2022-10-27 14:44:05 +07:00
Book Pauk
1669a21add Мелкий рефакторинг, поправки цветов 2022-10-27 14:36:55 +07:00
Book Pauk
7f68b1d68a Поправлен баг 2022-10-26 21:48:07 +07:00
Book Pauk
4cacecc13b Работа над TitleList 2022-10-26 21:32:08 +07:00
Book Pauk
2509b0f742 Доработки 2022-10-26 21:31:29 +07:00
Book Pauk
491c2f3406 Добавлен titleSearch 2022-10-26 17:50:48 +07:00
Book Pauk
20bb9f925a Добавлено формирование title_book 2022-10-26 17:35:45 +07:00
Book Pauk
87369a00a2 Доработка оптимизации 2022-10-26 16:37:04 +07:00
Book Pauk
04f0b17d32 Увеличена dbVersion 2022-10-26 16:22:35 +07:00
Book Pauk
86f6ba5ba0 Мелкая поправка 2022-10-26 16:09:01 +07:00
Book Pauk
dff2f0fcb0 Мелкая поправка 2022-10-26 16:02:40 +07:00
Book Pauk
801bf3aea9 Доработка поиска по разделу "Серии" 2022-10-26 15:58:08 +07:00
Book Pauk
a7db617c14 Дополнен readme 2022-10-26 15:08:47 +07:00
Book Pauk
ed4313b77b Рефакторинг 2022-10-26 01:48:14 +07:00
Book Pauk
d579be5376 Поправка бага 2022-10-26 01:36:45 +07:00
Book Pauk
b7d176c123 Поправки 2022-10-25 19:55:34 +07:00
Book Pauk
cc5d5167a3 Работа над разделом "Серии" 2022-10-25 19:48:20 +07:00
Book Pauk
3a8fa12894 Допиливание DbCreator 2022-10-25 17:42:50 +07:00
Book Pauk
1b503dba8e Использование параметра extendedSearch на клиенте 2022-10-25 17:20:53 +07:00
Book Pauk
53663b2867 Работа над SeriesList 2022-10-25 17:15:25 +07:00
Book Pauk
5c03e06648 Рефакторинг 2022-10-25 16:57:53 +07:00
Book Pauk
b598a43361 Поправка 2022-10-25 16:55:09 +07:00
Book Pauk
d770d499da Добавлена заготовка для seriesSearch 2022-10-25 16:50:56 +07:00
Book Pauk
739f06226f В конфиг добавлен параметр extendedSearch 2022-10-25 16:39:37 +07:00
Book Pauk
03e5a87eb3 Переименования, поправки 2022-10-25 15:58:05 +07:00
Book Pauk
dd5598a695 Переименования 2022-10-25 15:43:40 +07:00
Book Pauk
7553b88b89 Декомпозиция, выделение BaseList, переименования 2022-10-25 15:40:08 +07:00
Book Pauk
5f4b993154 К предыдущему 2022-10-25 14:53:05 +07:00
Book Pauk
6eae46eaa1 Рефакторинг 2022-10-25 14:44:30 +07:00
Book Pauk
e6485ec0a2 Оптимизации, кеширование 2022-10-25 14:43:06 +07:00
Book Pauk
8df8ba5e0e Поправка дебаг-сообщений 2022-10-25 14:41:51 +07:00
Book Pauk
993fe2d142 Мелкие поправки 2022-10-24 21:05:26 +07:00
Book Pauk
d7c1a83785 Добавлена маршрутизация, выбор вида списка: Авторы, Серии, Книги 2022-10-24 20:51:35 +07:00
Book Pauk
f582c34a72 Небольшое улучшение 2022-10-24 19:31:50 +07:00
Book Pauk
fd93d3f523 Рефакторинг 2022-10-24 17:31:21 +07:00
Book Pauk
634d646622 Рефакторинг, небольшие улучшения 2022-10-24 17:23:13 +07:00
Book Pauk
b707f8425e Небольшие улучшения 2022-10-24 17:02:44 +07:00
Book Pauk
bc2a54c4ab Декомпозиция, выделен AuthorList 2022-10-24 16:51:27 +07:00
Book Pauk
63dfdaf2c2 Рефакторинг 2022-10-23 20:51:56 +07:00
Book Pauk
723af3ea8b Поправки багов поиска 2022-10-23 19:26:59 +07:00
Book Pauk
f1267879af Поправки багов поиска 2022-10-23 19:10:13 +07:00
Book Pauk
784dda03fd Поправки багов поиска 2022-10-23 19:05:17 +07:00
Book Pauk
ea6d61ac6f Рефакторинг 2022-10-23 18:43:14 +07:00
Book Pauk
54f0cfec76 Добавлен heavyCalc.terminate() в close 2022-10-23 18:13:05 +07:00
Book Pauk
91d3d4c254 Тяжелые вычисления вынесены в отдельный поток 2022-10-23 18:09:02 +07:00
Book Pauk
ab4d54cc85 Добавлена возможность использования HeavyCalc как singleton 2022-10-23 18:01:20 +07:00
Book Pauk
86aa3511bf Новый модуль HeavyCalc для тяжелых вычислений в отдельном потоке 2022-10-23 16:32:55 +07:00
Book Pauk
8d5792d456 Добавлено отображение версии БД в статистику 2022-10-22 19:50:51 +07:00
Book Pauk
12e5c32578 Мелкий рефакторинг 2022-10-22 13:18:25 +07:00
Book Pauk
19aaba2492 Исправление потенциальных багов при использовании ООП 2022-10-22 00:49:45 +07:00
Book Pauk
1d3a661973 Ускорение процедуры поиска 2022-10-22 00:40:07 +07:00
Book Pauk
6a8f08fca8 "jembadb": "^5.0.2" 2022-10-22 00:32:32 +07:00
Book Pauk
93634b1137 Доработка vueComponent, теперь понимает иерархию классов 2022-10-21 20:18:32 +07:00
Book Pauk
cd72de5014 Рефакторинг 2022-10-21 19:59:19 +07:00
Book Pauk
5a19af3c7d Улучшены подсчет и отображение статистики 2022-10-21 18:52:20 +07:00
Book Pauk
46829a9b08 Адаптация к jembadb 5.0 2022-10-21 18:21:30 +07:00
Book Pauk
87d418a32a Добавлен консольный лог в режиме loggingEnabled: false 2022-10-21 18:09:52 +07:00
Book Pauk
d5a9b77334 "jembadb": "^5.0.1" 2022-10-21 17:30:36 +07:00
Book Pauk
e865070f23 Улучшение обработки ошибок 2022-10-21 13:50:55 +07:00
Book Pauk
294fb35f4d Улучшение загрузки конфига 2022-10-21 13:47:10 +07:00
Book Pauk
1490fc854a Добавлено подсвечивание номера страницы, если изменились критерии поиска 2022-10-21 13:27:28 +07:00
Book Pauk
8b67cd09ad Улучшение отображения списков 2022-10-21 12:41:35 +07:00
Book Pauk
48ebff923e 1.0.7 2022-10-20 22:02:37 +07:00
Book Pauk
7a58b5a3ba Мелкий рефакторинг 2022-10-20 21:57:10 +07:00
Book Pauk
8c1ec1dc93 Улучшение отдачи файлов книг и статики 2022-10-20 21:46:05 +07:00
Book Pauk
f7297ac573 Мелкий рефакторинг 2022-10-20 20:34:21 +07:00
Book Pauk
8e7fda9fbf Удалил ненужный модуль compression 2022-10-20 20:31:07 +07:00
Book Pauk
ba3d6adac6 Улучшение подхвата изменений inpx-файла 2022-10-20 18:03:47 +07:00
Book Pauk
11a6c06997 Поправка 2022-10-20 16:50:16 +07:00
Book Pauk
2d8dc8c04b Улучшение отображения BookView 2022-10-20 16:47:21 +07:00
Book Pauk
3204946fc8 Рефакторинг 2022-10-20 16:05:09 +07:00
Book Pauk
1aaa27e68b jembadb update 2022-10-20 15:51:22 +07:00
Book Pauk
31d5fc6df3 Улучшение отображения списков 2022-10-20 15:09:57 +07:00
Book Pauk
78dc1c87c8 Рефакторинг 2022-10-20 14:27:58 +07:00
Book Pauk
88d49852e0 Рефакторинг 2022-10-20 14:16:44 +07:00
Book Pauk
526683b7ef К предыдущему 2022-10-20 14:05:56 +07:00
Book Pauk
4ca56db142 Рефакторинг 2022-10-20 14:03:49 +07:00
Book Pauk
a556a88ee5 Добавлен подсчет статистики, сортировка серий и названий 2022-10-19 18:09:58 +07:00
Book Pauk
90be2a1447 Поправка формирования заголовка 2022-10-19 16:30:33 +07:00
Book Pauk
e70247e473 Поправлена настройка cacheSize при создании БД 2022-10-19 16:08:25 +07:00
Book Pauk
fba127f7ca Добавлена статистика по количеству файлов книг 2022-10-19 16:04:41 +07:00
Book Pauk
a5a734a333 Рефакторинг и улучшение отображения всех книг серии 2022-10-19 15:15:57 +07:00
Book Pauk
f8416a4aec Поправки багов 2022-10-19 14:30:57 +07:00
Book Pauk
e0e26df5cb Добавлено пояснение для пустого списка в рузультате поиска 2022-10-19 13:48:01 +07:00
Book Pauk
af3bac6a09 В конфиг добавлен параметр dbCacheSize 2022-10-19 13:30:00 +07:00
Book Pauk
43921953a7 Merge tag '1.0.6' into develop
1.0.6
2022-10-17 23:33:10 +07:00
Book Pauk
48eecf7b4c Merge branch 'release/1.0.6' 2022-10-17 23:32:59 +07:00
Book Pauk
97ca49571c 1.0.6 2022-10-17 23:32:36 +07:00
Book Pauk
d9cc0ffa23 Исправлен баг "Malicious entry" - ругался на невалидные имена файлов при извлечении из zip-архива 2022-10-17 23:16:27 +07:00
Book Pauk
de73eec019 Merge tag '1.0.5' into develop
1.0.5
2022-10-17 20:10:15 +07:00
Book Pauk
34311780d2 Merge branch 'release/1.0.5' 2022-10-17 20:10:08 +07:00
Book Pauk
f7e49e62b9 1.0.5 2022-10-17 20:09:52 +07:00
Book Pauk
8281798596 Ускорение выборки из author 2022-10-17 20:08:11 +07:00
Book Pauk
d3917bbc5c Поправлена опечатка 2022-10-17 00:10:27 +07:00
Book Pauk
c7d4522ee9 Merge tag '1.0.4' into develop
1.0.4
2022-10-16 23:22:38 +07:00
Book Pauk
a019adae33 Merge branch 'release/1.0.4' 2022-10-16 23:22:25 +07:00
Book Pauk
90d67b8880 1.0.4 2022-10-16 23:21:59 +07:00
Book Pauk
727a44986d Исправлены цели сборки релиза 2022-10-16 23:20:54 +07:00
Book Pauk
ae8ad29322 Поправлена опечатка 2022-10-16 23:15:50 +07:00
Book Pauk
a9637d1cbd Поправка описания параметров config.json 2022-10-16 22:58:55 +07:00
Book Pauk
f3bc0d2a42 Поправка описания параметров config.json 2022-10-16 22:57:12 +07:00
Book Pauk
65ea6d3d9a Небольшая доработка periodicCleanCache 2022-10-16 22:55:15 +07:00
Book Pauk
4e2760d39e Merge tag '1.0.3' into develop
1.0.3
2022-10-16 18:43:54 +07:00
80 changed files with 15300 additions and 6712 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,4 @@
/node_modules
/server/.inpx-web*
/server/inpx-web-filter.json
/dist
dev*.sh

109
CHANGELOG.md Normal file
View File

@@ -0,0 +1,109 @@
1.5.7 / 2024-04-04
- В параметр bookReadLink конфига добавлен вариант замены DOWNLOAD_URI на uri из ссылки для скачивания книги (#29)
- В параметры командной строки добавлена возможность задавать путь к файлу конфигурации, а все остальные настройки приложения можно указать в нем самом (#30)
1.5.6 / 2024-03-25
- Добавлен вывод version.info в статистику по коллекции (#27)
- Убрано (по умолчанию) ежеминутное журналирование статистики сервера. В конфиг добавлен параметр logServerStats (#26)
- В конфиг добавлен параметр logQueries для журналирования запросов и времени их выполнения
- Добавлена расшифровка имен жанров в информации о книге (#24)
1.5.5 / 2023-04-25
- Улучшение работы с inpx: теперь понимает zip-архивы, вложенные в каталоги (библиотека Траума)
- Улучшение работы с zip-файлами: теперь понимает кодировку cp866 в именах файлов
1.5.4 / 2023-04-12
- Добавлена возможность поиска по типу файла
- Улучшена работа с inpx, теперь понимает файлы в каталогах (без zip-архива)
- В readme добавлен раздел "Запуск без сборки релиза" для запуска inpx-web на любых платформах
- Исправления мелких багов
1.5.3 / 2023-03-02
- OPDS: исправление проблемы поиска для koreader
- OPDS: улучшено скачивание для не-fb2 форматов файлов (djvu, pdf и пр.)
- Добавлена полоска уведомления о выходе новой версии (отключается в настройках веб-интерфейса).
Проверка новой версии настраивается параметром checkReleaseLink в конфиге сервера (#15)
1.5.2 / 2023-02-05
- Исправление проблемы чтения каталога opds для koreader
1.5.1 / 2023-01-28
------------------
- Настройки веб-интерфейса и опции командной строки "--lib-dir", "--inpx" вынесены в конфиг (#6)
- Уменьшена длина имени файла при скачивании (#7)
- OPDS: добавлен раздел "Жанры", в поиск добавлен раздел "Поиск книг в жанре" (#9)
- Исправление проблем скроллинга панели инструментов (#12)
- Улучшено отображение веб-интерфейса на мобильных устройствах
1.4.1 / 2022-12-21
------------------
- Добавлена возможность поиска по регулярному выражению (префикс "~")
- Заплатка для исправления (#10)
1.4.0 / 2022-12-07
------------------
- Добавлена возможность расширенного поиска (раздел "</>"). Поиск не оптимизирован и может сильно нагружать сервер.
Отключить можно в конфиге, параметр extendedSearch
- Улучшение поддержки reverse-proxy, в конфиг добавлены параметры server.root и opds.root для встраивания inpx-web в уже существующий веб-сервер
- В настройки веб-интерфейса добавлена опция "Скачивать книги в виде zip-архива"
- Исправлен баг "Android-читалки не очень хорошо работают с OPDS" (#4)
- Добавлена сборка релизов для Linux arm64
- В readme добавлена ссылка для донатов: [отблагодарить автора проекта](https://donatty.com/liberama)
1.3.3 / 2022-11-28
------------------
- Исправление выявленных недочетов
1.3.2 / 2022-11-27
------------------
- Изменения механизма ограничения доступа по паролю:
- появилась возможность выхода из сессии
- в конфиг добавлена настройка таймаута для автозавершения сессии
- Добавлено отображение количества книг в серии в разделе "Авторы"
1.3.1 / 2022-11-25
------------------
- Улучшена кроссплатформенность приложения
1.3.0 / 2022-11-24
------------------
- Добавлен OPDS-сервер для inpx-коллекции
- Произведена небольшая оптимизация поисковой БД
- Добавлен релиз для macos, без тестирования
1.2.4 / 2022-11-14
------------------
- Добавлена возможность посмотреть обложку в увеличении
- Исправление выявленных недочетов
1.2.3 / 2022-11-12
------------------
- Добавлено диалоговое окно "Информация о книге"
- Небольшие изменения интерфейса, добавлена кнопка "Клонировать поиск"
1.1.4 / 2022-11-03
------------------
- Исправлен баг "Не качает книги #1"
1.1.2 / 2022-10-31
------------------
- Добавлены разделы "Серии" и "Книги"
- Расширена форма поиска: добавлен поиск по датам поступления и оценкам

232
README.md
View File

@@ -2,32 +2,41 @@ 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).
[Отблагодарить автора проекта](https://donatty.com/liberama)
##
* [Возможности программы](#capabilities)
* [Использование](#usage)
* [Параметры командной строки](#cli)
* [Конфигурация](#config)
* [Удаленная библиотека](#remotelib)
* [Фильтр по аторам и книгам](#filter)
* [Фильтр по авторам и книгам](#filter)
* [Настройка https с помощью nginx](#https)
* [Сборка проекта](#build)
* [Сборка релизов](#build)
* [Запуск без сборки релиза](#native_run)
* [Разработка](#development)
<a id="capabilities" />
## Возможности программы
- веб-интерфейс и OPDS-сервер
- поиск по автору, серии, названию и пр.
- скачивание книги, копирование ссылки или открытие в читалке
- возможность указать рабочий каталог при запуске, а также расположение .inpx и файлов библиотеки
@@ -36,13 +45,18 @@ inpx-web
- фильтр авторов и книг при создании поисковой БД для создания своей коллекции "на лету"
- подхват изменений .inpx-файла (периодическая проверка), автоматическое пересоздание поисковой БД
- мощная оптимизация, хорошая скорость поиска
- релизы под Linux и Windows
- релизы под Linux, MacOS и Windows
<a id="usage" />
## Использование
Поместите приложение `inpx-web` в папку с .inpx-файлом и файлами библиотеки и запустите.
По умолчанию сервер будет доступен по адресу http://127.0.0.1:12380
Там же, при первом запуске, будет создана рабочая директория `.inpx-web`, в которой хранится
конфигурационный файл `config.json`, файлы базы данных, журналы и прочее.
По умолчанию веб-интерфейс будет доступен по адресу [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" />
@@ -52,44 +66,119 @@ inpx-web
Usage: inpx-web [options]
Options:
--help Показать опции командной строки
--host=<ip> Задать имя хоста для веб сервера, по умолчанию: 0.0.0.0
--port=<port> Задать порт для веб сервера, по умолчанию: 12380
--app-dir=<dirpath> Задать рабочую директорию, по умолчанию: <execDir>/.inpx-web
--lib-dir=<dirpath> Задать директорию библиотеки (с zip-архивами), по умолчанию: там же, где лежит файл приложения
--inpx=<filepath> Задать путь к файлу .inpx, по умолчанию: тот, что найдется в директории библиотеки
--recreate Принудительно пересоздать поисковую БД при запуске приложения
--help Показать опции командной строки
--host=<ip> Задать имя хоста для веб сервера, по умолчанию: 0.0.0.0
--port=<port> Задать порт для веб сервера, по умолчанию: 12380
--config=<filepath> Задать файл конфигурации, по умолчанию: <dataDir>/config.json
--data-dir=<dirpath> (или --app-dir) Задать рабочую директорию, по умолчанию: <execDir>/.inpx-web
--lib-dir=<dirpath> Задать директорию библиотеки (с zip-архивами), по умолчанию: там же, где лежит файл приложения
--inpx=<filepath> Задать путь к файлу .inpx, по умолчанию: тот, что найдется в директории библиотеки
--recreate Принудительно пересоздать поисковую БД при запуске приложения
--unsafe-filter Использовать небезопасный фильтр на свой страх и риск
```
<a id="config" />
### Конфигурация
При первом запуске в рабочей директории будет создан конфигурационный файл `config.json`:
По умолчанию, при первом запуске в рабочей директории будет создан конфигурационный файл `config.json`.
При необходимости, можно настроить нужный параметр в этом файле вручную. Параметры командной
строки имеют больший приоритет, чем настройки из `config.json`.
```js
{
// рабочая директория приложения, аналог параметра командной строки --data-dir (или --app-dir)
// пустая строка: использовать значение по умолчанию - <execDir>/.inpx-web
// где execDir - директория файла приложения
"dataDir": "",
// директория для хранения временных файлов
// пустая строка: использовать значение по умолчанию - <dataDir>/tmp
// специальное значение "${OS}" указывается для использования системного каталога:
// "${OS}" => "<os_temporary_dir>/inpx-web"
"tempDir": "",
// директория для хранения логов
// пустая строка: использовать значение по умолчанию - <dataDir>/logs
"logDir": "",
// директория библиотеки (с zip-архивами), аналог параметра командной строки --lib-dir
// пустая строка: использовать значение по умолчанию - директорию файла приложения (execDir)
"libDir": "",
// путь к файлу .inpx, аналог параметра командной строки --inpx
// пустая строка: использовать значение по умолчанию - inpx-файл, что найдется в директории библиотеки
"inpx": "",
// конфигурационный файл для фильтра по авторам и книгам (см. ниже)
// пустая строка: использовать значение по умолчанию - файл filter.json в директории файла конфигурации
"inpxFilterFile": "",
// разрешить(true)/запретить(false) перезаписывать файл конфигурации, если появились новые параметры для настройки
// файл перезаписывается с сохранением всех предыдущих настроек и с новыми по умолчанию
// бывает полезно при выходе новых версий приложения
"allowConfigRewrite": false,
// разрешить(true)/запретить(false) использовать небезопасный фильтр (см. ниже)
// аналог параметра командной строки --unsafe-filter
"allowUnsafeFilter": false,
// пароль для ограничения доступа к веб-интерфейсу сервера
// пустое значение - доступ без ограничений
"accessPassword": "",
// содержимое кнопки-ссылки (читать), если не задано - кнопка (читать) не показывается
// таймаут автозавершения сессии доступа к веб-интерфейсу (если задан accessPassword),
// при неактивности в течение указанного времени (в минутах), пароль будет запрошен заново
// 0 - отключить таймаут, время доступа по паролю не ограничено
"accessTimeout": 0,
// включить(true)/выключить(false) возможность расширенного поиска (раздел "</>")
// расширенный поиск не оптимизирован, поэтому может сильно нагружать сервер
// чтобы ускорить поиск, увеличьте параметр dbCacheSize
"extendedSearch": true,
// содержимое кнопки-ссылки "(читать)", если не задано - кнопка "(читать)" не показывается
// пример: "https://omnireader.ru/#/reader?url=${DOWNLOAD_LINK}"
// на место ${DOWNLOAD_LINK} будет подставлена ссылка на скачивание файла книги
// пример: "https://mydomain.ru/#/reader?url=http://127.0.0.1:8086${DOWNLOAD_URI}"
// на место ${DOWNLOAD_URI} будут подставлены параметры (без имени хоста) из ссылки на скачивание файла книги
"bookReadLink": "",
// включить(true)/выключить(false) журналирование
"loggingEnabled": true,
// максимальный размер в байтах директории закешированных файлов в <раб.дир>/public/files
// включить/выключить ежеминутный вывод в лог memUsage и loadAvg
"logServerStats": false,
// включить/выключить вывод в лог запросов и времени их выполнения
"logQueries": false,
// максимальный размер кеша каждой таблицы в БД, в блоках (требуется примерно 1-10Мб памяти на один блок)
// если надо кешировать всю БД, можно поставить значение от 1000 и больше
"dbCacheSize": 5,
// максимальный размер в байтах директории закешированных файлов в <раб.дир>/public-files
// чистка каждый час
"maxFilesDirSize": 1073741824,
// включить(true)/выключить(false) кеширование запросов на сервере
// включить(true)/выключить(false) серверное кеширование запросов на диске и в памяти
"queryCacheEnabled": true,
// размер кеша запросов в оперативной памяти (количество)
// 0 - отключить кеширование запросов в оперативной памяти
"queryCacheMemSize": 50,
// размер кеша запросов на диске (количество)
// 0 - отключить кеширование запросов на диске
"queryCacheDiskSize": 500,
// периодичность чистки кеша запросов на сервере, в минутах
// 0 - отключить чистку
"cacheCleanInterval": 60,
// периодичность проверки изменений .inpx-файла
// периодичность проверки изменений .inpx-файла, в минутах
// если файл изменился, поисковая БД будет автоматически пересоздана
// 0 - отключить проверку
"inpxCheckInterval": 60,
// включить(true)/выключить(false) режим работы с малым количеством физической памяти на машине
@@ -97,6 +186,10 @@ Options:
// во столько же раз увеличивается время создания
"lowMemoryMode": false,
// включить(true)/выключить(false) полную оптимизацию поисковой БД
// ускоряет работу поиска, но увеличивает размер БД в 2-3 раза при импорте INPX
"fullOptimization": false,
// включить(true)/выключить(false) режим "Удаленная библиотека" (сервер)
"allowRemoteLib": false,
@@ -105,16 +198,52 @@ Options:
"remoteLib": false,
// настройки веб-сервера
// парамертр root указывает путь для кореневой страницы inpx-web
// например для "root": "/library", веб-интерфейс будет доступен по адресу http://127.0.0.1:12380/library
// root необходим при настройке reverse-proxy и встраивании inpx-web в уже существующий сервер
"server": {
"host": "0.0.0.0",
"port": "12380"
"port": "12380",
"root": ""
},
// настройки opds-сервера
// user, password используются для Basic HTTP authentication
// параметр root задает путь для доступа к opds-серверу
"opds": {
"enabled": true,
"user": "",
"password": "",
"root": "/opds"
},
// страница для скачивания свежего релиза
"latestReleaseLink": "https://github.com/bookpauk/inpx-web/releases/latest",
// api для проверки новой версии,
// пустая строка - отключить проверку выхода новых версий
"checkReleaseLink": "https://api.github.com/repos/bookpauk/inpx-web/releases/latest",
// настройки по умолчанию для веб-интерфейса
// устанавливаются при первой загрузке страницы в браузере
// дальнейшие изменения настроек с помощью веб-интерфейса уже сохраняются в самом браузере
"uiDefaults": {
"limit": 20, // результатов на странице
"downloadAsZip": false, // скачивать книги в виде zip-архива
"showCounts": true, // показывать количество
"showRates": true, // показывать оценки
"showInfo": true, // показывать кнопку (инфо)
"showGenres": true, // показывать жанры
"showDates": false, // показывать даты поступления
"showDeleted": false, // показывать удаленные
"abCacheEnabled": true, // кешировать запросы
"langDefault": "", // язык по умолчанию (например "ru,en")
"showJson": false, // показывать JSON (в расширенном поиске)
"showNewReleaseAvailable": true // уведомлять о выходе новой версии
}
}
```
При необходимости, можно настроить нужный параметр в этом файле вручную. Параметры командной
строки имеют больший приоритет, чем настройки из `config.json`.
<a id="remotelib" />
### Удаленная библиотека
@@ -140,22 +269,21 @@ Options:
```
Если сервер работает по протоколу `http://`, то указываем протокол `ws://`, а для `https://` соответственно `wss://`.
Пароль не обязателен, но необходим в случае, если сервер тоже "смотрит" в интернет, для ограничения доступа к его веб-интерфесу.
Пароль не обязателен, но необходим в случае, если сервер тоже "смотрит" в интернет, для ограничения доступа к его веб-интерфейсу.
При указании `"remoteLib": {...}` настройки командной строки --inpx и --lib-dir игнорируются,
т.к. файлы .inpx-индекса и библиотеки используются удаленно.
<a id="filter" />
### Фильтр по аторам и книгам
### Фильтр по авторам и книгам
При создании поисковой БД во время загрузки и парсинга .inpx-файла, имеется возможность
отфильтровать авторов и книги, задав определенные критерии. Для этого небходимо создать
в рабочей директории (там же, где `config.json`) файл `filter.json` следующего вида:
При создании поисковой БД, во время загрузки и парсинга .inpx-файла, имеется возможность
отфильтровать авторов и книги, задав определенные критерии. По умолчанию, для этого небходимо создать
в директории конфигурационного файла (там же, где `config.json`) файл `filter.json` следующего вида:
```json
{
"info": {
"collection": "Новое название коллекции",
"structure": "",
"version": "1.0.0"
},
"filter": "(r) => r.del == 0",
@@ -163,7 +291,7 @@ Options:
"excludeAuthors": ["Имя автора"]
}
```
При создании поисковой БД, авторы и книги из `includeAuthors` будут добавлены, а из `excludeAuthors` исключены.
При фильтрации, авторы и их книги из `includeAuthors` будут оставлены, а из `excludeAuthors` исключены.
Использование совместно `includeAuthors` и `excludeAuthors` имеет мало смысла, поэтому для включения
определенных авторов можно использовать только `includeAuthors`:
```json
@@ -196,8 +324,10 @@ Options:
}
```
Использование `filter` небезопасно, т.к. позволяет выполнить произвольный js-код внутри программы,
поэтому запуск приложения в этом случае должен сопровождаться дополнительным параметром командной строки `--unsafe-filter`.
поэтому запуск приложения в этом случае должен сопровождаться дополнительным параметром командной строки `--unsafe-filter`
или разрешением в конфиге `allowUnsafeFilter`.
Названия атрибутов inpxRec соответствуют названиям в нижнем регистре из структуры structure.info в .inpx-файле.
Файл `filter.json` можно расположить где угодно, что задается параметром `inpxFilterFile` в конфиге.
<a id="https" />
### Настройка https с помощью nginx
@@ -233,27 +363,45 @@ sudo service nginx reload
<a id="build" />
### Сборка проекта
### Сборка релизов
Сборка только в среде Linux.
Необходима версия node.js не ниже 16.
Для сборки linux-arm64 необходимо предварительно установить [QEMU](https://wiki.debian.org/QemuUserEmulation).
```sh
git clone https://github.com/bookpauk/inpx-web
cd inpx-web
npm i
npm run release
```
#### Для платформы Windows
Результат сборки будет доступен в каталоге `dist/release`
<a id="native_run" />
### Запуск без сборки релиза
Т.к. сборщик pkg поддерживает не все платформы, то не всегда удается собрать релиз.
Однако, можно скачать и запустить inpx-web нативным путем, с помощью nodejs.
Ниже пример для Ubuntu, для других линуксов различия не принципиальны:
```sh
npm run build:win
```
# установка nodejs v16 и выше:
curl -s https://deb.nodesource.com/setup_16.x | sudo bash
sudo apt install nodejs -y
#### Для платформы Linux
```sh
npm run build:linux
```
# подготовка
git clone https://github.com/bookpauk/inpx-web
cd inpx-web
npm i
npm run build:client && node build/prepkg.js linux
Результат сборки будет доступен в каталоге `dist/linux|win` в виде исполнимого (standalone) файла.
# удалим файл development-среды, чтобы запускался в production-режиме
rm ./server/config/application_env
# запуск inpx-web, тут же будет создан каталог .inpx-web
node server --app-dir=.inpx-web
```
<a id="development" />

1
build/appdir.js Normal file
View File

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

View File

@@ -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')
throw new Error(`Unknown platform: ${platform}`);
if (!platform)
throw new Error(`Please set 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)) {

View File

@@ -3,6 +3,7 @@ const path = require('path');
const { execSync } = require('child_process');
const pckg = require('../package.json');
const platform = process.argv[2];
const distDir = path.resolve(__dirname, '../dist');
const outDir = `${distDir}/release`;
@@ -20,8 +21,14 @@ async function makeRelease(target) {
async function main() {
try {
await fs.emptyDir(outDir);
await makeRelease('win');
await makeRelease('linux');
if (platform) {
await makeRelease(platform);
} else {
await makeRelease('win');
await makeRelease('linux');
await makeRelease('linux-arm64');
await makeRelease('macos');
}
} catch(e) {
console.error(e);
process.exit(1);

View File

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

View File

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

View File

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

View File

@@ -35,24 +35,22 @@ import vueComponent from '../vueComponent.js';
import wsc from './webSocketConnection';
import * as utils from '../../share/utils';
import * as cryptoUtils from '../../share/cryptoUtils';
import LockQueue from '../../share/LockQueue';
import LockQueue from '../../../server/core/LockQueue';
import packageJson from '../../../package.json';
const rotor = '|/-\\';
const stepBound = [
0,
0,// jobStep = 1
18,// jobStep = 2
20,// jobStep = 3
60,// jobStep = 4
72,// jobStep = 5
72,// jobStep = 6
74,// jobStep = 7
75,// jobStep = 8
79,// jobStep = 9
79,// jobStep = 10
80,// jobStep = 11
100,// jobStep = 12
40,// jobStep = 2
50,// jobStep = 3
54,// jobStep = 4
58,// jobStep = 5
69,// jobStep = 6
69,// jobStep = 7
70,// jobStep = 8
95,// jobStep = 9
100,// jobStep = 10
];
const componentOptions = {
@@ -62,10 +60,21 @@ const componentOptions = {
settings() {
this.loadSettings();
},
modelValue(newValue) {
this.accessGranted = newValue;
},
accessGranted(newValue) {
this.$emit('update:modelValue', newValue);
}
},
};
class Api {
_options = componentOptions;
_props = {
modelValue: Boolean,
};
accessGranted = false;
busyDialogVisible = false;
mainMessage = '';
jobMessage = '';
@@ -100,10 +109,6 @@ class Api {
}
}
get config() {
return this.$store.state.config;
}
get settings() {
return this.$store.state.settings;
}
@@ -125,7 +130,13 @@ class Api {
});
if (result && result.value) {
const accessToken = utils.toHex(cryptoUtils.sha256(result.value));
//получим свежую соль
const response = await wsc.message(await wsc.send({}), 10);
let salt = '';
if (response && response.error == 'need_access_token' && response.salt)
salt = response.salt;
const accessToken = utils.toHex(cryptoUtils.sha256(result.value + salt));
this.commit('setSettings', {accessToken});
}
} finally {
@@ -185,80 +196,81 @@ class Api {
}
async request(params, timeoutSecs = 10) {
let errCount = 0;
while (1) {// eslint-disable-line
if (this.accessToken)
params.accessToken = this.accessToken;
try {
if (this.accessToken)
params.accessToken = this.accessToken;
const response = await wsc.message(await wsc.send(params), timeoutSecs);
const response = await wsc.message(await wsc.send(params), timeoutSecs);
if (response && response.error == 'need_access_token') {
await this.showPasswordDialog();
} else if (response && response.error == 'server_busy') {
await this.showBusyDialog();
} else {
return response;
if (response && response.error == 'need_access_token') {
this.accessGranted = false;
await this.showPasswordDialog();
} else if (response && response.error == 'server_busy') {
this.accessGranted = true;
await this.showBusyDialog();
} else {
this.accessGranted = true;
if (response.error) {
throw new Error(response.error);
}
return response;
}
errCount = 0;
} catch(e) {
errCount++;
if (e.message !== 'WebSocket не отвечает' || errCount > 10) {
errCount = 0;
throw e;
}
await utils.sleep(100);
}
}
}
async search(query) {
const response = await this.request({action: 'search', query});
if (response.error) {
throw new Error(response.error);
}
return response;
async search(from, query) {
return await this.request({action: 'search', from, query}, 30);
}
async getBookList(authorId) {
const response = await this.request({action: 'get-book-list', authorId});
async bookSearch(query) {
return await this.request({action: 'bookSearch', query}, 30);
}
if (response.error) {
throw new Error(response.error);
}
async getAuthorBookList(authorId) {
return await this.request({action: 'get-author-book-list', authorId});
}
return response;
async getAuthorSeriesList(authorId) {
return await this.request({action: 'get-author-series-list', authorId});
}
async getSeriesBookList(series) {
const response = await this.request({action: 'get-series-book-list', series});
if (response.error) {
throw new Error(response.error);
}
return response;
return await this.request({action: 'get-series-book-list', series});
}
async getGenreTree() {
const response = await this.request({action: 'get-genre-tree'});
if (response.error) {
throw new Error(response.error);
}
return response;
return await this.request({action: 'get-genre-tree'});
}
async getBookLink(params) {
const response = await this.request(Object.assign({action: 'get-book-link'}, params), 120);
async getBookLink(bookUid) {
return await this.request({action: 'get-book-link', bookUid}, 120);
}
if (response.error) {
throw new Error(response.error);
}
return response;
async getBookInfo(bookUid) {
return await this.request({action: 'get-book-info', bookUid}, 120);
}
async getConfig() {
const response = await this.request({action: 'get-config'});
return await this.request({action: 'get-config'});
}
if (response.error) {
throw new Error(response.error);
}
return response;
async logout() {
await this.request({action: 'logout'});
this.accessGranted = false;
await this.request({action: 'test'});
}
}

View File

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

View File

@@ -1,10 +1,10 @@
<template>
<div class="fit row">
<Api ref="api" />
<Api ref="api" v-model="accessGranted" />
<Notify ref="notify" />
<StdDialog ref="stdDialog" />
<router-view v-slot="{ Component }">
<router-view v-if="accessGranted" v-slot="{ Component }">
<keep-alive>
<component :is="Component" class="col" />
</keep-alive>
@@ -37,6 +37,7 @@ const componentOptions = {
};
class App {
_options = componentOptions;
accessGranted = false;
created() {
this.commit = this.$store.commit;
@@ -121,7 +122,7 @@ body, html, #app {
padding: 0;
width: 100%;
height: 100%;
font: normal 12px GameDefault;
font: normal 13px Web Default;
}
.dborder {
@@ -133,6 +134,15 @@ body, html, #app {
animation: rotating 2s linear infinite;
}
.q-dialog__inner--minimized {
padding: 10px !important;
}
.q-dialog__inner--minimized > div {
max-height: 100% !important;
max-width: 800px !important;
}
@keyframes rotating {
from {
transform: rotate(0deg);
@@ -142,9 +152,13 @@ body, html, #app {
}
@font-face {
font-family: 'GameDefault';
src: url('fonts/web-default.woff') format('woff'),
url('fonts/web-default.ttf') format('truetype');
font-family: 'Web Default';
src: url('fonts/web-default.ttf') format('truetype');
}
@font-face {
font-family: 'Verdana';
font-weight: bold;
src: url('fonts/web-default-bold.ttf') format('truetype');
}
</style>

View File

@@ -0,0 +1,502 @@
<template>
<div>
<a ref="download" style="display: none;"></a>
<LoadingMessage :message="loadingMessage" z-index="2" />
<LoadingMessage :message="loadingMessage2" z-index="1" />
<!-- Формирование списка ------------------------------------------------------------------------>
<div v-for="item in tableData" :key="item.key" class="column" :class="{'odd-item': item.num % 2}" style="font-size: 120%">
<div class="row items-center q-ml-md q-mr-xs no-wrap">
<div class="row items-center clickable2 q-py-xs no-wrap" @click="expandAuthor(item)">
<div style="min-width: 30px">
<div v-if="!isExpandedAuthor(item)">
<q-icon name="la la-plus-square" size="28px" />
</div>
<div v-else>
<q-icon name="la la-minus-square" size="28px" />
</div>
</div>
</div>
<div class="clickable2 q-ml-xs q-py-sm text-green-10 text-bold" @click="selectAuthor(item.author)">
{{ item.name }}
</div>
<div class="q-ml-sm text-bold" style="color: #555">
{{ getBookCount(item) }}
</div>
</div>
<div v-if="item.bookLoading" class="book-row row items-center">
<q-icon class="la la-spinner icon-rotate text-blue-8" size="28px" />
<div class="q-ml-xs">
Обработка...
</div>
</div>
<div v-if="isExpandedAuthor(item) && item.books">
<div v-for="book in item.books" :key="book.key" class="book-row column">
<!-- серия книг -->
<div v-if="book.type == 'series'" class="column">
<div class="row items-center q-mr-xs no-wrap text-grey-9">
<div class="row items-center clickable2 q-py-xs no-wrap" @click="expandSeries(book)">
<div style="min-width: 30px">
<div v-if="!isExpandedSeries(book)">
<q-icon name="la la-plus-square" size="28px" />
</div>
<div v-else>
<q-icon name="la la-minus-square" size="28px" />
</div>
</div>
</div>
<div class="clickable2 q-ml-xs q-py-sm text-bold" @click="selectSeries(book.series)">
Серия: {{ book.series }}
</div>
<div class="q-ml-sm text-bold" style="color: #555">
{{ getSeriesBookCount(item, book) }}
</div>
</div>
<div v-if="isExpandedSeries(book) && book.seriesBooks">
<div v-if="book.showAllBooks" class="book-row column">
<BookView
v-for="seriesBook in book.allBooks" :key="seriesBook.id"
:book="seriesBook"
mode="series"
:genre-map="genreMap" :show-read-link="showReadLink"
:title-color="isFoundSeriesBook(book, seriesBook) ? 'text-blue-10' : 'text-red'"
@book-event="bookEvent"
/>
</div>
<div v-else class="book-row column">
<BookView
v-for="seriesBook in book.seriesBooks" :key="seriesBook.key"
:book="seriesBook" mode="author" :genre-map="genreMap" :show-read-link="showReadLink" @book-event="bookEvent"
/>
</div>
<div
v-if="book.allBooksLoaded && book.allBooksLoaded.length != book.seriesBooks.length"
class="row items-center q-my-sm"
style="margin-left: 100px"
>
<div v-if="book.showAllBooks && book.showMoreAll" class="row items-center q-mr-md">
<i class="las la-ellipsis-h text-red" style="font-size: 40px"></i>
<q-btn class="q-ml-md" color="red" style="width: 200px" dense rounded no-caps @click="showMoreAll(book)">
Показать еще (~{{ showMoreCount }})
</q-btn>
<q-btn class="q-ml-sm" color="red" style="width: 200px" dense rounded no-caps @click="showMoreAll(book, true)">
Показать все ({{ (book.allBooksLoaded && book.allBooksLoaded.length) || '?' }})
</q-btn>
</div>
<div v-if="book.showAllBooks" class="row items-center clickable2 text-blue-10" @click="book.showAllBooks = false">
<q-icon class="la la-long-arrow-alt-up" size="28px" />
Только найденные книги
</div>
<div v-else class="row items-center clickable2 text-red" @click="book.showAllBooks = true">
<q-icon class="la la-long-arrow-alt-down" size="28px" />
Все книги серии
</div>
</div>
</div>
</div>
<!-- книга без серии -->
<BookView v-else :book="book" mode="author" :genre-map="genreMap" :show-read-link="showReadLink" @book-event="bookEvent" />
</div>
<!--div v-if="isExpandedAuthor(item) && item.books && !item.books.length" class="book-row row items-center">
<q-icon class="la la-meh q-mr-xs" size="24px" />
По каждому из заданных критериев у этого автора были найдены разные книги, но нет полного совпадения
</div-->
</div>
<div v-if="isExpandedAuthor(item) && item.showMore" class="row items-center book-row q-mb-sm">
<i class="las la-ellipsis-h text-blue-10" style="font-size: 40px"></i>
<q-btn class="q-ml-md" color="primary" style="width: 200px" dense rounded no-caps @click="showMore(item)">
Показать еще (~{{ showMoreCount }})
</q-btn>
<q-btn class="q-ml-sm" color="primary" style="width: 200px" dense rounded no-caps @click="showMore(item, true)">
Показать все ({{ (item.booksLoaded && item.booksLoaded.length) || '?' }})
</q-btn>
</div>
</div>
<!-- Формирование списка конец ------------------------------------------------------------------>
<div v-if="!refreshing && (!tableData.length || error)" class="row items-center q-ml-md" style="font-size: 120%">
<q-icon class="la la-meh q-mr-xs" size="28px" />
{{ (error ? error : 'Поиск не дал результатов') }}
</div>
</div>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../../vueComponent.js';
import { reactive } from 'vue';
import BaseList from '../BaseList';
import authorBooksStorage from '../authorBooksStorage';
import * as utils from '../../../share/utils';
import _ from 'lodash';
class AuthorList extends BaseList {
cachedAuthors = {};
showHiddenHelp() {
this.$root.stdDialog.alert(`
Книги скрытых авторов помечены как удаленные. Для того, чтобы их увидеть, необходимо установить опцию "Показывать удаленные" в настройках.
`, 'Пояснение', {iconName: 'la la-info-circle'});
}
get foundCountMessage() {
return `${this.list.totalFound} автор${utils.wordEnding(this.list.totalFound)}`;
}
isFoundSeriesBook(seriesItem, seriesBook) {
if (!seriesItem.booksSet) {
seriesItem.booksSet = new Set(seriesItem.seriesBooks.map(b => b.id));
}
return seriesItem.booksSet.has(seriesBook.id);
}
getBookCount(item) {
let result = '';
if (!this.showCounts || item.count === undefined)
return result;
if (item.booksLoaded) {
let count = 0;
for (const book of item.booksLoaded) {
if (book.type == 'series')
count += book.seriesBooks.length;
else
count++;
}
result = `${count}/${item.count}`;
} else
result = `#/${item.count}`;
return `(${result})`;
}
getSeriesBookCount(item, book) {
let result = '';
if (!this.showCounts || book.type != 'series')
return result;
let count = book.seriesBooks.length;
result = `${count}`;
if (item.seriesLoaded) {
const rec = item.seriesLoaded[book.series];
// заплатка для исправления https://github.com/bookpauk/inpx-web/issues/10
// по невыясненным причинам rec иногда равен undefined
if (rec) {
const totalCount = (this.showDeleted ? rec.bookCount + rec.bookDelCount : rec.bookCount);
result += `/${totalCount}`;
}
}
return `(${result})`;
}
async expandAuthor(item) {
this.$emit('listEvent', {action: 'ignoreScroll'});
const expanded = _.cloneDeep(this.expandedAuthor);
const key = item.author;
if (!this.isExpandedAuthor(item)) {
expanded.push(key);
await this.getAuthorBooks(item);
if (expanded.length > 10) {
expanded.shift();
}
this.setSetting('expandedAuthor', expanded);
} else {
const i = expanded.indexOf(key);
if (i >= 0) {
expanded.splice(i, 1);
this.setSetting('expandedAuthor', expanded);
}
}
}
async getAuthorSeries(item) {
if (item.seriesLoaded)
return;
const series = await this.loadAuthorSeries(item.key);
const loaded = {};
for (const s of series) {
loaded[s.series] = {bookCount: s.bookCount, bookDelCount: s.bookDelCount};
}
item.seriesLoaded = loaded;
}
async getAuthorBooks(item) {
if (item.books) {
if (item.count > this.maxItemCount) {
item.bookLoading = true;
await utils.sleep(1);//для перерисовки списка
item.bookLoading = false;
}
return;
}
if (!this.getBooksFlag)
this.getBooksFlag = 0;
this.getBooksFlag++;
if (item.count > this.maxItemCount)
item.bookLoading = true;
try {
if (this.getBooksFlag == 1) {
(async() => {
await utils.sleep(500);
if (this.getBooksFlag > 0)
this.loadingMessage2 = 'Загрузка списка книг...';
})();
}
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(
{
key: book.id,
type: 'book',
},
book
);
};
//объединение по сериям
const books = [];
const seriesIndex = {};
for (const book of filtered) {
if (book.series) {
let index = seriesIndex[book.series];
if (index === undefined) {
index = books.length;
books.push(reactive({
key: book.series,
type: 'series',
series: book.series,
allBooksLoaded: false,
allBooks: false,
showAllBooks: false,
showMoreAll: false,
seriesBooks: [],
}));
seriesIndex[book.series] = index;
}
books[index].seriesBooks.push(prepareBook(book));
} else {
books.push(prepareBook(book));
}
}
//сортировка
books.sort((a, b) => {
if (a.type == 'series') {
return (b.type == 'series' ? a.key.localeCompare(b.key) : -1);
} else {
return (b.type == 'book' ? a.title.localeCompare(b.title) : 1);
}
});
//сортировка внутри серий
for (const book of books) {
if (book.type == 'series') {
this.sortSeriesBooks(book.seriesBooks);
//асинхронно подгрузим все книги серии, если она раскрыта
if (this.isExpandedSeries(book)) {
this.getSeriesBooks(book);//no await
}
}
}
if (books.length == 1 && books[0].type == 'series' && !this.isExpandedSeries(books[0])) {
this.expandSeries(books[0]);
}
item.booksLoaded = books;
this.getAuthorSeries(item);//no await
this.showMore(item);
await this.$nextTick();
} finally {
item.bookLoading = false;
this.getBooksFlag--;
if (this.getBooksFlag == 0)
this.loadingMessage2 = '';
}
}
async updateTableData() {
let result = [];
const expandedSet = new Set(this.expandedAuthor);
const authors = this.searchResult.found;
if (!authors)
return;
let num = 0;
for (const rec of authors) {
this.cachedAuthors[rec.author] = rec;
const count = (this.showDeleted ? rec.bookCount + rec.bookDelCount : rec.bookCount);
const item = reactive({
key: rec.id,
num,
author: rec.author,
name: rec.author.replace(/,/g, ', '),
count,
booksLoaded: false,
seriesLoaded: false,
books: false,
bookLoading: false,
showMore: false,
});
num++;
if (expandedSet.has(item.author)) {
if (authors.length > 1 || item.count > this.maxItemCount)
this.getAuthorBooks(item);//no await
else
if (await this.getAuthorBooks(item) === false) {
this.tableData = [];
return;
}
}
result.push(item);
}
if (result.length == 1 && !this.isExpandedAuthor(result[0])) {
this.expandAuthor(result[0]);
}
this.tableData = result;
}
async refresh() {
//параметры запроса
const newQuery = this.getQuery();
if (_.isEqual(newQuery, this.prevQuery))
return;
this.prevQuery = newQuery;
//оптимизация, вместо запроса к серверу, берем из кеша
if (this.abCacheEnabled && this.search.author && this.search.author[0] == '=') {
const authorSearch = this.search.author.substring(1);
const author = this.cachedAuthors[authorSearch];
if (author) {
const key = `author-${author.id}-${this.list.inpxHash}`;
let data = await authorBooksStorage.getData(key);
if (data) {
this.list.queryFound = 1;
this.list.totalFound = 1;
this.searchResult = {found: [author]};
await this.updateTableData();
return;
}
}
}
this.queryExecute = newQuery;
if (this.refreshing)
return;
this.error = '';
this.refreshing = true;
(async() => {
await utils.sleep(500);
if (this.refreshing)
this.loadingMessage = 'Поиск авторов...';
})();
try {
while (this.queryExecute) {
const query = this.queryExecute;
this.queryExecute = null;
try {
const response = await this.api.search('author', query);
this.list.queryFound = response.found.length;
this.list.totalFound = response.totalFound;
this.list.inpxHash = response.inpxHash;
this.searchResult = response;
await utils.sleep(1);
if (!this.queryExecute) {
await this.updateTableData();
this.scrollToTop();
this.highlightPageScroller(query);
}
} catch (e) {
this.list.queryFound = 0;
this.list.totalFound = 0;
this.searchResult = {found: []};
await this.updateTableData();
//this.$root.stdDialog.alert(e.message, 'Ошибка');
this.error = `Ошибка: ${e.message}`;
}
}
} finally {
this.refreshing = false;
this.loadingMessage = '';
}
}
}
export default vueComponent(AuthorList);
//-----------------------------------------------------------------------------
</script>
<style scoped>
.clickable2 {
cursor: pointer;
}
.odd-item {
background-color: #e8e8e8;
}
.book-row {
margin-left: 50px;
}
</style>

View File

@@ -0,0 +1,573 @@
import axios from 'axios';
import dayjs from 'dayjs';
import _ from 'lodash';
import authorBooksStorage from './authorBooksStorage';
import BookView from './BookView/BookView.vue';
import LoadingMessage from './LoadingMessage/LoadingMessage.vue';
import * as utils from '../../share/utils';
const showMoreCount = 100;//значение для "Показать еще"
const maxItemCount = 500;//выше этого значения показываем "Загрузка"
const componentOptions = {
components: {
BookView,
LoadingMessage,
},
watch: {
settings() {
this.loadSettings();
},
search: {
handler() {
if (!this.isExtendedSearch)
this.refresh();
},
deep: true,
},
extSearch: {
handler() {
if (this.isExtendedSearch)
this.refresh();
},
deep: true,
},
showDeleted() {
this.refresh();
},
},
};
export default class BaseList {
_options = componentOptions;
_props = {
list: Object,
search: Object,
extSearch: Object,
genreMap: Object,
};
error = '';
loadingMessage = '';
loadingMessage2 = '';
//settings
expandedAuthor = [];
expandedSeries = [];
downloadAsZip = false;
showCounts = true;
showRates = true;
showGenres = true;
showDeleted = false;
abCacheEnabled = true;
//stuff
refreshing = false;
showMoreCount = showMoreCount;
maxItemCount = maxItemCount;
searchResult = {};
tableData = [];
created() {
this.isExtendedSearch = false;
this.commit = this.$store.commit;
this.api = this.$root.api;
this.loadSettings();
}
mounted() {
this.refresh();//no await
}
loadSettings() {
const settings = this.settings;
this.expandedAuthor = _.cloneDeep(settings.expandedAuthor);
this.expandedSeries = _.cloneDeep(settings.expandedSeries);
this.downloadAsZip = settings.downloadAsZip;
this.showCounts = settings.showCounts;
this.showRates = settings.showRates;
this.showGenres = settings.showGenres;
this.showDeleted = settings.showDeleted;
this.abCacheEnabled = settings.abCacheEnabled;
}
get config() {
return this.$store.state.config;
}
get settings() {
return this.$store.state.settings;
}
get showReadLink() {
return this.config.bookReadLink != '' || this.list.liberamaReady;
}
scrollToTop() {
this.$emit('listEvent', {action: 'scrollToTop'});
}
selectAuthor(author) {
const search = (this.isExtendedSearch ? this.extSearch : this.search);
search.author = `=${author}`;
this.scrollToTop();
}
selectSeries(series) {
const search = (this.isExtendedSearch ? this.extSearch : this.search);
search.series = `=${series}`;
}
selectTitle(title) {
const search = (this.isExtendedSearch ? this.extSearch : this.search);
search.title = `=${title}`;
}
async download(book, action) {
if (this.downloadFlag)
return;
this.downloadFlag = true;
(async() => {
await utils.sleep(200);
if (this.downloadFlag)
this.loadingMessage2 = 'Подготовка файла...';
})();
try {
//подготовка
const response = await this.api.getBookLink(book._uid);
const link = response.link;
let href = `${window.location.origin}${link}`;
//downloadAsZip
if (this.downloadAsZip && (action == 'download' || action == 'copyLink')) {
href += '/zip';
//подожлем формирования zip-файла
await axios.head(href);
}
//action
if (action == 'download') {
//скачивание
const d = this.$refs.download;
d.href = href;
d.click();
} else if (action == 'copyLink') {
//копирование ссылки
if (await utils.copyTextToClipboard(href))
this.$root.notify.success('Ссылка успешно скопирована');
else
this.$root.stdDialog.alert(
`Копирование ссылки не удалось. Пожалуйста, попробуйте еще раз.
<br><br>
<b>Пояснение</b>: вероятно, браузер запретил копирование, т.к. прошло<br>
слишком много времени с момента нажатия на кнопку (инициация<br>
пользовательского события). Сейчас ссылка уже закеширована,<br>
поэтому повторная попытка должна быть успешной.`, 'Ошибка');
} else if (action == 'readBook') {
//читать
if (this.list.liberamaReady) {
this.$emit('listEvent', {action: 'submitUrl', data: href});
} else {
const bookReadLink = this.config.bookReadLink;
let url = bookReadLink;
if (bookReadLink.indexOf('${DOWNLOAD_LINK}') >= 0) {
url = bookReadLink.replace('${DOWNLOAD_LINK}', href);
} else if (bookReadLink.indexOf('${DOWNLOAD_URI}') >= 0) {
const hrefUrl = new URL(href);
const urlWithoutHost = hrefUrl.pathname + hrefUrl.search + hrefUrl.hash;
url = bookReadLink.replace('${DOWNLOAD_URI}', urlWithoutHost);
}
window.open(url, '_blank');
}
} else if (action == 'bookInfo') {
//информация о книге
const response = await this.api.getBookInfo(book._uid);
this.$emit('listEvent', {action: 'bookInfo', data: response.bookInfo});
}
} catch(e) {
this.$root.stdDialog.alert(e.message, 'Ошибка');
} finally {
this.downloadFlag = false;
this.loadingMessage2 = '';
}
}
bookEvent(event) {
switch (event.action) {
case 'authorClick':
this.selectAuthor(event.book.author);
break;
case 'seriesClick':
this.selectSeries(event.book.series);
break;
case 'titleClick':
this.selectTitle(event.book.title);
break;
case 'download':
case 'copyLink':
case 'readBook':
case 'bookInfo':
this.download(event.book, event.action);//no await
break;
}
}
isExpandedAuthor(item) {
return this.expandedAuthor.indexOf(item.author) >= 0;
}
isExpandedSeries(seriesItem) {
return this.expandedSeries.indexOf(seriesItem.key) >= 0;
}
setSetting(name, newValue) {
this.commit('setSettings', {[name]: _.cloneDeep(newValue)});
}
highlightPageScroller(query) {
this.$emit('listEvent', {action: 'highlightPageScroller', query});
}
async expandSeries(seriesItem) {
this.$emit('listEvent', {action: 'ignoreScroll'});
const expandedSeries = _.cloneDeep(this.expandedSeries);
const key = seriesItem.key;
if (!this.isExpandedSeries(seriesItem)) {
expandedSeries.push(key);
if (expandedSeries.length > 100) {
expandedSeries.shift();
}
this.getSeriesBooks(seriesItem); //no await
this.setSetting('expandedSeries', expandedSeries);
} else {
const i = expandedSeries.indexOf(key);
if (i >= 0) {
expandedSeries.splice(i, 1);
this.setSetting('expandedSeries', expandedSeries);
}
}
}
async loadAuthorBooks(authorId) {
try {
let result;
if (this.abCacheEnabled) {
const key = `author-${authorId}-${this.list.inpxHash}`;
const data = await authorBooksStorage.getData(key);
if (data) {
result = JSON.parse(data);
} else {
result = await this.api.getAuthorBookList(authorId);
await authorBooksStorage.setData(key, JSON.stringify(result));
}
} else {
result = await this.api.getAuthorBookList(authorId);
}
return result.books;
} catch (e) {
this.$root.stdDialog.alert(e.message, 'Ошибка');
}
}
async loadAuthorSeries(authorId) {
try {
let result;
if (this.abCacheEnabled) {
const key = `author-${authorId}-series-${this.list.inpxHash}`;
const data = await authorBooksStorage.getData(key);
if (data) {
result = JSON.parse(data);
} else {
result = await this.api.getAuthorSeriesList(authorId);
await authorBooksStorage.setData(key, JSON.stringify(result));
}
} else {
result = await this.api.getAuthorSeriesList(authorId);
}
return result.series;
} catch (e) {
this.$root.stdDialog.alert(e.message, 'Ошибка');
}
}
async loadSeriesBooks(series) {
try {
let result;
if (this.abCacheEnabled) {
const key = `series-${series}-${this.list.inpxHash}`;
const data = await authorBooksStorage.getData(key);
if (data) {
result = JSON.parse(data);
} else {
result = await this.api.getSeriesBookList(series);
await authorBooksStorage.setData(key, JSON.stringify(result));
}
} else {
result = await this.api.getSeriesBookList(series);
}
return result.books;
} catch (e) {
this.$root.stdDialog.alert(e.message, 'Ошибка');
}
}
async getSeriesBooks(seriesItem) {
//блокируем повторный вызов
if (seriesItem.seriesBookLoading)
return;
seriesItem.seriesBookLoading = true;
try {
seriesItem.allBooksLoaded = await this.loadSeriesBooks(seriesItem.series);
if (seriesItem.allBooksLoaded) {
seriesItem.allBooksLoaded = seriesItem.allBooksLoaded.filter(book => (this.showDeleted || !book.del));
this.sortSeriesBooks(seriesItem.allBooksLoaded);
this.showMoreAll(seriesItem);
}
} finally {
seriesItem.seriesBookLoading = false;
}
}
filterBooks(books) {
const s = this.search;
const emptyFieldValue = '?';
const maxUtf8Char = String.fromCodePoint(0xFFFFF);
const ruAlphabet = 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя';
const enAlphabet = 'abcdefghijklmnopqrstuvwxyz';
const enru = new Set((ruAlphabet + enAlphabet).split(''));
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();
if (searchValue[0] !== '~')
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);
if (!bookValue)
return false;
return bookValue !== emptyFieldValue && !enru.has(bookValue[0]) && bookValue.indexOf(searchValue) >= 0;
} else if (searchValue[0] == '~') {//RegExp
searchValue = searchValue.substring(1);
const re = new RegExp(searchValue, 'i');
return re.test(bookValue);
} 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);
}
//ext
let extFound = !s.ext;
if (!extFound) {
const searchExt = new Set(s.ext.split('|'));
extFound = searchExt.has(book.ext.toLowerCase() || emptyFieldValue);
}
return (this.showDeleted || !book.del)
&& authorFound
&& filterBySearch(book.series, s.series)
&& filterBySearch(book.title, s.title)
&& genreFound
&& langFound
&& dateFound
&& librateFound
&& extFound
;
});
}
showMore(item, all = false) {
if (item.booksLoaded) {
const currentLen = (item.books ? item.books.length : 0);
let books;
if (all || currentLen + this.showMoreCount*1.5 > item.booksLoaded.length) {
books = item.booksLoaded;
} else {
books = item.booksLoaded.slice(0, currentLen + this.showMoreCount);
}
item.showMore = (books.length < item.booksLoaded.length);
item.books = books;
}
}
showMoreAll(seriesItem, all = false) {
if (seriesItem.allBooksLoaded) {
const currentLen = (seriesItem.allBooks ? seriesItem.allBooks.length : 0);
let books;
if (all || currentLen + this.showMoreCount*1.5 > seriesItem.allBooksLoaded.length) {
books = seriesItem.allBooksLoaded;
} else {
books = seriesItem.allBooksLoaded.slice(0, currentLen + this.showMoreCount);
}
seriesItem.showMoreAll = (books.length < seriesItem.allBooksLoaded.length);
seriesItem.allBooks = 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));
});
}
queryDate(date) {
if (!utils.isManualDate(date)) {//!manual
/*
{label: 'сегодня', value: 'today'},
{label: 'за 3 дня', value: '3days'},
{label: 'за неделю', value: 'week'},
{label: 'за 2 недели', value: '2weeks'},
{label: 'за месяц', value: 'month'},
{label: 'за 2 месяца', value: '2months'},
{label: 'за 3 месяца', value: '3months'},
{label: 'указать даты', value: 'manual'},
*/
const sqlFormat = 'YYYY-MM-DD';
switch (date) {
case 'today': date = utils.dateFormat(dayjs(), sqlFormat); break;
case '3days': date = utils.dateFormat(dayjs().subtract(3, 'days'), sqlFormat); break;
case 'week': date = utils.dateFormat(dayjs().subtract(1, 'weeks'), sqlFormat); break;
case '2weeks': date = utils.dateFormat(dayjs().subtract(2, 'weeks'), sqlFormat); break;
case 'month': date = utils.dateFormat(dayjs().subtract(1, 'months'), sqlFormat); break;
case '2months': date = utils.dateFormat(dayjs().subtract(2, 'months'), sqlFormat); break;
case '3months': date = utils.dateFormat(dayjs().subtract(3, 'months'), sqlFormat); break;
default:
date = '';
}
}
return date;
}
getQuery() {
const search = (this.isExtendedSearch ? this.extSearch : this.search);
const newQuery = {};
search.setDefaults(newQuery, search);
//дата
if (newQuery.date) {
newQuery.date = this.queryDate(newQuery.date);
}
//offset
newQuery.offset = (newQuery.page - 1)*newQuery.limit;
//del
if (!newQuery.del && !this.showDeleted)
newQuery.del = '0';
return newQuery;
}
}

View File

@@ -0,0 +1,370 @@
<template>
<Dialog ref="dialog" v-model="dialogVisible">
<template #header>
<div class="row items-center">
<div style="font-size: 110%">
Информация о книге
</div>
</div>
</template>
<div ref="box" class="fit column q-mt-xs overflow-auto no-wrap" style="padding: 0px 10px 10px 10px;">
<div class="text-green-10">
{{ bookAuthor }}
</div>
<div>
<b>{{ book.title }}</b>
</div>
<div class="row q-mt-sm no-wrap">
<div class="poster-size">
<div class="column justify-center items-center" :class="{'poster': coverSrc, 'no-poster': !coverSrc}" @click.stop.prevent="posterClick">
<img v-if="coverSrc" :src="coverSrc" class="fit row justify-center items-center" style="object-fit: contain" @error="coverSrc = ''" />
<div v-if="!coverSrc" class="fit row justify-center items-center text-grey-5 overflow-hidden" style="border: 1px solid #ccc; font-size: 300%">
<i>{{ book.ext }}</i>
</div>
</div>
</div>
<div class="col column q-ml-sm" style="min-width: 400px; border: 1px solid #ccc">
<div class="bg-grey-3 row">
<q-tabs
v-model="selectedTab"
active-color="black"
active-bg-color="white"
indicator-color="white"
dense
no-caps
inline-label
class="bg-grey-4 text-grey-7"
>
<q-tab v-if="fb2.length" name="fb2" label="Fb2 инфо" />
<q-tab name="inpx" label="Inpx инфо" />
</q-tabs>
</div>
<div class="overflow-auto full-width" style="height: 262px">
<div v-for="item in info" :key="item.name">
<div class="row q-ml-sm q-mt-sm items-center">
<div class="text-blue" style="font-size: 90%">
{{ item.label }}
</div>
<div class="col q-mx-xs" style="height: 0px; border-top: 1px solid #ccc"></div>
</div>
<div v-for="subItem in item.value" :key="subItem.name" class="row q-ml-md">
<div style="width: 100px">
{{ subItem.label }}
</div>
<div class="q-ml-sm" v-html="subItem.value" />
</div>
</div>
<div class="q-mt-xs"></div>
</div>
</div>
</div>
<div class="q-mt-md" v-html="annotation" />
</div>
<template #footer>
<q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="okClick">
OK
</q-btn>
</template>
<Dialog 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>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../../vueComponent.js';
import Dialog from '../../share/Dialog.vue';
import Fb2Parser from '../../../../server/core/fb2/Fb2Parser';
import * as utils from '../../../share/utils';
import _ from 'lodash';
const componentOptions = {
components: {
Dialog
},
watch: {
modelValue(newValue) {
this.dialogVisible = newValue;
if (newValue)
this.init();
},
dialogVisible(newValue) {
this.$emit('update:modelValue', newValue);
},
}
};
class BookInfoDialog {
_options = componentOptions;
_props = {
modelValue: Boolean,
bookInfo: Object,
genreMap: Object,
};
dialogVisible = false;
posterDialogVisible = false;
selectedTab = 'fb2';
//info props
coverSrc = '';
annotation = '';
fb2 = [];
book = {};
created() {
this.commit = this.$store.commit;
}
mounted() {
}
init() {
//defaults
this.coverSrc = '';
this.annotation = '';
this.fb2 = [];
this.book = {};
this.parseBookInfo();
if (!this.fb2.length)
this.selectedTab = 'inpx';
}
get bookAuthor() {
if (this.book.author) {
let a = this.book.author.split(',');
return a.slice(0, 3).join(', ') + (a.length > 3 ? ' и др.' : '');
}
return '';
}
formatSize(size) {
size = size/1024;
let unit = 'KB';
if (size > 1024) {
size = size/1024;
unit = 'MB';
}
return `${size.toFixed(1)} ${unit}`;
}
convertGenres(genreArr) {
let result = [];
if (genreArr) {
for (const genre of genreArr) {
const g = genre.trim();
const name = this.genreMap.get(g);
result.push(name ? name : g);
}
}
return result.join(', ');
}
get inpx() {
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 utils.sqlDateFormat(value);
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/genre')
return this.convertGenres(value.split(','));
if (nodePath == 'titleInfo/librate' && !value)
return null;
if (typeof(value) === 'string') {
return value;
}
return (value.toString ? value.toString() : '');
};
let result = [];
const book = _.cloneDeep(this.book);
book.series = [book.series, book.serno].filter(v => v).join(' #');
for (const item of mapping) {
const itemOut = {name: item.name, label: item.label, value: []};
for (const subItem of item.value) {
const subItemOut = {
name: subItem.name,
label: subItem.label,
value: valueToString(book[subItem.name], `${item.name}/${subItem.name}`, book)
};
if (subItemOut.value)
itemOut.value.push(subItemOut);
}
if (itemOut.value.length)
result.push(itemOut);
}
return result;
}
get info() {
let result = [];
switch (this.selectedTab) {
case 'fb2':
return this.fb2;
case 'inpx':
return this.inpx;
}
return result;
}
parseBookInfo() {
const bookInfo = this.bookInfo;
//cover
if (bookInfo.cover)
this.coverSrc = bookInfo.cover;
//fb2
if (bookInfo.fb2) {
const parser = new Fb2Parser(bookInfo.fb2);
const infoObj = parser.bookInfo();
if (infoObj.titleInfo) {
let ann = infoObj.titleInfo.annotationHtml;
if (ann) {
ann = ann.replace(/<p>/g, `<p class="p-annotation">`);
this.annotation = ann;
}
}
const self = this;
this.fb2 = parser.bookInfoList(infoObj, {
valueToString(value, nodePath, origVTS) {//eslint-disable-line no-unused-vars
if (nodePath == 'documentInfo/historyHtml' && value)
return value.replace(/<p>/g, `<p class="p-history">`);
if ((nodePath == 'titleInfo/genre' || nodePath == 'srcTitleInfo/genre') && value) {
return self.convertGenres(value);
}
return origVTS(value, nodePath);
},
});
}
//book
if (bookInfo.book)
this.book = bookInfo.book;
}
posterClick() {
if (!this.coverSrc)
return;
this.posterDialogVisible = true;
}
okClick() {
this.dialogVisible = false;
}
}
export default vueComponent(BookInfoDialog);
//-----------------------------------------------------------------------------
</script>
<style scoped>
.poster-size {
height: 300px;
width: 200px;
min-width: 100px;
}
.poster, .no-poster {
width: 100%;
height: 100%;
}
.poster:hover {
position: relative;
top: -1%;
left: -1%;
width: 102%;
height: 102%;
cursor: pointer;
}
</style>
<style>
.p-annotation {
text-indent: 20px;
text-align: justify;
padding: 0;
margin: 0;
}
.p-history {
padding: 0;
margin: 0;
}
</style>

View File

@@ -1,8 +1,8 @@
<template>
<div class="row items-center q-my-sm">
<div class="row items-center no-wrap">
<div v-if="showRate || showDeleted">
<div v-if="showRate && !book.del">
<div class="row items-center q-my-sm no-wrap">
<div class="row items-center">
<div v-if="showRates || showDeleted">
<div v-if="showRates && !book.del">
<div v-if="book.librate">
<q-knob
:model-value="book.librate"
@@ -30,35 +30,59 @@
</q-icon>
</div>
</div>
</div>
<div class="q-ml-sm clickable2" @click="selectTitle">
{{ book.serno ? `${book.serno}. ` : '' }}
<span :class="titleColor">{{ bookTitle }}</span>
<div class="q-ml-sm column">
<div v-if="(mode == 'series' || mode == 'title' || mode == 'extended') && bookAuthor" class="row">
<div class="clickable2 text-green-10" @click.stop.prevent="emit('authorClick')">
{{ bookAuthor }}
</div>
</div>
</div>
<div class="q-ml-sm">
{{ bookSize }}, {{ book.ext }}
</div>
<div class="row items-center">
<div v-if="book.serno" class="q-mr-xs">
{{ book.serno }}.
</div>
<div class="clickable2" :class="titleColor" @click.stop.prevent="emit('titleClick')">
{{ book.title }}
</div>
<div v-if="(mode == 'title' || mode == 'extended') && bookSeries" class="q-ml-xs clickable2" @click.stop.prevent="emit('seriesClick')">
{{ bookSeries }}
</div>
<div class="q-ml-sm clickable" @click="download">
(скачать)
</div>
<div class="q-ml-sm clickable" @click="copyLink">
<q-icon name="la la-copy" size="20px" />
</div>
<div class="q-ml-sm">
{{ bookSize }}, {{ book.ext }}
</div>
<div v-if="showReadLink" class="q-ml-sm clickable" @click="readBook">
(читать)
</div>
<div v-if="showInfo" class="q-ml-sm clickable" @click.stop.prevent="emit('bookInfo')">
(инфо)
</div>
<div v-if="showGenres && book.genre" class="q-ml-sm">
{{ bookGenre }}
</div>
<div class="q-ml-sm clickable" @click.stop.prevent="emit('download')">
(скачать)
</div>
<div v-show="false">
{{ book }}
<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.stop.prevent="emit('readBook')">
(читать)
</div>
<div v-if="showGenres && book.genre" class="q-ml-sm">
{{ bookGenre }}
</div>
<div v-if="showDates && book.date" class="q-ml-sm">
{{ bookDate }}
</div>
</div>
<div v-show="showJson && mode == 'extended'">
<pre style="font-size: 80%; white-space: pre-wrap;">{{ book }}</pre>
</div>
</div>
</div>
</template>
@@ -67,6 +91,8 @@
//-----------------------------------------------------------------------------
import vueComponent from '../../vueComponent.js';
import * as utils from '../../../share/utils';
const componentOptions = {
components: {
},
@@ -80,15 +106,18 @@ class BookView {
_options = componentOptions;
_props = {
book: Object,
genreTree: Array,
showAuthor: Boolean,
mode: String,
genreMap: Object,
showReadLink: Boolean,
titleColor: { type: String, default: 'text-blue-10'},
};
showRate = true;
showRates = true;
showInfo = true;
showGenres = true;
showDeleted = false;
showDates = false;
showJson = false;
created() {
this.loadSettings();
@@ -97,24 +126,33 @@ class BookView {
loadSettings() {
const settings = this.settings;
this.showRate = settings.showRate;
this.showRates = settings.showRates;
this.showInfo = settings.showInfo;
this.showGenres = settings.showGenres;
this.showDates = settings.showDates;
this.showDeleted = settings.showDeleted;
this.showJson = settings.showJson;
}
get settings() {
return this.$store.state.settings;
}
get bookTitle() {
if (this.showAuthor && this.book.author) {
get bookAuthor() {
if (this.book.author) {
let a = this.book.author.split(',');
const author = a.slice(0, 2).join(', ') + (a.length > 2 ? ' и др.' : '');
return `${author} - ${this.book.title}`;
} else {
return this.book.title;
return a.slice(0, 3).join(', ') + (a.length > 3 ? ' и др.' : '');
}
return '';
}
get bookSeries() {
if (this.book.series) {
return `(Серия: ${this.book.series})`;
}
return '';
}
get bookSize() {
@@ -137,31 +175,26 @@ class BookView {
get bookGenre() {
let result = [];
const genre = new Set(this.book.genre.split(','));
const genre = this.book.genre.split(',');
for (const section of this.genreTree) {
for (const g of section.value)
if (genre.has(g.value))
result.push(g.name);
for (const g of genre) {
const name = this.genreMap.get(g);
if (name)
result.push(name);
}
return `(${result.join(' / ')})`;
}
selectTitle() {
this.$emit('bookEvent', {action: 'titleClick', book: this.book});
get bookDate() {
if (!this.book.date)
return '';
return utils.sqlDateFormat(this.book.date);
}
download() {
this.$emit('bookEvent', {action: 'download', book: this.book});
}
copyLink() {
this.$emit('bookEvent', {action: 'copyLink', book: this.book});
}
readBook() {
this.$emit('bookEvent', {action: 'readBook', book: this.book});
emit(action) {
this.$emit('bookEvent', {action, book: this.book});
}
}

View File

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

View File

@@ -0,0 +1,33 @@
<template>
<div v-show="message" class="fit row justify-center items-center" :style="`position: fixed; top: 0; left: 0; background-color: rgba(0, 0, 0, 0.2); z-index: ${zIndex}`">
<div class="bg-white row justify-center items-center q-px-lg" style="min-width: 180px; height: 50px; border-radius: 10px; box-shadow: 2px 2px 10px #333333">
<q-icon class="la la-spinner icon-rotate text-blue-8" size="28px" />
<div class="q-ml-sm">
{{ message }}
</div>
</div>
</div>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../../vueComponent.js';
const componentOptions = {
components: {
},
};
class LoadingMessage {
_options = componentOptions;
_props = {
message: String,
zIndex: {type: String, dafault: '100'},
};
}
export default vueComponent(LoadingMessage);
//-----------------------------------------------------------------------------
</script>
<style scoped>
</style>

View File

@@ -3,7 +3,7 @@
<div class="q-mr-xs">
Страница
</div>
<div class="bg-white">
<div class="trans" :class="{'bg-green-4': highlight, 'bg-white': !highlight}">
<NumInput
v-model="page" :min="1" :max="pageCount" mask="#######"
style="width: 220px" minus-icon="la la-chevron-circle-left" plus-icon="la la-chevron-circle-right" :disable="disable" mm-buttons
@@ -20,6 +20,7 @@
import vueComponent from '../../vueComponent.js';
import NumInput from '../../share/NumInput.vue';
import * as utils from '../../../share/utils';
const componentOptions = {
components: {
@@ -43,10 +44,23 @@ class PageScroller {
};
page = 1;
highlight = false;
created() {
}
async highlightScroller() {
if (this.inTrans)
return;
this.inTrans = true;
await utils.sleep(300);
this.highlight = true;
await utils.sleep(300);
this.highlight = false;
await utils.sleep(300);
this.inTrans = false;
}
}
export default vueComponent(PageScroller);
@@ -54,4 +68,8 @@ export default vueComponent(PageScroller);
</script>
<style scoped>
.trans {
border-radius: 5px;
transition: background-color 0.3s linear;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,139 @@
<template>
<Dialog ref="dialog" v-model="dialogVisible">
<template #header>
<div class="row items-center">
<div style="font-size: 110%">
Выбрать даты
</div>
</div>
</template>
<div ref="box" class="column q-mt-xs overflow-auto no-wrap" style="width: 240px; padding: 0px 10px 10px 10px;">
<div class="row items-center">
<div class="row justify-end q-mr-sm" style="width: 15px">
С:
</div>
<q-btn icon="la la-calendar" color="secondary" :label="labelFrom" dense no-caps style="width: 150px;">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date v-model="from" mask="YYYY-MM-DD">
<div class="row items-center justify-end q-gutter-sm">
<q-btn v-close-popup label="Отмена" color="primary" flat />
<q-btn v-close-popup label="OK" color="primary" flat @click="save" />
</div>
</q-date>
</q-popup-proxy>
</q-btn>
<q-icon name="la la-times-circle" class="q-ml-sm text-grey-6 clickable2" size="28px" @click="from = ''; save();" />
</div>
<div class="q-my-sm" />
<div class="row items-center">
<div class="row justify-end q-mr-sm" style="width: 15px">
По:
</div>
<q-btn icon="la la-calendar" color="secondary" :label="labelTo" dense no-caps style="width: 150px;">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date v-model="to" mask="YYYY-MM-DD">
<div class="row items-center justify-end q-gutter-sm">
<q-btn v-close-popup label="Отмена" color="primary" flat />
<q-btn v-close-popup label="OK" color="primary" flat @click="save" />
</div>
</q-date>
</q-popup-proxy>
</q-btn>
<q-icon name="la la-times-circle" class="q-ml-sm text-grey-6 clickable2" size="28px" @click="to = ''; save();" />
</div>
</div>
<template #footer>
<q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="okClick">
OK
</q-btn>
</template>
</Dialog>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../../vueComponent.js';
import Dialog from '../../share/Dialog.vue';
import * as utils from '../../../share/utils';
const componentOptions = {
components: {
Dialog
},
watch: {
modelValue(newValue) {
this.dialogVisible = newValue;
},
dialogVisible(newValue) {
this.$emit('update:modelValue', newValue);
},
date() {
this.updateFromTo();
},
}
};
class SelectDateDialog {
_options = componentOptions;
_props = {
modelValue: Boolean,
date: String,
};
dialogVisible = false;
from = '';
to = '';
created() {
}
mounted() {
this.updateFromTo();
}
updateFromTo() {
this.from = this.splitDate.from;
this.to = this.splitDate.to;
}
get splitDate() {
if (!utils.isManualDate(this.date))
return {from: '', to: ''};
const [from = '', to = ''] = (this.date || '').split(',');
return {from, to};
}
get labelFrom() {
return (this.splitDate.from ? utils.sqlDateFormat(this.splitDate.from) : 'Не указано');
}
get labelTo() {
return (this.splitDate.to ? utils.sqlDateFormat(this.splitDate.to) : 'Не указано');
}
save() {
let d = this.from;
if (this.to)
d += `,${this.to}`;
this.$emit('update:date', d);
}
okClick() {
this.dialogVisible = false;
}
}
export default vueComponent(SelectDateDialog);
//-----------------------------------------------------------------------------
</script>
<style scoped>
.clickable2 {
cursor: pointer;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,103 @@
<template>
<Dialog ref="dialog" v-model="dialogVisible">
<template #header>
<div class="row items-center">
<div style="font-size: 110%">
Выбрать оценки
</div>
</div>
</template>
<div ref="box" class="column q-mt-xs overflow-auto no-wrap" style="width: 200px; padding: 0px 10px 10px 10px;">
<q-option-group
v-model="ticked"
:options="options"
type="checkbox"
>
</q-option-group>
</div>
<template #footer>
<q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="okClick">
OK
</q-btn>
</template>
</Dialog>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../../vueComponent.js';
import Dialog from '../../share/Dialog.vue';
const componentOptions = {
components: {
Dialog
},
watch: {
modelValue(newValue) {
this.dialogVisible = newValue;
},
dialogVisible(newValue) {
this.$emit('update:modelValue', newValue);
},
librate() {
this.updateTicked();
},
ticked() {
this.updateLibrate();
},
}
};
class SelectLibRateDialog {
_options = componentOptions;
_props = {
modelValue: Boolean,
librate: String,
};
dialogVisible = false;
ticked = [];
tickAll = false;
created() {
this.commit = this.$store.commit;
}
mounted() {
this.updateTicked();
}
get options() {
return [
{label: 'Без оценки', value: '0'},
{label: '1', value: '1'},
{label: '2', value: '2'},
{label: '3', value: '3'},
{label: '4', value: '4'},
{label: '5', value: '5'},
];
}
updateTicked() {
this.ticked = this.librate.split(',').filter(s => s);
}
updateLibrate() {
this.ticked.sort((a, b) => a.localeCompare(b))
this.$emit('update:librate', this.ticked.join(','));
}
okClick() {
this.dialogVisible = false;
}
}
export default vueComponent(SelectLibRateDialog);
//-----------------------------------------------------------------------------
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,298 @@
<template>
<div>
<a ref="download" style="display: none;"></a>
<LoadingMessage :message="loadingMessage" z-index="2" />
<LoadingMessage :message="loadingMessage2" z-index="1" />
<!-- Формирование списка ------------------------------------------------------------------------>
<div v-for="item in tableData" :key="item.key" class="column" :class="{'odd-item': item.num % 2}" style="font-size: 120%">
<div class="row items-center q-ml-md q-mr-xs no-wrap">
<div class="row items-center clickable2 q-py-xs no-wrap" @click="expandSeries(item)">
<div style="min-width: 30px">
<div v-if="!isExpandedSeries(item)">
<q-icon name="la la-plus-square" size="28px" />
</div>
<div v-else>
<q-icon name="la la-minus-square" size="28px" />
</div>
</div>
</div>
<div class="clickable2 q-ml-xs q-py-sm text-bold" @click="selectSeries(item.series)">
Серия: {{ item.series }}
</div>
<div class="q-ml-sm text-bold" style="color: #555">
{{ getBookCount(item) }}
</div>
</div>
<div v-if="item.bookLoading" class="book-row row items-center">
<q-icon class="la la-spinner icon-rotate text-blue-8" size="28px" />
<div class="q-ml-xs">
Обработка...
</div>
</div>
<div v-if="isExpandedSeries(item) && item.books">
<div v-if="item.showAllBooks" class="book-row column">
<BookView
v-for="seriesBook in item.allBooks" :key="seriesBook.id"
:book="seriesBook"
mode="series"
:genre-map="genreMap" :show-read-link="showReadLink"
:title-color="isFoundSeriesBook(item, seriesBook) ? 'text-blue-10' : 'text-red'"
@book-event="bookEvent"
/>
</div>
<div v-else class="book-row column">
<BookView
v-for="seriesBook in item.books" :key="seriesBook.key"
:book="seriesBook" mode="series" :genre-map="genreMap" :show-read-link="showReadLink" @book-event="bookEvent"
/>
</div>
<!--div v-if="!item.showAllBooks && isExpandedSeries(item) && item.books && !item.books.length" class="book-row row items-center">
<q-icon class="la la-meh q-mr-xs" size="24px" />
Возможно у этой серии были найдены книги, помеченные как удаленные, но подходящие по критериям
</div-->
<div
v-if="item.allBooksLoaded && item.allBooksLoaded.length != item.booksLoaded.length"
class="row items-center q-my-sm"
style="margin-left: 100px"
>
<div v-if="item.showAllBooks && item.showMoreAll" class="row items-center q-mr-md">
<i class="las la-ellipsis-h text-red" style="font-size: 40px"></i>
<q-btn class="q-ml-md" color="red" style="width: 200px" dense rounded no-caps @click="showMoreAll(item)">
Показать еще (~{{ showMoreCount }})
</q-btn>
<q-btn class="q-ml-sm" color="red" style="width: 200px" dense rounded no-caps @click="showMoreAll(item, true)">
Показать все ({{ (item.allBooksLoaded && item.allBooksLoaded.length) || '?' }})
</q-btn>
</div>
<div v-if="item.showAllBooks" class="row items-center clickable2 text-blue-10" @click="item.showAllBooks = false">
<q-icon class="la la-long-arrow-alt-up" size="28px" />
Только найденные книги
</div>
<div v-else class="row items-center clickable2 text-red" @click="item.showAllBooks = true">
<q-icon class="la la-long-arrow-alt-down" size="28px" />
Все книги серии
</div>
</div>
</div>
<div v-if="isExpandedSeries(item) && item.showMore" class="row items-center book-row q-mb-sm">
<i class="las la-ellipsis-h text-blue-10" style="font-size: 40px"></i>
<q-btn class="q-ml-md" color="primary" style="width: 200px" dense rounded no-caps @click="showMore(item)">
Показать еще (~{{ showMoreCount }})
</q-btn>
<q-btn class="q-ml-sm" color="primary" style="width: 200px" dense rounded no-caps @click="showMore(item, true)">
Показать все ({{ (item.booksLoaded && item.booksLoaded.length) || '?' }})
</q-btn>
</div>
</div>
<!-- Формирование списка конец ------------------------------------------------------------------>
<div v-if="!refreshing && (!tableData.length || error)" class="row items-center q-ml-md" style="font-size: 120%">
<q-icon class="la la-meh q-mr-xs" size="28px" />
{{ (error ? error : 'Поиск не дал результатов') }}
</div>
</div>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../../vueComponent.js';
import { reactive } from 'vue';
import BaseList from '../BaseList';
import * as utils from '../../../share/utils';
import _ from 'lodash';
class SeriesList extends BaseList {
get foundCountMessage() {
return `${this.list.totalFound} сери${utils.wordEnding(this.list.totalFound, 1)}`;
}
isFoundSeriesBook(seriesItem, seriesBook) {
if (!seriesItem.booksSet) {
seriesItem.booksSet = new Set(seriesItem.books.map(b => b.id));
}
return seriesItem.booksSet.has(seriesBook.id);
}
getBookCount(item) {
let result = '';
if (!this.showCounts || item.count === undefined)
return result;
if (item.booksLoaded) {
result = `${item.booksLoaded.length}/${item.count}`;
} else
result = `#/${item.count}`;
return `(${result})`;
}
async getSeriesBooks(seriesItem) {
if (seriesItem.count > this.maxItemCount) {
seriesItem.bookLoading = true;
await this.$nextTick();
}
try {
await super.getSeriesBooks(seriesItem);
if (seriesItem.allBooksLoaded) {
const prepareBook = (book) => {
return Object.assign(
{
key: book.id,
type: 'book',
},
book
);
};
const filtered = this.filterBooks(seriesItem.allBooksLoaded);
const books = [];
for (const book of filtered) {
books.push(prepareBook(book));
}
seriesItem.booksLoaded = books;
this.showMore(seriesItem);
}
} finally {
seriesItem.bookLoading = false;
}
}
async updateTableData() {
let result = [];
const expandedSet = new Set(this.expandedSeries);
const series = this.searchResult.found;
if (!series)
return;
let num = 0;
for (const rec of series) {
const count = (this.showDeleted ? rec.bookCount + rec.bookDelCount : rec.bookCount);
const item = reactive({
key: rec.series,
series: rec.series,
num,
count,
bookLoading: false,
allBooksLoaded: false,
allBooks: false,
showAllBooks: false,
showMoreAll: false,
booksLoaded: false,
books: false,
showMore: false,
});
num++;
if (expandedSet.has(item.series)) {
if (series.length > 1 || item.count > this.maxItemCount)
this.getSeriesBooks(item);//no await
else
await this.getSeriesBooks(item);
}
result.push(item);
}
if (result.length == 1 && !this.isExpandedSeries(result[0])) {
this.expandSeries(result[0]);
}
this.tableData = result;
}
async refresh() {
//параметры запроса
const newQuery = this.getQuery();
if (_.isEqual(newQuery, this.prevQuery))
return;
this.prevQuery = newQuery;
this.queryExecute = newQuery;
if (this.refreshing)
return;
this.error = '';
this.refreshing = true;
(async() => {
await utils.sleep(500);
if (this.refreshing)
this.loadingMessage = 'Поиск серий...';
})();
try {
while (this.queryExecute) {
const query = this.queryExecute;
this.queryExecute = null;
try {
const response = await this.api.search('series', query);
this.list.queryFound = response.found.length;
this.list.totalFound = response.totalFound;
this.list.inpxHash = response.inpxHash;
this.searchResult = response;
await utils.sleep(1);
if (!this.queryExecute) {
await this.updateTableData();
this.scrollToTop();
this.highlightPageScroller(query);
}
} catch (e) {
this.list.queryFound = 0;
this.list.totalFound = 0;
this.searchResult = {found: []};
await this.updateTableData();
//this.$root.stdDialog.alert(e.message, 'Ошибка');
this.error = `Ошибка: ${e.message}`;
}
}
} finally {
this.refreshing = false;
this.loadingMessage = '';
}
}
}
export default vueComponent(SeriesList);
//-----------------------------------------------------------------------------
</script>
<style scoped>
.clickable2 {
cursor: pointer;
}
.odd-item {
background-color: #e8e8e8;
}
.book-row {
margin-left: 50px;
}
</style>

View File

@@ -0,0 +1,167 @@
<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-show="config.latestVersion" v-model="showNewReleaseAvailable" size="36px" label="Уведомлять о выходе новой версии" />
<q-checkbox v-model="downloadAsZip" size="36px" label="Скачивать книги в виде zip-архива" />
<q-checkbox v-model="showCounts" size="36px" label="Показывать количество" />
<q-checkbox v-model="showRates" size="36px" label="Показывать оценки" />
<q-checkbox v-model="showInfo" size="36px" label="Показывать кнопку (инфо)" />
<q-checkbox v-model="showGenres" size="36px" label="Показывать жанры" />
<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});
},
downloadAsZip(newValue) {
this.commit('setSettings', {'downloadAsZip': 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});
},
showNewReleaseAvailable(newValue) {
this.commit('setSettings', {'showNewReleaseAvailable': newValue});
},
}
};
class SettingsDialog {
_options = componentOptions;
_props = {
modelValue: Boolean,
};
dialogVisible = false;
//settings
limit = 20;
downloadAsZip = false;
showCounts = true;
showRates = true;
showInfo = true;
showGenres = true;
showDates = true;
showDeleted = false;
abCacheEnabled = true;
showNewReleaseAvailable = 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 config() {
return this.$store.state.config;
}
get settings() {
return this.$store.state.settings;
}
loadSettings() {
const settings = this.settings;
this.limit = settings.limit;
this.downloadAsZip = settings.downloadAsZip;
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.showNewReleaseAvailable = settings.showNewReleaseAvailable;
}
okClick() {
this.dialogVisible = false;
}
}
export default vueComponent(SettingsDialog);
//-----------------------------------------------------------------------------
</script>
<style scoped>
</style>

View File

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

View File

@@ -8,6 +8,8 @@ const abStore = localForage.createInstance({
name: 'authorBooksStorage'
});
const storageVersion = '1';
class AuthorBooksStorage {
constructor() {
}
@@ -17,6 +19,8 @@ class AuthorBooksStorage {
}
async setData(key, data) {
key += storageVersion;
if (typeof data !== 'string')
throw new Error('AuthorBooksStorage: data must be a string');
@@ -25,6 +29,8 @@ class AuthorBooksStorage {
}
async getData(key) {
key += storageVersion;
const item = await abStore.getItem(key);
//обновим addTime
@@ -34,9 +40,9 @@ class AuthorBooksStorage {
return item;
}
async removeData(key) {
await abStore.removeItem(key);
await abStore.removeItem(`addTime-${key}`);
async _removeData(fullKey) {
await abStore.removeItem(fullKey);
await abStore.removeItem(`addTime-${fullKey}`);
}
async cleanStorage() {
@@ -62,7 +68,7 @@ class AuthorBooksStorage {
}
if (size > maxDataSize && toDel) {
await this.removeData(toDel);
await this._removeData(toDel);
} else {
break;
}

View File

@@ -0,0 +1,94 @@
Copyright (c) 2010, ParaType Ltd. (http://www.paratype.com/public),
with Reserved Font Names "PT Sans" and "ParaType".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View File

@@ -1,9 +1,10 @@
<template>
<div ref="btn" class="button clickable row justify-center items-center" @click="clickEffect">
<div ref="btn" class="button clickable row justify-center items-center" :class="{disabled}" @click.stop.prevent="clickEffect">
<div class="row justify-center items-center no-wrap" :class="{'button-pressed': pressed}">
<i :class="icon" :style="`font-size: ${iconSize}px; margin-top: ${imt}px`" />
<slot></slot>
</div>
<slot name="tooltip"></slot>
</div>
</template>
@@ -28,8 +29,10 @@ class DivBtn {
height: { type: Number, default: 0 },
icon: { type: String, default: '' },
iconSize: { type: Number, default: 14 },
round: { type: Boolean },
round: Boolean,
imt: { type: Number, default: 0 },// icon margin top
disabled: Boolean,
noShadow: Boolean,
};
pressed = false;
@@ -54,9 +57,17 @@ class DivBtn {
style.borderRadius = `${this.size}px`;
else
style.borderRadius = `${this.size/10}px`;
if (!this.noShadow)
style.boxShadow = '0.5px 1px 3px #333333';
}
async clickEffect() {
async clickEffect(event) {
if (this.disabled) {
return;
}
this.$emit('meClick', event);
this.pressed = true;
await utils.sleep(100);
this.pressed = false;
@@ -70,7 +81,6 @@ export default vueComponent(DivBtn);
<style scoped>
.button {
position: relative;
box-shadow: 0.5px 1px 3px #333333;
}
.button:hover {
@@ -79,8 +89,10 @@ export default vueComponent(DivBtn);
}
.button-pressed {
margin-left: 2px;
margin-top: 2px;
margin-left: 1px;
margin-top: 1px;
margin-right: -1px;
margin-bottom: -1px;
}
.clickable {

View File

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

View File

@@ -17,7 +17,7 @@ export default function(componentClass) {
}
}
} else if (prop === '_props') {
comp['props'] = obj[prop];
comp.props = obj[prop];
}
} else {//usual prop
data[prop] = obj[prop];
@@ -26,23 +26,32 @@ export default function(componentClass) {
comp.data = () => _.cloneDeep(data);
//methods
const classProto = Object.getPrototypeOf(obj);
const classMethods = Object.getOwnPropertyNames(classProto);
const methods = {};
const computed = {};
for (const method of classMethods) {
const desc = Object.getOwnPropertyDescriptor(classProto, method);
if (desc.get) {//has getter, computed
computed[method] = {get: desc.get};
if (desc.set)
computed[method].set = desc.set;
} else if ( ['beforeCreate', 'created', 'beforeMount', 'mounted', 'beforeUpdate', 'updated', 'activated',//life cycle hooks
'deactivated', 'beforeUnmount', 'unmounted', 'errorCaptured', 'renderTracked', 'renderTriggered',//life cycle hooks
'setup'].includes(method) ) {
comp[method] = obj[method];
} else if (method !== 'constructor') {//usual
methods[method] = obj[method];
let classProto = Object.getPrototypeOf(obj);
while (classProto) {
const classMethods = Object.getOwnPropertyNames(classProto);
for (const method of classMethods) {
const desc = Object.getOwnPropertyDescriptor(classProto, method);
if (desc.get) {//has getter, computed
if (!computed[method]) {
computed[method] = {get: desc.get};
if (desc.set)
computed[method].set = desc.set;
}
} else if ( ['beforeCreate', 'created', 'beforeMount', 'mounted', 'beforeUpdate', 'updated', 'activated',
'deactivated', 'beforeUnmount', 'unmounted', 'errorCaptured', 'renderTracked', 'renderTriggered',
'setup'].includes(method) ) {//life cycle hooks
if (!comp[method])
comp[method] = obj[method];
} else if (method !== 'constructor') {//usual
if (!methods[method])
methods[method] = obj[method];
}
}
classProto = Object.getPrototypeOf(classProto);
}
comp.methods = methods;
comp.computed = computed;

View File

@@ -14,21 +14,22 @@ import {QLinearProgress} from 'quasar/src/components/linear-progress';
import {QInput} from 'quasar/src/components/input';
import {QBtn} from 'quasar/src/components/btn';
//import {QBtnGroup} from 'quasar/src/components/btn-group';
//import {QBtnToggle} from 'quasar/src/components/btn-toggle';
import {QBtnToggle} from 'quasar/src/components/btn-toggle';
import {QIcon} from 'quasar/src/components/icon';
//import {QSlider} from 'quasar/src/components/slider';
//import {QTabs, QTab} from 'quasar/src/components/tabs';
import {QTabs, QTab} from 'quasar/src/components/tabs';
//import {QTabPanels, QTabPanel} from 'quasar/src/components/tab-panels';
//import {QSeparator} from 'quasar/src/components/separator';
//import {QList} from 'quasar/src/components/item';
//import {QItem, QItemSection, QItemLabel} from 'quasar/src/components/item';
import {QItem, QItemSection, QItemLabel} from 'quasar/src/components/item';
import {QTooltip} from 'quasar/src/components/tooltip';
//import {QSpinner} from 'quasar/src/components/spinner';
//import {QTable, QTh, QTr, QTd} from 'quasar/src/components/table';
import {QCheckbox} from 'quasar/src/components/checkbox';
import {QSelect} from 'quasar/src/components/select';
//import {QColor} from 'quasar/src/components/color';
//import {QPopupProxy} from 'quasar/src/components/popup-proxy';
import {QPopupProxy} from 'quasar/src/components/popup-proxy';
import {QDate} from 'quasar/src/components/date';
import {QDialog} from 'quasar/src/components/dialog';
//import {QChip} from 'quasar/src/components/chip';
import {QTree} from 'quasar/src/components/tree';
@@ -48,21 +49,22 @@ const components = {
QInput,
QBtn,
//QBtnGroup,
//QBtnToggle,
QBtnToggle,
QIcon,
//QSlider,
//QTabs, QTab,
QTabs, QTab,
//QTabPanels, QTabPanel,
//QSeparator,
//QList,
//QItem, QItemSection, QItemLabel,
QItem, QItemSection, QItemLabel,
QTooltip,
//QSpinner,
//QTable, QTh, QTr, QTd,
QCheckbox,
QSelect,
//QColor,
//QPopupProxy,
QPopupProxy,
QDate,
QDialog,
//QChip,
QTree,
@@ -91,12 +93,13 @@ const plugins = {
//import '@quasar/extras/fontawesome-v5/fontawesome-v5.css';
//import fontawesomeV5 from 'quasar/icon-set/fontawesome-v5.js'
import lang from 'quasar/lang/ru';
import '@quasar/extras/line-awesome/line-awesome.css';
import lineAwesome from 'quasar/icon-set/line-awesome.js'
export default {
quasar: Quasar,
options: { config, components, directives, plugins },
options: { config, components, directives, plugins, lang },
init: () => {
Quasar.iconSet.set(lineAwesome);
}

View File

@@ -5,6 +5,10 @@ const Search = () => import('./components/Search/Search.vue');
const myRoutes = [
['/', Search],
['/author', Search],
['/series', Search],
['/title', Search],
['/extended', Search],
['/:pathMatch(.*)*', null, null, '/'],
];

View File

@@ -1,3 +1,4 @@
import dayjs from 'dayjs';
import {Buffer} from 'safe-buffer';
//import _ from 'lodash';
@@ -35,10 +36,14 @@ export function keyEventToCode(event) {
export function wordEnding(num, type = 0) {
const endings = [
['ов', '', 'а', 'а', 'а', 'ов', 'ов', 'ов', 'ов', 'ов'],
['й', 'я', 'и', 'и', 'и', 'й', 'й', 'й', 'й', 'й'],
['о', '', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],
['ий', 'ие', 'ия', 'ия', 'ия', 'ий', 'ий', 'ий', 'ий', 'ий']
['ов', '', 'а', 'а', 'а', 'ов', 'ов', 'ов', 'ов', 'ов'],//0
['й', 'я', 'и', 'и', 'и', 'й', 'й', 'й', 'й', 'й'],//1
['о', '', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],//2
['ий', 'ие', 'ия', 'ия', 'ия', 'ий', 'ий', 'ий', 'ий', 'ий'],//3
['о', 'а', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],//4
['ок', 'ка', 'ки', 'ки', 'ки', 'ок', 'ок', 'ок', 'ок', 'ок'],//5
['ых', 'ое', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых'],//6
['о', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],//7
];
const deci = num % 100;
if (deci > 10 && deci < 20) {
@@ -82,15 +87,19 @@ export async function copyTextToClipboard(text) {
return result;
}
export function makeValidFilename(filename, repl = '_') {
let f = filename.replace(/[\x00\\/:*"<>|]/g, repl); // eslint-disable-line no-control-regex
f = f.trim();
while (f.length && (f[f.length - 1] == '.' || f[f.length - 1] == '_')) {
f = f.substring(0, f.length - 1);
}
if (f)
return f;
else
throw new Error('Invalid filename');
export function isDigit(c) {
return !isNaN(parseInt(c, 10));
}
export function dateFormat(date, format = 'DD.MM.YYYY') {
return dayjs(date).format(format);
}
export function sqlDateFormat(date, format = 'DD.MM.YYYY') {
return dayjs(date, 'YYYY-MM-DD').format(format);
}
export function isManualDate(date) {
return date && (date[0] == ',' || (isDigit(date[0]) && isDigit(date[1])));
}

View File

@@ -3,15 +3,25 @@ const state = {
config: {},
settings: {
accessToken: '',
limit: 20,
expanded: [],
extendedParams: false,
expandedAuthor: [],
expandedSeries: [],
defaultsSet: false,
//uiDefaults
limit: 20,
downloadAsZip: false,
showCounts: true,
showRate: true,
showRates: true,
showInfo: true,
showGenres: true,
showDates: false,
showDeleted: false,
abCacheEnabled: true,
langDefault: '',
showJson: false,
showNewReleaseAvailable: true,
},
};

9618
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "inpx-web",
"version": "1.0.3",
"version": "1.5.8",
"author": "Book Pauk <bookpauk@gmail.com>",
"license": "CC0-1.0",
"repository": "bookpauk/inpx-web",
@@ -11,9 +11,16 @@
"dev": "nodemon --inspect --ignore server/.inpx-web --ignore client --exec 'node --expose-gc server --lib-dir=server/.inpx-web/lib'",
"build:client": "webpack --config build/webpack.prod.config.js",
"build:linux": "npm run build:client && node build/prepkg.js linux && pkg -t node16-linux-x64 -C GZip --options max-old-space-size=4096,expose-gc -o dist/linux/inpx-web .",
"build:linux-arm64": "npm run build:client && node build/prepkg.js linux-arm64 && pkg -t node16-linuxstatic-arm64 -C GZip --options max-old-space-size=4096,expose-gc -o dist/linux-arm64/inpx-web .",
"build:win": "npm run build:client && node build/prepkg.js win && pkg -t node16-win-x64 -C GZip --options max-old-space-size=4096,expose-gc -o dist/win/inpx-web .",
"build:macos": "npm run build:client && node build/prepkg.js macos && pkg -t node16-macos-x64 -C GZip --options max-old-space-size=4096,expose-gc -o dist/macos/inpx-web .",
"build:client-dev": "webpack --config build/webpack.dev.config.js",
"release": "npm run build:linux && npm run build:client && node build/release.js",
"build:all": "npm run build:linux && npm run build:win && npm run build:macos && npm run build:linux-arm64",
"release": "npm run build:all && node build/release.js",
"release:linux": "npm run build:linux && node build/release.js linux",
"release:win": "npm run build:win && node build/release.js win",
"release:macos": "npm run build:macos && node build/release.js macos",
"release:arm64": "npm run build:linux-arm64 && node build/release.js arm64",
"postinstall": "npm run build:client-dev"
},
"bin": "server/index.js",
@@ -22,48 +29,54 @@
"assets": "dist/public.json"
},
"devDependencies": {
"@babel/core": "^7.18.9",
"@babel/eslint-parser": "^7.18.9",
"@babel/eslint-plugin": "^7.17.7",
"@babel/plugin-proposal-decorators": "^7.18.9",
"@babel/preset-env": "^7.18.9",
"@babel/core": "^7.24.3",
"@babel/eslint-parser": "^7.24.1",
"@babel/eslint-plugin": "^7.23.5",
"@babel/plugin-proposal-decorators": "^7.24.1",
"@babel/preset-env": "^7.24.3",
"@vue/compiler-sfc": "^3.2.22",
"babel-loader": "^8.2.5",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^4.0.0",
"eslint": "^8.20.0",
"eslint-plugin-vue": "^9.3.0",
"html-webpack-plugin": "^5.5.0",
"mini-css-extract-plugin": "^2.6.1",
"pkg": "^5.8.0",
"terser-webpack-plugin": "^5.3.3",
"vue-eslint-parser": "^9.0.3",
"vue-loader": "^17.0.0",
"css-loader": "^6.10.0",
"css-minimizer-webpack-plugin": "^4.2.2",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"html-webpack-plugin": "^5.6.0",
"mini-css-extract-plugin": "^2.8.1",
"pkg": "^5.8.1",
"showdown": "^2.1.0",
"terser-webpack-plugin": "^5.3.10",
"vue-eslint-parser": "^9.4.2",
"vue-loader": "^17.4.2",
"vue-style-loader": "^4.1.3",
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0",
"webpack-dev-middleware": "^5.3.3",
"webpack-hot-middleware": "^2.25.1",
"webpack-merge": "^5.8.0"
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4",
"webpack-dev-middleware": "^6.1.2",
"webpack-hot-middleware": "^2.26.1",
"webpack-merge": "^5.10.0"
},
"dependencies": {
"@quasar/extras": "^1.15.0",
"@quasar/extras": "^1.16.9",
"axios": "^0.27.2",
"compression": "^1.7.4",
"express": "^4.18.1",
"chardet": "^1.6.0",
"dayjs": "^1.11.10",
"express": "^4.19.1",
"express-basic-auth": "^1.2.1",
"fs-extra": "^10.1.0",
"jembadb": "^4.2.0",
"he": "^1.2.0",
"iconv-lite": "^0.6.3",
"jembadb": "^5.1.7",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"minimist": "^1.2.6",
"minimist": "^1.2.8",
"node-stream-zip": "^1.15.0",
"quasar": "^2.7.5",
"quasar": "^2.15.1",
"safe-buffer": "^5.2.1",
"vue": "^3.2.37",
"vue-router": "^4.1.2",
"vuex": "^4.0.2",
"vue-router": "^4.3.0",
"vuex": "^4.1.0",
"vuex-persist": "^3.1.3",
"ws": "^8.8.1"
"ws": "^8.16.0",
"yazl": "^2.5.1"
}
}

View File

@@ -6,22 +6,43 @@ const execDir = path.resolve(__dirname, '..');
module.exports = {
branch: 'unknown',
version: pckg.version,
latestVersion: '',
name: pckg.name,
execDir,
dataDir: '',
tempDir: '',
logDir: '',
libDir: '',
inpx: '',
inpxFilterFile: '',
allowConfigRewrite: false,
allowUnsafeFilter: false,
accessPassword: '',
accessTimeout: 0,
extendedSearch: true,
bookReadLink: '',
loggingEnabled: true,
logServerStats: false,
logQueries: false,
//поправить в случае, если были критические изменения в DbCreator или InpxParser
//иначе будет рассинхронизация по кешу между сервером и клиентом на уровне БД
dbVersion: '12',
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,
fullOptimization: false,
webConfigParams: ['name', 'version', 'branch', 'bookReadLink'],
webConfigParams: ['name', 'version', 'latestVersion', 'branch', 'bookReadLink', 'dbVersion', 'extendedSearch', 'latestReleaseLink', 'uiDefaults'],
allowRemoteLib: false,
remoteLib: false,
@@ -36,6 +57,32 @@ module.exports = {
server: {
host: '0.0.0.0',
port: '22380',
root: '',
},
//opds: false,
opds: {
enabled: true,
user: '',
password: '',
root: '/opds',
},
latestReleaseLink: 'https://github.com/bookpauk/inpx-web/releases/latest',
checkReleaseLink: 'https://api.github.com/repos/bookpauk/inpx-web/releases/latest',
uiDefaults: {
limit: 20,
downloadAsZip: false,
showCounts: true,
showRates: true,
showInfo: true,
showGenres: true,
showDates: false,
showDeleted: false,
abCacheEnabled: true,
langDefault: '',
showJson: false,
showNewReleaseAvailable: true,
},
};

View File

@@ -5,17 +5,37 @@ const fs = require('fs-extra');
const branchFilename = __dirname + '/application_env';
const propsToSave = [
'dataDir',
'tempDir',
'logDir',
'libDir',
'inpx',
'inpxFilterFile',
'allowConfigRewrite',
'allowUnsafeFilter',
'accessPassword',
'accessTimeout',
'extendedSearch',
'bookReadLink',
'loggingEnabled',
'logServerStats',
'logQueries',
'dbCacheSize',
'maxFilesDirSize',
'queryCacheEnabled',
'queryCacheMemSize',
'queryCacheDiskSize',
'cacheCleanInterval',
'inpxCheckInterval',
'lowMemoryMode',
'fullOptimization',
'allowRemoteLib',
'remoteLib',
'server',
'opds',
'latestReleaseLink',
'checkReleaseLink',
'uiDefaults',
];
let instance = null;
@@ -32,7 +52,7 @@ class ConfigManager {
return instance;
}
async init(dataDir) {
async init(defaultDataDir, configFile) {
if (this.inited)
throw new Error('already inited');
@@ -49,14 +69,17 @@ class ConfigManager {
this.branchConfigFile = __dirname + `/${this.branch}.js`;
const config = require(this.branchConfigFile);
if (dataDir) {
config.dataDir = path.resolve(dataDir);
} else {
config.dataDir = `${config.execDir}/.${config.name}`;
if (!defaultDataDir) {
defaultDataDir = `${config.execDir}/.${config.name}`;
}
if (configFile) {
config.configFile = path.resolve(configFile);
} else {
await fs.ensureDir(defaultDataDir);
config.configFile = `${defaultDataDir}/config.json`;
}
await fs.ensureDir(config.dataDir);
this._userConfigFile = `${config.dataDir}/config.json`;
this._config = config;
this.inited = true;
@@ -72,25 +95,32 @@ class ConfigManager {
Object.assign(this._config, value);
}
get userConfigFile() {
return this._userConfigFile;
}
set userConfigFile(value) {
if (value)
this._userConfigFile = value;
}
async load() {
if (!this.inited)
throw new Error('not inited');
if (!await fs.pathExists(this.userConfigFile)) {
await this.save();
return;
}
try {
if (!this.inited)
throw new Error('not inited');
const data = await fs.readFile(this.userConfigFile, 'utf8');
this.config = JSON.parse(data);
if (await fs.pathExists(this._config.configFile)) {
const data = JSON.parse(await fs.readFile(this._config.configFile, 'utf8'));
const config = _.pick(data, propsToSave);
this.config = config;
//сохраним конфиг, если не все атрибуты присутствуют в файле конфига
if (config.allowConfigRewrite) {
for (const prop of propsToSave) {
if (!Object.prototype.hasOwnProperty.call(config, prop)) {
await this.save();
break;
}
}
}
} else {
await this.save();
}
} catch(e) {
throw new Error(`Error while loading "${this._config.configFile}": ${e.message}`);
}
}
async save() {
@@ -98,7 +128,7 @@ class ConfigManager {
throw new Error('not inited');
const dataToSave = _.pick(this._config, propsToSave);
await fs.writeFile(this.userConfigFile, JSON.stringify(dataToSave, null, 4));
await fs.writeFile(this._config.configFile, JSON.stringify(dataToSave, null, 4));
}
}

View File

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

View File

@@ -10,12 +10,11 @@ const cleanPeriod = 1*60*1000;//1 минута
const closeSocketOnIdle = 5*60*1000;//5 минут
class WebSocketController {
constructor(wss, config) {
constructor(wss, webAccess, config) {
this.config = config;
this.isDevelopment = (config.branch == 'development');
this.accessToken = '';
if (config.accessPassword)
this.accessToken = utils.getBufHash(config.accessPassword, 'sha256', 'hex');
this.webAccess = webAccess;
this.workerState = new WorkerState();
this.webWorker = new WebWorker(config);
@@ -32,58 +31,77 @@ class WebSocketController {
});
});
setTimeout(() => { this.periodicClean(); }, cleanPeriod);
this.periodicClean();//no await
}
periodicClean() {
try {
const now = Date.now();
this.wss.clients.forEach((ws) => {
if (!ws.lastActivity || now - ws.lastActivity > closeSocketOnIdle - 50) {
ws.terminate();
}
});
} finally {
setTimeout(() => { this.periodicClean(); }, cleanPeriod);
async periodicClean() {
while (1) {//eslint-disable-line no-constant-condition
try {
const now = Date.now();
//почистим ws-клиентов
this.wss.clients.forEach((ws) => {
if (!ws.lastActivity || now - ws.lastActivity > closeSocketOnIdle - 50) {
ws.terminate();
}
});
} catch(e) {
log(LM_ERR, `WebSocketController.periodicClean error: ${e.message}`);
}
await utils.sleep(cleanPeriod);
}
}
async onMessage(ws, message) {
let req = {};
try {
if (this.isDevelopment) {
log(`WebSocket-IN: ${message.substr(0, 4000)}`);
if (this.isDevelopment || this.config.logQueries) {
log(`WebSocket-IN: ${utils.cutString(message)}`);
}
req = JSON.parse(message);
req.__startTime = Date.now();
ws.lastActivity = Date.now();
//pong for WebSocketConnection
this.send({_rok: 1}, req, ws);
if (this.accessToken && req.accessToken !== this.accessToken) {
await utils.sleep(1000);
throw new Error('need_access_token');
//access
if (!await this.webAccess.hasAccess(req.accessToken)) {
await utils.sleep(500);
const salt = this.webAccess.newToken();
this.send({error: 'need_access_token', salt}, req, ws);
return;
}
//api
switch (req.action) {
case 'test':
await this.test(req, ws); break;
case 'logout':
await this.logout(req, ws); break;
case 'get-config':
await this.getConfig(req, ws); break;
case 'get-worker-state':
await this.getWorkerState(req, ws); break;
case 'search':
await this.search(req, ws); break;
case 'get-book-list':
await this.getBookList(req, ws); break;
case 'bookSearch':
await this.bookSearch(req, ws); break;
case 'get-author-book-list':
await this.getAuthorBookList(req, ws); break;
case 'get-author-series-list':
await this.getAuthorSeriesList(req, ws); break;
case 'get-series-book-list':
await this.getSeriesBookList(req, ws); break;
case 'get-genre-tree':
await this.getGenreTree(req, ws); break;
case 'get-book-link':
await this.getBookLink(req, ws); break;
case 'get-book-info':
await this.getBookInfo(req, ws); break;
case 'get-inpx-file':
await this.getInpxFile(req, ws); break;
@@ -106,8 +124,9 @@ class WebSocketController {
const message = JSON.stringify(r);
ws.send(message);
if (this.isDevelopment) {
log(`WebSocket-OUT: ${message.substr(0, 4000)}`);
if (this.isDevelopment || this.config.logQueries) {
log(`WebSocket-OUT: ${utils.cutString(message)}`);
log(`${Date.now() - req.__startTime}ms`);
}
}
@@ -118,9 +137,15 @@ class WebSocketController {
this.send({message: `${this.config.name} project is awesome`}, req, ws);
}
async logout(req, ws) {
await this.webAccess.deleteAccess(req.accessToken);
this.send({success: true}, req, ws);
}
async getConfig(req, ws) {
const config = _.pick(this.config, this.config.webConfigParams);
config.dbConfig = await this.webWorker.dbConfig();
config.freeAccess = this.webAccess.freeAccess;
this.send(config, req, ws);
}
@@ -136,25 +161,38 @@ class WebSocketController {
async search(req, ws) {
if (!req.query)
throw new Error(`query is empty`);
if (!req.from)
throw new Error(`from is empty`);
const result = await this.webWorker.search(req.query);
const result = await this.webWorker.search(req.from, req.query);
this.send(result, req, ws);
}
async getBookList(req, ws) {
if (!utils.hasProp(req, 'authorId'))
throw new Error(`authorId is empty`);
async bookSearch(req, ws) {
if (!this.config.extendedSearch)
throw new Error('config.extendedSearch disabled');
if (!req.query)
throw new Error(`query is empty`);
const result = await this.webWorker.getBookList(req.authorId);
const result = await this.webWorker.bookSearch(req.query);
this.send(result, req, ws);
}
async getAuthorBookList(req, ws) {
const result = await this.webWorker.getAuthorBookList(req.authorId);
this.send(result, req, ws);
}
async getAuthorSeriesList(req, ws) {
const result = await this.webWorker.getAuthorSeriesList(req.authorId);
this.send(result, req, ws);
}
async getSeriesBookList(req, ws) {
if (!utils.hasProp(req, 'series'))
throw new Error(`series is empty`);
const result = await this.webWorker.getSeriesBookList(req.series);
this.send(result, req, ws);
@@ -167,12 +205,19 @@ class WebSocketController {
}
async getBookLink(req, ws) {
if (!utils.hasProp(req, 'bookPath'))
throw new Error(`bookPath is empty`);
if (!utils.hasProp(req, 'downFileName'))
throw new Error(`downFileName is empty`);
if (!utils.hasProp(req, 'bookUid'))
throw new Error(`bookUid is empty`);
const result = await this.webWorker.getBookLink({bookPath: req.bookPath, downFileName: req.downFileName});
const result = await this.webWorker.getBookLink(req.bookUid);
this.send(result, req, ws);
}
async getBookInfo(req, ws) {
if (!utils.hasProp(req, 'bookUid'))
throw new Error(`bookUid is empty`);
const result = await this.webWorker.getBookInfo(req.bookUid);
this.send(result, req, ws);
}

View File

@@ -37,6 +37,10 @@ class AppLogger {
{log: 'FileLog', fileName: this.errLogFileName, exclude: [LM_OK, LM_INFO, LM_TOTAL]},
{log: 'FileLog', fileName: this.fatalLogFileName, exclude: [LM_OK, LM_INFO, LM_WARN, LM_ERR, LM_TOTAL]},//LM_FATAL only
];
} else {
loggerParams = [
{log: 'ConsoleLog'},
];
}
this._logger = new Logger(loggerParams);

View File

@@ -58,6 +58,16 @@ class DbCreator {
let genreArr = [];
let langMap = new Map();//языки
let langArr = [];
let delMap = new Map();//удаленные
let delArr = [];
let dateMap = new Map();//дата поступления
let dateArr = [];
let librateMap = new Map();//оценка
let librateArr = [];
let extMap = new Map();//тип файла
let extArr = [];
let uidSet = new Set();//уникальные идентификаторы
//stats
let authorCount = 0;
@@ -132,18 +142,100 @@ class DbCreator {
callback({progress: (readState.current || 0)/totalFiles});
};
const parseField = (fieldValue, fieldMap, fieldArr, bookId, rec, fillBookIds = true) => {
let value = fieldValue;
if (typeof(fieldValue) == 'string') {
if (!fieldValue)
fieldValue = emptyFieldValue;
value = fieldValue.toLowerCase();
}
let fieldRec;
if (fieldMap.has(value)) {
const fieldId = fieldMap.get(value);
fieldRec = fieldArr[fieldId];
} else {
fieldRec = {id: fieldArr.length, value, bookIds: new Set()};
if (rec !== undefined) {
fieldRec.name = fieldValue;
fieldRec.bookCount = 0;
fieldRec.bookDelCount = 0;
}
fieldArr.push(fieldRec);
fieldMap.set(value, fieldRec.id);
}
if (fieldValue !== emptyFieldValue || fillBookIds)
fieldRec.bookIds.add(bookId);
if (rec !== undefined) {
if (!rec.del)
fieldRec.bookCount++;
else
fieldRec.bookDelCount++;
}
};
const parseBookRec = (rec) => {
//авторы
const author = splitAuthor(rec.author);
for (let i = 0; i < author.length; i++) {
const a = author[i];
//статистика
if (!authorMap.has(a.toLowerCase()) && (author.length == 1 || i < author.length - 1)) //без соавторов
authorCount++;
parseField(a, authorMap, authorArr, rec.id, rec);
}
//серии
parseField(rec.series, seriesMap, seriesArr, rec.id, rec, false);
//названия
parseField(rec.title, titleMap, titleArr, rec.id, rec);
//жанры
let genre = rec.genre || emptyFieldValue;
genre = rec.genre.split(',');
for (let g of genre) {
parseField(g, genreMap, genreArr, rec.id);
}
//языки
parseField(rec.lang, langMap, langArr, rec.id);
//удаленные
parseField(rec.del, delMap, delArr, rec.id);
//дата поступления
parseField(rec.date, dateMap, dateArr, rec.id);
//оценка
parseField(rec.librate, librateMap, librateArr, rec.id);
//тип файла
parseField(rec.ext, extMap, extArr, rec.id);
};
//основная процедура парсинга
let id = 0;
const parsedCallback = async(chunk) => {
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++;
@@ -153,40 +245,7 @@ class DbCreator {
bookDelCount++;
}
//авторы
const author = splitAuthor(rec.author);
for (let i = 0; i < author.length; i++) {
const a = author[i];
const value = a.toLowerCase();
let authorRec;
if (authorMap.has(value)) {
const authorTmpId = authorMap.get(value);
authorRec = authorArr[authorTmpId];
} else {
authorRec = {tmpId: authorArr.length, author: a, value, bookCount: 0, bookDelCount: 0, bookId: []};
authorArr.push(authorRec);
authorMap.set(value, authorRec.tmpId);
if (author.length == 1 || i < author.length - 1) //без соавторов
authorCount++;
}
//это нужно для того, чтобы имя автора начиналось с заглавной
if (a[0].toUpperCase() === a[0])
authorRec.author = a;
//счетчики
if (!rec.del) {
authorRec.bookCount++;
} else {
authorRec.bookDelCount++;
}
//ссылки на книги
authorRec.bookId.push(id);
}
parseBookRec(rec);
}
let saveChunk = [];
@@ -205,248 +264,69 @@ class DbCreator {
utils.freeMemory();
};
//парсинг 1
//парсинг
const parser = new InpxParser();
await parser.parse(config.inpxFile, readFileCallback, parsedCallback);
utils.freeMemory();
//отсортируем авторов и выдадим им правильные id
//порядок id соответствует ASC-сортировке по author.toLowerCase
callback({job: 'author sort', jobMessage: 'Сортировка авторов', jobStep: 2, progress: 0});
await utils.sleep(100);
authorArr.sort((a, b) => a.value.localeCompare(b.value));
id = 0;
authorMap = new Map();
for (const authorRec of authorArr) {
authorRec.id = ++id;
authorMap.set(authorRec.author, id);
delete authorRec.tmpId;
}
utils.freeMemory();
//подготовка к сохранению author_book
const saveBookChunk = async(authorChunk, callback) => {
callback(0);
const ids = [];
for (const a of authorChunk) {
for (const id of a.bookId) {
ids.push(id);
}
}
ids.sort();// обязательно, иначе будет тормозить - особенности JembaDb
callback(0.1);
const rows = await db.select({table: 'book', where: `@@id(${db.esc(ids)})`});
callback(0.6);
await utils.sleep(100);
const bookArr = new Map();
for (const row of rows)
bookArr.set(row.id, row);
const abRows = [];
for (const a of authorChunk) {
const aBooks = [];
for (const id of a.bookId) {
const rec = bookArr.get(id);
aBooks.push(rec);
}
abRows.push({id: a.id, author: a.author, books: JSON.stringify(aBooks)});
delete a.bookId;//в дальнейшем не понадобится, authorArr сохраняем без него
}
callback(0.7);
await db.insert({
table: 'author_book',
rows: abRows,
});
callback(1);
};
callback({job: 'book sort', jobMessage: 'Сортировка книг', jobStep: 3, progress: 0});
//сохранение author_book
await db.create({
table: 'author_book',
});
let idsLen = 0;
let aChunk = [];
let prevI = 0;
for (let i = 0; i < authorArr.length; i++) {// eslint-disable-line
const author = authorArr[i];
aChunk.push(author);
idsLen += author.bookId.length;
if (idsLen > 50000) {//константа выяснена эмпирическим путем "память/скорость"
await saveBookChunk(aChunk, (p) => {
callback({progress: (prevI + (i - prevI)*p)/authorArr.length});
});
prevI = i;
idsLen = 0;
aChunk = [];
await utils.sleep(100);
utils.freeMemory();
await db.freeMemory();
}
}
if (aChunk.length) {
await saveBookChunk(aChunk, () => {});
aChunk = null;
}
callback({progress: 1});
//чистка памяти, ибо жрет как не в себя
await db.close({table: 'book'});
await db.freeMemory();
utils.freeMemory();
//парсинг 2, подготовка
const parseField = (fieldValue, fieldMap, fieldArr, authorIds, bookId) => {
let addBookId = bookId;
if (!fieldValue) {
fieldValue = emptyFieldValue;
addBookId = 0;//!!!
}
const value = fieldValue.toLowerCase();
let fieldRec;
if (fieldMap.has(value)) {
const fieldId = fieldMap.get(value);
fieldRec = fieldArr[fieldId];
} else {
fieldRec = {id: fieldArr.length, value, authorId: new Set()};
if (bookId)
fieldRec.bookId = new Set();
fieldArr.push(fieldRec);
fieldMap.set(value, fieldRec.id);
}
for (const id of authorIds) {
fieldRec.authorId.add(id);
}
if (addBookId)
fieldRec.bookId.add(addBookId);
};
const parseBookRec = (rec) => {
//авторы
const author = splitAuthor(rec.author);
const authorIds = [];
for (const a of author) {
const authorId = authorMap.get(a);
if (!authorId) //подстраховка
continue;
authorIds.push(authorId);
}
//серии
parseField(rec.series, seriesMap, seriesArr, authorIds, rec.id);
//названия
parseField(rec.title, titleMap, titleArr, authorIds);
//жанры
let genre = rec.genre || emptyFieldValue;
genre = rec.genre.split(',');
for (let g of genre) {
if (!g)
g = emptyFieldValue;
let genreRec;
if (genreMap.has(g)) {
const genreId = genreMap.get(g);
genreRec = genreArr[genreId];
} else {
genreRec = {id: genreArr.length, value: g, authorId: new Set()};
genreArr.push(genreRec);
genreMap.set(g, genreRec.id);
}
for (const id of authorIds) {
genreRec.authorId.add(id);
}
}
//языки
parseField(rec.lang, langMap, langArr, authorIds);
};
callback({job: 'search tables create', jobMessage: 'Создание поисковых таблиц', jobStep: 4, progress: 0});
//парсинг 2, теперь можно создавать остальные поисковые таблицы
let proc = 0;
while (1) {// eslint-disable-line
const rows = await db.select({
table: 'author_book',
where: `
let iter = @getItem('parse_book');
if (!iter) {
iter = @all();
@setItem('parse_book', iter);
}
const ids = new Set();
let id = iter.next();
while (!id.done) {
ids.add(id.value);
if (ids.size >= 10000)
break;
id = iter.next();
}
return ids;
`
});
if (rows.length) {
for (const row of rows) {
const books = JSON.parse(row.books);
for (const rec of books)
parseBookRec(rec);
}
proc += rows.length;
callback({progress: proc/authorArr.length});
} else
break;
await utils.sleep(100);
if (config.lowMemoryMode) {
utils.freeMemory();
await db.freeMemory();
}
}
await parser.parse(config.inpxFile, readFileCallback, parsedCallback);
//чистка памяти, ибо жрет как не в себя
authorMap = null;
seriesMap = null;
titleMap = null;
genreMap = null;
langMap = null;
delMap = null;
dateMap = null;
librateMap = null;
extMap = null;
uidSet = null;
await db.close({table: 'book'});
await db.freeMemory();
utils.freeMemory();
//config
callback({job: 'config save', jobMessage: 'Сохранение конфигурации', jobStep: 5, progress: 0});
await db.create({
table: 'config'
});
//отсортируем таблицы выдадим им правильные id
//порядок id соответствует ASC-сортировке по value
callback({job: 'sort', jobMessage: 'Сортировка', jobStep: 2, progress: 0});
await utils.sleep(100);
//сортировка авторов
authorArr.sort((a, b) => a.value.localeCompare(b.value));
callback({progress: 0.2});
await utils.sleep(100);
id = 0;
for (const authorRec of authorArr) {
authorRec.id = ++id;
}
callback({progress: 0.3});
await utils.sleep(100);
//сортировка серий
seriesArr.sort((a, b) => a.value.localeCompare(b.value));
callback({progress: 0.5});
await utils.sleep(100);
id = 0;
for (const seriesRec of seriesArr) {
seriesRec.id = ++id;
}
callback({progress: 0.6});
await utils.sleep(100);
//сортировка названий
titleArr.sort((a, b) => a.value.localeCompare(b.value));
callback({progress: 0.8});
await utils.sleep(100);
id = 0;
for (const titleRec of titleArr) {
titleRec.id = ++id;
}
//stats
const stats = {
filesCount: 0,//вычислим позднее
filesCountAll: 0,//вычислим позднее
filesDelCount: 0,//вычислим позднее
recsLoaded,
authorCount,
authorCountAll: authorArr.length,
@@ -461,50 +341,45 @@ class DbCreator {
};
//console.log(stats);
const inpxHashCreator = new InpxHashCreator(config);
await db.insert({table: 'config', rows: [
{id: 'inpxInfo', value: (inpxFilter && inpxFilter.info ? inpxFilter.info : parser.info)},
{id: 'stats', value: stats},
{id: 'inpxHash', value: await inpxHashCreator.getHash()},
]});
//сохраним поисковые таблицы
const chunkSize = 10000;
const saveTable = async(table, arr, nullArr, authorIdToArray = false, bookIdToArray = false) => {
const saveTable = async(table, arr, nullArr, indexType = 'string', delEmpty = false) => {
arr.sort((a, b) => a.value.localeCompare(b.value));
if (indexType == 'string')
arr.sort((a, b) => a.value.localeCompare(b.value));
else
arr.sort((a, b) => a.value - b.value);
await db.create({
table,
index: {field: 'value', unique: true, depth: 1000000},
index: {field: 'value', unique: true, type: indexType, depth: 1000000},
});
//вставка в БД по кусочкам, экономим память
for (let i = 0; i < arr.length; i += chunkSize) {
const chunk = arr.slice(i, i + chunkSize);
if (authorIdToArray) {
for (const rec of chunk)
rec.authorId = Array.from(rec.authorId);
}
if (bookIdToArray) {
for (const rec of chunk)
rec.bookId = Array.from(rec.bookId);
}
for (const rec of chunk)
rec.bookIds = Array.from(rec.bookIds);
await db.insert({table, rows: chunk});
if (i % 5 == 0) {
await db.freeMemory();
await utils.sleep(100);
await utils.sleep(10);
}
callback({progress: i/arr.length});
}
if (delEmpty) {
const delResult = await db.delete({table, where: `@@indexLR('value', '?', '?')`});
const statField = `${table}Count`;
if (stats[statField])
stats[statField] -= delResult.deleted;
}
nullArr();
await db.close({table});
utils.freeMemory();
@@ -512,24 +387,36 @@ class DbCreator {
};
//author
callback({job: 'author save', jobMessage: 'Сохранение индекса авторов', jobStep: 6, progress: 0});
callback({job: 'author save', jobMessage: 'Сохранение индекса авторов', jobStep: 3, progress: 0});
await saveTable('author', authorArr, () => {authorArr = null});
//series
callback({job: 'series save', jobMessage: 'Сохранение индекса серий', jobStep: 7, progress: 0});
await saveTable('series_temporary', seriesArr, () => {seriesArr = null}, true, true);
callback({job: 'series save', jobMessage: 'Сохранение индекса серий', jobStep: 4, progress: 0});
await saveTable('series', seriesArr, () => {seriesArr = null}, 'string', true);
//title
callback({job: 'title save', jobMessage: 'Сохранение индекса названий', jobStep: 8, progress: 0});
await saveTable('title', titleArr, () => {titleArr = null}, true);
callback({job: 'title save', jobMessage: 'Сохранение индекса названий', jobStep: 5, progress: 0});
await saveTable('title', titleArr, () => {titleArr = null});
//genre
callback({job: 'genre save', jobMessage: 'Сохранение индекса жанров', jobStep: 9, progress: 0});
await saveTable('genre', genreArr, () => {genreArr = null}, true);
callback({job: 'genre save', jobMessage: 'Сохранение индекса жанров', jobStep: 6, progress: 0});
await saveTable('genre', genreArr, () => {genreArr = null});
callback({job: 'others save', jobMessage: 'Сохранение остальных индексов', jobStep: 7, progress: 0});
//lang
callback({job: 'lang save', jobMessage: 'Сохранение индекса языков', jobStep: 10, progress: 0});
await saveTable('lang', langArr, () => {langArr = null}, true);
await saveTable('lang', langArr, () => {langArr = null});
//del
await saveTable('del', delArr, () => {delArr = null}, 'number');
//date
await saveTable('date', dateArr, () => {dateArr = null});
//librate
await saveTable('librate', librateArr, () => {librateArr = null}, 'number');
//ext
await saveTable('ext', extArr, () => {extArr = null});
//кэш-таблицы запросов
await db.create({table: 'query_cache'});
@@ -539,92 +426,233 @@ class DbCreator {
await db.create({table: 'file_hash'});
//-- завершающие шаги --------------------------------
//оптимизация series, превращаем массив bookId в books
callback({job: 'series optimization', jobMessage: 'Оптимизация', jobStep: 11, progress: 0});
await db.open({
table: 'book',
cacheSize: (config.lowMemoryMode ? 5 : 500),
});
await db.open({table: 'series_temporary'});
await db.create({
table: 'series',
index: {field: 'value', unique: true, depth: 1000000},
callback({job: 'optimization', jobMessage: 'Оптимизация', jobStep: 8, progress: 0});
await this.optimizeTable('author', db, (p) => {
if (p.progress)
p.progress = 0.3*p.progress;
callback(p);
});
await this.optimizeTable('series', db, (p) => {
if (p.progress)
p.progress = 0.3 + 0.2*p.progress;
callback(p);
});
await this.optimizeTable('title', db, (p) => {
if (p.progress)
p.progress = 0.5 + 0.5*p.progress;
callback(p);
});
const count = await db.select({table: 'series_temporary', count: true});
const seriesCount = (count.length ? count[0].count : 0);
callback({job: 'stats count', jobMessage: 'Подсчет статистики', jobStep: 9, progress: 0});
await this.countStats(db, callback, stats);
const saveSeriesChunk = async(seriesChunk) => {
//чистка памяти, ибо жрет как не в себя
await db.close({table: 'book'});
await db.freeMemory();
utils.freeMemory();
//config сохраняем в самом конце, нет конфига - с базой что-то не так
const inpxHashCreator = new InpxHashCreator(config);
await db.create({
table: 'config'
});
const inpxInfo = parser.info;
if (inpxFilter && inpxFilter.info) {
if (inpxFilter.info.collection)
inpxInfo.collection = inpxFilter.info.collection;
if (inpxFilter.info.version)
inpxInfo.version = inpxFilter.info.version;
}
await db.insert({table: 'config', rows: [
{id: 'inpxInfo', value: inpxInfo},
{id: 'stats', value: stats},
{id: 'inpxHash', value: await inpxHashCreator.getHash()},
]});
callback({job: 'done', jobMessage: ''});
}
async optimizeTable(from, db, callback) {
const config = this.config;
const to = `${from}_book`;
await db.open({table: from});
await db.create({table: to});
let bookId2RecId = new Map();
const saveChunk = async(chunk) => {
const ids = [];
for (const s of seriesChunk) {
for (const id of s.bookId) {
for (const rec of chunk) {
for (const id of rec.bookIds) {
let b2r = bookId2RecId.get(id);
if (!b2r) {
b2r = [];
bookId2RecId.set(id, b2r);
}
b2r.push(rec.id);
ids.push(id);
}
}
ids.sort();// обязательно, иначе будет тормозить - особенности JembaDb
if (config.fullOptimization) {
ids.sort((a, b) => a - b);// обязательно, иначе будет тормозить - особенности JembaDb
const rows = await db.select({table: 'book', where: `@@id(${db.esc(ids)})`});
const rows = await db.select({table: 'book', where: `@@id(${db.esc(ids)})`});
const bookArr = new Map();
for (const row of rows)
bookArr.set(row.id, row);
const bookArr = new Map();
for (const row of rows)
bookArr.set(row.id, row);
for (const s of seriesChunk) {
const sBooks = [];
for (const id of s.bookId) {
const rec = bookArr.get(id);
sBooks.push(rec);
for (const rec of chunk) {
rec.books = [];
for (const id of rec.bookIds) {
const book = bookArr.get(id);
if (book) {//на всякий случай
rec.books.push(book);
}
}
delete rec.name;
delete rec.value;
delete rec.bookIds;
}
s.books = JSON.stringify(sBooks);
delete s.bookId;
await db.insert({
table: to,
rows: chunk,
});
}
await db.insert({
table: 'series',
rows: seriesChunk,
});
};
const rows = await db.select({table: 'series_temporary'});
const rows = await db.select({table: from, count: true});
const fromLength = rows[0].count;
idsLen = 0;
aChunk = [];
proc = 0;
for (const row of rows) {// eslint-disable-line
aChunk.push(row);
idsLen += row.bookId.length;
proc++;
let processed = 0;
while (1) {// eslint-disable-line
const chunk = await db.select({
table: from,
where: `
let iter = @getItem('optimize');
if (!iter) {
iter = @all();
@setItem('optimize', iter);
}
if (idsLen > 20000) {//константа выяснена эмпирическим путем "память/скорость"
await saveSeriesChunk(aChunk);
const ids = new Set();
let bookIdsLen = 0;
let id = iter.next();
while (!id.done) {
ids.add(id.value);
idsLen = 0;
aChunk = [];
const row = @row(id.value);
bookIdsLen += row.bookIds.length;
if (bookIdsLen >= 50000)
break;
callback({progress: proc/seriesCount});
id = iter.next();
}
await utils.sleep(100);
return ids;
`
});
if (chunk.length) {
await saveChunk(chunk);
processed += chunk.length;
callback({progress: 0.9*processed/fromLength});
} else
break;
if (this.config.lowMemoryMode) {
await utils.sleep(10);
utils.freeMemory();
await db.freeMemory();
}
}
if (aChunk.length) {
await saveSeriesChunk(aChunk);
aChunk = null;
await db.close({table: to});
await db.close({table: from});
const idMap = {arr: [], map: []};
for (const [id, value] of bookId2RecId) {
if (value.length > 1) {
idMap.map.push([id, value]);
idMap.arr[id] = 0;
} else {
idMap.arr[id] = value[0];
}
}
//чистка памяти, ибо жрет как не в себя
await db.drop({table: 'book'});//таблица больше не понадобится
await db.drop({table: 'series_temporary'});//таблица больше не понадобится
callback({progress: 1});
await fs.writeFile(`${this.config.dataDir}/db/${from}_id.map`, JSON.stringify(idMap));
await db.close({table: 'series'});
await db.freeMemory();
bookId2RecId = null;
utils.freeMemory();
}
callback({job: 'done', jobMessage: ''});
async countStats(db, callback, stats) {
//статистика по количеству файлов
//эмуляция прогресса
let countDone = false;
(async() => {
let i = 0;
while (!countDone) {
callback({progress: i/100});
i = (i < 100 ? i + 5 : 100);
await utils.sleep(1000);
}
})();
//подчсет
const countRes = await db.select({table: 'book', rawResult: true, where: `
const files = new Set();
const filesDel = new Set();
for (const id of @all()) {
const r = @row(id);
const file = ${"`${r.folder}/${r.file}.${r.ext}`"};
if (!r.del) {
files.add(file);
} else {
filesDel.add(file);
}
}
for (const file of filesDel)
if (files.has(file))
filesDel.delete(file);
return {filesCount: files.size, filesDelCount: filesDel.size};
`});
if (countRes.length) {
const res = countRes[0].rawResult;
stats.filesCount = res.filesCount;
stats.filesCountAll = res.filesCount + res.filesDelCount;
stats.filesDelCount = res.filesDelCount;
}
//заодно добавим нужный индекс
await db.create({
in: 'book',
hash: {field: '_uid', type: 'string', depth: 100, unique: true},
});
countDone = true;
}
}

File diff suppressed because it is too large Load Diff

View File

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

144
server/core/HeavyCalc.js Normal file
View File

@@ -0,0 +1,144 @@
const { Worker } = require('worker_threads');
class CalcThread {
constructor() {
this.worker = null;
this.listeners = new Map();
this.requestId = 0;
this.runWorker();
}
terminate() {
if (this.worker) {
this.worker.terminate();
for (const listener of this.listeners.values()) {
listener({error: 'Worker terminated'});
}
}
this.worker = null;
}
runWorker() {
const workerProc = () => {
const { parentPort } = require('worker_threads');
const sleep = (ms) => {
return new Promise(resolve => setTimeout(resolve, ms));
};
if (parentPort) {
parentPort.on('message', async(mes) => {
let result = {};
try {
const fn = new Function(`'use strict'; return ${mes.fn}`)();
result.result = await fn(mes.args, sleep);
} catch (e) {
result = {error: e.message};
}
result.requestId = mes.requestId;
parentPort.postMessage(result);
});
}
};
const worker = new Worker(`const wp = ${workerProc.toString()}; wp();`, {eval: true});
worker.on('message', (mes) => {
const listener = this.listeners.get(mes.requestId);
if (listener) {
this.listeners.delete(mes.requestId);
listener(mes);
}
});
worker.on('error', (err) => {
console.error(err);
});
worker.on('exit', () => {
this.terminate();
});
this.worker = worker;
}
//async
run(args, fn) {
return new Promise((resolve, reject) => {
this.requestId++;
this.listeners.set(this.requestId, (mes) => {
if (mes.error)
reject(new Error(mes.error));
else
resolve(mes.result);
});
if (this.worker) {
this.worker.postMessage({requestId: this.requestId, args, fn: fn.toString()});
} else {
reject(new Error('Worker does not exist'));
}
});
}
}
//singleton
let instance = null;
class HeavyCalc {
constructor(opts = {}) {
const singleton = opts.singleton || false;
if (singleton && instance)
return instance;
this.threads = opts.threads || 1;
this.terminated = false;
this.workers = [];
this.load = [];
for (let i = 0; i < this.threads; i++) {
const worker = new CalcThread();
this.workers.push(worker);
this.load.push(0);
}
if (singleton) {
instance = this;
}
}
async run(args, fn) {
if (this.terminated || !this.workers.length)
throw new Error('All workers terminated');
//находим поток с минимальной нагрузкой
let found = 0;
for (let i = 1; i < this.load.length; i++) {
if (this.load[i] < this.load[found])
found = i;
}
try {
this.load[found]++;
return await this.workers[found].run(args, fn);
} finally {
this.load[found]--;
}
}
terminate() {
for (let i = 0; i < this.workers.length; i++) {
this.workers[i].terminate();
}
this.workers = [];
this.load = [];
this.terminated = true;
}
}
module.exports = HeavyCalc;

View File

@@ -2,10 +2,6 @@ const fs = require('fs-extra');
const utils = require('./utils');
//поправить в случае, если были критические изменения в DbCreator
//иначе будет рассинхронизация между сервером и клиентом на уровне БД
const dbCreatorVersion = '2';
class InpxHashCreator {
constructor(config) {
this.config = config;
@@ -18,7 +14,7 @@ class InpxHashCreator {
if (await fs.pathExists(config.inpxFilterFile))
inpxFilterHash = await utils.getFileHash(config.inpxFilterFile, 'sha256', 'hex');
const joinedHash = dbCreatorVersion + inpxFilterHash +
const joinedHash = this.config.dbVersion + inpxFilterHash +
await utils.getFileHash(config.inpxFile, 'sha256', 'hex');
return utils.getBufHash(joinedHash, 'sha256', 'hex');

View File

@@ -1,11 +1,32 @@
const path = require('path');
const crypto = require('crypto');
const ZipReader = require('./ZipReader');
const utils = require('./utils');
const collectionInfo = 'collection.info';
const structureInfo = 'structure.info';
const versionInfo = 'version.info';
const defaultStructure = 'AUTHOR;GENRE;TITLE;SERIES;SERNO;FILE;SIZE;LIBID;DEL;EXT;DATE;LANG;LIBRATE;KEYWORDS';
//'AUTHOR;GENRE;TITLE;SERIES;SERNO;FILE;SIZE;LIBID;DEL;EXT;DATE;INSNO;FOLDER;LANG;LIBRATE;KEYWORDS;'
const recStructType = {
author: 'S',
genre: 'S',
title: 'S',
series: 'S',
serno: 'N',
file: 'S',
size: 'N',
libid: 'S',
del: 'N',
ext: 'S',
date: 'S',
insno: 'N',
folder: 'S',
lang: 'S',
librate: 'N',
keywords: 'S',
}
class InpxParser {
constructor() {
@@ -23,6 +44,21 @@ class InpxParser {
return result;
}
getRecStruct(structure) {
const result = [];
let struct = structure;
//folder есть всегда
if (!struct.includes('folder'))
struct = struct.concat(['folder']);
for (const field of struct) {
if (utils.hasProp(recStructType, field))
result.push({field, type: recStructType[field]});
}
return result;
}
async parse(inpxFile, readFileCallback, parsedCallback) {
if (!readFileCallback)
readFileCallback = async() => {};
@@ -60,19 +96,18 @@ class InpxParser {
info.version = await this.safeExtractToString(zipReader, versionInfo);
//структура
let inpxStructure = info.structure;
if (!inpxStructure)
inpxStructure = defaultStructure;
inpxStructure = inpxStructure.toLowerCase();
const structure = inpxStructure.split(';');
if (!info.structure)
info.structure = defaultStructure;
const structure = info.structure.toLowerCase().split(';');
info.recStruct = this.getRecStruct(structure);
//парсим inp-файлы
this.chunk = [];
for (const inpFile of inpFiles) {
await readFileCallback({fileName: inpFile, current: ++current});
const buf = await zipReader.extractToBuf(inpFile);
await this.parseInp(buf, structure, parsedCallback);
await this.parseInp(zipReader, inpFile, structure, parsedCallback);
}
if (this.chunk.length) {
@@ -84,10 +119,13 @@ class InpxParser {
}
}
async parseInp(inpBuf, structure, parsedCallback) {
const structLen = structure.length;
async parseInp(zipReader, inpFile, structure, parsedCallback) {
const inpBuf = await zipReader.extractToBuf(inpFile);
const rows = inpBuf.toString().split('\n');
const defaultFolder = `${path.basename(inpFile, '.inp')}.zip`;
const structLen = structure.length;
for (const row of rows) {
let line = row;
if (!line)
@@ -96,9 +134,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++) {
@@ -115,6 +157,9 @@ class InpxParser {
rec.genre = rec.genre.split(':').filter(s => s).join(',');
}
if (!rec.folder)
rec.folder = defaultFolder;
rec.serno = parseInt(rec.serno, 10) || 0;
rec.size = parseInt(rec.size, 10) || 0;
rec.del = parseInt(rec.del, 10) || 0;

View File

@@ -50,4 +50,4 @@ class LockQueue {
}
export default LockQueue;
module.exports = LockQueue;

View File

@@ -16,8 +16,6 @@ class RemoteLib {
this.config = config;
this.wsc = new WebSocketConnection(config.remoteLib.url, 10, 30, {rejectUnauthorized: false});
if (config.remoteLib.accessPassword)
this.accessToken = utils.getBufHash(config.remoteLib.accessPassword, 'sha256', 'hex');
this.remoteHost = config.remoteLib.url.replace(/^ws:\/\//, 'http://').replace(/^wss:\/\//, 'https://');
@@ -31,7 +29,7 @@ class RemoteLib {
return instance;
}
async wsRequest(query) {
async wsRequest(query, recurse = false) {
if (this.accessToken)
query.accessToken = this.accessToken;
@@ -40,6 +38,11 @@ class RemoteLib {
120
);
if (!recurse && response && response.error == 'need_access_token' && this.config.remoteLib.accessPassword) {
this.accessToken = utils.getBufHash(this.config.remoteLib.accessPassword + response.salt, 'sha256', 'hex');
return await this.wsRequest(query, true);
}
if (response.error)
throw new Error(response.error);
@@ -58,14 +61,15 @@ class RemoteLib {
}
}
async downloadBook(bookPath, downFileName) {
async downloadBook(bookUid) {
try {
const response = await await this.wsRequest({action: 'get-book-link', bookPath, downFileName});
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});
const publicPath = `${this.config.publicDir}${link}`;
const hash = path.basename(link);
const publicPath = `${this.config.bookDir}/${hash}`;
await fs.writeFile(publicPath, buf);

154
server/core/WebAccess.js Normal file
View File

@@ -0,0 +1,154 @@
const { JembaDbThread } = require('jembadb');
const utils = require('../core/utils');
const log = new (require('../core/AppLogger'))().log;//singleton
const asyncExit = new (require('./AsyncExit'))();
const cleanPeriod = 1*60*1000;//1 минута
const cleanUnusedTokenTimeout = 5*60*1000;//5 минут
class WebAccess {
constructor(config) {
this.config = config;
this.freeAccess = (config.accessPassword === '');
this.accessTimeout = config.accessTimeout*60*1000;
this.accessMap = new Map();
asyncExit.add(this.closeDb.bind(this));
setTimeout(() => { this.periodicClean(); }, cleanPeriod);
}
async init() {
const config = this.config;
const dbPath = `${config.dataDir}/web-access`;
const db = new JembaDbThread();//в отдельном потоке
await db.lock({
dbPath,
create: true,
softLock: true,
tableDefaults: {
cacheSize: config.dbCacheSize,
},
});
try {
//открываем таблицы
await db.openAll();
} catch(e) {
if (
e.message.indexOf('corrupted') >= 0
|| e.message.indexOf('Unexpected token') >= 0
|| e.message.indexOf('invalid stored block lengths') >= 0
) {
log(LM_ERR, `DB ${dbPath} corrupted`);
log(`Open "${dbPath}" with auto repair`);
await db.openAll({autoRepair: true});
} else {
throw e;
}
}
await db.create({table: 'access', quietIfExists: true});
//проверим, нужно ли обнулить таблицу access
const pass = utils.getBufHash(this.config.accessPassword, 'sha256', 'hex');
await db.create({table: 'config', quietIfExists: true});
let rows = await db.select({table: 'config', where: `@@id('pass')`});
if (!rows.length || rows[0].value !== pass) {
//пароль сменился в конфиге, обнуляем токены
await db.truncate({table: 'access'});
await db.insert({table: 'config', replace: true, rows: [{id: 'pass', value: pass}]});
}
//загрузим токены сессий
rows = await db.select({table: 'access'});
for (const row of rows)
this.accessMap.set(row.id, row.value);
this.db = db;
}
async closeDb() {
if (this.db) {
await this.db.unlock();
this.db = null;
}
}
async periodicClean() {
while (1) {//eslint-disable-line no-constant-condition
try {
const now = Date.now();
//почистим accessMap
if (!this.freeAccess) {
for (const [accessToken, accessRec] of this.accessMap) {
if ( !(accessRec.used > 0 || now - accessRec.time < cleanUnusedTokenTimeout)
|| !(this.accessTimeout === 0 || now - accessRec.time < this.accessTimeout)
) {
await this.deleteAccess(accessToken);
} else if (!accessRec.saved) {
await this.saveAccess(accessToken);
}
}
}
} catch(e) {
log(LM_ERR, `WebAccess.periodicClean error: ${e.message}`);
}
await utils.sleep(cleanPeriod);
}
}
async hasAccess(accessToken) {
if (this.freeAccess)
return true;
const accessRec = this.accessMap.get(accessToken);
if (accessRec) {
const now = Date.now();
if (this.accessTimeout === 0 || now - accessRec.time < this.accessTimeout) {
accessRec.used++;
accessRec.time = now;
accessRec.saved = false;
if (accessRec.used === 1)
await this.saveAccess(accessToken);
return true;
}
}
return false;
}
async deleteAccess(accessToken) {
await this.db.delete({table: 'access', where: `@@id(${this.db.esc(accessToken)})`});
this.accessMap.delete(accessToken);
}
async saveAccess(accessToken) {
const value = this.accessMap.get(accessToken);
if (!value || value.saved)
return;
value.saved = true;
await this.db.insert({
table: 'access',
replace: true,
rows: [{id: accessToken, value}]
});
}
newToken() {
const salt = utils.randomHexString(32);
const accessToken = utils.getBufHash(this.config.accessPassword + salt, 'sha256', 'hex');
this.accessMap.set(accessToken, {time: Date.now(), used: 0});
return salt;
}
}
module.exports = WebAccess;

View File

@@ -2,19 +2,22 @@ const os = require('os');
const path = require('path');
const fs = require('fs-extra');
const _ = require('lodash');
const iconv = require('iconv-lite');
const ZipReader = require('./ZipReader');
const WorkerState = require('./WorkerState');//singleton
const { JembaDbThread } = require('jembadb');
const { JembaDb, JembaDbThread } = require('jembadb');
const DbCreator = require('./DbCreator');
const DbSearcher = require('./DbSearcher');
const InpxHashCreator = require('./InpxHashCreator');
const RemoteLib = require('./RemoteLib');//singleton
const FileDownloader = require('./FileDownloader');
const ayncExit = new (require('./AsyncExit'))();
const asyncExit = new (require('./AsyncExit'))();
const log = new (require('./AppLogger'))().log;//singleton
const utils = require('./utils');
const genreTree = require('./genres');
const Fb2Helper = require('./fb2/Fb2Helper');
//server states
const ssNormal = 'normal';
@@ -27,7 +30,8 @@ const stateToText = {
[ssDbCreating]: 'Создание поисковой базы',
};
const cleanDirPeriod = 60*60*1000;//каждый час
const cleanDirInterval = 60*60*1000;//каждый час
const checkReleaseInterval = 7*60*60*1000;//каждые 7 часов
//singleton
let instance = null;
@@ -44,6 +48,7 @@ class WebWorker {
}
this.inpxHashCreator = new InpxHashCreator(config);
this.fb2Helper = new Fb2Helper();
this.inpxFileHash = '';
this.wState = this.workerState.getControl('server_state');
@@ -51,20 +56,21 @@ class WebWorker {
this.db = null;
this.dbSearcher = null;
ayncExit.add(this.closeDb.bind(this));
asyncExit.add(this.closeDb.bind(this));
this.loadOrCreateDb();//no await
this.periodicLogServerStats();//no await
const dirConfig = [
{
dir: `${this.config.publicDir}/files`,
maxSize: this.config.maxFilesDirSize,
dir: config.bookDir,
maxSize: config.maxFilesDirSize,
},
];
this.periodicCleanDir(dirConfig);//no await
this.periodicCheckInpx();//no await
this.periodicCheckNewRelease();//no await
instance = this;
}
@@ -108,7 +114,7 @@ class WebWorker {
softLock: true,
tableDefaults: {
cacheSize: 5,
cacheSize: config.dbCacheSize,
},
});
@@ -132,7 +138,7 @@ class WebWorker {
}
}
async loadOrCreateDb(recreate = false) {
async loadOrCreateDb(recreate = false, iteration = 0) {
this.setMyState(ssDbLoading);
try {
@@ -141,18 +147,35 @@ class WebWorker {
this.inpxFileHash = await this.inpxHashCreator.getInpxFileHash();
//пересоздаем БД из INPX если нужно
//проверим полный InxpHash (включая фильтр и версию БД)
//для этого заглянем в конфиг внутри БД, если он есть
if (!(config.recreateDb || recreate) && await fs.pathExists(dbPath)) {
const newInpxHash = await this.inpxHashCreator.getHash();
const tmpDb = new JembaDb();
await tmpDb.lock({dbPath, softLock: true});
try {
await tmpDb.open({table: 'config'});
const rows = await tmpDb.select({table: 'config', where: `@@id('inpxHash')`});
if (!rows.length || newInpxHash !== rows[0].value)
throw new Error('inpx file: changes found on start, recreating DB');
} catch (e) {
log(LM_WARN, e.message);
recreate = true;
} finally {
await tmpDb.unlock();
}
}
//удалим БД если нужно
if (config.recreateDb || recreate)
await fs.remove(dbPath);
//пересоздаем БД из INPX если нужно
if (!await fs.pathExists(dbPath)) {
try {
await this.createDb(dbPath);
} catch (e) {
//при ошибке создания БД удалим ее, чтобы не работать с поломанной базой при следующем запуске
await fs.remove(dbPath);
throw e;
}
await this.createDb(dbPath);
utils.freeMemory();
}
@@ -160,32 +183,50 @@ class WebWorker {
this.setMyState(ssDbLoading);
log('Searcher DB loading');
const db = new JembaDbThread();
const db = new JembaDbThread();//в отдельном потоке
await db.lock({
dbPath,
softLock: true,
tableDefaults: {
cacheSize: 5,
cacheSize: config.dbCacheSize,
},
});
//открываем все таблицы
await db.openAll();
try {
//открываем таблицы
await db.openAll({exclude: ['author_id', 'series_id', 'title_id', 'book']});
const bookCacheSize = 500;
await db.open({
table: 'book',
cacheSize: (config.lowMemoryMode || config.dbCacheSize > bookCacheSize ? config.dbCacheSize : bookCacheSize)
});
} catch(e) {
log(LM_ERR, `Database error: ${e.message}`);
if (iteration < 1) {
log('Recreating DB');
await this.loadOrCreateDb(true, iteration + 1);
} else
throw e;
return;
}
//поисковый движок
this.dbSearcher = new DbSearcher(config, db);
await this.dbSearcher.init();
//stuff
db.wwCache = {};
this.db = db;
log('Searcher DB ready');
this.setMyState(ssNormal);
log('Searcher DB ready');
this.logServerStats();
} catch (e) {
log(LM_FATAL, e.message);
ayncExit.exit(1);
} finally {
this.setMyState(ssNormal);
asyncExit.exit(1);
}
}
@@ -220,29 +261,50 @@ class WebWorker {
return db.wwCache.config;
}
async search(query) {
async search(from, query) {
this.checkMyState();
const result = await this.dbSearcher.search(from, query);
const config = await this.dbConfig();
const result = await this.dbSearcher.search(query);
result.inpxHash = (config.inpxHash ? config.inpxHash : '');
return {
author: result.result,
totalFound: result.totalFound,
inpxHash: (config.inpxHash ? config.inpxHash : ''),
};
return result;
}
async getBookList(authorId) {
async bookSearch(query) {
this.checkMyState();
return await this.dbSearcher.getBookList(authorId);
const result = await this.dbSearcher.bookSearch(query);
const config = await this.dbConfig();
result.inpxHash = (config.inpxHash ? config.inpxHash : '');
return result;
}
async getSeriesBookList(seriesId) {
async opdsQuery(from, query) {
this.checkMyState();
return await this.dbSearcher.getSeriesBookList(seriesId);
return await this.dbSearcher.opdsQuery(from, query);
}
async getAuthorBookList(authorId, author) {
this.checkMyState();
return await this.dbSearcher.getAuthorBookList(authorId, author);
}
async getAuthorSeriesList(authorId) {
this.checkMyState();
return await this.dbSearcher.getAuthorSeriesList(authorId);
}
async getSeriesBookList(series) {
this.checkMyState();
return await this.dbSearcher.getSeriesBookList(series);
}
async getGenreTree() {
@@ -252,7 +314,7 @@ class WebWorker {
let result;
const db = this.db;
if (!db.wwCache.genres) {
if (!db.wwCache.genreTree) {
const genres = _.cloneDeep(genreTree);
const last = genres[genres.length - 1];
@@ -289,107 +351,183 @@ class WebWorker {
rows = await db.select({table: 'lang', map: `(r) => ({value: r.value})`});
const langs = rows.map(r => r.value);
// exts
rows = await db.select({table: 'ext', map: `(r) => ({value: r.value})`});
const exts = rows.map(r => r.value);
result = {
genreTree: genres,
langList: langs,
extList: exts,
inpxHash: (config.inpxHash ? config.inpxHash : ''),
};
db.wwCache.genres = result;
db.wwCache.genreTree = result;
} else {
result = db.wwCache.genres;
result = db.wwCache.genreTree;
}
return result;
}
async extractBook(bookPath) {
async getGenreMap() {
this.checkMyState();
let result;
const db = this.db;
if (!db.wwCache.genreMap) {
const genreTree = await this.getGenreTree();
result = new Map();
for (const section of genreTree.genreTree) {
for (const g of section.value)
result.set(g.value, g.name);
}
db.wwCache.genreMap = result;
} else {
result = db.wwCache.genreMap;
}
return result;
}
async extractBook(libFolder, libFile) {
const outFile = `${this.config.tempDir}/${utils.randomHexString(30)}`;
const folder = `${this.config.libDir}/${path.dirname(bookPath)}`;
const file = path.basename(bookPath);
libFolder = libFolder.replace(/\\/g, '/').replace(/\/\//g, '/');
const zipReader = new ZipReader();
await zipReader.open(folder);
const folder = `${this.config.libDir}/${libFolder}`;
const file = libFile;
const fullPath = `${folder}/${file}`;
try {
await zipReader.extractToFile(file, outFile);
if (!file || await fs.pathExists(fullPath)) {// файл есть на диске
await fs.copy(fullPath, outFile);
return outFile;
} finally {
await zipReader.close();
} else {// файл в zip-архиве
const zipReader = new ZipReader();
await zipReader.open(folder);
try {
await zipReader.extractToFile(file, outFile);
if (!await fs.pathExists(outFile)) {//не удалось найти в архиве, попробуем имя файла в кодировке cp866
await zipReader.extractToFile(iconv.encode(file, 'cp866').toString(), outFile);
}
return outFile;
} finally {
await zipReader.close();
}
}
}
async restoreBook(bookPath, downFileName) {
async restoreBook(bookUid, libFolder, libFile, downFileName) {
const db = this.db;
let extractedFile = '';
let hash = '';
if (!this.remoteLib) {
extractedFile = await this.extractBook(bookPath);
extractedFile = await this.extractBook(libFolder, libFile);
hash = await utils.getFileHash(extractedFile, 'sha256', 'hex');
} else {
hash = await this.remoteLib.downloadBook(bookPath, downFileName);
hash = await this.remoteLib.downloadBook(bookUid);
}
const link = `/files/${hash}`;
const publicPath = `${this.config.publicDir}${link}`;
const link = `${this.config.bookPathStatic}/${hash}`;
const bookFile = `${this.config.bookDir}/${hash}`;
const bookFileDesc = `${bookFile}.d.json`;
if (!await fs.pathExists(publicPath)) {
await fs.ensureDir(path.dirname(publicPath));
if (!await fs.pathExists(bookFile) || !await fs.pathExists(bookFileDesc)) {
if (!await fs.pathExists(bookFile) && extractedFile) {
const tmpFile = `${this.config.tempDir}/${utils.randomHexString(30)}`;
await utils.gzipFile(extractedFile, tmpFile, 4);
await fs.remove(extractedFile);
await fs.move(tmpFile, bookFile, {overwrite: true});
} else {
await utils.touchFile(bookFile);
}
const tmpFile = `${this.config.tempDir}/${utils.randomHexString(30)}`;
await utils.gzipFile(extractedFile, tmpFile, 4);
await fs.remove(extractedFile);
await fs.move(tmpFile, publicPath, {overwrite: true});
await fs.writeFile(bookFileDesc, JSON.stringify({libFolder, libFile, downFileName}));
} else {
if (extractedFile)
await fs.remove(extractedFile);
await utils.touchFile(publicPath);
await utils.touchFile(bookFile);
await utils.touchFile(bookFileDesc);
}
await db.insert({
table: 'file_hash',
replace: true,
rows: [
{id: bookPath, hash},
{id: hash, bookPath, downFileName}
{id: bookUid, hash},
]
});
return link;
}
async getBookLink(params) {
async getBookLink(bookUid) {
this.checkMyState();
const {bookPath, downFileName} = params;
try {
const db = this.db;
let link = '';
//найдем хеш
const rows = await db.select({table: 'file_hash', where: `@@id(${db.esc(bookPath)})`});
if (rows.length) {//хеш найден по bookPath
const hash = rows[0].hash;
link = `/files/${hash}`;
const publicPath = `${this.config.publicDir}${link}`;
//найдем downFileName, libFolder, libFile
let rows = await db.select({table: 'book', where: `@@hash('_uid', ${db.esc(bookUid)})`});
if (!rows.length)
throw new Error('404 Файл не найден');
if (!await fs.pathExists(publicPath)) {
link = '';
const book = rows[0];
let downFileName = book.file;
const authors = book.author.split(',');
let author = authors[0];
author = author.split(' ').filter(r => r.trim());
for (let i = 1; i < author.length; i++)
author[i] = `${(i === 1 ? ' ' : '')}${author[i][0]}.`;
if (authors.length > 1)
author.push(' и др.');
const at = [author.join(''), (book.title ? `_${book.title}` : '')];
downFileName = utils.makeValidFileNameOrEmpty(at.filter(r => r).join(''))
|| utils.makeValidFileNameOrEmpty(at[0])
|| utils.makeValidFileNameOrEmpty(at[1])
|| downFileName;
if (downFileName.length > 50)
downFileName = `${downFileName.substring(0, 50)}_`;
const ext = `.${book.ext}`;
if (downFileName.substring(downFileName.length - ext.length) != ext)
downFileName += ext;
const libFolder = book.folder;
const libFile = `${book.file}${ext}`;
//найдем хеш
rows = await db.select({table: 'file_hash', where: `@@id(${db.esc(bookUid)})`});
if (rows.length) {//хеш найден по bookUid
const hash = rows[0].hash;
const bookFile = `${this.config.bookDir}/${hash}`;
const bookFileDesc = `${bookFile}.d.json`;
if (await fs.pathExists(bookFile) && await fs.pathExists(bookFileDesc)) {
link = `${this.config.bookPathStatic}/${hash}`;
}
}
if (!link) {
link = await this.restoreBook(bookPath, downFileName)
link = await this.restoreBook(bookUid, libFolder, libFile, downFileName);
}
if (!link)
throw new Error('404 Файл не найден');
return {link};
return {link, libFolder, libFile, downFileName};
} catch(e) {
log(LM_ERR, `getBookLink error: ${e.message}`);
if (e.message.indexOf('ENOENT') >= 0)
@@ -398,46 +536,78 @@ class WebWorker {
}
}
async restoreBookFile(publicPath) {
async getBookInfo(bookUid) {
this.checkMyState();
try {
const db = this.db;
const hash = path.basename(publicPath);
//найдем bookPath и downFileName
const rows = await db.select({table: 'file_hash', where: `@@id(${db.esc(hash)})`});
if (rows.length) {//нашли по хешу
const rec = rows[0];
await this.restoreBook(rec.bookPath, rec.downFileName);
let bookInfo = await this.getBookLink(bookUid);
const hash = path.basename(bookInfo.link);
const bookFile = `${this.config.bookDir}/${hash}`;
const bookFileInfo = `${bookFile}.i.json`;
return rec.downFileName;
} else {//bookPath не найден
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 = {};
result.book = book;
result.cover = '';
result.fb2 = false;
let parser = null;
if (book.ext == 'fb2') {
const {fb2, cover, coverExt} = await this.fb2Helper.getDescAndCover(bookFile);
parser = fb2;
result.fb2 = fb2.rawNodes;
if (cover) {
result.cover = `${this.config.bookPathStatic}/${hash}${coverExt}`;
await fs.writeFile(`${bookFile}${coverExt}`, cover);
}
}
Object.assign(info, result);
await fs.writeFile(bookFileInfo, JSON.stringify(info));
if (this.config.branch === 'development') {
await fs.writeFile(`${bookFile}.dev`, `${JSON.stringify(info, null, 2)}\n\n${parser ? parser.toString({format: true}) : ''}`);
}
};
if (!await fs.pathExists(bookFileInfo)) {
await restoreBookInfo(bookInfo);
} else {
await utils.touchFile(bookFileInfo);
const info = await fs.readFile(bookFileInfo, 'utf-8');
const tmpInfo = JSON.parse(info);
//проверим существование файла обложки, восстановим если нету
let coverFile = '';
if (tmpInfo.cover)
coverFile = `${this.config.publicFilesDir}${tmpInfo.cover}`;
if (book.id != tmpInfo.book.id || (coverFile && !await fs.pathExists(coverFile))) {
await restoreBookInfo(bookInfo);
} else {
bookInfo = tmpInfo;
}
}
return {bookInfo};
} catch(e) {
log(LM_ERR, `restoreBookFile error: ${e.message}`);
log(LM_ERR, `getBookInfo error: ${e.message}`);
if (e.message.indexOf('ENOENT') >= 0)
throw new Error('404 Файл не найден');
throw e;
}
}
async getDownFileName(publicPath) {
this.checkMyState();
const db = this.db;
const hash = path.basename(publicPath);
//найдем downFileName
const rows = await db.select({table: 'file_hash', where: `@@id(${db.esc(hash)})`});
if (rows.length) {//downFileName найден по хешу
return rows[0].downFileName;
} else {//bookPath не найден
throw new Error('404 Файл не найден');
}
}
async getInpxFile(params) {
let data = null;
if (params.inpxFileHash && this.inpxFileHash && params.inpxFileHash === this.inpxFileHash) {
@@ -456,16 +626,16 @@ class WebWorker {
let loadAvg = os.loadavg();
loadAvg = loadAvg.map(v => v.toFixed(2));
log(`Server info [ memUsage: ${memUsage.toFixed(2)}MB, loadAvg: (${loadAvg.join(', ')}) ]`);
if (this.config.server.ready)
log(`Server accessible at http://127.0.0.1:${this.config.server.port} (listening on ${this.config.server.host}:${this.config.server.port})`);
log(`Server stats [ memUsage: ${memUsage.toFixed(2)}MB, loadAvg: (${loadAvg.join(', ')}) ]`);
} catch (e) {
log(LM_ERR, e.message);
}
}
async periodicLogServerStats() {
if (!this.config.logServerStats)
return;
while (1) {// eslint-disable-line
this.logServerStats();
await utils.sleep(60*1000);
@@ -489,8 +659,6 @@ class WebWorker {
}
}
log(LM_WARN, `clean dir ${dir}, maxSize=${maxSize}, found ${files.length} files, total size=${size}`);
files.sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs);
let i = 0;
@@ -503,7 +671,10 @@ class WebWorker {
i++;
}
log(LM_WARN, `removed ${i} files`);
if (i) {
log(LM_WARN, `clean dir ${dir}, maxSize=${maxSize}, found ${files.length} files, total size=${size}`);
log(LM_WARN, `removed ${i} files`);
}
}
async periodicCleanDir(dirConfig) {
@@ -514,7 +685,7 @@ class WebWorker {
let lastCleanDirTime = 0;
while (1) {// eslint-disable-line no-constant-condition
//чистка папок
if (Date.now() - lastCleanDirTime >= cleanDirPeriod) {
if (Date.now() - lastCleanDirTime >= cleanDirInterval) {
for (const config of dirConfig) {
try {
await this.cleanDir(config);
@@ -530,7 +701,7 @@ class WebWorker {
}
} catch (e) {
log(LM_FATAL, e.message);
ayncExit.exit(1);
asyncExit.exit(1);
}
}
@@ -557,7 +728,7 @@ class WebWorker {
log('inpx file: changes found, recreating DB');
await this.recreateDb();
} else {
log('inpx file: no changes');
//log('inpx file: no changes');
}
} catch(e) {
log(LM_ERR, `periodicCheckInpx: ${e.message}`);
@@ -566,6 +737,27 @@ class WebWorker {
await utils.sleep(inpxCheckInterval*60*1000);
}
}
async periodicCheckNewRelease() {
const checkReleaseLink = this.config.checkReleaseLink;
if (!checkReleaseLink)
return;
const down = new FileDownloader(1024*1024);
while (1) {// eslint-disable-line no-constant-condition
try {
let release = await down.load(checkReleaseLink);
release = JSON.parse(release.toString());
if (release.tag_name)
this.config.latestVersion = release.tag_name;
} catch(e) {
log(LM_ERR, `periodicCheckNewRelease: ${e.message}`);
}
await utils.sleep(checkReleaseInterval);
}
}
}
module.exports = WebWorker;

View File

@@ -1,4 +1,4 @@
const StreamZip = require('node-stream-zip');
const StreamUnzip = require('node-stream-zip');
class ZipReader {
constructor() {
@@ -14,7 +14,7 @@ class ZipReader {
if (this.zip)
throw new Error('Zip file is already open');
const zip = new StreamZip.async({file: zipFile});
const zip = new StreamUnzip.async({file: zipFile, skipEntryNameValidation: true});
if (zipEntries)
this.zipEntries = await zip.entries();

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,203 @@
const BasePage = require('./BasePage');
const utils = require('../utils');
class AuthorPage extends BasePage {
constructor(config) {
super(config);
this.id = 'author';
this.title = 'Авторы';
}
sortBooks(bookList) {
//схлопывание серий
const books = [];
const seriesMap = new Map();
for (const book of bookList) {
if (book.series) {
let seriesIndex = seriesMap.get(book.series);
if (seriesIndex === undefined) {
seriesIndex = books.length;
books.push({
type: 'series',
book,
bookCount: 0,
});
seriesMap.set(book.series, seriesIndex);
}
books[seriesIndex].bookCount++;
} 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 = bookList.books;
const booksAll = this.filterBooks(books);
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})`;
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.author && query.author[0] == '=') {
//книги по автору
const bookList = await this.webWorker.getAuthorBookList(0, query.author.substring(1));
if (bookList.books) {
let books = 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)}`}),
content: {
'*ATTRS': {type: 'text'},
'*TEXT': `${b.bookCount} книг${utils.wordEnding(b.bookCount, 8)} по автору${(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)}`}),
content: {
'*ATTRS': {type: 'text'},
'*TEXT': this.bookAuthor(b.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('author', query, '[Остальные авторы]');
for (const rec of queryRes) {
const e = {
id: rec.id,
title: this.bookAuthor(rec.title),
link: this.navLink({href: `/${this.id}?author=${rec.q}&genre=${encodeURIComponent(query.genre)}`}),
};
let countStr = '';
if (rec.count)
countStr = `${rec.count} автор${utils.wordEnding(rec.count, 0)}${(query.genre ? ' (в выбранном жанре)' : '')}`;
if (!countStr && rec.bookCount && !query.genre)
countStr = `${rec.bookCount} книг${utils.wordEnding(rec.bookCount, 8)}`;
if (countStr) {
e.content = {
'*ATTRS': {type: 'text'},
'*TEXT': countStr,
};
}
entry.push(this.makeEntry(e));
}
}
result.entry = entry;
return this.makeBody(result, req);
}
}
module.exports = AuthorPage;

View File

@@ -0,0 +1,374 @@
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);
const ruOnly = new Set(ruAlphabet.split(''));
class BasePage {
constructor(config) {
this.config = config;
this.webWorker = new WebWorker(config);
this.rootTag = 'feed';
this.opdsRoot = config.opdsRoot;
this.showDeleted = false;
}
escape(s) {
//костыль для koreader, не понимает hex-экранирование вида &#x27;
return he.escape(s).replace(/&#x27;/g, '&#39;').replace(/&#x60;/g, '&#96;');
}
makeEntry(entry = {}) {
if (!entry.id)
throw new Error('makeEntry: no id');
if (!entry.title)
throw new Error('makeEntry: no title');
entry.title = this.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 = this.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])}`,
bookCount: row.bookCount,
};
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;
const enResult = [];
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)}`,
bookCount: row.bookCount,
};
} else {
rec = {
id: row.id,
title: `${value.toUpperCase().replace(/ /g, spaceChar)}~`,
q: encodeURIComponent(value),
count: row.count,
};
}
if (query.depth > 1 || enru.has(value[0]) ) {
//такой костыль из-за проблем с локалями в pkg
//русский язык всегда идет первым!
if (ruOnly.has(value[0]))
result.push(rec)
else
enResult.push(rec);
} else {
others.push(rec);
}
}
result = result.concat(enResult);
if (query[from] && query.depth > 1 && result.length < 10 && len > prevLen) {
//рекурсия, с увеличением глубины, для облегчения навигации
const newQuery = _.cloneDeep(query);
newQuery.depth++;
return await this.opdsQuery(from, newQuery, otherTitle, len);
}
}
if (!query.others && others.length)
result.unshift({id: 'other', title: otherTitle, q: '___others', count: others.length});
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();
if (searchValue[0] !== '~')
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);
if (!bookValue)
return false;
return bookValue !== emptyFieldValue && !enru.has(bookValue[0]) && bookValue.indexOf(searchValue) >= 0;
} else if (searchValue[0] == '~') {//RegExp
searchValue = searchValue.substring(1);
const re = new RegExp(searchValue, 'i');
return re.test(bookValue);
} 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;

View File

@@ -0,0 +1,253 @@
const path = require('path');
const _ = require('lodash');
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}`;
}
convertGenres(genreArr) {
let result = [];
if (genreArr) {
for (const genre of genreArr) {
const g = genre.trim();
const name = this.genreMap.get(g);
result.push(name ? name : g);
}
}
return result.join(', ');
}
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/genre')
return this.convertGenres(value.split(','));
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 = {};
this.genreMap = await this.webWorker.getGenreMap();
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();
//format
const ext = bookInfo.book.ext;
const formats = {
[`${ext}+zip`]: `${bookInfo.link}/zip`,
[ext]: bookInfo.link,
};
if (ext === 'mobi') {
formats['x-mobipocket-ebook'] = bookInfo.link;
} else if (ext == 'epub') {
formats[`${ext}+zip`] = bookInfo.link;
}
//entry
const e = this.makeEntry({
id: bookUid,
title: bookInfo.book.title || 'Без названия',
});
//author bookInfo
if (bookInfo.book.author) {
e.author = bookInfo.book.author.split(',').map(a => ({name: a}));
}
e['dc:language'] = bookInfo.book.lang;
e['dc:format'] = ext;
//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) {
//author fb2Info
if (!e.author && infoObj.titleInfo.author.length) {
e.author = infoObj.titleInfo.author.map(a => ({name: a}));
}
ann = infoObj.titleInfo.annotationHtml || '';
const self = this;
const infoList = parser.bookInfoList(infoObj, {
valueToString(value, nodePath, origVTS) {//eslint-disable-line no-unused-vars
if ((nodePath == 'titleInfo/genre' || nodePath == 'srcTitleInfo/genre') && value) {
return self.convertGenres(value);
}
return origVTS(value, nodePath);
},
});
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': this.escape(content),
};
}
//links
e.link = [];
for (const [fileFormat, downHref] of Object.entries(formats))
e.link.push(this.downLink({href: downHref, 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;

View File

@@ -0,0 +1,76 @@
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 || 'search',
term: req.query.term || '',
section: req.query.section || '',
};
let searchQuery = '';
if (query.from == 'search')
searchQuery = `&type=title&term=${encodeURIComponent(query.term)}`;
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)}${searchQuery}`}),
})
);
}
entry.unshift(
this.makeEntry({
id: 'whole_section',
title: '[Весь раздел]',
link: this.navLink({href: `/${encodeURIComponent(query.from)}?genre=${encodeURIComponent(all.join(','))}${searchQuery}`}),
})
);
}
} 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)}&section=${encodeURIComponent(section.name)}${searchQuery}`}),
})
);
}
}
}
result.entry = entry;
return this.makeBody(result, req);
}
}
module.exports = GenrePage;

View 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;

View File

@@ -0,0 +1,42 @@
const BasePage = require('./BasePage');
const AuthorPage = require('./AuthorPage');
const SeriesPage = require('./SeriesPage');
const TitlePage = require('./TitlePage');
const GenrePage = require('./GenrePage');
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);
this.genrePage = new GenrePage(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(),
this.genrePage.myEntry(),
];
return this.makeBody(result, req);
}
}
module.exports = RootPage;

View File

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

View File

@@ -0,0 +1,136 @@
const BasePage = require('./BasePage');
const utils = require('../utils');
const iconv = require('iconv-lite');
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 || '',
genre: req.query.genre || '',
page: parseInt(req.query.page, 10) || 1,
};
let entry = [];
if (query.type) {
if (['author', 'series', 'title'].includes(query.type)) {
try {
const from = query.type;
const page = query.page;
const limit = 100;
const offset = (page - 1)*limit;
const searchQuery = {[from]: query.term, genre: query.genre, del: '0', offset, limit};
let queryRes = await this.webWorker.search(from, searchQuery);
if (queryRes.totalFound === 0) { // не нашли ничего, проверим, может term в кодировке ISO-8859-1 (баг koreader)
searchQuery[from] = iconv.encode(query.term, 'ISO-8859-1').toString();
queryRes = await this.webWorker.search(from, searchQuery);
}
const found = queryRes.found;
for (let i = 0; i < found.length; i++) {
const row = found[i];
if (!row.bookCount)
continue;
entry.push(
this.makeEntry({
id: row.id,
title: `${(from === 'series' ? 'Серия: ': '')}${from === 'author' ? this.bookAuthor(row[from]) : row[from]}`,
link: this.navLink({href: `/${from}?${from}==${encodeURIComponent(row[from])}`}),
content: {
'*ATTRS': {type: 'text'},
'*TEXT': `${row.bookCount} книг${utils.wordEnding(row.bookCount, 8)}`,
},
}),
);
}
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)}&genre=${encodeURIComponent(query.genre)}&page=${page + 1}`}),
})
);
}
} catch(e) {
entry.push(
this.makeEntry({
id: 'error',
title: `Ошибка: ${e.message}`,
link: this.navLink({href: `/fake-error-link`}),
})
);
}
}
} else {
//корневой раздел
entry = [
this.makeEntry({
id: 'search_author',
title: 'Поиск авторов',
link: this.navLink({href: `/${this.id}?type=author&term=${encodeURIComponent(query.term)}`}),
content: {
'*ATTRS': {type: 'text'},
'*TEXT': `Искать по именам авторов`,
},
}),
this.makeEntry({
id: 'search_series',
title: 'Поиск серий',
link: this.navLink({href: `/${this.id}?type=series&term=${encodeURIComponent(query.term)}`}),
content: {
'*ATTRS': {type: 'text'},
'*TEXT': `Искать по названиям серий`,
},
}),
this.makeEntry({
id: 'search_title',
title: 'Поиск книг',
link: this.navLink({href: `/${this.id}?type=title&term=${encodeURIComponent(query.term)}`}),
content: {
'*ATTRS': {type: 'text'},
'*TEXT': `Искать по названиям книг`,
},
}),
this.makeEntry({
id: 'search_genre',
title: 'Поиск книг в жанре',
link: this.navLink({href: `/genre?from=search&term=${encodeURIComponent(query.term)}`}),
content: {
'*ATTRS': {type: 'text'},
'*TEXT': `Искать по названиям книг в выбранном жанре`,
},
}),
this.makeEntry({
id: 'search_help',
title: '[Памятка по поиску]',
link: this.acqLink({href: `/search-help`}),
content: {
'*ATTRS': {type: 'text'},
'*TEXT': `Описание формата поискового значения`,
},
}),
]
}
result.entry = entry;
return this.makeBody(result, req);
}
}
module.exports = SearchPage;

View File

@@ -0,0 +1,123 @@
const BasePage = require('./BasePage');
const utils = require('../utils');
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 = bookList.books;
const booksAll = this.filterBooks(books);
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})`;
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('series', query, '[Остальные серии]');
for (const rec of queryRes) {
const e = {
id: rec.id,
title: (rec.count ? rec.title : `Серия: ${rec.title}`),
link: this.navLink({href: `/${this.id}?series=${rec.q}&genre=${encodeURIComponent(query.genre)}`}),
};
let countStr = '';
if (rec.count)
countStr = `${rec.count} сери${utils.wordEnding(rec.count, 1)}${(query.genre ? ' (в выбранном жанре)' : '')}`;
if (!countStr && rec.bookCount && !query.genre)
countStr = `${rec.bookCount} книг${utils.wordEnding(rec.bookCount, 8)}`;
if (countStr) {
e.content = {
'*ATTRS': {type: 'text'},
'*TEXT': countStr,
};
}
entry.push(this.makeEntry(e));
}
}
result.entry = entry;
return this.makeBody(result, req);
}
}
module.exports = SeriesPage;

View File

@@ -0,0 +1,98 @@
const BasePage = require('./BasePage');
const utils = require('../utils');
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) {
const e = {
id: rec.id,
title: rec.title,
link: this.navLink({href: `/${this.id}?title=${rec.q}&genre=${encodeURIComponent(query.genre)}`}),
};
let countStr = '';
if (rec.count)
countStr = `${rec.count} назван${utils.wordEnding(rec.count, 3)}${(query.genre ? ' (в выбранном жанре)' : '')}`;
if (!countStr && rec.bookCount && !query.genre)
countStr = `${rec.bookCount} книг${utils.wordEnding(rec.bookCount, 8)}`;
if (countStr) {
e.content = {
'*ATTRS': {type: 'text'},
'*TEXT': countStr,
};
}
entry.push(this.makeEntry(e));
}
}
result.entry = entry;
return this.makeBody(result, req);
}
}
module.exports = TitlePage;

90
server/core/opds/index.js Normal file
View File

@@ -0,0 +1,90 @@
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');
const SearchHelpPage = require('./SearchHelpPage');
const log = new (require('../AppLogger'))().log;//singleton
module.exports = function(app, config) {
if (!config.opds || !config.opds.enabled)
return;
const opdsRoot = config.opds.root || '/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 searchHelp = new SearchHelpPage(config);
const routes = [
['', root],
['/root', root],
['/author', author],
['/series', series],
['/title', title],
['/genre', genre],
['/book', book],
['/opensearch', opensearch],
['/search', search],
['/search-help', searchHelp],
];
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) {
log(LM_ERR, `OPDS: ${e.message}, url: ${req.originalUrl}`);
res.status(500).send({error: e.message});
}
};
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((req, res, next) => {
console.log(req.headers);
next();
});
*/
app.use(opdsPaths, basicAuth({
users: {[config.opds.user]: config.opds.password},
challenge: true,
}));
}
app.get(opdsPaths, opds);
};

View File

@@ -7,8 +7,12 @@ function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function processLoop() {
return new Promise(resolve => setImmediate(resolve));
}
function versionText(config) {
return `${config.name} v${config.version}, Node.js ${process.version}`;
return `${config.name} v${config.version}, Node.js ${process.version}, ${process.platform}`;
}
async function findFiles(callback, dir, recursive = true) {
@@ -101,9 +105,48 @@ function gzipFile(inputFile, outputFile, level = 1) {
const input = fs.createReadStream(inputFile);
const output = fs.createWriteStream(outputFile);
input.pipe(gzip).pipe(output).on('finish', (err) => {
input.on('error', reject)
.pipe(gzip).on('error', reject)
.pipe(output).on('error', reject)
.on('finish', (err) => {
if (err) reject(err);
else resolve();
}
);
});
}
function gunzipFile(inputFile, outputFile) {
return new Promise((resolve, reject) => {
const gzip = zlib.createGunzip();
const input = fs.createReadStream(inputFile);
const output = fs.createWriteStream(outputFile);
input.on('error', reject)
.pipe(gzip).on('error', reject)
.pipe(output).on('error', reject)
.on('finish', (err) => {
if (err) reject(err);
else resolve();
}
);
});
}
function gzipBuffer(buf) {
return new Promise((resolve, reject) => {
zlib.gzip(buf, {level: 1}, (err, result) => {
if (err) reject(err);
else resolve();
resolve(result);
});
});
}
function gunzipBuffer(buf) {
return new Promise((resolve, reject) => {
zlib.gunzip(buf, (err, result) => {
if (err) reject(err);
resolve(result);
});
});
}
@@ -112,8 +155,64 @@ function toUnixPath(dir) {
return dir.replace(/\\/g, '/');
}
function makeValidFileName(fileName, repl = '_') {
let f = fileName.replace(/[\x00\\/:*"<>|]/g, repl); // eslint-disable-line no-control-regex
f = f.trim();
while (f.length && (f[f.length - 1] == '.' || f[f.length - 1] == '_')) {
f = f.substring(0, f.length - 1);
}
if (f)
return f;
else
throw new Error('Invalid filename');
}
function makeValidFileNameOrEmpty(fileName) {
try {
return makeValidFileName(fileName);
} catch(e) {
return '';
}
}
function wordEnding(num, type = 0) {
const endings = [
['ов', '', 'а', 'а', 'а', 'ов', 'ов', 'ов', 'ов', 'ов'],//0
['й', 'я', 'и', 'и', 'и', 'й', 'й', 'й', 'й', 'й'],//1
['о', '', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],//2
['ий', 'ие', 'ия', 'ия', 'ия', 'ий', 'ий', 'ий', 'ий', 'ий'],//3
['о', 'а', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],//4
['ок', 'ка', 'ки', 'ки', 'ки', 'ок', 'ок', 'ок', 'ок', 'ок'],//5
['ых', 'ое', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых'],//6
['о', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],//7
['', 'а', 'и', 'и', 'и', '', '', '', '', ''],//8
];
const deci = num % 100;
if (deci > 10 && deci < 20) {
return endings[type][0];
} else {
return endings[type][num % 10];
}
}
function cutString(data, len = 500) {
try {
if (!data)
return '';
if (typeof(data) !== 'string')
data = JSON.stringify(data);
return `${data.substring(0, len)}${data.length > len ? ' ...' : ''}`;
} catch (e) {
return '';
}
}
module.exports = {
sleep,
processLoop,
versionText,
findFiles,
touchFile,
@@ -124,5 +223,12 @@ module.exports = {
intersectSet,
randomHexString,
gzipFile,
gunzipFile,
gzipBuffer,
gunzipBuffer,
toUnixPath,
makeValidFileName,
makeValidFileNameOrEmpty,
wordEnding,
cutString,
};

View File

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

View File

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

367
server/core/xml/sax.js Normal file
View File

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

View File

@@ -9,19 +9,10 @@ module.exports = async(config) => {
if (await fs.pathExists(verFile)) {
const curPublicVersion = await fs.readFile(verFile, 'utf8');
if (curPublicVersion == config.version)
if (curPublicVersion == config.version + config.rootPathStatic)
return;
}
//сохраним files
const filesDir = `${config.publicDir}/files`;
let tmpFilesDir = '';
if (await fs.pathExists(filesDir)) {
tmpFilesDir = `${config.dataDir}/files`;
if (!await fs.pathExists(tmpFilesDir))
await fs.move(filesDir, tmpFilesDir);
}
await fs.remove(config.publicDir);
//извлекаем новый webApp
@@ -35,10 +26,6 @@ module.exports = async(config) => {
await zipReader.close();
}
//восстановим files
if (tmpFilesDir)
await fs.move(tmpFilesDir, filesDir);
await fs.writeFile(verFile, config.version);
await fs.writeFile(verFile, config.version + config.rootPathStatic);
await fs.remove(zipFile);
};

View File

@@ -1,8 +1,8 @@
const fs = require('fs-extra');
const path = require('path');
const os = require('os');
const express = require('express');
const compression = require('compression');
const http = require('http');
const WebSocket = require ('ws');
@@ -14,32 +14,35 @@ let log;
let config;
let argv;
let branch = '';
const argvStrings = ['host', 'port', 'app-dir', 'lib-dir', 'inpx'];
const argvStrings = ['host', 'port', 'config', 'data-dir', 'app-dir', 'lib-dir', 'inpx'];
function showHelp(defaultConfig) {
console.log(utils.versionText(config));
console.log(utils.versionText(defaultConfig));
console.log(
`Usage: ${config.name} [options]
`Usage: ${defaultConfig.name} [options]
Options:
--help Print ${config.name} command line options
--host=<ip> Set web server host, default: ${defaultConfig.server.host}
--port=<port> Set web server port, default: ${defaultConfig.server.port}
--app-dir=<dirpath> Set application working directory, default: <execDir>/.${config.name}
--lib-dir=<dirpath> Set library directory, default: the same as ${config.name} executable's
--inpx=<filepath> Set INPX collection file, default: the one that found in library dir
--recreate Force recreation of the search database on start
--help Print ${defaultConfig.name} command line options
--host=<ip> Set web server host, default: ${defaultConfig.server.host}
--port=<port> Set web server port, default: ${defaultConfig.server.port}
--config=<filepath> Set config filename, default: <dataDir>/config.json
--data-dir=<dirpath> (or --app-dir) Set application working directory, default: <execDir>/.${defaultConfig.name}
--lib-dir=<dirpath> Set library directory, default: the same as ${defaultConfig.name} executable's
--inpx=<filepath> Set INPX collection file, default: the one that found in library dir
--recreate Force recreation of the search database on start
--unsafe-filter Use filter config at your own risk
`
);
}
async function init() {
argv = require('minimist')(process.argv.slice(2), {string: argvStrings});
const dataDir = argv['app-dir'];
const argvDataDir = argv['data-dir'] || argv['app-dir'];
const configFile = argv['config'];
//config
const configManager = new (require('./config'))();//singleton
await configManager.init(dataDir);
await configManager.init(argvDataDir, configFile);
const defaultConfig = configManager.config;
await configManager.load();
@@ -47,12 +50,22 @@ async function init() {
branch = config.branch;
//dirs
config.tempDir = `${config.dataDir}/tmp`;
config.logDir = `${config.dataDir}/log`;
config.dataDir = config.dataDir || argvDataDir || `${config.execDir}/.${config.name}`;
config.tempDir = config.tempDir || `${config.dataDir}/tmp`;
if (config.tempDir === '${OS}')
config.tempDir = `${os.tmpdir()}/${config.name}`
config.logDir = config.logDir || `${config.dataDir}/log`;
config.publicDir = `${config.dataDir}/public`;
config.publicFilesDir = `${config.dataDir}/public-files`;
config.rootPathStatic = config.server.root || '';
config.bookPathStatic = `${config.rootPathStatic}/book`;
config.bookDir = `${config.publicFilesDir}/book`;
configManager.config = config;
await fs.ensureDir(config.dataDir);
await fs.ensureDir(config.bookDir);
await fs.ensureDir(config.tempDir);
await fs.emptyDir(config.tempDir);
@@ -79,7 +92,7 @@ async function init() {
}
if (!config.remoteLib) {
const libDir = argv['lib-dir'];
const libDir = argv['lib-dir'] || config.libDir;
if (libDir) {
if (await fs.pathExists(libDir)) {
config.libDir = libDir;
@@ -90,11 +103,12 @@ async function init() {
config.libDir = config.execDir;
}
if (argv.inpx) {
if (await fs.pathExists(argv.inpx)) {
config.inpxFile = argv.inpx;
const inpxFile = argv.inpx || config.inpx;
if (inpxFile) {
if (await fs.pathExists(inpxFile)) {
config.inpxFile = inpxFile;
} else {
throw new Error(`File "${argv.inpx}" not found`);
throw new Error(`File "${inpxFile}" not found`);
}
} else {
const inpxFiles = [];
@@ -114,21 +128,41 @@ async function init() {
}
}
} else {
config.inpxFile = `${config.tempDir}/${utils.randomHexString(20)}`;
config.inpxFile = `${config.dataDir}/remote.inpx`;
const RemoteLib = require('./core/RemoteLib');//singleton
const remoteLib = new RemoteLib(config);
await remoteLib.downloadInpxFile();
}
config.recreateDb = argv.recreate || false;
config.inpxFilterFile = `${config.dataDir}/filter.json`;
config.allowUnsafeFilter = argv['unsafe-filter'] || false;
config.inpxFilterFile = config.inpxFilterFile || `${path.dirname(config.configFile)}/filter.json`;
config.allowUnsafeFilter = argv['unsafe-filter'] || config.allowUnsafeFilter || false;
//web app
if (branch !== 'development') {
const createWebApp = require('./createWebApp');
await createWebApp(config);
}
//log dirs
for (const prop of ['configFile', 'dataDir', 'tempDir', 'logDir']) {
log(`${prop}: ${config[prop]}`);
}
if (await fs.pathExists(config.inpxFilterFile))
log(`inpxFilterFile: ${config.inpxFilterFile}`)
}
function logQueries(app) {
app.use(function(req, res, next) {
const start = Date.now();
log(`${req.method} ${req.originalUrl} ${utils.cutString(req.body)}`);
//log(`${JSON.stringify(req.headers, null, 2)}`)
res.once('finish', () => {
log(`${Date.now() - start}ms`);
});
next();
});
}
async function main() {
@@ -147,15 +181,24 @@ async function main() {
devModule.webpackDevMiddleware(app);
}
app.use(compression({ level: 1 }));
//app.use(express.json({limit: `${config.maxPayloadSize}mb`}));
if (devModule)
devModule.logQueries(app);
const opds = require('./core/opds');
opds(app, config);
const initStatic = require('./static');
initStatic(app, config);
const webAccess = new (require('./core/WebAccess'))(config);
await webAccess.init();
const { WebSocketController } = require('./controllers');
new WebSocketController(wss, config);
new WebSocketController(wss, webAccess, config);
if (config.logQueries) {
logQueries(app);
}
if (devModule) {
devModule.logErrors(app);
@@ -168,68 +211,20 @@ async function main() {
server.listen(config.server.port, config.server.host, () => {
config.server.ready = true;
log(`Server ready`);
log(`Server accessible at http://127.0.0.1:${config.server.port} (listening on ${config.server.host}:${config.server.port})`);
});
}
function initStatic(app, config) {
const WebWorker = require('./core/WebWorker');//singleton
const webWorker = new WebWorker(config);
//загрузка или восстановление файлов в /files, при необходимости
app.use(async(req, res, next) => {
if ((req.method !== 'GET' && req.method !== 'HEAD') ||
!(req.path.indexOf('/files/') === 0)
) {
return next();
}
const publicPath = `${config.publicDir}${req.path}`;
let downFileName = '';
//восстановим
try {
if (!await fs.pathExists(publicPath)) {
downFileName = await webWorker.restoreBookFile(publicPath);
} else {
downFileName = await webWorker.getDownFileName(publicPath);
}
} catch(e) {
//quiet
}
if (downFileName)
res.downFileName = downFileName;
return next();
});
//заголовки при отдаче
const filesDir = utils.toUnixPath(`${config.publicDir}/files`);
app.use(express.static(config.publicDir, {
setHeaders: (res, filePath) => {
//res.set('Cache-Control', 'no-cache');
//res.set('Expires', '-1');
if (utils.toUnixPath(path.dirname(filePath)) == filesDir) {
res.set('Content-Encoding', 'gzip');
if (res.downFileName)
res.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(res.downFileName)}`);
}
},
}));
}
(async() => {
try {
await init();
await main();
} catch (e) {
const mes = (branch == 'development' ? e.stack : e.message);
if (log)
log(LM_FATAL, (branch == 'development' ? e.stack : e.message));
log(LM_FATAL, mes);
else
console.error(branch == 'development' ? e.stack : e.message);
console.error(mes);
ayncExit.exit(1);
}

127
server/static.js Normal file
View File

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