200 Commits

Author SHA1 Message Date
Book Pauk
894349618c Merge branch 'release/1.0.3' 2022-10-16 18:43:47 +07:00
Book Pauk
2c74afd19b 1.0.3 2022-10-16 18:43:29 +07:00
Book Pauk
9f665eaeac Добавлен сборщик релизов 2022-10-16 18:43:08 +07:00
Book Pauk
96e0df783d Поправил openReleasePage 2022-10-16 18:29:03 +07:00
Book Pauk
54f82d73f3 Редактирование readme 2022-10-16 18:28:24 +07:00
Book Pauk
d6ee223414 Редактирование README 2022-10-16 17:25:09 +07:00
Book Pauk
319d8e7fdf Мелкий рефакторинг 2022-10-16 03:17:06 +07:00
Book Pauk
6bdfdebeaf Улучшение обработки ошибок 2022-10-16 00:40:06 +07:00
Book Pauk
58b1ed5638 Merge tag '1.0.2' into develop
1.0.2
2022-10-16 00:16:21 +07:00
Book Pauk
420c0f2464 Merge branch 'release/1.0.2' 2022-10-16 00:16:14 +07:00
Book Pauk
7e86621fcd Мелкая поправка 2022-10-15 22:02:36 +07:00
Book Pauk
4ddabd1ca9 1.0.2 2022-10-15 22:02:22 +07:00
Book Pauk
061f50b714 Исправлена отдача статики под Windows 2022-10-15 21:51:45 +07:00
Book Pauk
8aab918ac5 Поправил баг 2022-10-15 21:04:56 +07:00
Book Pauk
9aa9261b6a Улучшение работы RemoteLib.downloadInpxFile 2022-10-15 20:59:42 +07:00
Book Pauk
0894a38978 Рефакторинг 2022-10-15 18:59:13 +07:00
Book Pauk
f519bf3f67 Добавлено пояснение в случае неуспешного копирования ссылки 2022-10-15 18:43:14 +07:00
Book Pauk
ecf0aada37 Merge tag '1.0.1' into develop
1.0.1
2022-10-14 21:18:04 +07:00
Book Pauk
eb9bf89854 Merge branch 'release/1.0.1' 2022-10-14 21:17:59 +07:00
Book Pauk
e1418678e0 1.0.1 2022-10-14 21:17:18 +07:00
Book Pauk
ecc7e2b2c3 Исправление багов 2022-10-14 21:16:26 +07:00
Book Pauk
7799bba9f2 Merge tag '1.0.0' into develop
1.0.0
2022-10-14 20:29:20 +07:00
Book Pauk
caad7c3f32 Merge branch 'release/1.0.0' 2022-10-14 20:29:12 +07:00
Book Pauk
2010419da5 Версия 1.0.0 (ура!) 2022-10-14 20:16:54 +07:00
Book Pauk
911ee3f2d7 Улучшение работы ссылки "читать" 2022-10-14 20:05:14 +07:00
Book Pauk
bc5b895cf1 Доработки для встраивания в сетевую библиотеку проекта Liberama 2022-10-14 19:52:01 +07:00
Book Pauk
a2249e8d07 Мелкая поправка 2022-10-14 19:50:56 +07:00
Book Pauk
6eab678109 Мелкая поправка 2022-10-14 17:58:04 +07:00
Book Pauk
ff26b5999d Добавил очистку publicDir 2022-10-14 17:07:48 +07:00
Book Pauk
45705084e5 0.7.0 2022-10-14 17:00:57 +07:00
Book Pauk
2f430d02a4 К предыдущему 2022-10-14 16:58:31 +07:00
Book Pauk
c739c5abbb Улучшено формирование заголовка 2022-10-14 16:56:30 +07:00
Book Pauk
f231bb75bd Добавлена возможность сделать новый поиск по клику на лого 2022-10-14 16:23:09 +07:00
Book Pauk
e22992fb35 Улучшение работы с параметрами URL 2022-10-14 15:46:22 +07:00
Book Pauk
252376412f Поправки вывода в лог 2022-10-14 15:22:40 +07:00
Book Pauk
2ebffbf7f4 0.6.0 2022-10-14 15:22:33 +07:00
Book Pauk
810efb80b7 Поправлен баг 2022-10-14 02:30:25 +07:00
Book Pauk
5791138ff6 Мелкие поправки 2022-10-12 14:10:46 +07:00
Book Pauk
c69d0d77be 0.5.0 2022-10-11 02:05:01 +07:00
Book Pauk
295091d99a Доработка отображения всех книг серии 2022-10-11 02:02:19 +07:00
Book Pauk
58f2483b97 Добавлена возможность отображения всех книг серии 2022-10-10 22:03:59 +07:00
Book Pauk
09b7c38348 Убрал лишнее кеширование 2022-10-10 20:57:30 +07:00
Book Pauk
7cf5e91cc4 Добавлен еще один шаг при создании БД - оптимизация 2022-10-10 19:11:26 +07:00
Book Pauk
6c18c269d9 dbCreatorVersion = '2'; 2022-10-10 17:28:48 +07:00
Book Pauk
5d5ec9c7b0 Поправки вывода консоль текста помощи 2022-10-10 17:25:52 +07:00
Book Pauk
a2bbafd47a К предыдущему 2022-10-10 17:16:55 +07:00
Book Pauk
fc8fb27c41 Мелкие поправки 2022-10-10 17:16:25 +07:00
Book Pauk
a54645b686 Переименование inpx-web-filter.json -> filter.json 2022-10-10 17:03:46 +07:00
Book Pauk
0e2ef4133e Работа над списком книг в серии 2022-10-10 17:02:58 +07:00
Book Pauk
ec96d4af39 Поправлено местоположение inpxFilterFile 2022-10-09 20:58:46 +07:00
Book Pauk
dc7eca9e3d Добавлены cli-параметры --host, --port 2022-10-09 20:55:42 +07:00
Book Pauk
0dc30e730d Рефакторинг, улучшение отображения версии 2022-10-09 20:41:42 +07:00
Book Pauk
578efbd110 Поправил заголовки 2022-10-08 01:59:51 +07:00
Book Pauk
2d817e9c98 Мелкие поправки 2022-10-08 01:31:06 +07:00
Book Pauk
3009d99510 Убрал лишнее 2022-10-07 19:25:58 +07:00
Book Pauk
4817123599 Поправлен баг 2022-10-07 19:19:55 +07:00
Book Pauk
68c4aac16e 0.4.0 2022-10-07 19:08:25 +07:00
Book Pauk
a82392197f Работа над RemoteLib 2022-10-07 19:06:00 +07:00
Book Pauk
e6dfe4d221 Работа над RemoteLib 2022-10-07 17:59:34 +07:00
Book Pauk
8bec2275ae Доработка AsyncExit для выдачи сообщений при сигналах 2022-10-07 17:36:46 +07:00
Book Pauk
248781315a Поправки .gitignore 2022-10-07 16:27:19 +07:00
Book Pauk
612666c723 Поправил цвет панели 2022-10-07 15:06:38 +07:00
Book Pauk
e14c8823d2 Улучшение createWebApp 2022-10-07 15:05:37 +07:00
Book Pauk
9c72651804 В cli добавлен параметр 'unsafe-filter' 2022-10-05 14:59:17 +07:00
Book Pauk
53873910c2 Улучшение фильтра 2022-10-03 16:50:51 +07:00
Book Pauk
904e3e6c2f Добавлена возможность фильтрации по авторам при формировании поисковой БД.
Критерии фильтрации должны находиться в файле inpx-web-filter.json
2022-10-03 16:25:51 +07:00
Book Pauk
f5bb97a081 Поправлен gitignore 2022-10-03 16:25:03 +07:00
Book Pauk
0b95d62fdb Мелкая поправка 2022-10-03 16:24:35 +07:00
Book Pauk
f834703bae Поправки periodicCheckInpx 2022-10-02 16:10:21 +07:00
Book Pauk
fe0f272acc Добавлена периодическая проверка изменений inpx
для автоматического пересоздания поисковой БД
2022-10-02 15:54:40 +07:00
Book Pauk
0946941e59 Добавлено автообновление страницы приложения в браузере при обнаружении новой версии 2022-10-02 15:31:42 +07:00
Book Pauk
e1fb8afa27 Рефакторинг 2022-10-02 14:43:11 +07:00
Book Pauk
0a53648279 Добавлена чистка output перед сборкой 2022-10-01 13:39:25 +07:00
Book Pauk
316aedcfd1 0.3.0 2022-10-01 13:30:35 +07:00
Book Pauk
0af22618ce Добавлена кнопка "Показать еще" при отображении большого количества книг 2022-10-01 13:28:31 +07:00
Book Pauk
ee53b7bb14 Поправки отображения оценок 2022-10-01 12:31:33 +07:00
Book Pauk
12eb77f12e 0.2.0 2022-09-30 17:33:05 +07:00
Book Pauk
d3f1e19b8f Добавлена ссылка "читать" 2022-09-30 17:32:05 +07:00
Book Pauk
55b4cc39d9 Добавлено отображение оценок книг 2022-09-30 17:16:04 +07:00
Book Pauk
b5fe352ca6 Поправлен баг в Firefox 2022-09-30 16:22:14 +07:00
Book Pauk
385e102d7b Добавлена кнопка очистки на жанры 2022-09-29 15:32:23 +07:00
Book Pauk
792c1ca8ae Поправка для dev-режима 2022-09-29 15:18:39 +07:00
Book Pauk
cfa38d21c5 Мелкая поправка 2022-09-29 15:16:08 +07:00
Book Pauk
03e89502d5 Добавлена упаковка web-приложения внутрь исполнимого файла 2022-09-29 13:54:28 +07:00
Book Pauk
bf5140fe0f Поправки 2022-09-27 23:43:56 +07:00
Book Pauk
7d123fed11 Поправки 2022-09-27 16:01:29 +07:00
Book Pauk
8f1c222548 Добавлена памятка, поправки поиска 2022-09-27 15:53:36 +07:00
Book Pauk
6b9287176c Поправки 2022-09-27 14:42:55 +07:00
Book Pauk
d119d217b4 Добавлено отображение статистики по коллекции 2022-09-27 14:32:24 +07:00
Book Pauk
c584738156 Улучшение NumInput 2022-09-27 13:35:42 +07:00
Book Pauk
f7e1b132fc Небольшие поправки 2022-09-27 13:21:19 +07:00
Book Pauk
4548b5c4d9 Поправки 2022-09-27 13:13:47 +07:00
Book Pauk
7b75ec466c Исправление багов 2022-09-26 18:56:30 +07:00
Book Pauk
90c41f3c37 Доработка формы ввода пароля 2022-09-26 18:19:46 +07:00
Book Pauk
0b6b014d5f Добавлена возможность доступа по паролю 2022-09-26 17:27:45 +07:00
Book Pauk
afef0ed04c Улучшено скачивание файлов - добавлено читабельное имя файла 2022-09-26 16:04:26 +07:00
Book Pauk
92303cadc3 Поправлен gitignore 2022-09-26 16:03:59 +07:00
Book Pauk
95a4e96227 Добавлена периодическая чистка /files 2022-09-26 13:27:45 +07:00
Book Pauk
f4625b7d29 Поправка отображения жанров 2022-09-26 12:49:08 +07:00
Book Pauk
66e5985335 Работа над проектом 2022-09-25 17:53:44 +07:00
Book Pauk
e6d0c6e519 Работа над проектом 2022-09-25 17:27:40 +07:00
Book Pauk
7e5ea30579 Работа над проектом 2022-09-25 16:12:54 +07:00
Book Pauk
1cfa787e5a Добавлена настройка "Показывать жанры" 2022-09-25 13:44:58 +07:00
Book Pauk
5e3fe21c25 Добавлен параметр limit в routeQuery 2022-09-25 13:35:23 +07:00
Book Pauk
b6cd6b7c51 Небольшие поправки 2022-09-25 13:18:41 +07:00
Book Pauk
b4c8f5ad72 Работа над проектом 2022-09-23 17:48:15 +07:00
Book Pauk
aaf3c0d076 Работа над проектом 2022-09-23 15:00:25 +07:00
Book Pauk
ef8fd1dd39 Небольшие улучшения 2022-09-22 19:16:23 +07:00
Book Pauk
ff7b0743c6 Добавлена оптимизация запросов в случае поиска по одному автору 2022-09-22 18:26:51 +07:00
Book Pauk
ce5f736a50 Добавлен фильтр на стороне клиента 2022-09-22 17:07:28 +07:00
Book Pauk
567d0bc28e В PageScroller добавлены кнопки "в начало", "в конец" 2022-09-22 16:10:20 +07:00
Book Pauk
88a7e53114 Добавлен favicon 2022-09-20 21:26:20 +07:00
Book Pauk
93a41f9158 Поправки параметров CopyWebpackPlugin 2022-09-20 21:25:30 +07:00
Book Pauk
96b1b4a573 Добавлен линк на github-страницу проекта 2022-09-20 17:43:40 +07:00
Book Pauk
475f210f68 Небольшие поправки 2022-09-15 21:38:08 +07:00
Book Pauk
9d3a1e45da Рефакторинг + добавлено формирование и парсинг параметров в URL 2022-09-15 20:53:28 +07:00
Book Pauk
f362026a21 Поправка разметки 2022-09-12 18:02:01 +07:00
Book Pauk
a46276cf1a Работа над проектом 2022-09-12 17:59:29 +07:00
Book Pauk
00150966e2 Поправки разметки 2022-09-12 16:15:53 +07:00
Book Pauk
dd3685990b Поправки дерева жанров 2022-09-12 16:15:30 +07:00
Book Pauk
75773a3e20 Мелкая поправка 2022-09-02 21:29:01 +07:00
Book Pauk
274dead481 Поправки разметки 2022-09-02 21:16:56 +07:00
Book Pauk
a119abe9cc Поправки разметки 2022-09-02 21:14:47 +07:00
Book Pauk
5aa72fc610 Поправки разметки 2022-09-02 21:01:56 +07:00
Book Pauk
bc46142426 Небольшие поправки 2022-09-02 14:12:21 +07:00
Book Pauk
63a7eddcab Работа над проектом 2022-09-02 13:48:30 +07:00
Book Pauk
6542d17b0b Небольшие поправки 2022-09-02 13:05:31 +07:00
Book Pauk
0cd9384939 Поправки 2022-08-31 19:56:11 +07:00
Book Pauk
fc1b879659 Исправлен баг 2022-08-31 19:55:35 +07:00
Book Pauk
77938aac04 Работа над проектом 2022-08-31 18:51:31 +07:00
Book Pauk
9aae057f32 Добавлена настройка lowMemoryMode на случай, если памяти на машине 2Гб или меньше 2022-08-27 14:47:29 +07:00
Book Pauk
a1cdd6b116 Работа над проектом 2022-08-26 17:37:41 +07:00
Book Pauk
2c51bfaf96 Добавлено кеширование запросов на клиенте 2022-08-26 14:34:50 +07:00
Book Pauk
a6bac669f6 Поправки 2022-08-26 00:51:30 +07:00
Book Pauk
67faa25e8b Работа над проектом 2022-08-26 00:41:08 +07:00
Book Pauk
1e5c97dfac Добавлен параметр "queryCacheEnabled" в конфиг 2022-08-25 23:35:12 +07:00
Book Pauk
6222827593 Работа над проектом 2022-08-25 19:43:27 +07:00
Book Pauk
7a351bbad3 Работа над проектом 2022-08-25 19:21:56 +07:00
Book Pauk
a6b2b102ae Работа над проектом 2022-08-25 19:01:05 +07:00
Book Pauk
cfb8e44c0f Поправка emptyFieldValue 2022-08-25 17:02:29 +07:00
Book Pauk
781114547f Оптимизация работы по памяти 2022-08-25 16:28:58 +07:00
Book Pauk
4b6ad9775b Убрал ошибочные изменения 2022-08-25 03:15:19 +07:00
Book Pauk
9c04535e34 Микрооптимизации 2022-08-25 00:31:23 +07:00
Book Pauk
8c266b634d Убрал лишнее 2022-08-24 23:43:26 +07:00
Book Pauk
eaf5010a34 Оптимизация по памяти 2022-08-24 23:28:21 +07:00
Book Pauk
2f8b4e72b8 Поправки 2022-08-24 22:45:45 +07:00
Book Pauk
22506a91f4 Улучшение поисковой БД 2022-08-24 22:45:24 +07:00
Book Pauk
102e584850 Добавлено кеширование запросов getBookList 2022-08-23 20:14:20 +07:00
Book Pauk
b495a16626 Переименования 2022-08-23 20:09:07 +07:00
Book Pauk
9c0d943d8f Работа над проектом 2022-08-22 01:13:16 +07:00
Book Pauk
de540a1316 К предыдущему 2022-08-21 21:52:31 +07:00
Book Pauk
3564089b7c Работа над проектом 2022-08-21 21:47:31 +07:00
Book Pauk
705fce73f7 Работа над проектом 2022-08-21 21:10:56 +07:00
Book Pauk
c7073635e3 Работа над проектом 2022-08-21 18:44:06 +07:00
Book Pauk
f3d25039bc Работа над проектом 2022-08-21 18:08:50 +07:00
Book Pauk
fc411565b9 Доработки NumInput 2022-08-21 17:00:10 +07:00
Book Pauk
5a6e3ac8b3 Улучшения работы с памятью 2022-08-21 16:17:47 +07:00
Book Pauk
e938b739d5 JembaDb 4.2.0 2022-08-21 16:14:39 +07:00
Book Pauk
27a91d5597 Поправки PageScroller 2022-08-20 21:42:39 +07:00
Book Pauk
909311328e Поправки лимита 2022-08-20 21:39:58 +07:00
Book Pauk
ff5e3b28d0 Добавлен PageScroller 2022-08-20 21:34:20 +07:00
Book Pauk
507c2a0bbd Работа над проектом 2022-08-20 02:48:50 +07:00
Book Pauk
40d6b9c807 Работа над проектом 2022-08-20 02:16:27 +07:00
Book Pauk
d9a03a7e0d Улучшения скроллинга 2022-08-20 01:06:39 +07:00
Book Pauk
0fb86b2174 Доработка DbSearcher 2022-08-20 00:29:09 +07:00
Book Pauk
14adeb7c8d Улучшение поисковой базы 2022-08-19 21:08:59 +07:00
Book Pauk
884a64fe79 Работа над проектом 2022-08-19 20:47:06 +07:00
Book Pauk
32cbde1c4e Работа над проектом 2022-08-19 18:35:01 +07:00
Book Pauk
c0d95115ea Работа над проектом 2022-08-19 18:04:46 +07:00
Book Pauk
0b0a51e5d4 Работа над проектом 2022-08-19 17:16:39 +07:00
Book Pauk
fc38c55eb1 Работа над проектом 2022-08-19 02:27:39 +07:00
Book Pauk
eaf6f5692d Улучшение поисковой БД 2022-08-19 01:31:20 +07:00
Book Pauk
adb1d55141 Работа над приложением 2022-08-19 00:24:05 +07:00
Book Pauk
3877ad15c1 Небольшая доработка 2022-08-18 20:25:01 +07:00
Book Pauk
676e6f6909 Работа над WebWorker и DbSearcher 2022-08-18 18:54:56 +07:00
Book Pauk
61975b6f2b Доработки DbCreator 2022-08-18 18:54:38 +07:00
Book Pauk
587ba2c957 Поправка цели dev 2022-08-18 18:53:27 +07:00
Book Pauk
3850a7d624 Работа над клиентской частью 2022-08-18 18:53:06 +07:00
Book Pauk
41d1dc1441 Работа над WebWorker и DbCreator 2022-08-18 00:41:00 +07:00
Book Pauk
801a4cdbb5 Работа над WebWorker и DbCreator 2022-08-17 20:47:34 +07:00
Book Pauk
a93ae030c4 К предыдущему 2022-08-17 19:59:09 +07:00
Book Pauk
0e0c01e419 Добавил для pkg --options max-old-space-size=4096,expose-gc 2022-08-17 19:57:59 +07:00
Book Pauk
38f63232a3 Работа над WebWorker и DbCreator 2022-08-17 17:53:47 +07:00
Book Pauk
3cfb2beb3d Работа над WebWorker и DbCreator 2022-08-17 03:13:47 +07:00
Book Pauk
f76a8f14ec Работа над DbCreator и WebWorker 2022-08-17 02:40:57 +07:00
Book Pauk
d00cb200a3 Небольшая поправка 2022-08-17 02:40:39 +07:00
Book Pauk
a0f701123d Работа над WebWorker и DbCreator 2022-08-17 01:17:14 +07:00
Book Pauk
425fdf607d Добавил несколько утилитарных функций 2022-08-17 01:15:35 +07:00
Book Pauk
6f8c323557 Добавлен парсинг cli-аргументов 2022-08-17 01:13:09 +07:00
Book Pauk
8476f8b3c8 Добавил execDir в конфиг 2022-08-17 01:02:14 +07:00
Book Pauk
8a4164922f Файл жанров из MHL 2022-08-16 23:18:16 +07:00
Book Pauk
eddfde141e Работа над InpxParser 2022-08-16 20:27:04 +07:00
Book Pauk
2948cfdc27 Начало работы над InpxParser 2022-08-16 18:34:16 +07:00
Book Pauk
50ea7a5ca7 Мелкая поправка 2022-08-16 18:34:03 +07:00
Book Pauk
16e10a84ce Добавлен класс для чтения zip-архивов 2022-08-16 17:34:17 +07:00
Book Pauk
69667a3446 Добавлен node-stream-zip 2022-08-16 16:52:09 +07:00
Book Pauk
1dd5d4e2f8 jembadb 4.0.0 2022-08-16 15:35:53 +07:00
Book Pauk
f5ca8155b4 Поправка минимальной версии node 2022-08-16 15:35:13 +07:00
Book Pauk
78be5a9856 Каркас будущего приложения 2022-08-16 14:54:41 +07:00
Book Pauk
c3a0ce183e Начальная структура директорий, каркас проекта 2022-08-15 18:38:31 +07:00
67 changed files with 24161 additions and 10 deletions

4
.gitignore vendored
View File

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

266
README.md
View File

@@ -1,3 +1,265 @@
# inpx-web
inpx-web
========
Веб-сервер для поиска по inpx-коллекции.
Веб-сервер для поиска по .inpx-коллекции.
Выглядит это так: 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
После открытия веб-приложения в бразуере, для быстрого понимания того, как работает поиск, воспользуйтесь памяткой (кнопка со знаком вопроса).
##
* [Возможности программы](#capabilities)
* [Использование](#usage)
* [Параметры командной строки](#cli)
* [Конфигурация](#config)
* [Удаленная библиотека](#remotelib)
* [Фильтр по аторам и книгам](#filter)
* [Настройка https с помощью nginx](#https)
* [Сборка проекта](#build)
* [Разработка](#development)
<a id="capabilities" />
## Возможности программы
- поиск по автору, серии, названию и пр.
- скачивание книги, копирование ссылки или открытие в читалке
- возможность указать рабочий каталог при запуске, а также расположение .inpx и файлов библиотеки
- ограничение доступа по паролю
- работа в режиме "удаленная библиотека"
- фильтр авторов и книг при создании поисковой БД для создания своей коллекции "на лету"
- подхват изменений .inpx-файла (периодическая проверка), автоматическое пересоздание поисковой БД
- мощная оптимизация, хорошая скорость поиска
- релизы под Linux и Windows
<a id="usage" />
## Использование
Поместите приложение `inpx-web` в папку с .inpx-файлом и файлами библиотеки и запустите.
По умолчанию сервер будет доступен по адресу http://127.0.0.1:12380
<a id="cli" />
### Параметры командной строки
Запустите `inpx-web --help`, чтобы увидеть список опций:
```console
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 Принудительно пересоздать поисковую БД при запуске приложения
```
<a id="config" />
### Конфигурация
При первом запуске в рабочей директории будет создан конфигурационный файл `config.json`:
```js
{
// пароль для ограничения доступа к веб-интерфейсу сервера
"accessPassword": "",
// содержимое кнопки-ссылки (читать), если не задано - кнопка (читать) не показывается
// пример: "https://omnireader.ru/#/reader?url=${DOWNLOAD_LINK}"
// на место ${DOWNLOAD_LINK} будет подставлена ссылка на скачивание файла книги
"bookReadLink": "",
// включить(true)/выключить(false) журналирование
"loggingEnabled": true,
// максимальный размер в байтах директории закешированных файлов в <раб.дир>/public/files
// чистка каждый час
"maxFilesDirSize": 1073741824,
// включить(true)/выключить(false) кеширование запросов на сервере
"queryCacheEnabled": true,
// периодичность чистки кеша запросов на сервере, в минутах
"cacheCleanInterval": 60,
// периодичность проверки изменений .inpx-файла
// если файл изменился, поисковая БД будет автоматически пересоздана
"inpxCheckInterval": 60,
// включить(true)/выключить(false) режим работы с малым количеством физической памяти на машине
// при включении этого режима, количество требуемой для создания БД памяти снижается примерно в 1.5-2 раза
// во столько же раз увеличивается время создания
"lowMemoryMode": false,
// включить(true)/выключить(false) режим "Удаленная библиотека" (сервер)
"allowRemoteLib": false,
// включить(Object)/выключить(false) режим "Удаленная библиотека" (клиент)
// подробнее см. раздел "Удаленная библиотека" ниже
"remoteLib": false,
// настройки веб-сервера
"server": {
"host": "0.0.0.0",
"port": "12380"
}
}
```
При необходимости, можно настроить нужный параметр в этом файле вручную. Параметры командной
строки имеют больший приоритет, чем настройки из `config.json`.
<a id="remotelib" />
### Удаленная библиотека
В случае, когда необходимо физически разнести веб-интерфейс и библиотеку файлов на разные машины,
приложение может работать в режиме клиент-сервер: веб-интерфейс, поисковый движок и поисковая БД на одной машине (клиент),
а библиотека книг и .inpx-файл на другой (сервер).
Для этого необходимо развернуть два приложения, первое из которых будет клиентом для второго.
На сервере правим `config.json`:
```
"accessPassword": "123456",
"allowRemoteLib": true,
```
На клиенте:
```
"remoteLib": {
"accessPassword": "123456",
"url": "ws://server.host:12380"
},
```
Если сервер работает по протоколу `http://`, то указываем протокол `ws://`, а для `https://` соответственно `wss://`.
Пароль не обязателен, но необходим в случае, если сервер тоже "смотрит" в интернет, для ограничения доступа к его веб-интерфесу.
При указании `"remoteLib": {...}` настройки командной строки --inpx и --lib-dir игнорируются,
т.к. файлы .inpx-индекса и библиотеки используются удаленно.
<a id="filter" />
### Фильтр по аторам и книгам
При создании поисковой БД во время загрузки и парсинга .inpx-файла, имеется возможность
отфильтровать авторов и книги, задав определенные критерии. Для этого небходимо создать
в рабочей директории (там же, где `config.json`) файл `filter.json` следующего вида:
```json
{
"info": {
"collection": "Новое название коллекции",
"structure": "",
"version": "1.0.0"
},
"filter": "(r) => r.del == 0",
"includeAuthors": ["Имя автора 1", "Имя автора 2"],
"excludeAuthors": ["Имя автора"]
}
```
При создании поисковой БД, авторы и книги из `includeAuthors` будут добавлены, а из `excludeAuthors` исключены.
Использование совместно `includeAuthors` и `excludeAuthors` имеет мало смысла, поэтому для включения
определенных авторов можно использовать только `includeAuthors`:
```json
{
"info": {
"collection": "Новое название коллекции"
},
"includeAuthors": ["Имя автора 1", "Имя автора 2"]
}
```
Для исключения:
```json
{
"info": {
"collection": "Новое название коллекции"
},
"excludeAuthors": ["Имя автора 1", "Имя автора 2"]
}
```
Параметр `filter` используется для более гибкой фильтрации по атрибутам записей из .inpx.
Уберем все записи, помеченные как удаленные и исключим "Имя автора 1":
```json
{
"info": {
"collection": "Новое название коллекции"
},
"filter": "(inpxRec) => inpxRec.del == 0",
"excludeAuthors": ["Имя автора 1"]
}
```
Использование `filter` небезопасно, т.к. позволяет выполнить произвольный js-код внутри программы,
поэтому запуск приложения в этом случае должен сопровождаться дополнительным параметром командной строки `--unsafe-filter`.
Названия атрибутов inpxRec соответствуют названиям в нижнем регистре из структуры structure.info в .inpx-файле.
<a id="https" />
### Настройка https с помощью nginx
Проще всего настроить https с помощью certbot и проксирования в nginx (пример для debian-based linux):
```sh
#ставим nginx
sudo apt install nginx
```
```
#правим конфиг nginx
server {
listen 80;
server_name <имя сервера>;
set $inpx_web http://127.0.0.1:12380;
client_max_body_size 512m;
proxy_read_timeout 1h;
location / {
proxy_pass $inpx_web;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
```sh
#загружаем новый конфиг
sudo service nginx reload
```
Далее следовать инструкции установки https://certbot.eff.org/instructions?ws=nginx&os=debianbuster
<a id="build" />
### Сборка проекта
Сборка только в среде Linux.
Необходима версия node.js не ниже 16.
```sh
git clone https://github.com/bookpauk/inpx-web
cd inpx-web
npm i
```
#### Для платформы Windows
```sh
npm run build:win
```
#### Для платформы Linux
```sh
npm run build:linux
```
Результат сборки будет доступен в каталоге `dist/linux|win` в виде исполнимого (standalone) файла.
<a id="development" />
### Разработка
```sh
npm run dev
```
Связаться с автором проекта: [bookpauk@gmail.com](mailto:bookpauk@gmail.com)

43
build/prepkg.js Normal file
View File

@@ -0,0 +1,43 @@
const fs = require('fs-extra');
const path = require('path');
const { execSync } = require('child_process');
const platform = process.argv[2];
const distDir = path.resolve(__dirname, '../dist');
const tmpDir = `${distDir}/tmp`;
const publicDir = `${tmpDir}/public`;
const outDir = `${distDir}/${platform}`;
async function build() {
if (platform != 'linux' && platform != 'win')
throw new Error(`Unknown platform: ${platform}`);
await fs.emptyDir(outDir);
// перемещаем public на место
if (await fs.pathExists(publicDir)) {
const zipFile = `${tmpDir}/public.zip`;
const jsonFile = `${distDir}/public.json`;//distDir !!!
await fs.remove(zipFile);
execSync(`zip -r ${zipFile} .`, {cwd: publicDir, stdio: 'inherit'});
const data = (await fs.readFile(zipFile)).toString('base64');
await fs.writeFile(jsonFile, JSON.stringify({data}));
} else {
throw new Error(`publicDir: ${publicDir} does not exist`);
}
}
async function main() {
try {
await build();
} catch(e) {
console.error(e);
process.exit(1);
}
}
main();

31
build/release.js Normal file
View File

@@ -0,0 +1,31 @@
const fs = require('fs-extra');
const path = require('path');
const { execSync } = require('child_process');
const pckg = require('../package.json');
const distDir = path.resolve(__dirname, '../dist');
const outDir = `${distDir}/release`;
async function makeRelease(target) {
const srcDir = `${distDir}/${target}`;
if (await fs.pathExists(srcDir)) {
const zipFile = `${outDir}/${pckg.name}-${pckg.version}-${target}.zip`;
execSync(`zip -r ${zipFile} .`, {cwd: srcDir, stdio: 'inherit'});
}
}
async function main() {
try {
await fs.emptyDir(outDir);
await makeRelease('win');
await makeRelease('linux');
} catch(e) {
console.error(e);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,69 @@
const path = require('path');
const DefinePlugin = require('webpack').DefinePlugin;
const { VueLoaderPlugin } = require('vue-loader');
const clientDir = path.resolve(__dirname, '../client');
module.exports = {
resolve: {
alias: {
ws: false,
}
},
entry: [`${clientDir}/main.js`],
output: {
publicPath: '/app/',
clean: true
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: [['@babel/preset-env', { targets: { esmodules: true } }]],
plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }]
]
}
},
{
test: /\.(gif|png)$/,
type: 'asset/inline',
},
{
test: /\.jpg$/,
type: 'asset/resource',
generator: {
filename: 'images/[name]-[hash:6][ext]'
},
},
{
test: /\.(ttf|eot|woff|woff2)$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[name]-[hash:6][ext]'
},
},
]
},
plugins: [
new DefinePlugin({
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
__QUASAR_SSR__: false,
__QUASAR_SSR_SERVER__: false,
__QUASAR_SSR_CLIENT__: false,
__QUASAR_VERSION__: false,
}),
new VueLoaderPlugin(),
]
};

View File

@@ -0,0 +1,45 @@
const path = require('path');
const webpack = require('webpack');
const pckg = require('../package.json');
const { merge } = require('webpack-merge');
const baseWpConfig = require('./webpack.base.config');
baseWpConfig.entry.unshift('webpack-hot-middleware/client');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const publicDir = path.resolve(__dirname, `../server/.${pckg.name}/public`);
const clientDir = path.resolve(__dirname, '../client');
module.exports = merge(baseWpConfig, {
mode: 'development',
devtool: 'inline-source-map',
output: {
path: `${publicDir}/app`,
filename: 'bundle.js',
clean: true
},
module: {
rules: [
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
]
},
]
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin(),
new HtmlWebpackPlugin({
template: `${clientDir}/index.html.template`,
filename: `${publicDir}/index.html`
}),
new CopyWebpackPlugin({patterns: [{context: `${clientDir}/assets`, from: `${clientDir}/assets/*`, to: `${publicDir}/`}]})
]
});

View File

@@ -0,0 +1,61 @@
const fs = require('fs-extra');
const path = require('path');
//const webpack = require('webpack');
const { merge } = require('webpack-merge');
const baseWpConfig = require('./webpack.base.config');
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerWebpackPlugin = require('css-minimizer-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const publicDir = path.resolve(__dirname, '../dist/tmp/public');
const clientDir = path.resolve(__dirname, '../client');
fs.emptyDirSync(publicDir);
module.exports = merge(baseWpConfig, {
mode: 'production',
output: {
path: `${publicDir}/app`,
filename: 'bundle.[contenthash].js',
clean: true
},
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
optimization: {
minimizer: [
new TerserPlugin({
parallel: true,
terserOptions: {
format: {
comments: false,
},
},
}),
new CssMinimizerWebpackPlugin()
]
},
plugins: [
new MiniCssExtractPlugin({
filename: "[name].[contenthash].css"
}),
new HtmlWebpackPlugin({
template: `${clientDir}/index.html.template`,
filename: `${publicDir}/index.html`
}),
new CopyWebpackPlugin({patterns:
[{context: `${clientDir}/assets`, from: `${clientDir}/assets/*`, to: `${publicDir}/` }]
}),
]
});

BIN
client/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

2
client/assets/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /#

View File

@@ -0,0 +1,270 @@
<template>
<div>
<q-dialog v-model="busyDialogVisible" no-route-dismiss no-esc-dismiss no-backdrop-dismiss>
<div class="q-pa-lg bg-white column" style="width: 400px">
<div style="font-weight: bold; font-size: 120%;">
{{ mainMessage }}
</div>
<div v-show="jobMessage" class="q-mt-sm" style="width: 350px; white-space: nowrap; overflow: hidden">
{{ jobMessage }}
</div>
<div v-show="jobMessage">
<q-linear-progress stripe rounded size="30px" :value="progress" color="green">
<div class="absolute-full flex flex-center">
<div class="text-black bg-white" style="font-size: 10px; padding: 1px 4px 1px 4px; border-radius: 4px">
{{ (progress*100).toFixed(2) }}%
</div>
</div>
</q-linear-progress>
</div>
<!--div class="q-ml-sm">
{{ jsonMessage }}
</div-->
</div>
</q-dialog>
</div>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../vueComponent.js';
//import _ from 'lodash';
import wsc from './webSocketConnection';
import * as utils from '../../share/utils';
import * as cryptoUtils from '../../share/cryptoUtils';
import LockQueue from '../../share/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
];
const componentOptions = {
components: {
},
watch: {
settings() {
this.loadSettings();
},
},
};
class Api {
_options = componentOptions;
busyDialogVisible = false;
mainMessage = '';
jobMessage = '';
//jsonMessage = '';
progress = 0;
accessToken = '';
created() {
this.commit = this.$store.commit;
this.lock = new LockQueue();
this.loadSettings();
}
mounted() {
this.updateConfig();//no await
}
loadSettings() {
const settings = this.settings;
this.accessToken = settings.accessToken;
}
async updateConfig() {
try {
const config = await this.getConfig();
config.webAppVersion = packageJson.version;
this.commit('setConfig', config);
} catch (e) {
this.$root.stdDialog.alert(e.message, 'Ошибка');
}
}
get config() {
return this.$store.state.config;
}
get settings() {
return this.$store.state.settings;
}
async showPasswordDialog() {
try {
await this.lock.get();//заход только один раз, остальные ждут закрытия диалога
} catch (e) {
return;
}
try {
const result = await this.$root.stdDialog.password('Введите пароль:', 'Доступ ограничен', {
inputValidator: (str) => (str ? true : 'Пароль не должен быть пустым'),
userName: 'access',
noEscDismiss: true,
noBackdropDismiss: true,
noCancel: true,
});
if (result && result.value) {
const accessToken = utils.toHex(cryptoUtils.sha256(result.value));
this.commit('setSettings', {accessToken});
}
} finally {
this.lock.errAll();
this.lock.ret();
}
}
async showBusyDialog() {
try {
await this.lock.get();//заход только один раз, остальные ждут закрытия диалога
} catch (e) {
return;
}
this.mainMessage = '';
this.jobMessage = '';
this.busyDialogVisible = true;
try {
let ri = 0;
while (1) {// eslint-disable-line
const params = {action: 'get-worker-state', workerId: 'server_state'};
if (this.accessToken)
params.accessToken = this.accessToken;
const server = await wsc.message(await wsc.send(params));
if (server.state != 'normal') {
this.mainMessage = `${server.serverMessage} ${rotor[ri]}`;
if (server.job == 'load inpx') {
this.jobMessage = `${server.jobMessage} (${server.recsLoaded}): ${server.fileName}`;
} else {
this.jobMessage = server.jobMessage;
}
//this.jsonMessage = server;
const jStep = server.jobStep;
if (jStep && stepBound[jStep] !== undefined) {
const sp = server.progress || 0;
const delta = stepBound[jStep + 1] - stepBound[jStep];
this.progress = (stepBound[jStep] + sp*delta)/100;
}
} else {
break;
}
await utils.sleep(300);
ri = (ri < rotor.length - 1 ? ri + 1 : 0);
}
} finally {
this.busyDialogVisible = false;
this.lock.errAll();
this.lock.ret();
}
}
async request(params, timeoutSecs = 10) {
while (1) {// eslint-disable-line
if (this.accessToken)
params.accessToken = this.accessToken;
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;
}
}
}
async search(query) {
const response = await this.request({action: 'search', query});
if (response.error) {
throw new Error(response.error);
}
return response;
}
async getBookList(authorId) {
const response = await this.request({action: 'get-book-list', authorId});
if (response.error) {
throw new Error(response.error);
}
return response;
}
async getSeriesBookList(series) {
const response = await this.request({action: 'get-series-book-list', series});
if (response.error) {
throw new Error(response.error);
}
return response;
}
async getGenreTree() {
const response = await this.request({action: 'get-genre-tree'});
if (response.error) {
throw new Error(response.error);
}
return response;
}
async getBookLink(params) {
const response = await this.request(Object.assign({action: 'get-book-link'}, params), 120);
if (response.error) {
throw new Error(response.error);
}
return response;
}
async getConfig() {
const response = await this.request({action: 'get-config'});
if (response.error) {
throw new Error(response.error);
}
return response;
}
}
export default vueComponent(Api);
//-----------------------------------------------------------------------------
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,3 @@
import WebSocketConnection from '../../../server/core/WebSocketConnection';
export default new WebSocketConnection();

150
client/components/App.vue Normal file
View File

@@ -0,0 +1,150 @@
<template>
<div class="fit row">
<Api ref="api" />
<Notify ref="notify" />
<StdDialog ref="stdDialog" />
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" class="col" />
</keep-alive>
</router-view>
</div>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from './vueComponent.js';
//import * as utils from '../share/utils';
import Notify from './share/Notify.vue';
import StdDialog from './share/StdDialog.vue';
import Api from './Api/Api.vue';
import Search from './Search/Search.vue';
const componentOptions = {
components: {
Api,
Notify,
StdDialog,
Search,
},
watch: {
},
};
class App {
_options = componentOptions;
created() {
this.commit = this.$store.commit;
//root route
let cachedRoute = '';
let cachedPath = '';
this.$root.getRootRoute = () => {
if (this.$route.path != cachedPath) {
cachedPath = this.$route.path;
const m = cachedPath.match(/^(\/[^/]*).*$/i);
cachedRoute = (m ? m[1] : this.$route.path);
}
return cachedRoute;
}
this.$root.isMobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent);
this.$root.setAppTitle = this.setAppTitle;
//global keyHooks
this.keyHooks = [];
this.keyHook = (event) => {
for (const hook of this.keyHooks)
hook(event);
}
this.$root.addKeyHook = (hook) => {
if (this.keyHooks.indexOf(hook) < 0)
this.keyHooks.push(hook);
}
this.$root.removeKeyHook = (hook) => {
const i = this.keyHooks.indexOf(hook);
if (i >= 0)
this.keyHooks.splice(i, 1);
}
document.addEventListener('keyup', (event) => {
this.keyHook(event);
});
document.addEventListener('keypress', (event) => {
this.keyHook(event);
});
document.addEventListener('keydown', (event) => {
this.keyHook(event);
});
}
mounted() {
this.$root.api = this.$refs.api;
this.$root.notify = this.$refs.notify;
this.$root.stdDialog = this.$refs.stdDialog;
this.setAppTitle();
}
get config() {
return this.$store.state.config;
}
get rootRoute() {
return this.$root.getRootRoute();
}
setAppTitle(title) {
if (title) {
document.title = title;
}
}
}
export default vueComponent(App);
//-----------------------------------------------------------------------------
</script>
<style scoped>
</style>
<style>
body, html, #app {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
font: normal 12px GameDefault;
}
.dborder {
border: 2px solid yellow;
}
.icon-rotate {
vertical-align: middle;
animation: rotating 2s linear infinite;
}
@keyframes rotating {
from {
transform: rotate(0deg);
} to {
transform: rotate(360deg);
}
}
@font-face {
font-family: 'GameDefault';
src: url('fonts/web-default.woff') format('woff'),
url('fonts/web-default.ttf') format('truetype');
}
</style>

View File

@@ -0,0 +1,182 @@
<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 v-if="book.librate">
<q-knob
:model-value="book.librate"
:min="0"
:max="5"
size="18px"
font-size="12px"
:thickness="1"
:color="rateColor"
track-color="grey-4"
readonly
/>
<q-tooltip :delay="500" anchor="top middle" content-style="font-size: 80%" max-width="400px">
Оценка {{ book.librate }}
</q-tooltip>
</div>
<div v-else style="width: 18px" />
</div>
<div v-else class="row justify-center" style="width: 18px">
<q-icon v-if="book.del" class="la la-trash text-bold text-red" size="18px">
<q-tooltip :delay="500" anchor="top middle" content-style="font-size: 80%" max-width="400px">
Удалено
</q-tooltip>
</q-icon>
</div>
</div>
<div class="q-ml-sm clickable2" @click="selectTitle">
{{ book.serno ? `${book.serno}. ` : '' }}
<span :class="titleColor">{{ bookTitle }}</span>
</div>
</div>
<div class="q-ml-sm">
{{ bookSize }}, {{ book.ext }}
</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 v-if="showReadLink" class="q-ml-sm clickable" @click="readBook">
(читать)
</div>
<div v-if="showGenres && book.genre" class="q-ml-sm">
{{ bookGenre }}
</div>
<div v-show="false">
{{ book }}
</div>
</div>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../../vueComponent.js';
const componentOptions = {
components: {
},
watch: {
settings() {
this.loadSettings();
},
}
};
class BookView {
_options = componentOptions;
_props = {
book: Object,
genreTree: Array,
showAuthor: Boolean,
showReadLink: Boolean,
titleColor: { type: String, default: 'text-blue-10'},
};
showRate = true;
showGenres = true;
showDeleted = false;
created() {
this.loadSettings();
}
loadSettings() {
const settings = this.settings;
this.showRate = settings.showRate;
this.showGenres = settings.showGenres;
this.showDeleted = settings.showDeleted;
}
get settings() {
return this.$store.state.settings;
}
get bookTitle() {
if (this.showAuthor && 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;
}
}
get bookSize() {
let size = this.book.size/1024;
let unit = 'KB';
if (size > 1024) {
size = size/1024;
unit = 'MB';
}
return `${size.toFixed(0)}${unit}`;
}
get rateColor() {
const rate = (this.book.librate > 5 ? 5 : this.book.librate);
if (rate > 2)
return `green-${(rate - 1)*2}`;
else
return `red-${10 - rate*2}`;
}
get bookGenre() {
let result = [];
const genre = new Set(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);
}
return `(${result.join(' / ')})`;
}
selectTitle() {
this.$emit('bookEvent', {action: 'titleClick', book: this.book});
}
download() {
this.$emit('bookEvent', {action: 'download', book: this.book});
}
copyLink() {
this.$emit('bookEvent', {action: 'copyLink', book: this.book});
}
readBook() {
this.$emit('bookEvent', {action: 'readBook', book: this.book});
}
}
export default vueComponent(BookView);
//-----------------------------------------------------------------------------
</script>
<style scoped>
.clickable {
color: blue;
cursor: pointer;
}
.clickable2 {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<div class="row items-center q-ml-md q-my-xs" style="font-size: 120%">
<div class="q-mr-xs">
Страница
</div>
<div class="bg-white">
<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
/>
</div>
<div class="q-ml-xs">
из {{ pageCount }}
</div>
</div>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../../vueComponent.js';
import NumInput from '../../share/NumInput.vue';
const componentOptions = {
components: {
NumInput
},
watch: {
modelValue(newValue) {
this.page = newValue;
},
page(newValue) {
this.$emit('update:modelValue', newValue);
},
}
};
class PageScroller {
_options = componentOptions;
_props = {
modelValue: Number,
disable: Boolean,
pageCount: Number,
};
page = 1;
created() {
}
}
export default vueComponent(PageScroller);
//-----------------------------------------------------------------------------
</script>
<style scoped>
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
<template>
<Dialog ref="dialog" v-model="dialogVisible">
<template #header>
<div class="row items-center">
<div style="font-size: 130%">
Выбрать жанры
</div>
</div>
</template>
<div ref="box" class="column q-mt-xs overflow-auto no-wrap" style="width: 370px; padding: 0px 10px 10px 10px;">
<div class="row items-center top-panel bg-grey-3">
<q-input ref="search" v-model="search" class="col" outlined dense bg-color="white" placeholder="Найти" clearable />
</div>
<div v-show="nodes.length" class="checkbox-tick-all">
<q-checkbox v-model="tickAll" size="36px" label="Выбрать/снять все" toggle-order="ft" @update:model-value="makeTickAll" />
</div>
<q-tree
v-model:ticked="ticked"
v-model:expanded="expanded"
class="q-my-xs"
:nodes="nodes"
node-key="key"
tick-strategy="leaf"
selected-color="black"
:filter="search"
no-nodes-label="Жанров нет"
no-results-label="Ничего не найдено"
>
</q-tree>
</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);
},
genre() {
this.updateTicked();
},
ticked() {
this.checkAllTicked();
this.updateGenre();
},
}
};
class GenreSelectDialog {
_options = componentOptions;
_props = {
modelValue: Boolean,
genre: {type: String, value: ''},
genreTree: Array,
};
dialogVisible = false;
search = '';
ticked = [];
expanded = [];
tickAll = false;
allKeys = [];
created() {
}
mounted() {
this.updateTicked();
}
async init() {
await this.$refs.dialog.waitShown();
//чтобы не скакало при поиске
this.$refs.box.style.height = `${document.body.clientHeight - 160}px`;
}
get nodes() {
const result = [];
this.allKeys = [];
for (const section of this.genreTree) {
const rkey = `r-${section.name}`;
const sec = {label: section.name, key: rkey, children: []};
for (const g of section.value) {
sec.children.push({label: g.name, key: g.value});
this.allKeys.push(g.value);
}
result.push(sec);
}
return result;
}
makeTickAll() {
if (this.tickAll) {
const newTicked = [];
for (const key of this.allKeys) {
newTicked.push(key);
}
this.ticked = newTicked;
} else {
this.ticked = [];
this.tickAll = false;
}
}
checkAllTicked() {
const ticked = new Set(this.ticked);
let newTickAll = !!(this.nodes.length);
for (const key of this.allKeys) {
if (!ticked.has(key)) {
newTickAll = false;
break;
}
}
if (this.ticked.length && !newTickAll) {
this.tickAll = undefined;
} else {
this.tickAll = newTickAll;
}
}
updateTicked() {
this.ticked = this.genre.split(',').filter(s => s);
}
updateGenre() {
this.$emit('update:genre', this.ticked.join(','));
}
okClick() {
this.dialogVisible = false;
}
}
export default vueComponent(GenreSelectDialog);
//-----------------------------------------------------------------------------
</script>
<style scoped>
.top-panel {
border-radius: 10px;
padding: 5px;
}
.checkbox-tick-all {
border-bottom: 1px solid #bbbbbb;
margin-bottom: 7px;
padding: 5px 5px 2px 16px;
}
</style>

View File

@@ -0,0 +1,191 @@
<template>
<Dialog ref="dialog" v-model="dialogVisible">
<template #header>
<div class="row items-center">
<div style="font-size: 130%">
Выбрать язык
</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="langList.length" class="checkbox-tick-all">
<div class="row items-center">
<q-option-group
v-model="ticked"
:options="optionsPre"
type="checkbox"
inline
/>
<div class="col" />
<div v-show="lang != langDefault" class="clickable" @click="setAsDefaults">
Установить по умолчанию
</div>
</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"
inline
>
<template #label="opt">
<div class="row items-center" style="width: 35px">
<span>{{ opt.label }}</span>
</div>
</template>
</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);
},
lang() {
this.updateTicked();
},
ticked() {
this.checkAllTicked();
this.updateLang();
},
}
};
class SelectLangDialog {
_options = componentOptions;
_props = {
modelValue: Boolean,
lang: {type: String, value: ''},
langDefault: {type: String, value: ''},
langList: 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 lang of this.langList) {
result.push({label: lang, value: lang});
}
return result;
}
get optionsPre() {
const result = [];
for (const lang of this.langList) {
if (['ru', 'en'].includes(lang)) {
result.push({label: lang, value: lang});
}
}
return result.reverse();
}
makeTickAll() {
if (this.tickAll) {
const newTicked = [];
for (const lang of this.langList) {
newTicked.push(lang);
}
this.ticked = newTicked;
} else {
this.ticked = [];
this.tickAll = false;
}
}
checkAllTicked() {
const ticked = new Set(this.ticked);
let newTickAll = !!(this.langList.length);
for (const lang of this.langList) {
if (!ticked.has(lang)) {
newTickAll = false;
break;
}
}
if (this.ticked.length && !newTickAll) {
this.tickAll = undefined;
} else {
this.tickAll = newTickAll;
}
}
updateTicked() {
this.ticked = this.lang.split(',').filter(s => s);
}
updateLang() {
this.$emit('update:lang', this.ticked.join(','));
}
setAsDefaults() {
this.commit('setSettings', {langDefault: this.lang});
}
okClick() {
this.dialogVisible = false;
}
}
export default vueComponent(SelectLangDialog);
//-----------------------------------------------------------------------------
</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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

View File

@@ -0,0 +1,74 @@
import localForage from 'localforage';
//import _ from 'lodash';
import * as utils from '../../share/utils';
const maxDataSize = 100*1024*1024;//100 Mb
const abStore = localForage.createInstance({
name: 'authorBooksStorage'
});
class AuthorBooksStorage {
constructor() {
}
async init() {
this.cleanStorage(); //no await
}
async setData(key, data) {
if (typeof data !== 'string')
throw new Error('AuthorBooksStorage: data must be a string');
await abStore.setItem(key, data);
await abStore.setItem(`addTime-${key}`, Date.now());
}
async getData(key) {
const item = await abStore.getItem(key);
//обновим addTime
if (item !== undefined)
abStore.setItem(`addTime-${key}`, Date.now());//no await
return item;
}
async removeData(key) {
await abStore.removeItem(key);
await abStore.removeItem(`addTime-${key}`);
}
async cleanStorage() {
await utils.sleep(5000);
while (1) {// eslint-disable-line no-constant-condition
let size = 0;
let min = Date.now();
let toDel = null;
for (const key of (await abStore.keys())) {
if (key.indexOf('addTime-') == 0)
continue;
const item = await abStore.getItem(key);
const addTime = await abStore.getItem(`addTime-${key}`);
size += item.length;
if (addTime < min) {
toDel = key;
min = addTime;
}
}
if (size > maxDataSize && toDel) {
await this.removeData(toDel);
} else {
break;
}
}
}
}
export default new AuthorBooksStorage();

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,80 @@
<template>
<q-dialog v-model="active" no-route-dismiss @show="onShow" @hide="onHide">
<div class="column bg-white no-wrap">
<div class="header row">
<div class="caption col row items-center q-ml-md">
<slot name="header"></slot>
</div>
<div class="close-icon column justify-center items-center">
<q-btn v-close-popup flat round dense>
<q-icon name="la la-times" size="18px"></q-icon>
</q-btn>
</div>
</div>
<div class="col column q-mx-md">
<slot></slot>
</div>
<div class="row justify-end q-pa-md">
<slot name="footer"></slot>
</div>
</div>
</q-dialog>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../vueComponent.js';
import * as utils from '../../share/utils';
class Dialog {
_props = {
modelValue: Boolean,
};
shown = false;
get active() {
return this.modelValue;
}
set active(value) {
this.$emit('update:modelValue', value);
}
onShow() {
this.shown = true;
}
onHide() {
this.shown = false;
}
async waitShown() {
let i = 100;
while (!this.shown && i > 0) {
await utils.sleep(10);
i--;
}
}
}
export default vueComponent(Dialog);
//-----------------------------------------------------------------------------
</script>
<style scoped>
.header {
min-height: 50px;
}
.caption {
font-size: 110%;
overflow: hidden;
}
.close-icon {
width: 50px;
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<div ref="btn" class="button clickable row justify-center items-center" @click="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>
</div>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../vueComponent.js';
import * as utils from '../../share/utils';
const componentOptions = {
watch: {
size() {
this.updateSizes();
},
}
};
class DivBtn {
_options = componentOptions;
_props = {
size: { type: Number, default: 24 },
minWidth: { type: Number, default: 0 },
height: { type: Number, default: 0 },
icon: { type: String, default: '' },
iconSize: { type: Number, default: 14 },
round: { type: Boolean },
imt: { type: Number, default: 0 },// icon margin top
};
pressed = false;
created() {
}
mounted() {
this.updateSizes();
}
updateSizes() {
const style = this.$refs.btn.style;
style.minWidth = `${(this.minWidth ? this.minWidth : this.size)}px`;
style.height = `${(this.height ? this.height : this.size)}px`;
if (this.pad) {
style.paddingLeft = `${this.pad}px`;
style.paddingRight = `${this.pad + 5}px`;
}
if (this.round)
style.borderRadius = `${this.size}px`;
else
style.borderRadius = `${this.size/10}px`;
}
async clickEffect() {
this.pressed = true;
await utils.sleep(100);
this.pressed = false;
}
}
export default vueComponent(DivBtn);
//-----------------------------------------------------------------------------
</script>
<style scoped>
.button {
position: relative;
box-shadow: 0.5px 1px 3px #333333;
}
.button:hover {
opacity: 0.8;
transition: opacity 0.2s linear;
}
.button-pressed {
margin-left: 2px;
margin-top: 2px;
}
.clickable {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<div class="hidden"></div>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../vueComponent.js';
class Notify {
notify(opts) {
let {
caption = null,
captionColor = 'black',
color = 'positive',
icon = '',
iconColor = 'white',
message = '',
messageColor = 'black',
position = 'top-right',
} = opts;
caption = (caption ? `<div style="font-size: 120%; color: ${captionColor}"><b>${caption}</b></div><br>` : '');
return this.$q.notify({
position,
color,
textColor: iconColor,
icon,
actions: [{icon: 'la la-times notify-button-icon', color: 'black'}],
html: true,
message:
`<div style="max-width: 350px;">
${caption}
<div style="color: ${messageColor}; overflow-wrap: break-word; word-wrap: break-word;">${message}</div>
</div>`
});
}
success(message, caption, options) {
this.notify(Object.assign({color: 'positive', icon: 'la la-check-circle', message, caption}, options));
}
warning(message, caption, options) {
this.notify(Object.assign({color: 'warning', icon: 'la la-exclamation-circle', message, caption}, options));
}
error(message, caption, options) {
this.notify(Object.assign({color: 'negative', icon: 'la la-exclamation-circle', messageColor: 'yellow', captionColor: 'white', message, caption}, options));
}
info(message, caption, options) {
this.notify(Object.assign({color: 'info', icon: 'la la-bell', message, caption}, options));
}
}
export default vueComponent(Notify);
//-----------------------------------------------------------------------------
</script>

View File

@@ -0,0 +1,258 @@
<template>
<q-input
v-model="filteredValue"
outlined dense
input-style="text-align: center"
class="no-mp"
:class="(error ? 'error' : '')"
:disable="disable"
:mask="mask"
>
<slot></slot>
<template #prepend>
<q-icon
v-show="mmButtons"
v-ripple="modelValue != min"
style="font-size: 100%"
:class="(modelValue != min ? '' : 'disable')"
name="la la-angle-double-left"
class="button"
@click="toMin"
/>
<q-icon
v-ripple="validate(modelValue - step)"
:class="(validate(modelValue - step) ? '' : 'disable')"
:name="minusIcon"
class="button"
@click="onClick('minus')"
@mousedown.prevent.stop="onMouseDown($event, 'minus')"
@mouseup.prevent.stop="onMouseUp"
@mouseout.prevent.stop="onMouseUp"
@touchstart.stop="onTouchStart($event, 'minus')"
@touchend.stop="onTouchEnd"
@touchcancel.prevent.stop="onTouchEnd"
/>
</template>
<template #append>
<q-icon
v-ripple="validate(modelValue + step)"
:class="(validate(modelValue + step) ? '' : 'disable')"
:name="plusIcon"
class="button"
@click="onClick('plus')"
@mousedown.prevent.stop="onMouseDown($event, 'plus')"
@mouseup.prevent.stop="onMouseUp"
@mouseout.prevent.stop="onMouseUp"
@touchstart.stop="onTouchStart($event, 'plus')"
@touchend.stop="onTouchEnd"
@touchcancel.prevent.stop="onTouchEnd"
/>
<q-icon
v-show="mmButtons"
v-ripple="modelValue != max"
style="font-size: 100%"
:class="(modelValue != max ? '' : 'disable')"
name="la la-angle-double-right"
class="button"
@click="toMax"
/>
</template>
</q-input>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../vueComponent.js';
import * as utils from '../../share/utils';
const componentOptions = {
watch: {
filteredValue() {
this.checkErrorAndEmit(true);
},
modelValue(newValue) {
this.filteredValue = newValue;
},
min() {
this.checkErrorAndEmit();
},
max() {
this.checkErrorAndEmit();
}
}
};
class NumInput {
_options = componentOptions;
_props = {
modelValue: Number,
min: { type: Number, default: -Number.MAX_VALUE },
max: { type: Number, default: Number.MAX_VALUE },
step: { type: Number, default: 1 },
digits: { type: Number, default: 0 },
disable: Boolean,
minusIcon: {type: String, default: 'la la-minus-circle'},
plusIcon: {type: String, default: 'la la-plus-circle'},
mmButtons: Boolean,
mask: String,
};
filteredValue = 0;
error = false;
created() {
this.filteredValue = this.modelValue;
}
string2number(value) {
return Number.parseFloat(Number.parseFloat(value).toFixed(this.digits));
}
validate(value) {
let n = this.string2number(value);
if (isNaN(n))
return false;
if (n < this.min)
return false;
if (n > this.max)
return false;
return true;
}
checkErrorAndEmit(emit = false) {
if (this.validate(this.filteredValue)) {
this.error = false;
if (emit)
this.$emit('update:modelValue', this.string2number(this.filteredValue));
} else {
this.error = true;
}
}
plus() {
const newValue = this.modelValue + this.step;
if (this.validate(newValue))
this.filteredValue = newValue;
}
minus() {
const newValue = this.modelValue - this.step;
if (this.validate(newValue))
this.filteredValue = newValue;
}
onClick(way) {
if (this.clickRepeat)
return;
if (way == 'plus') {
this.plus();
} else {
this.minus();
}
}
onMouseDown(event, way) {
this.startClickRepeat = true;
this.clickRepeat = false;
if (event.button == 0) {
(async() => {
if (this.inRepeatFunc)
return;
this.inRepeatFunc = true;
try {
await utils.sleep(300);
if (this.startClickRepeat) {
this.clickRepeat = true;
while (this.clickRepeat) {
if (way == 'plus') {
this.plus();
} else {
this.minus();
}
await utils.sleep(200);
}
}
} finally {
this.inRepeatFunc = false;
}
})();
}
}
onMouseUp() {
if (this.inTouch)
return;
this.startClickRepeat = false;
if (this.clickRepeat) {
(async() => {
await utils.sleep(50);
this.clickRepeat = false;
})();
}
}
onTouchStart(event, way) {
if (!this.$root.isMobileDevice)
return;
if (event.touches.length == 1) {
this.inTouch = true;
this.onMouseDown({button: 0}, way);
}
}
onTouchEnd() {
if (!this.$root.isMobileDevice)
return;
this.inTouch = false;
this.onMouseUp();
}
toMin() {
this.filteredValue = this.min;
}
toMax() {
this.filteredValue = this.max;
}
}
export default vueComponent(NumInput);
//-----------------------------------------------------------------------------
</script>
<style scoped>
.no-mp {
margin: 0;
padding: 0;
}
.button {
font-size: 130%;
border-radius: 15px;
width: 30px;
height: 30px;
color: #bbb;
cursor: pointer;
}
.button:hover {
color: #616161;
background-color: #efebe9;
}
.error {
background-color: #ffabab;
border-radius: 3px;
}
.disable, .disable:hover {
cursor: not-allowed;
color: #bbb;
background-color: white;
}
</style>

View File

@@ -0,0 +1,426 @@
<template>
<q-dialog ref="dialog" v-model="active" no-route-dismiss :no-esc-dismiss="noEscDismiss" :no-backdrop-dismiss="noBackdropDismiss" @show="onShow" @hide="onHide">
<slot></slot>
<!--------------------------------------------------->
<div v-show="type == 'alert'" class="bg-white no-wrap">
<div class="header row">
<div class="caption col row items-center q-ml-md">
<q-icon v-show="caption" class="q-mr-sm" :class="iconColor" :name="iconName" size="28px"></q-icon>
<div v-html="caption"></div>
</div>
<div class="close-icon column justify-center items-center">
<q-btn v-close-popup flat round dense>
<q-icon name="la la-times" size="18px"></q-icon>
</q-btn>
</div>
</div>
<div class="q-mx-md">
<div v-html="message"></div>
</div>
<div class="buttons row justify-end q-pa-md">
<q-btn class="q-px-md" dense no-caps @click="okClick">
OK
</q-btn>
</div>
</div>
<!--------------------------------------------------->
<div v-show="type == 'confirm'" class="bg-white no-wrap">
<div class="header row">
<div class="caption col row items-center q-ml-md">
<q-icon v-show="caption" class="q-mr-sm" :class="iconColor" :name="iconName" size="28px"></q-icon>
<div v-html="caption"></div>
</div>
<div class="close-icon column justify-center items-center">
<q-btn v-close-popup flat round dense>
<q-icon name="la la-times" size="18px"></q-icon>
</q-btn>
</div>
</div>
<div class="q-mx-md">
<div v-html="message"></div>
</div>
<div class="buttons row justify-end q-pa-md">
<q-btn v-close-popup class="q-px-md q-ml-sm" dense no-caps>
Отмена
</q-btn>
<q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="okClick">
OK
</q-btn>
</div>
</div>
<!--------------------------------------------------->
<div v-show="type == 'prompt'" class="bg-white no-wrap">
<div class="header row">
<div class="caption col row items-center q-ml-md">
<q-icon v-show="caption" class="q-mr-sm" :class="iconColor" :name="iconName" size="28px"></q-icon>
<div v-html="caption"></div>
</div>
<div v-if="!noCancel" class="close-icon column justify-center items-center">
<q-btn v-close-popup flat round dense>
<q-icon name="la la-times" size="18px"></q-icon>
</q-btn>
</div>
</div>
<div class="q-mx-md">
<div v-html="message"></div>
<q-input ref="input" v-model="inputValue" class="q-mt-xs" outlined dense />
<div class="error">
<span v-show="error != ''">{{ error }}</span>
</div>
</div>
<div class="buttons row justify-end q-pa-md">
<q-btn v-if="!noCancel" v-close-popup class="q-px-md q-ml-sm" dense no-caps>
Отмена
</q-btn>
<q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="okClick">
OK
</q-btn>
</div>
</div>
<!--------------------------------------------------->
<div v-show="type == 'password'" class="bg-white no-wrap">
<div class="header row">
<div class="caption col row items-center q-ml-md">
<q-icon v-show="caption" class="q-mr-sm" :class="iconColor" :name="iconName" size="28px"></q-icon>
<div v-html="caption"></div>
</div>
<div v-if="!noCancel" class="close-icon column justify-center items-center">
<q-btn v-close-popup flat round dense>
<q-icon name="la la-times" size="18px"></q-icon>
</q-btn>
</div>
</div>
<div class="q-mx-md">
<div v-html="message"></div>
<input type="text" name="username" autocomplete="username" :value="userName" hidden />
<q-input ref="input" v-model="inputValue" type="password" autocomplete="current-password" class="q-mt-xs" outlined dense />
<div class="error">
<span v-show="error != ''">{{ error }}</span>
</div>
</div>
<div class="buttons row justify-end q-pa-md">
<q-btn v-if="!noCancel" v-close-popup class="q-px-md q-ml-sm" dense no-caps>
Отмена
</q-btn>
<q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="okClick">
OK
</q-btn>
</div>
</div>
<!--------------------------------------------------->
<div v-show="type == 'hotKey'" class="bg-white no-wrap">
<div class="header row">
<div class="caption col row items-center q-ml-md">
<q-icon v-show="caption" class="q-mr-sm" :class="iconColor" :name="iconName" size="28px"></q-icon>
<div v-html="caption"></div>
</div>
<div class="close-icon column justify-center items-center">
<q-btn v-close-popup flat round dense>
<q-icon name="la la-times" size="18px"></q-icon>
</q-btn>
</div>
</div>
<div class="q-mx-md">
<div v-html="message"></div>
<div class="q-my-md text-center">
<div v-show="hotKeyCode == ''" class="text-grey-5">
Нет
</div>
<div>{{ hotKeyCode }}</div>
</div>
</div>
<div class="buttons row justify-end q-pa-md">
<q-btn v-close-popup class="q-px-md q-ml-sm" dense no-caps>
Отмена
</q-btn>
<q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps :disabled="hotKeyCode == ''" @click="okClick">
OK
</q-btn>
</div>
</div>
</q-dialog>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../vueComponent.js';
import * as utils from '../../share/utils';
const componentOptions = {
watch: {
inputValue: function(newValue) {
this.validate(newValue);
},
}
};
class StdDialog {
_options = componentOptions;
caption = '';
message = '';
active = false;
type = '';
inputValue = '';
error = '';
iconColor = '';
iconName = '';
hotKeyCode = '';
userName = '';
noEscDismiss = false;
noBackdropDismiss = false;
noCancel = false;
created() {
if (this.$root.addKeyHook) {
this.$root.addKeyHook(this.keyHook);
}
}
init(message, caption, opts) {
this.caption = caption;
this.message = message;
this.ok = false;
this.type = '';
this.inputValidator = null;
this.inputValue = '';
this.error = '';
this.showed = false;
this.noEscDismiss = (opts && opts.noEscDismiss) || false;
this.noBackdropDismiss = (opts && opts.noBackdropDismiss) || false;
this.noCancel = (opts && opts.noCancel) || false;
this.iconColor = 'text-warning';
if (opts && opts.color) {
this.iconColor = `text-${opts.color}`;
}
this.iconName = 'las la-exclamation-circle';
if (opts && opts.iconName) {
this.iconName = opts.iconName;
}
this.hotKeyCode = '';
if (opts && opts.hotKeyCode) {
this.hotKeyCode = opts.hotKeyCode;
}
}
onHide() {
if (this.hideTrigger) {
this.hideTrigger();
this.hideTrigger = null;
}
this.showed = false;
}
onShow() {
if (this.type == 'prompt' || this.type == 'password') {
this.enableValidator = true;
if (this.inputValue)
this.validate(this.inputValue);
this.$refs.input.focus();
}
this.showed = true;
}
validate(value) {
if (!this.enableValidator)
return false;
if (this.inputValidator) {
const result = this.inputValidator(value);
if (result !== true) {
this.error = result;
return false;
}
}
this.error = '';
return true;
}
okClick() {
if ((this.type == 'prompt' || this.type == 'password') && !this.validate(this.inputValue)) {
this.$refs.dialog.shake();
return;
}
if (this.type == 'hotKey' && this.hotKeyCode == '') {
this.$refs.dialog.shake();
return;
}
this.ok = true;
this.$refs.dialog.hide();
}
alert(message, caption, opts) {
return new Promise((resolve) => {
this.init(message, caption, opts);
this.hideTrigger = () => {
if (this.ok) {
resolve(true);
} else {
resolve(false);
}
};
this.type = 'alert';
this.active = true;
});
}
confirm(message, caption, opts) {
return new Promise((resolve) => {
this.init(message, caption, opts);
this.hideTrigger = () => {
if (this.ok) {
resolve(true);
} else {
resolve(false);
}
};
this.type = 'confirm';
this.active = true;
});
}
prompt(message, caption, opts) {
return new Promise((resolve) => {
this.enableValidator = false;
this.init(message, caption, opts);
this.hideTrigger = () => {
if (this.ok) {
resolve({value: this.inputValue});
} else {
resolve(false);
}
};
this.type = 'prompt';
if (opts) {
this.inputValidator = opts.inputValidator || null;
this.inputValue = opts.inputValue || '';
}
this.active = true;
});
}
password(message, caption, opts) {
return new Promise((resolve) => {
this.enableValidator = false;
this.init(message, caption, opts);
this.hideTrigger = () => {
if (this.ok) {
history.pushState({}, null);
resolve({value: this.inputValue});
} else {
resolve(false);
}
};
this.type = 'password';
this.userName = '';
if (opts) {
this.inputValidator = opts.inputValidator || null;
this.inputValue = opts.inputValue || '';
this.userName = opts.userName || '';
}
this.active = true;
});
}
getHotKey(message, caption, opts) {
return new Promise((resolve) => {
this.init(message, caption, opts);
this.hideTrigger = () => {
if (this.ok) {
resolve(this.hotKeyCode);
} else {
resolve(false);
}
};
this.type = 'hotKey';
this.active = true;
});
}
keyHook(event) {
if (this.active && this.showed) {
let handled = false;
if (this.type == 'hotKey') {
if (event.type == 'keydown') {
this.hotKeyCode = utils.keyEventToCode(event);
handled = true;
}
} else {
if (event.key == 'Enter') {
this.okClick();
handled = true;
}
if (event.key == 'Escape' && !this.noEscDismiss) {
this.$nextTick(() => {
this.$refs.dialog.hide();
});
handled = true;
}
}
if (handled) {
event.stopPropagation();
event.preventDefault();
}
}
}
}
export default vueComponent(StdDialog);
//-----------------------------------------------------------------------------
</script>
<style scoped>
.header {
height: 50px;
}
.caption {
font-size: 110%;
overflow: hidden;
}
.close-icon {
width: 50px;
}
.buttons {
height: 60px;
}
.error {
height: 20px;
font-size: 80%;
color: red;
}
</style>

View File

@@ -0,0 +1,52 @@
import { defineComponent } from 'vue';
import _ from 'lodash';
export default function(componentClass) {
const comp = {};
const obj = new componentClass();
//data, options, props
const data = {};
for (const prop of Object.getOwnPropertyNames(obj)) {
if (['_options', '_props'].includes(prop)) {//meta props
if (prop === '_options') {
const options = obj[prop];
for (const optName of ['components', 'watch', 'emits']) {
if (options[optName]) {
comp[optName] = options[optName];
}
}
} else if (prop === '_props') {
comp['props'] = obj[prop];
}
} else {//usual prop
data[prop] = obj[prop];
}
}
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];
}
}
comp.methods = methods;
comp.computed = computed;
//console.log(comp);
return defineComponent(comp);
}

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<title></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
</head>
<body>
<div id="app"></div>
</body>
</html>

16
client/main.js Normal file
View File

@@ -0,0 +1,16 @@
import { createApp } from 'vue';
import router from './router';
import store from './store';
import q from './quasar';
import App from './components/App.vue';
const app = createApp(App);
app.use(router);
app.use(store);
app.use(q.quasar, q.options);
q.init();
app.mount('#app');

103
client/quasar.js Normal file
View File

@@ -0,0 +1,103 @@
import 'quasar/dist/quasar.css';
import Quasar from 'quasar/src/vue-plugin.js';
//config
const config = {};
//components
//import {QLayout} from 'quasar/src/components/layout';
//import {QPageContainer, QPage} from 'quasar/src/components/page';
//import {QDrawer} from 'quasar/src/components/drawer';
//import {QCircularProgress} from 'quasar/src/components/circular-progress';
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 {QIcon} from 'quasar/src/components/icon';
//import {QSlider} from 'quasar/src/components/slider';
//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 {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 {QDialog} from 'quasar/src/components/dialog';
//import {QChip} from 'quasar/src/components/chip';
import {QTree} from 'quasar/src/components/tree';
//import {QVirtualScroll} from 'quasar/src/components/virtual-scroll';
//import {QExpansionItem} from 'quasar/src/components/expansion-item';
import {QOptionGroup} from 'quasar/src/components/option-group';
import {QKnob} from 'quasar/src/components/knob';
const components = {
//QLayout,
//QPageContainer, QPage,
//QDrawer,
//QCircularProgress,
QLinearProgress,
QInput,
QBtn,
//QBtnGroup,
//QBtnToggle,
QIcon,
//QSlider,
//QTabs, QTab,
//QTabPanels, QTabPanel,
//QSeparator,
//QList,
//QItem, QItemSection, QItemLabel,
QTooltip,
//QSpinner,
//QTable, QTh, QTr, QTd,
QCheckbox,
QSelect,
//QColor,
//QPopupProxy,
QDialog,
//QChip,
QTree,
//QExpansionItem,
//QVirtualScroll,
QOptionGroup,
QKnob,
};
//directives
import Ripple from 'quasar/src/directives/Ripple';
import ClosePopup from 'quasar/src/directives/ClosePopup';
const directives = {Ripple, ClosePopup};
//plugins
//import AppFullscreen from 'quasar/src/plugins/AppFullscreen';
import Notify from 'quasar/src/plugins/Notify';
const plugins = {
//AppFullscreen,
Notify,
};
//icons
//import '@quasar/extras/fontawesome-v5/fontawesome-v5.css';
//import fontawesomeV5 from 'quasar/icon-set/fontawesome-v5.js'
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 },
init: () => {
Quasar.iconSet.set(lineAwesome);
}
};

38
client/router.js Normal file
View File

@@ -0,0 +1,38 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import _ from 'lodash';
const Search = () => import('./components/Search/Search.vue');
const myRoutes = [
['/', Search],
['/:pathMatch(.*)*', null, null, '/'],
];
let routes = {};
for (let route of myRoutes) {
const [path, component, name, redirect] = route;
let cleanRoute = _.pickBy({path, component, name, redirect}, _.identity);
let parts = cleanRoute.path.split('~');
let f = routes;
for (let part of parts) {
const curRoute = _.assign({}, cleanRoute, { path: part });
if (!f.children)
f.children = [];
let r = f.children;
f = _.find(r, {path: part});
if (!f) {
r.push(curRoute);
f = curRoute;
}
}
}
routes = routes.children;
export default createRouter({
history: createWebHashHistory(),
routes
});

53
client/share/LockQueue.js Normal file
View File

@@ -0,0 +1,53 @@
class LockQueue {
constructor(queueSize = 100) {
this.queueSize = queueSize;
this.freed = true;
this.waitingQueue = [];
}
//async
get(take = true) {
return new Promise((resolve, reject) => {
if (this.freed) {
if (take)
this.freed = false;
resolve();
return;
}
if (this.waitingQueue.length < this.queueSize) {
this.waitingQueue.push({resolve, reject});
} else {
reject(new Error('Lock queue is too long'));
}
});
}
ret() {
if (this.waitingQueue.length) {
this.waitingQueue.shift().resolve();
} else {
this.freed = true;
}
}
//async
wait() {
return this.get(false);
}
retAll() {
while (this.waitingQueue.length) {
this.waitingQueue.shift().resolve();
}
}
errAll(error = 'rejected') {
while (this.waitingQueue.length) {
this.waitingQueue.shift().reject(new Error(error));
}
}
}
export default LockQueue;

View File

@@ -0,0 +1,26 @@
//WebCrypto API (crypto.subtle) не работает без https, поэтому приходится извращаться через sjcl
import sjclWrapper from './sjclWrapper';
//не менять
const iv = 'EWSjglyTWkktH';
const salt = 'inpx-web project is awesome';
export function aesEncrypt(data, password) {
return sjclWrapper.codec.bytes.fromBits(
sjclWrapper.encryptArray(
password, sjclWrapper.codec.bytes.toBits(data), {iv, salt}
).ct
);
}
export function aesDecrypt(data, password) {
return sjclWrapper.codec.bytes.fromBits(
sjclWrapper.decryptArray(
password, {ct: sjclWrapper.codec.bytes.toBits(data)}, {iv, salt}
)
);
}
export function sha256(str) {
return sjclWrapper.codec.bytes.fromBits(sjclWrapper.hash.sha256.hash(str));
}

143
client/share/diffUtils.js Normal file
View File

@@ -0,0 +1,143 @@
const _ = require('lodash');
function getObjDiff(oldObj, newObj, opts = {}) {
const {
exclude = [],
excludeAdd = [],
excludeDel = [],
} = opts;
const ex = new Set(exclude);
const exAdd = new Set(excludeAdd);
const exDel = new Set(excludeDel);
const makeObjDiff = (oldObj, newObj, keyPath) => {
const result = {__isDiff: true, change: {}, add: {}, del: []};
keyPath = `${keyPath}${keyPath ? '/' : ''}`;
for (const key of Object.keys(oldObj)) {
const kp = `${keyPath}${key}`;
if (newObj.hasOwnProperty(key)) {
if (ex.has(kp))
continue;
if (!_.isEqual(oldObj[key], newObj[key])) {
if (_.isObject(oldObj[key]) && _.isObject(newObj[key])) {
result.change[key] = makeObjDiff(oldObj[key], newObj[key], kp);
} else {
result.change[key] = _.cloneDeep(newObj[key]);
}
}
} else {
if (exDel.has(kp))
continue;
result.del.push(key);
}
}
for (const key of Object.keys(newObj)) {
const kp = `${keyPath}${key}`;
if (exAdd.has(kp))
continue;
if (!oldObj.hasOwnProperty(key)) {
result.add[key] = _.cloneDeep(newObj[key]);
}
}
return result;
}
return makeObjDiff(oldObj, newObj, '');
}
function isObjDiff(diff) {
return (_.isObject(diff) && diff.__isDiff && diff.change && diff.add && diff.del);
}
function isEmptyObjDiff(diff) {
return (!isObjDiff(diff) ||
!(Object.keys(diff.change).length ||
Object.keys(diff.add).length ||
diff.del.length
)
);
}
function isEmptyObjDiffDeep(diff, opts = {}) {
if (!isObjDiff(diff))
return true;
const {
isApplyChange = true,
isApplyAdd = true,
isApplyDel = true,
} = opts;
let notEmptyDeep = false;
const change = diff.change;
for (const key of Object.keys(change)) {
if (_.isObject(change[key]))
notEmptyDeep |= !isEmptyObjDiffDeep(change[key], opts);
else if (isApplyChange)
notEmptyDeep = true;
}
return !(
notEmptyDeep ||
(isApplyAdd && Object.keys(diff.add).length) ||
(isApplyDel && diff.del.length)
);
}
function applyObjDiff(obj, diff, opts = {}) {
const {
isAddChanged = false,
isApplyChange = true,
isApplyAdd = true,
isApplyDel = true,
} = opts;
let result = _.cloneDeep(obj);
if (!diff.__isDiff)
return result;
const change = diff.change;
for (const key of Object.keys(change)) {
if (result.hasOwnProperty(key)) {
if (_.isObject(change[key])) {
result[key] = applyObjDiff(result[key], change[key], opts);
} else {
if (isApplyChange)
result[key] = _.cloneDeep(change[key]);
}
} else if (isAddChanged) {
result[key] = _.cloneDeep(change[key]);
}
}
if (isApplyAdd) {
for (const key of Object.keys(diff.add)) {
result[key] = _.cloneDeep(diff.add[key]);
}
}
if (isApplyDel && diff.del.length) {
for (const key of diff.del) {
delete result[key];
}
if (_.isArray(result))
result = result.filter(v => v);
}
return result;
}
module.exports = {
getObjDiff,
isObjDiff,
isEmptyObjDiff,
applyObjDiff,
}

60
client/share/sjcl.js Normal file
View File

@@ -0,0 +1,60 @@
"use strict";var sjcl={cipher:{},hash:{},keyexchange:{},mode:{},misc:{},codec:{},exception:{corrupt:function(a){this.toString=function(){return"CORRUPT: "+this.message};this.message=a},invalid:function(a){this.toString=function(){return"INVALID: "+this.message};this.message=a},bug:function(a){this.toString=function(){return"BUG: "+this.message};this.message=a},notReady:function(a){this.toString=function(){return"NOT READY: "+this.message};this.message=a}}};
sjcl.cipher.aes=function(a){this.s[0][0][0]||this.O();var b,c,d,e,f=this.s[0][4],g=this.s[1];b=a.length;var h=1;if(4!==b&&6!==b&&8!==b)throw new sjcl.exception.invalid("invalid aes key size");this.b=[d=a.slice(0),e=[]];for(a=b;a<4*b+28;a++){c=d[a-1];if(0===a%b||8===b&&4===a%b)c=f[c>>>24]<<24^f[c>>16&255]<<16^f[c>>8&255]<<8^f[c&255],0===a%b&&(c=c<<8^c>>>24^h<<24,h=h<<1^283*(h>>7));d[a]=d[a-b]^c}for(b=0;a;b++,a--)c=d[b&3?a:a-4],e[b]=4>=a||4>b?c:g[0][f[c>>>24]]^g[1][f[c>>16&255]]^g[2][f[c>>8&255]]^g[3][f[c&
255]]};
sjcl.cipher.aes.prototype={encrypt:function(a){return t(this,a,0)},decrypt:function(a){return t(this,a,1)},s:[[[],[],[],[],[]],[[],[],[],[],[]]],O:function(){var a=this.s[0],b=this.s[1],c=a[4],d=b[4],e,f,g,h=[],k=[],l,n,m,p;for(e=0;0x100>e;e++)k[(h[e]=e<<1^283*(e>>7))^e]=e;for(f=g=0;!c[f];f^=l||1,g=k[g]||1)for(m=g^g<<1^g<<2^g<<3^g<<4,m=m>>8^m&255^99,c[f]=m,d[m]=f,n=h[e=h[l=h[f]]],p=0x1010101*n^0x10001*e^0x101*l^0x1010100*f,n=0x101*h[m]^0x1010100*m,e=0;4>e;e++)a[e][f]=n=n<<24^n>>>8,b[e][m]=p=p<<24^p>>>8;for(e=
0;5>e;e++)a[e]=a[e].slice(0),b[e]=b[e].slice(0)}};
function t(a,b,c){if(4!==b.length)throw new sjcl.exception.invalid("invalid aes block size");var d=a.b[c],e=b[0]^d[0],f=b[c?3:1]^d[1],g=b[2]^d[2];b=b[c?1:3]^d[3];var h,k,l,n=d.length/4-2,m,p=4,r=[0,0,0,0];h=a.s[c];a=h[0];var q=h[1],v=h[2],w=h[3],x=h[4];for(m=0;m<n;m++)h=a[e>>>24]^q[f>>16&255]^v[g>>8&255]^w[b&255]^d[p],k=a[f>>>24]^q[g>>16&255]^v[b>>8&255]^w[e&255]^d[p+1],l=a[g>>>24]^q[b>>16&255]^v[e>>8&255]^w[f&255]^d[p+2],b=a[b>>>24]^q[e>>16&255]^v[f>>8&255]^w[g&255]^d[p+3],p+=4,e=h,f=k,g=l;for(m=
0;4>m;m++)r[c?3&-m:m]=x[e>>>24]<<24^x[f>>16&255]<<16^x[g>>8&255]<<8^x[b&255]^d[p++],h=e,e=f,f=g,g=b,b=h;return r}
sjcl.bitArray={bitSlice:function(a,b,c){a=sjcl.bitArray.$(a.slice(b/32),32-(b&31)).slice(1);return void 0===c?a:sjcl.bitArray.clamp(a,c-b)},extract:function(a,b,c){var d=Math.floor(-b-c&31);return((b+c-1^b)&-32?a[b/32|0]<<32-d^a[b/32+1|0]>>>d:a[b/32|0]>>>d)&(1<<c)-1},concat:function(a,b){if(0===a.length||0===b.length)return a.concat(b);var c=a[a.length-1],d=sjcl.bitArray.getPartial(c);return 32===d?a.concat(b):sjcl.bitArray.$(b,d,c|0,a.slice(0,a.length-1))},bitLength:function(a){var b=a.length;return 0===
b?0:32*(b-1)+sjcl.bitArray.getPartial(a[b-1])},clamp:function(a,b){if(32*a.length<b)return a;a=a.slice(0,Math.ceil(b/32));var c=a.length;b=b&31;0<c&&b&&(a[c-1]=sjcl.bitArray.partial(b,a[c-1]&2147483648>>b-1,1));return a},partial:function(a,b,c){return 32===a?b:(c?b|0:b<<32-a)+0x10000000000*a},getPartial:function(a){return Math.round(a/0x10000000000)||32},equal:function(a,b){if(sjcl.bitArray.bitLength(a)!==sjcl.bitArray.bitLength(b))return!1;var c=0,d;for(d=0;d<a.length;d++)c|=a[d]^b[d];return 0===
c},$:function(a,b,c,d){var e;e=0;for(void 0===d&&(d=[]);32<=b;b-=32)d.push(c),c=0;if(0===b)return d.concat(a);for(e=0;e<a.length;e++)d.push(c|a[e]>>>b),c=a[e]<<32-b;e=a.length?a[a.length-1]:0;a=sjcl.bitArray.getPartial(e);d.push(sjcl.bitArray.partial(b+a&31,32<b+a?c:d.pop(),1));return d},i:function(a,b){return[a[0]^b[0],a[1]^b[1],a[2]^b[2],a[3]^b[3]]},byteswapM:function(a){var b,c;for(b=0;b<a.length;++b)c=a[b],a[b]=c>>>24|c>>>8&0xff00|(c&0xff00)<<8|c<<24;return a}};
sjcl.codec.utf8String={fromBits:function(a){var b="",c=sjcl.bitArray.bitLength(a),d,e;for(d=0;d<c/8;d++)0===(d&3)&&(e=a[d/4]),b+=String.fromCharCode(e>>>8>>>8>>>8),e<<=8;return decodeURIComponent(escape(b))},toBits:function(a){a=unescape(encodeURIComponent(a));var b=[],c,d=0;for(c=0;c<a.length;c++)d=d<<8|a.charCodeAt(c),3===(c&3)&&(b.push(d),d=0);c&3&&b.push(sjcl.bitArray.partial(8*(c&3),d));return b}};
sjcl.codec.hex={fromBits:function(a){var b="",c;for(c=0;c<a.length;c++)b+=((a[c]|0)+0xf00000000000).toString(16).substr(4);return b.substr(0,sjcl.bitArray.bitLength(a)/4)},toBits:function(a){var b,c=[],d;a=a.replace(/\s|0x/g,"");d=a.length;a=a+"00000000";for(b=0;b<a.length;b+=8)c.push(parseInt(a.substr(b,8),16)^0);return sjcl.bitArray.clamp(c,4*d)}};
sjcl.codec.base32={B:"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",X:"0123456789ABCDEFGHIJKLMNOPQRSTUV",BITS:32,BASE:5,REMAINING:27,fromBits:function(a,b,c){var d=sjcl.codec.base32.BASE,e=sjcl.codec.base32.REMAINING,f="",g=0,h=sjcl.codec.base32.B,k=0,l=sjcl.bitArray.bitLength(a);c&&(h=sjcl.codec.base32.X);for(c=0;f.length*d<l;)f+=h.charAt((k^a[c]>>>g)>>>e),g<d?(k=a[c]<<d-g,g+=e,c++):(k<<=d,g-=d);for(;f.length&7&&!b;)f+="=";return f},toBits:function(a,b){a=a.replace(/\s|=/g,"").toUpperCase();var c=sjcl.codec.base32.BITS,
d=sjcl.codec.base32.BASE,e=sjcl.codec.base32.REMAINING,f=[],g,h=0,k=sjcl.codec.base32.B,l=0,n,m="base32";b&&(k=sjcl.codec.base32.X,m="base32hex");for(g=0;g<a.length;g++){n=k.indexOf(a.charAt(g));if(0>n){if(!b)try{return sjcl.codec.base32hex.toBits(a)}catch(p){}throw new sjcl.exception.invalid("this isn't "+m+"!");}h>e?(h-=e,f.push(l^n>>>h),l=n<<c-h):(h+=d,l^=n<<c-h)}h&56&&f.push(sjcl.bitArray.partial(h&56,l,1));return f}};
sjcl.codec.base32hex={fromBits:function(a,b){return sjcl.codec.base32.fromBits(a,b,1)},toBits:function(a){return sjcl.codec.base32.toBits(a,1)}};
sjcl.codec.base64={B:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",fromBits:function(a,b,c){var d="",e=0,f=sjcl.codec.base64.B,g=0,h=sjcl.bitArray.bitLength(a);c&&(f=f.substr(0,62)+"-_");for(c=0;6*d.length<h;)d+=f.charAt((g^a[c]>>>e)>>>26),6>e?(g=a[c]<<6-e,e+=26,c++):(g<<=6,e-=6);for(;d.length&3&&!b;)d+="=";return d},toBits:function(a,b){a=a.replace(/\s|=/g,"");var c=[],d,e=0,f=sjcl.codec.base64.B,g=0,h;b&&(f=f.substr(0,62)+"-_");for(d=0;d<a.length;d++){h=f.indexOf(a.charAt(d));
if(0>h)throw new sjcl.exception.invalid("this isn't base64!");26<e?(e-=26,c.push(g^h>>>e),g=h<<32-e):(e+=6,g^=h<<32-e)}e&56&&c.push(sjcl.bitArray.partial(e&56,g,1));return c}};sjcl.codec.base64url={fromBits:function(a){return sjcl.codec.base64.fromBits(a,1,1)},toBits:function(a){return sjcl.codec.base64.toBits(a,1)}};sjcl.hash.sha256=function(a){this.b[0]||this.O();a?(this.F=a.F.slice(0),this.A=a.A.slice(0),this.l=a.l):this.reset()};sjcl.hash.sha256.hash=function(a){return(new sjcl.hash.sha256).update(a).finalize()};
sjcl.hash.sha256.prototype={blockSize:512,reset:function(){this.F=this.Y.slice(0);this.A=[];this.l=0;return this},update:function(a){"string"===typeof a&&(a=sjcl.codec.utf8String.toBits(a));var b,c=this.A=sjcl.bitArray.concat(this.A,a);b=this.l;a=this.l=b+sjcl.bitArray.bitLength(a);if(0x1fffffffffffff<a)throw new sjcl.exception.invalid("Cannot hash more than 2^53 - 1 bits");if("undefined"!==typeof Uint32Array){var d=new Uint32Array(c),e=0;for(b=512+b-(512+b&0x1ff);b<=a;b+=512)u(this,d.subarray(16*e,
16*(e+1))),e+=1;c.splice(0,16*e)}else for(b=512+b-(512+b&0x1ff);b<=a;b+=512)u(this,c.splice(0,16));return this},finalize:function(){var a,b=this.A,c=this.F,b=sjcl.bitArray.concat(b,[sjcl.bitArray.partial(1,1)]);for(a=b.length+2;a&15;a++)b.push(0);b.push(Math.floor(this.l/0x100000000));for(b.push(this.l|0);b.length;)u(this,b.splice(0,16));this.reset();return c},Y:[],b:[],O:function(){function a(a){return 0x100000000*(a-Math.floor(a))|0}for(var b=0,c=2,d,e;64>b;c++){e=!0;for(d=2;d*d<=c;d++)if(0===c%d){e=
!1;break}e&&(8>b&&(this.Y[b]=a(Math.pow(c,.5))),this.b[b]=a(Math.pow(c,1/3)),b++)}}};
function u(a,b){var c,d,e,f=a.F,g=a.b,h=f[0],k=f[1],l=f[2],n=f[3],m=f[4],p=f[5],r=f[6],q=f[7];for(c=0;64>c;c++)16>c?d=b[c]:(d=b[c+1&15],e=b[c+14&15],d=b[c&15]=(d>>>7^d>>>18^d>>>3^d<<25^d<<14)+(e>>>17^e>>>19^e>>>10^e<<15^e<<13)+b[c&15]+b[c+9&15]|0),d=d+q+(m>>>6^m>>>11^m>>>25^m<<26^m<<21^m<<7)+(r^m&(p^r))+g[c],q=r,r=p,p=m,m=n+d|0,n=l,l=k,k=h,h=d+(k&l^n&(k^l))+(k>>>2^k>>>13^k>>>22^k<<30^k<<19^k<<10)|0;f[0]=f[0]+h|0;f[1]=f[1]+k|0;f[2]=f[2]+l|0;f[3]=f[3]+n|0;f[4]=f[4]+m|0;f[5]=f[5]+p|0;f[6]=f[6]+r|0;f[7]=
f[7]+q|0}
sjcl.mode.ccm={name:"ccm",G:[],listenProgress:function(a){sjcl.mode.ccm.G.push(a)},unListenProgress:function(a){a=sjcl.mode.ccm.G.indexOf(a);-1<a&&sjcl.mode.ccm.G.splice(a,1)},fa:function(a){var b=sjcl.mode.ccm.G.slice(),c;for(c=0;c<b.length;c+=1)b[c](a)},encrypt:function(a,b,c,d,e){var f,g=b.slice(0),h=sjcl.bitArray,k=h.bitLength(c)/8,l=h.bitLength(g)/8;e=e||64;d=d||[];if(7>k)throw new sjcl.exception.invalid("ccm: iv must be at least 7 bytes");for(f=2;4>f&&l>>>8*f;f++);f<15-k&&(f=15-k);c=h.clamp(c,
8*(15-f));b=sjcl.mode.ccm.V(a,b,c,d,e,f);g=sjcl.mode.ccm.C(a,g,c,b,e,f);return h.concat(g.data,g.tag)},decrypt:function(a,b,c,d,e){e=e||64;d=d||[];var f=sjcl.bitArray,g=f.bitLength(c)/8,h=f.bitLength(b),k=f.clamp(b,h-e),l=f.bitSlice(b,h-e),h=(h-e)/8;if(7>g)throw new sjcl.exception.invalid("ccm: iv must be at least 7 bytes");for(b=2;4>b&&h>>>8*b;b++);b<15-g&&(b=15-g);c=f.clamp(c,8*(15-b));k=sjcl.mode.ccm.C(a,k,c,l,e,b);a=sjcl.mode.ccm.V(a,k.data,c,d,e,b);if(!f.equal(k.tag,a))throw new sjcl.exception.corrupt("ccm: tag doesn't match");
return k.data},na:function(a,b,c,d,e,f){var g=[],h=sjcl.bitArray,k=h.i;d=[h.partial(8,(b.length?64:0)|d-2<<2|f-1)];d=h.concat(d,c);d[3]|=e;d=a.encrypt(d);if(b.length)for(c=h.bitLength(b)/8,65279>=c?g=[h.partial(16,c)]:0xffffffff>=c&&(g=h.concat([h.partial(16,65534)],[c])),g=h.concat(g,b),b=0;b<g.length;b+=4)d=a.encrypt(k(d,g.slice(b,b+4).concat([0,0,0])));return d},V:function(a,b,c,d,e,f){var g=sjcl.bitArray,h=g.i;e/=8;if(e%2||4>e||16<e)throw new sjcl.exception.invalid("ccm: invalid tag length");
if(0xffffffff<d.length||0xffffffff<b.length)throw new sjcl.exception.bug("ccm: can't deal with 4GiB or more data");c=sjcl.mode.ccm.na(a,d,c,e,g.bitLength(b)/8,f);for(d=0;d<b.length;d+=4)c=a.encrypt(h(c,b.slice(d,d+4).concat([0,0,0])));return g.clamp(c,8*e)},C:function(a,b,c,d,e,f){var g,h=sjcl.bitArray;g=h.i;var k=b.length,l=h.bitLength(b),n=k/50,m=n;c=h.concat([h.partial(8,f-1)],c).concat([0,0,0]).slice(0,4);d=h.bitSlice(g(d,a.encrypt(c)),0,e);if(!k)return{tag:d,data:[]};for(g=0;g<k;g+=4)g>n&&(sjcl.mode.ccm.fa(g/
k),n+=m),c[3]++,e=a.encrypt(c),b[g]^=e[0],b[g+1]^=e[1],b[g+2]^=e[2],b[g+3]^=e[3];return{tag:d,data:h.clamp(b,l)}}};
sjcl.mode.ocb2={name:"ocb2",encrypt:function(a,b,c,d,e,f){if(128!==sjcl.bitArray.bitLength(c))throw new sjcl.exception.invalid("ocb iv must be 128 bits");var g,h=sjcl.mode.ocb2.S,k=sjcl.bitArray,l=k.i,n=[0,0,0,0];c=h(a.encrypt(c));var m,p=[];d=d||[];e=e||64;for(g=0;g+4<b.length;g+=4)m=b.slice(g,g+4),n=l(n,m),p=p.concat(l(c,a.encrypt(l(c,m)))),c=h(c);m=b.slice(g);b=k.bitLength(m);g=a.encrypt(l(c,[0,0,0,b]));m=k.clamp(l(m.concat([0,0,0]),g),b);n=l(n,l(m.concat([0,0,0]),g));n=a.encrypt(l(n,l(c,h(c))));
d.length&&(n=l(n,f?d:sjcl.mode.ocb2.pmac(a,d)));return p.concat(k.concat(m,k.clamp(n,e)))},decrypt:function(a,b,c,d,e,f){if(128!==sjcl.bitArray.bitLength(c))throw new sjcl.exception.invalid("ocb iv must be 128 bits");e=e||64;var g=sjcl.mode.ocb2.S,h=sjcl.bitArray,k=h.i,l=[0,0,0,0],n=g(a.encrypt(c)),m,p,r=sjcl.bitArray.bitLength(b)-e,q=[];d=d||[];for(c=0;c+4<r/32;c+=4)m=k(n,a.decrypt(k(n,b.slice(c,c+4)))),l=k(l,m),q=q.concat(m),n=g(n);p=r-32*c;m=a.encrypt(k(n,[0,0,0,p]));m=k(m,h.clamp(b.slice(c),p).concat([0,
0,0]));l=k(l,m);l=a.encrypt(k(l,k(n,g(n))));d.length&&(l=k(l,f?d:sjcl.mode.ocb2.pmac(a,d)));if(!h.equal(h.clamp(l,e),h.bitSlice(b,r)))throw new sjcl.exception.corrupt("ocb: tag doesn't match");return q.concat(h.clamp(m,p))},pmac:function(a,b){var c,d=sjcl.mode.ocb2.S,e=sjcl.bitArray,f=e.i,g=[0,0,0,0],h=a.encrypt([0,0,0,0]),h=f(h,d(d(h)));for(c=0;c+4<b.length;c+=4)h=d(h),g=f(g,a.encrypt(f(h,b.slice(c,c+4))));c=b.slice(c);128>e.bitLength(c)&&(h=f(h,d(h)),c=e.concat(c,[-2147483648,0,0,0]));g=f(g,c);
return a.encrypt(f(d(f(h,d(h))),g))},S:function(a){return[a[0]<<1^a[1]>>>31,a[1]<<1^a[2]>>>31,a[2]<<1^a[3]>>>31,a[3]<<1^135*(a[0]>>>31)]}};
sjcl.mode.gcm={name:"gcm",encrypt:function(a,b,c,d,e){var f=b.slice(0);b=sjcl.bitArray;d=d||[];a=sjcl.mode.gcm.C(!0,a,f,d,c,e||128);return b.concat(a.data,a.tag)},decrypt:function(a,b,c,d,e){var f=b.slice(0),g=sjcl.bitArray,h=g.bitLength(f);e=e||128;d=d||[];e<=h?(b=g.bitSlice(f,h-e),f=g.bitSlice(f,0,h-e)):(b=f,f=[]);a=sjcl.mode.gcm.C(!1,a,f,d,c,e);if(!g.equal(a.tag,b))throw new sjcl.exception.corrupt("gcm: tag doesn't match");return a.data},ka:function(a,b){var c,d,e,f,g,h=sjcl.bitArray.i;e=[0,0,
0,0];f=b.slice(0);for(c=0;128>c;c++){(d=0!==(a[Math.floor(c/32)]&1<<31-c%32))&&(e=h(e,f));g=0!==(f[3]&1);for(d=3;0<d;d--)f[d]=f[d]>>>1|(f[d-1]&1)<<31;f[0]>>>=1;g&&(f[0]^=-0x1f000000)}return e},j:function(a,b,c){var d,e=c.length;b=b.slice(0);for(d=0;d<e;d+=4)b[0]^=0xffffffff&c[d],b[1]^=0xffffffff&c[d+1],b[2]^=0xffffffff&c[d+2],b[3]^=0xffffffff&c[d+3],b=sjcl.mode.gcm.ka(b,a);return b},C:function(a,b,c,d,e,f){var g,h,k,l,n,m,p,r,q=sjcl.bitArray;m=c.length;p=q.bitLength(c);r=q.bitLength(d);h=q.bitLength(e);
g=b.encrypt([0,0,0,0]);96===h?(e=e.slice(0),e=q.concat(e,[1])):(e=sjcl.mode.gcm.j(g,[0,0,0,0],e),e=sjcl.mode.gcm.j(g,e,[0,0,Math.floor(h/0x100000000),h&0xffffffff]));h=sjcl.mode.gcm.j(g,[0,0,0,0],d);n=e.slice(0);d=h.slice(0);a||(d=sjcl.mode.gcm.j(g,h,c));for(l=0;l<m;l+=4)n[3]++,k=b.encrypt(n),c[l]^=k[0],c[l+1]^=k[1],c[l+2]^=k[2],c[l+3]^=k[3];c=q.clamp(c,p);a&&(d=sjcl.mode.gcm.j(g,h,c));a=[Math.floor(r/0x100000000),r&0xffffffff,Math.floor(p/0x100000000),p&0xffffffff];d=sjcl.mode.gcm.j(g,d,a);k=b.encrypt(e);
d[0]^=k[0];d[1]^=k[1];d[2]^=k[2];d[3]^=k[3];return{tag:q.bitSlice(d,0,f),data:c}}};sjcl.misc.hmac=function(a,b){this.W=b=b||sjcl.hash.sha256;var c=[[],[]],d,e=b.prototype.blockSize/32;this.w=[new b,new b];a.length>e&&(a=b.hash(a));for(d=0;d<e;d++)c[0][d]=a[d]^909522486,c[1][d]=a[d]^1549556828;this.w[0].update(c[0]);this.w[1].update(c[1]);this.R=new b(this.w[0])};
sjcl.misc.hmac.prototype.encrypt=sjcl.misc.hmac.prototype.mac=function(a){if(this.aa)throw new sjcl.exception.invalid("encrypt on already updated hmac called!");this.update(a);return this.digest(a)};sjcl.misc.hmac.prototype.reset=function(){this.R=new this.W(this.w[0]);this.aa=!1};sjcl.misc.hmac.prototype.update=function(a){this.aa=!0;this.R.update(a)};sjcl.misc.hmac.prototype.digest=function(){var a=this.R.finalize(),a=(new this.W(this.w[1])).update(a).finalize();this.reset();return a};
sjcl.misc.pbkdf2=function(a,b,c,d,e){c=c||1E4;if(0>d||0>c)throw new sjcl.exception.invalid("invalid params to pbkdf2");"string"===typeof a&&(a=sjcl.codec.utf8String.toBits(a));"string"===typeof b&&(b=sjcl.codec.utf8String.toBits(b));e=e||sjcl.misc.hmac;a=new e(a);var f,g,h,k,l=[],n=sjcl.bitArray;for(k=1;32*l.length<(d||1);k++){e=f=a.encrypt(n.concat(b,[k]));for(g=1;g<c;g++)for(f=a.encrypt(f),h=0;h<f.length;h++)e[h]^=f[h];l=l.concat(e)}d&&(l=n.clamp(l,d));return l};
sjcl.prng=function(a){this.c=[new sjcl.hash.sha256];this.m=[0];this.P=0;this.H={};this.N=0;this.U={};this.Z=this.f=this.o=this.ha=0;this.b=[0,0,0,0,0,0,0,0];this.h=[0,0,0,0];this.L=void 0;this.M=a;this.D=!1;this.K={progress:{},seeded:{}};this.u=this.ga=0;this.I=1;this.J=2;this.ca=0x10000;this.T=[0,48,64,96,128,192,0x100,384,512,768,1024];this.da=3E4;this.ba=80};
sjcl.prng.prototype={randomWords:function(a,b){var c=[],d;d=this.isReady(b);var e;if(d===this.u)throw new sjcl.exception.notReady("generator isn't seeded");if(d&this.J){d=!(d&this.I);e=[];var f=0,g;this.Z=e[0]=(new Date).valueOf()+this.da;for(g=0;16>g;g++)e.push(0x100000000*Math.random()|0);for(g=0;g<this.c.length&&(e=e.concat(this.c[g].finalize()),f+=this.m[g],this.m[g]=0,d||!(this.P&1<<g));g++);this.P>=1<<this.c.length&&(this.c.push(new sjcl.hash.sha256),this.m.push(0));this.f-=f;f>this.o&&(this.o=
f);this.P++;this.b=sjcl.hash.sha256.hash(this.b.concat(e));this.L=new sjcl.cipher.aes(this.b);for(d=0;4>d&&(this.h[d]=this.h[d]+1|0,!this.h[d]);d++);}for(d=0;d<a;d+=4)0===(d+1)%this.ca&&y(this),e=z(this),c.push(e[0],e[1],e[2],e[3]);y(this);return c.slice(0,a)},setDefaultParanoia:function(a,b){if(0===a&&"Setting paranoia=0 will ruin your security; use it only for testing"!==b)throw new sjcl.exception.invalid("Setting paranoia=0 will ruin your security; use it only for testing");this.M=a},addEntropy:function(a,
b,c){c=c||"user";var d,e,f=(new Date).valueOf(),g=this.H[c],h=this.isReady(),k=0;d=this.U[c];void 0===d&&(d=this.U[c]=this.ha++);void 0===g&&(g=this.H[c]=0);this.H[c]=(this.H[c]+1)%this.c.length;switch(typeof a){case "number":void 0===b&&(b=1);this.c[g].update([d,this.N++,1,b,f,1,a|0]);break;case "object":c=Object.prototype.toString.call(a);if("[object Uint32Array]"===c){e=[];for(c=0;c<a.length;c++)e.push(a[c]);a=e}else for("[object Array]"!==c&&(k=1),c=0;c<a.length&&!k;c++)"number"!==typeof a[c]&&
(k=1);if(!k){if(void 0===b)for(c=b=0;c<a.length;c++)for(e=a[c];0<e;)b++,e=e>>>1;this.c[g].update([d,this.N++,2,b,f,a.length].concat(a))}break;case "string":void 0===b&&(b=a.length);this.c[g].update([d,this.N++,3,b,f,a.length]);this.c[g].update(a);break;default:k=1}if(k)throw new sjcl.exception.bug("random: addEntropy only supports number, array of numbers or string");this.m[g]+=b;this.f+=b;h===this.u&&(this.isReady()!==this.u&&A("seeded",Math.max(this.o,this.f)),A("progress",this.getProgress()))},
isReady:function(a){a=this.T[void 0!==a?a:this.M];return this.o&&this.o>=a?this.m[0]>this.ba&&(new Date).valueOf()>this.Z?this.J|this.I:this.I:this.f>=a?this.J|this.u:this.u},getProgress:function(a){a=this.T[a?a:this.M];return this.o>=a?1:this.f>a?1:this.f/a},startCollectors:function(){if(!this.D){this.a={loadTimeCollector:B(this,this.ma),mouseCollector:B(this,this.oa),keyboardCollector:B(this,this.la),accelerometerCollector:B(this,this.ea),touchCollector:B(this,this.qa)};if(window.addEventListener)window.addEventListener("load",
this.a.loadTimeCollector,!1),window.addEventListener("mousemove",this.a.mouseCollector,!1),window.addEventListener("keypress",this.a.keyboardCollector,!1),window.addEventListener("devicemotion",this.a.accelerometerCollector,!1),window.addEventListener("touchmove",this.a.touchCollector,!1);else if(document.attachEvent)document.attachEvent("onload",this.a.loadTimeCollector),document.attachEvent("onmousemove",this.a.mouseCollector),document.attachEvent("keypress",this.a.keyboardCollector);else throw new sjcl.exception.bug("can't attach event");
this.D=!0}},stopCollectors:function(){this.D&&(window.removeEventListener?(window.removeEventListener("load",this.a.loadTimeCollector,!1),window.removeEventListener("mousemove",this.a.mouseCollector,!1),window.removeEventListener("keypress",this.a.keyboardCollector,!1),window.removeEventListener("devicemotion",this.a.accelerometerCollector,!1),window.removeEventListener("touchmove",this.a.touchCollector,!1)):document.detachEvent&&(document.detachEvent("onload",this.a.loadTimeCollector),document.detachEvent("onmousemove",
this.a.mouseCollector),document.detachEvent("keypress",this.a.keyboardCollector)),this.D=!1)},addEventListener:function(a,b){this.K[a][this.ga++]=b},removeEventListener:function(a,b){var c,d,e=this.K[a],f=[];for(d in e)e.hasOwnProperty(d)&&e[d]===b&&f.push(d);for(c=0;c<f.length;c++)d=f[c],delete e[d]},la:function(){C(this,1)},oa:function(a){var b,c;try{b=a.x||a.clientX||a.offsetX||0,c=a.y||a.clientY||a.offsetY||0}catch(d){c=b=0}0!=b&&0!=c&&this.addEntropy([b,c],2,"mouse");C(this,0)},qa:function(a){a=
a.touches[0]||a.changedTouches[0];this.addEntropy([a.pageX||a.clientX,a.pageY||a.clientY],1,"touch");C(this,0)},ma:function(){C(this,2)},ea:function(a){a=a.accelerationIncludingGravity.x||a.accelerationIncludingGravity.y||a.accelerationIncludingGravity.z;if(window.orientation){var b=window.orientation;"number"===typeof b&&this.addEntropy(b,1,"accelerometer")}a&&this.addEntropy(a,2,"accelerometer");C(this,0)}};
function A(a,b){var c,d=sjcl.random.K[a],e=[];for(c in d)d.hasOwnProperty(c)&&e.push(d[c]);for(c=0;c<e.length;c++)e[c](b)}function C(a,b){"undefined"!==typeof window&&window.performance&&"function"===typeof window.performance.now?a.addEntropy(window.performance.now(),b,"loadtime"):a.addEntropy((new Date).valueOf(),b,"loadtime")}function y(a){a.b=z(a).concat(z(a));a.L=new sjcl.cipher.aes(a.b)}function z(a){for(var b=0;4>b&&(a.h[b]=a.h[b]+1|0,!a.h[b]);b++);return a.L.encrypt(a.h)}
function B(a,b){return function(){b.apply(a,arguments)}}sjcl.random=new sjcl.prng(6);
a:try{var D,E,F,G;if(G="undefined"!==typeof module&&module.exports){var H;try{H=null}catch(a){H=null}G=E=H}if(G&&E.randomBytes)D=E.randomBytes(128),D=new Uint32Array((new Uint8Array(D)).buffer),sjcl.random.addEntropy(D,1024,"crypto['randomBytes']");else if("undefined"!==typeof window&&"undefined"!==typeof Uint32Array){F=new Uint32Array(32);if(window.crypto&&window.crypto.getRandomValues)window.crypto.getRandomValues(F);else if(window.msCrypto&&window.msCrypto.getRandomValues)window.msCrypto.getRandomValues(F);
else break a;sjcl.random.addEntropy(F,1024,"crypto['getRandomValues']")}}catch(a){"undefined"!==typeof window&&window.console&&(console.log("There was an error collecting entropy from the browser:"),console.log(a))}
sjcl.json={defaults:{v:1,iter:1E4,ks:128,ts:64,mode:"ccm",adata:"",cipher:"aes"},ja:function(a,b,c,d){c=c||{};d=d||{};var e=sjcl.json,f=e.g({iv:sjcl.random.randomWords(4,0)},e.defaults),g;e.g(f,c);c=f.adata;"string"===typeof f.salt&&(f.salt=sjcl.codec.base64.toBits(f.salt));"string"===typeof f.iv&&(f.iv=sjcl.codec.base64.toBits(f.iv));if(!sjcl.mode[f.mode]||!sjcl.cipher[f.cipher]||"string"===typeof a&&100>=f.iter||64!==f.ts&&96!==f.ts&&128!==f.ts||128!==f.ks&&192!==f.ks&&0x100!==f.ks||2>f.iv.length||
4<f.iv.length)throw new sjcl.exception.invalid("json encrypt: invalid parameters");"string"===typeof a?(g=sjcl.misc.cachedPbkdf2(a,f),a=g.key.slice(0,f.ks/32),f.salt=g.salt):sjcl.ecc&&a instanceof sjcl.ecc.elGamal.publicKey&&(g=a.kem(),f.kemtag=g.tag,a=g.key.slice(0,f.ks/32));"string"===typeof b&&(b=sjcl.codec.utf8String.toBits(b));"string"===typeof c&&(f.adata=c=sjcl.codec.utf8String.toBits(c));g=new sjcl.cipher[f.cipher](a);e.g(d,f);d.key=a;f.ct="ccm"===f.mode&&sjcl.arrayBuffer&&sjcl.arrayBuffer.ccm&&
b instanceof ArrayBuffer?sjcl.arrayBuffer.ccm.encrypt(g,b,f.iv,c,f.ts):sjcl.mode[f.mode].encrypt(g,b,f.iv,c,f.ts);return f},encrypt:function(a,b,c,d){var e=sjcl.json,f=e.ja.apply(e,arguments);return e.encode(f)},ia:function(a,b,c,d){c=c||{};d=d||{};var e=sjcl.json;b=e.g(e.g(e.g({},e.defaults),b),c,!0);var f,g;f=b.adata;"string"===typeof b.salt&&(b.salt=sjcl.codec.base64.toBits(b.salt));"string"===typeof b.iv&&(b.iv=sjcl.codec.base64.toBits(b.iv));if(!sjcl.mode[b.mode]||!sjcl.cipher[b.cipher]||"string"===
typeof a&&100>=b.iter||64!==b.ts&&96!==b.ts&&128!==b.ts||128!==b.ks&&192!==b.ks&&0x100!==b.ks||!b.iv||2>b.iv.length||4<b.iv.length)throw new sjcl.exception.invalid("json decrypt: invalid parameters");"string"===typeof a?(g=sjcl.misc.cachedPbkdf2(a,b),a=g.key.slice(0,b.ks/32),b.salt=g.salt):sjcl.ecc&&a instanceof sjcl.ecc.elGamal.secretKey&&(a=a.unkem(sjcl.codec.base64.toBits(b.kemtag)).slice(0,b.ks/32));"string"===typeof f&&(f=sjcl.codec.utf8String.toBits(f));g=new sjcl.cipher[b.cipher](a);f="ccm"===
b.mode&&sjcl.arrayBuffer&&sjcl.arrayBuffer.ccm&&b.ct instanceof ArrayBuffer?sjcl.arrayBuffer.ccm.decrypt(g,b.ct,b.iv,b.tag,f,b.ts):sjcl.mode[b.mode].decrypt(g,b.ct,b.iv,f,b.ts);e.g(d,b);d.key=a;return 1===c.raw?f:sjcl.codec.utf8String.fromBits(f)},decrypt:function(a,b,c,d){var e=sjcl.json;return e.ia(a,e.decode(b),c,d)},encode:function(a){var b,c="{",d="";for(b in a)if(a.hasOwnProperty(b)){if(!b.match(/^[a-z0-9]+$/i))throw new sjcl.exception.invalid("json encode: invalid property name");c+=d+'"'+
b+'":';d=",";switch(typeof a[b]){case "number":case "boolean":c+=a[b];break;case "string":c+='"'+escape(a[b])+'"';break;case "object":c+='"'+sjcl.codec.base64.fromBits(a[b],0)+'"';break;default:throw new sjcl.exception.bug("json encode: unsupported type");}}return c+"}"},decode:function(a){a=a.replace(/\s/g,"");if(!a.match(/^\{.*\}$/))throw new sjcl.exception.invalid("json decode: this isn't json!");a=a.replace(/^\{|\}$/g,"").split(/,/);var b={},c,d;for(c=0;c<a.length;c++){if(!(d=a[c].match(/^\s*(?:(["']?)([a-z][a-z0-9]*)\1)\s*:\s*(?:(-?\d+)|"([a-z0-9+\/%*_.@=\-]*)"|(true|false))$/i)))throw new sjcl.exception.invalid("json decode: this isn't json!");
null!=d[3]?b[d[2]]=parseInt(d[3],10):null!=d[4]?b[d[2]]=d[2].match(/^(ct|adata|salt|iv)$/)?sjcl.codec.base64.toBits(d[4]):unescape(d[4]):null!=d[5]&&(b[d[2]]="true"===d[5])}return b},g:function(a,b,c){void 0===a&&(a={});if(void 0===b)return a;for(var d in b)if(b.hasOwnProperty(d)){if(c&&void 0!==a[d]&&a[d]!==b[d])throw new sjcl.exception.invalid("required parameter overridden");a[d]=b[d]}return a},sa:function(a,b){var c={},d;for(d in a)a.hasOwnProperty(d)&&a[d]!==b[d]&&(c[d]=a[d]);return c},ra:function(a,
b){var c={},d;for(d=0;d<b.length;d++)void 0!==a[b[d]]&&(c[b[d]]=a[b[d]]);return c}};sjcl.encrypt=sjcl.json.encrypt;sjcl.decrypt=sjcl.json.decrypt;sjcl.misc.pa={};sjcl.misc.cachedPbkdf2=function(a,b){var c=sjcl.misc.pa,d;b=b||{};d=b.iter||1E3;c=c[a]=c[a]||{};d=c[d]=c[d]||{firstSalt:b.salt&&b.salt.length?b.salt.slice(0):sjcl.random.randomWords(2,0)};c=void 0===b.salt?d.firstSalt:b.salt;d[c]=d[c]||sjcl.misc.pbkdf2(a,c,b.iter);return{key:d[c].slice(0),salt:c.slice(0)}};
"undefined"!==typeof module&&module.exports&&(module.exports=sjcl);"function"===typeof define&&define([],function(){return sjcl});

146
client/share/sjclWrapper.js Normal file
View File

@@ -0,0 +1,146 @@
//sjcl.js подправлен (убран лишний require, добавлявший +400kb к bundle) и скопирован локально
import sjcl from './sjcl';
//везде недоработки...
sjcl.codec.bytes = {
fromBits: function(arr) {
var out = [], bl = sjcl.bitArray.bitLength(arr), i, tmp;
for (i=0; i<bl/8; i++) {
if ((i&3) === 0) {
tmp = arr[i/4];
}
out.push(tmp >>> 24);
tmp <<= 8;
}
return out;
},
toBits: function(bytes) {
var out = [], i, tmp=0;
for (i=0; i<bytes.length; i++) {
tmp = tmp << 8 | bytes[i];
if ((i&3) === 3) {
out.push(tmp);
tmp = 0;
}
}
if (i&3) {
out.push(sjcl.bitArray.partial(8*(i&3), tmp));
}
return out;
}
};
sjcl.json._add = function(target, src, requireSame) {
if (target === undefined) { target = {}; }
if (src === undefined) { return target; }
var i;
for (i in src) {
if (src.hasOwnProperty(i)) {
if (requireSame && target[i] !== undefined && target[i] !== src[i]) {
throw new sjcl.exception.invalid("required parameter overridden");
}
target[i] = src[i];
}
}
return target;
}
sjcl.encryptArray = function(password, plaintext, params, rp) {
params = params || {};
rp = rp || {};
var j = sjcl.json, p = j._add({ iv: sjcl.random.randomWords(4,0) },
j.defaults), tmp, prp, adata;
j._add(p, params);
adata = p.adata;
if (typeof p.salt === "string") {
p.salt = sjcl.codec.base64.toBits(p.salt);
}
if (typeof p.iv === "string") {
p.iv = sjcl.codec.base64.toBits(p.iv);
}
if (!sjcl.mode[p.mode] ||
!sjcl.cipher[p.cipher] ||
(typeof password === "string" && p.iter <= 100) ||
(p.ts !== 64 && p.ts !== 96 && p.ts !== 128) ||
(p.ks !== 128 && p.ks !== 192 && p.ks !== 256) ||
(p.iv.length < 2 || p.iv.length > 4)) {
throw new sjcl.exception.invalid("json encrypt: invalid parameters");
}
if (typeof password === "string") {
tmp = sjcl.misc.cachedPbkdf2(password, p);
password = tmp.key.slice(0,p.ks/32);
p.salt = tmp.salt;
} else if (sjcl.ecc && password instanceof sjcl.ecc.elGamal.publicKey) {
tmp = password.kem();
p.kemtag = tmp.tag;
password = tmp.key.slice(0,p.ks/32);
}
if (typeof plaintext === "string") {
plaintext = sjcl.codec.utf8String.toBits(plaintext);
}
if (typeof adata === "string") {
p.adata = adata = sjcl.codec.utf8String.toBits(adata);
}
prp = new sjcl.cipher[p.cipher](password);
j._add(rp, p);
rp.key = password;
/* do the encryption */
if (p.mode === "ccm" && sjcl.arrayBuffer && sjcl.arrayBuffer.ccm && plaintext instanceof ArrayBuffer) {
p.ct = sjcl.arrayBuffer.ccm.encrypt(prp, plaintext, p.iv, adata, p.ts);
} else {
p.ct = sjcl.mode[p.mode].encrypt(prp, plaintext, p.iv, adata, p.ts);
}
return p;
}
sjcl.decryptArray = function(password, ciphertext, params) {
params = params || {};
var j = sjcl.json, p = j._add(j._add(j._add({},j.defaults),ciphertext), params, true), ct, tmp, prp, adata=p.adata;
if (typeof p.salt === "string") {
p.salt = sjcl.codec.base64.toBits(p.salt);
}
if (typeof p.iv === "string") {
p.iv = sjcl.codec.base64.toBits(p.iv);
}
if (!sjcl.mode[p.mode] ||
!sjcl.cipher[p.cipher] ||
(typeof password === "string" && p.iter <= 100) ||
(p.ts !== 64 && p.ts !== 96 && p.ts !== 128) ||
(p.ks !== 128 && p.ks !== 192 && p.ks !== 256) ||
(!p.iv) ||
(p.iv.length < 2 || p.iv.length > 4)) {
throw new sjcl.exception.invalid("json decrypt: invalid parameters");
}
if (typeof password === "string") {
tmp = sjcl.misc.cachedPbkdf2(password, p);
password = tmp.key.slice(0,p.ks/32);
p.salt = tmp.salt;
} else if (sjcl.ecc && password instanceof sjcl.ecc.elGamal.secretKey) {
password = password.unkem(sjcl.codec.base64.toBits(p.kemtag)).slice(0,p.ks/32);
}
if (typeof adata === "string") {
adata = sjcl.codec.utf8String.toBits(adata);
}
prp = new sjcl.cipher[p.cipher](password);
/* do the decryption */
if (p.mode === "ccm" && sjcl.arrayBuffer && sjcl.arrayBuffer.ccm && p.ct instanceof ArrayBuffer) {
ct = sjcl.arrayBuffer.ccm.decrypt(prp, p.ct, p.iv, p.tag, adata, p.ts);
} else {
ct = sjcl.mode[p.mode].decrypt(prp, p.ct, p.iv, adata, p.ts);
}
return ct;
}
export default sjcl;

96
client/share/utils.js Normal file
View File

@@ -0,0 +1,96 @@
import {Buffer} from 'safe-buffer';
//import _ from 'lodash';
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function toHex(buf) {
return Buffer.from(buf).toString('hex');
}
export function keyEventToCode(event) {
let result = [];
let code = event.code;
const modCode = code.substring(0, 3);
if (event.metaKey && modCode != 'Met')
result.push('Meta');
if (event.ctrlKey && modCode != 'Con')
result.push('Ctrl');
if (event.shiftKey && modCode != 'Shi')
result.push('Shift');
if (event.altKey && modCode != 'Alt')
result.push('Alt');
if (modCode == 'Dig') {
code = code.substring(5, 6);
} else if (modCode == 'Key') {
code = code.substring(3, 4);
}
result.push(code);
return result.join('+');
}
export function wordEnding(num, type = 0) {
const endings = [
['ов', '', 'а', 'а', 'а', 'ов', 'ов', 'ов', 'ов', 'ов'],
['й', 'я', 'и', 'и', 'и', 'й', 'й', 'й', 'й', 'й'],
['о', '', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],
['ий', 'ие', 'ия', 'ия', 'ия', 'ий', 'ий', 'ий', 'ий', 'ий']
];
const deci = num % 100;
if (deci > 10 && deci < 20) {
return endings[type][0];
} else {
return endings[type][num % 10];
}
}
export function fallbackCopyTextToClipboard(text) {
let textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
let result = false;
try {
result = document.execCommand('copy');
} catch (e) {
console.error(e);
}
document.body.removeChild(textArea);
return result;
}
export async function copyTextToClipboard(text) {
if (!navigator.clipboard) {
return fallbackCopyTextToClipboard(text);
}
let result = false;
try {
await navigator.clipboard.writeText(text);
result = true;
} catch (e) {
console.error(e);
}
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');
}

15
client/store/index.js Normal file
View File

@@ -0,0 +1,15 @@
import { createStore } from 'vuex';
import VuexPersistence from 'vuex-persist';
import root from './root.js';
const debug = process.env.NODE_ENV !== 'production';
const vuexLocal = new VuexPersistence();
export default createStore(Object.assign({}, root, {
modules: {
},
strict: debug,
plugins: [vuexLocal.plugin]
}));

40
client/store/root.js Normal file
View File

@@ -0,0 +1,40 @@
// initial state
const state = {
config: {},
settings: {
accessToken: '',
limit: 20,
expanded: [],
expandedSeries: [],
showCounts: true,
showRate: true,
showGenres: true,
showDeleted: false,
abCacheEnabled: true,
langDefault: '',
},
};
// getters
const getters = {};
// actions
const actions = {};
// mutations
const mutations = {
setConfig(state, value) {
state.config = value;
},
setSettings(state, value) {
state.settings = Object.assign({}, state.settings, value);
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations
};

15284
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,25 @@
{
"name": "inpx-web",
"version": "0.1.0",
"version": "1.0.3",
"author": "Book Pauk <bookpauk@gmail.com>",
"license": "CC0-1.0",
"repository": "bookpauk/inpx-web",
"engines": {
"node": ">=14.4.0"
"node": ">=16.16.0"
},
"scripts": {
"dev": "nodemon --inspect --ignore server/.inpx-web --ignore client --exec 'node server --lib-dir=.inpx-web/lib'",
"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/linux && pkg -t node16-linux-x64 -C GZip -o dist/linux/inpx-web .",
"build:win": "npm run build:client && node build/win && pkg -t node16-win-x64 -C GZip -o dist/win/inpx-web .",
"build:linux": "npm run build:client && node build/prepkg.js linux && pkg -t node16-linux-x64 -C GZip --options max-old-space-size=4096,expose-gc -o dist/linux/inpx-web .",
"build:win": "npm run build:client && node build/prepkg.js win && pkg -t node16-win-x64 -C GZip --options max-old-space-size=4096,expose-gc -o dist/win/inpx-web .",
"build:client-dev": "webpack --config build/webpack.dev.config.js",
"release": "npm run build:linux && npm run build:client && node build/release.js",
"postinstall": "npm run build:client-dev"
},
"bin": "server/index.js",
"pkg": {
"scripts": "server/config/*.js"
"scripts": "server/config/*.js",
"assets": "dist/public.json"
},
"devDependencies": {
"@babel/core": "^7.18.9",
@@ -51,11 +53,13 @@
"compression": "^1.7.4",
"express": "^4.18.1",
"fs-extra": "^10.1.0",
"jembadb": "^3.0.10",
"jembadb": "^4.2.0",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"minimist": "^1.2.6",
"node-stream-zip": "^1.15.0",
"quasar": "^2.7.5",
"safe-buffer": "^5.2.1",
"vue": "^3.2.37",
"vue-router": "^4.1.2",
"vuex": "^4.0.2",

View File

@@ -0,0 +1 @@
development

41
server/config/base.js Normal file
View File

@@ -0,0 +1,41 @@
const path = require('path');
const pckg = require('../../package.json');
const execDir = path.resolve(__dirname, '..');
module.exports = {
branch: 'unknown',
version: pckg.version,
name: pckg.name,
execDir,
accessPassword: '',
bookReadLink: '',
loggingEnabled: true,
maxPayloadSize: 500,//in MB
maxFilesDirSize: 1024*1024*1024,//1Gb
queryCacheEnabled: true,
cacheCleanInterval: 60,//minutes
inpxCheckInterval: 60,//minutes
lowMemoryMode: false,
webConfigParams: ['name', 'version', 'branch', 'bookReadLink'],
allowRemoteLib: false,
remoteLib: false,
/*
allowRemoteLib: true, // на сервере
remoteLib: { // на клиенте
accessPassword: '',
url: 'wss://remoteInpxWeb.ru',
},
*/
server: {
host: '0.0.0.0',
port: '22380',
},
};

View File

@@ -0,0 +1,5 @@
const base = require('./base');
module.exports = Object.assign({}, base, {
branch: 'development',
});

105
server/config/index.js Normal file
View File

@@ -0,0 +1,105 @@
const _ = require('lodash');
const path = require('path');
const fs = require('fs-extra');
const branchFilename = __dirname + '/application_env';
const propsToSave = [
'accessPassword',
'bookReadLink',
'loggingEnabled',
'maxFilesDirSize',
'queryCacheEnabled',
'cacheCleanInterval',
'inpxCheckInterval',
'lowMemoryMode',
'allowRemoteLib',
'remoteLib',
'server',
];
let instance = null;
//singleton
class ConfigManager {
constructor() {
if (!instance) {
this.inited = false;
instance = this;
}
return instance;
}
async init(dataDir) {
if (this.inited)
throw new Error('already inited');
this.branch = 'production';
try {
await fs.access(branchFilename);
this.branch = (await fs.readFile(branchFilename, 'utf8')).trim();
} catch (err) {
//
}
process.env.NODE_ENV = this.branch;
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}`;
}
await fs.ensureDir(config.dataDir);
this._userConfigFile = `${config.dataDir}/config.json`;
this._config = config;
this.inited = true;
}
get config() {
if (!this.inited)
throw new Error('not inited');
return _.cloneDeep(this._config);
}
set config(value) {
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;
}
const data = await fs.readFile(this.userConfigFile, 'utf8');
this.config = JSON.parse(data);
}
async save() {
if (!this.inited)
throw new Error('not inited');
const dataToSave = _.pick(this._config, propsToSave);
await fs.writeFile(this.userConfigFile, JSON.stringify(dataToSave, null, 4));
}
}
module.exports = ConfigManager;

View File

@@ -0,0 +1,16 @@
const path = require('path');
const base = require('./base');
const execDir = path.dirname(process.execPath);
module.exports = Object.assign({}, base, {
branch: 'production',
execDir,
server: {
host: '0.0.0.0',
port: '12380',
},
});

View File

@@ -0,0 +1,191 @@
const _ = require('lodash');
const WebSocket = require ('ws');
const WorkerState = require('../core/WorkerState');//singleton
const WebWorker = require('../core/WebWorker');//singleton
const log = new (require('../core/AppLogger'))().log;//singleton
const utils = require('../core/utils');
const cleanPeriod = 1*60*1000;//1 минута
const closeSocketOnIdle = 5*60*1000;//5 минут
class WebSocketController {
constructor(wss, config) {
this.config = config;
this.isDevelopment = (config.branch == 'development');
this.accessToken = '';
if (config.accessPassword)
this.accessToken = utils.getBufHash(config.accessPassword, 'sha256', 'hex');
this.workerState = new WorkerState();
this.webWorker = new WebWorker(config);
this.wss = wss;
wss.on('connection', (ws) => {
ws.on('message', (message) => {
this.onMessage(ws, message.toString());
});
ws.on('error', (err) => {
log(LM_ERR, err);
});
});
setTimeout(() => { this.periodicClean(); }, cleanPeriod);
}
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 onMessage(ws, message) {
let req = {};
try {
if (this.isDevelopment) {
log(`WebSocket-IN: ${message.substr(0, 4000)}`);
}
req = JSON.parse(message);
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');
}
switch (req.action) {
case 'test':
await this.test(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 '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-inpx-file':
await this.getInpxFile(req, ws); break;
default:
throw new Error(`Action not found: ${req.action}`);
}
} catch (e) {
this.send({error: e.message}, req, ws);
}
}
send(res, req, ws) {
if (ws.readyState == WebSocket.OPEN) {
ws.lastActivity = Date.now();
let r = res;
if (req.requestId)
r = Object.assign({requestId: req.requestId}, r);
const message = JSON.stringify(r);
ws.send(message);
if (this.isDevelopment) {
log(`WebSocket-OUT: ${message.substr(0, 4000)}`);
}
}
}
//Actions ------------------------------------------------------------------
async test(req, ws) {
this.send({message: `${this.config.name} project is awesome`}, req, ws);
}
async getConfig(req, ws) {
const config = _.pick(this.config, this.config.webConfigParams);
config.dbConfig = await this.webWorker.dbConfig();
this.send(config, req, ws);
}
async getWorkerState(req, ws) {
if (!req.workerId)
throw new Error(`key 'workerId' is empty`);
const state = this.workerState.getState(req.workerId);
this.send((state ? state : {}), req, ws);
}
async search(req, ws) {
if (!req.query)
throw new Error(`query is empty`);
const result = await this.webWorker.search(req.query);
this.send(result, req, ws);
}
async getBookList(req, ws) {
if (!utils.hasProp(req, 'authorId'))
throw new Error(`authorId is empty`);
const result = await this.webWorker.getBookList(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);
}
async getGenreTree(req, ws) {
const result = await this.webWorker.getGenreTree();
this.send(result, req, ws);
}
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`);
const result = await this.webWorker.getBookLink({bookPath: req.bookPath, downFileName: req.downFileName});
this.send(result, req, ws);
}
async getInpxFile(req, ws) {
if (!this.config.allowRemoteLib)
throw new Error('Remote lib access disabled');
const result = await this.webWorker.getInpxFile(req);
this.send(result, req, ws);
}
}
module.exports = WebSocketController;

View File

@@ -0,0 +1,3 @@
module.exports = {
WebSocketController: require('./WebSocketController'),
}

60
server/core/AppLogger.js Normal file
View File

@@ -0,0 +1,60 @@
const fs = require('fs-extra');
const Logger = require('./Logger');
let instance = null;
//singleton
class AppLogger {
constructor() {
if (!instance) {
this.inited = false;
this.logFileName = '';
this.errLogFileName = '';
this.fatalLogFileName = '';
instance = this;
}
return instance;
}
async init(config) {
if (this.inited)
throw new Error('already inited');
let loggerParams = null;
if (config.loggingEnabled) {
await fs.ensureDir(config.logDir);
this.logFileName = `${config.logDir}/${config.name}.log`;
this.errLogFileName = `${config.logDir}/${config.name}.err.log`;
this.fatalLogFileName = `${config.logDir}/${config.name}.fatal.log`;
loggerParams = [
{log: 'ConsoleLog'},
{log: 'FileLog', fileName: this.logFileName},
{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
];
}
this._logger = new Logger(loggerParams);
this.inited = true;
return this.logger;
}
get logger() {
if (!this.inited)
throw new Error('not inited');
return this._logger;
}
get log() {
const l = this.logger;
return l.log.bind(l);
}
}
module.exports = AppLogger;

109
server/core/AsyncExit.js Normal file
View File

@@ -0,0 +1,109 @@
const defaultTimeout = 15*1000;//15 sec
const exitSignals = ['SIGINT', 'SIGTERM', 'SIGBREAK', 'SIGHUP', 'uncaughtException'];
//singleton
let instance = null;
class AsyncExit {
constructor(signals = exitSignals, codeOnSignal = 2) {
if (!instance) {
this.onSignalCallbacks = new Map();
this.callbacks = new Map();
this.afterCallbacks = new Map();
this.exitTimeout = defaultTimeout;
this._init(signals, codeOnSignal);
instance = this;
}
return instance;
}
_init(signals, codeOnSignal) {
const runSingalCallbacks = async(signal, err, origin) => {
if (!this.onSignalCallbacks.size) {
console.error(`Uncaught signal "${signal}" received, error: "${(err.stack ? err.stack : err)}"`);
}
for (const signalCallback of this.onSignalCallbacks.keys()) {
try {
await signalCallback(signal, err, origin);
} catch(e) {
console.error(e);
}
}
};
for (const signal of signals) {
process.once(signal, async(err, origin) => {
await runSingalCallbacks(signal, err, origin);
this.exit(codeOnSignal);
});
}
}
onSignal(signalCallback) {
if (!this.onSignalCallbacks.has(signalCallback)) {
this.onSignalCallbacks.set(signalCallback, true);
}
}
add(exitCallback) {
if (!this.callbacks.has(exitCallback)) {
this.callbacks.set(exitCallback, true);
}
}
addAfter(exitCallback) {
if (!this.afterCallbacks.has(exitCallback)) {
this.afterCallbacks.set(exitCallback, true);
}
}
remove(exitCallback) {
if (this.callbacks.has(exitCallback)) {
this.callbacks.delete(exitCallback);
}
if (this.afterCallbacks.has(exitCallback)) {
this.afterCallbacks.delete(exitCallback);
}
}
setExitTimeout(timeout) {
this.exitTimeout = timeout;
}
exit(code = 0) {
if (this.exiting)
return;
this.exiting = true;
const timer = setTimeout(() => { process.exit(code); }, this.exitTimeout);
(async() => {
for (const exitCallback of this.callbacks.keys()) {
try {
await exitCallback();
} catch(e) {
console.error(e);
}
}
for (const exitCallback of this.afterCallbacks.keys()) {
try {
await exitCallback();
} catch(e) {
console.error(e);
}
}
clearTimeout(timer);
//console.log('Exited gracefully');
process.exit(code);
})();
}
}
module.exports = AsyncExit;

631
server/core/DbCreator.js Normal file
View File

@@ -0,0 +1,631 @@
const fs = require('fs-extra');
const InpxParser = require('./InpxParser');
const InpxHashCreator = require('./InpxHashCreator');
const utils = require('./utils');
const emptyFieldValue = '?';
class DbCreator {
constructor(config) {
this.config = config;
}
async loadInpxFilter() {
const inpxFilterFile = this.config.inpxFilterFile;
if (await fs.pathExists(inpxFilterFile)) {
let filter = await fs.readFile(inpxFilterFile, 'utf8');
filter = JSON.parse(filter);
if (filter.includeAuthors) {
filter.includeAuthors = filter.includeAuthors.map(a => a.toLowerCase());
filter.includeSet = new Set(filter.includeAuthors);
}
if (filter.excludeAuthors) {
filter.excludeAuthors = filter.excludeAuthors.map(a => a.toLowerCase());
filter.excludeSet = new Set(filter.excludeAuthors);
}
return filter;
} else {
return false;
}
}
//процедура формировани БД несколько усложнена, в целях экономии памяти
async run(db, callback) {
const config = this.config;
callback({jobStepCount: 5});
callback({job: 'load inpx', jobMessage: 'Загрузка INPX', jobStep: 1, progress: 0});
//временная таблица
await db.create({
table: 'book',
cacheSize: (config.lowMemoryMode ? 5 : 500),
});
//поисковые таблицы, позже сохраним в БД
let authorMap = new Map();//авторы
let authorArr = [];
let seriesMap = new Map();//серии
let seriesArr = [];
let titleMap = new Map();//названия
let titleArr = [];
let genreMap = new Map();//жанры
let genreArr = [];
let langMap = new Map();//языки
let langArr = [];
//stats
let authorCount = 0;
let bookCount = 0;
let noAuthorBookCount = 0;
let bookDelCount = 0;
//stuff
let recsLoaded = 0;
callback({recsLoaded});
let chunkNum = 0;
//фильтр
const inpxFilter = await this.loadInpxFilter();
let filter = () => true;
if (inpxFilter) {
let recFilter = () => true;
if (inpxFilter.filter) {
if (config.allowUnsafeFilter)
recFilter = new Function(`'use strict'; return ${inpxFilter.filter}`)();
else
throw new Error(`Unsafe property 'filter' detected in ${this.config.inpxFilterFile}. Please specify '--unsafe-filter' param if you know what you're doing.`);
}
filter = (rec) => {
let author = rec.author;
if (!author)
author = emptyFieldValue;
author = author.toLowerCase();
let excluded = false;
if (inpxFilter.excludeSet) {
const authors = author.split(',');
for (const a of authors) {
if (inpxFilter.excludeSet.has(a)) {
excluded = true;
break;
}
}
}
return recFilter(rec)
&& (!inpxFilter.includeSet || inpxFilter.includeSet.has(author))
&& !excluded
;
};
}
//вспомогательные функции
const splitAuthor = (author) => {
if (!author)
author = emptyFieldValue;
const result = author.split(',');
if (result.length > 1)
result.push(author);
return result;
}
let totalFiles = 0;
const readFileCallback = async(readState) => {
callback(readState);
if (readState.totalFiles)
totalFiles = readState.totalFiles;
if (totalFiles)
callback({progress: (readState.current || 0)/totalFiles});
};
let id = 0;
const parsedCallback = async(chunk) => {
let filtered = false;
for (const rec of chunk) {
//сначала фильтр
if (!filter(rec)) {
rec.id = 0;
filtered = true;
continue;
}
rec.id = ++id;
if (!rec.del) {
bookCount++;
if (!rec.author)
noAuthorBookCount++;
} else {
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);
}
}
let saveChunk = [];
if (filtered) {
saveChunk = chunk.filter(r => r.id);
} else {
saveChunk = chunk;
}
await db.insert({table: 'book', rows: saveChunk});
recsLoaded += chunk.length;
callback({recsLoaded});
if (chunkNum++ % 10 == 0 && config.lowMemoryMode)
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();
}
}
//чистка памяти, ибо жрет как не в себя
authorMap = null;
seriesMap = null;
titleMap = null;
genreMap = null;
utils.freeMemory();
//config
callback({job: 'config save', jobMessage: 'Сохранение конфигурации', jobStep: 5, progress: 0});
await db.create({
table: 'config'
});
const stats = {
recsLoaded,
authorCount,
authorCountAll: authorArr.length,
bookCount,
bookCountAll: bookCount + bookDelCount,
bookDelCount,
noAuthorBookCount,
titleCount: titleArr.length,
seriesCount: seriesArr.length,
genreCount: genreArr.length,
langCount: langArr.length,
};
//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) => {
arr.sort((a, b) => a.value.localeCompare(b.value));
await db.create({
table,
index: {field: 'value', unique: true, 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);
}
await db.insert({table, rows: chunk});
if (i % 5 == 0) {
await db.freeMemory();
await utils.sleep(100);
}
callback({progress: i/arr.length});
}
nullArr();
await db.close({table});
utils.freeMemory();
await db.freeMemory();
};
//author
callback({job: 'author save', jobMessage: 'Сохранение индекса авторов', jobStep: 6, 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);
//title
callback({job: 'title save', jobMessage: 'Сохранение индекса названий', jobStep: 8, progress: 0});
await saveTable('title', titleArr, () => {titleArr = null}, true);
//genre
callback({job: 'genre save', jobMessage: 'Сохранение индекса жанров', jobStep: 9, progress: 0});
await saveTable('genre', genreArr, () => {genreArr = null}, true);
//lang
callback({job: 'lang save', jobMessage: 'Сохранение индекса языков', jobStep: 10, progress: 0});
await saveTable('lang', langArr, () => {langArr = null}, true);
//кэш-таблицы запросов
await db.create({table: 'query_cache'});
await db.create({table: 'query_time'});
//кэш-таблица имен файлов и их хешей
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},
});
const count = await db.select({table: 'series_temporary', count: true});
const seriesCount = (count.length ? count[0].count : 0);
const saveSeriesChunk = async(seriesChunk) => {
const ids = [];
for (const s of seriesChunk) {
for (const id of s.bookId) {
ids.push(id);
}
}
ids.sort();// обязательно, иначе будет тормозить - особенности JembaDb
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);
for (const s of seriesChunk) {
const sBooks = [];
for (const id of s.bookId) {
const rec = bookArr.get(id);
sBooks.push(rec);
}
s.books = JSON.stringify(sBooks);
delete s.bookId;
}
await db.insert({
table: 'series',
rows: seriesChunk,
});
};
const rows = await db.select({table: 'series_temporary'});
idsLen = 0;
aChunk = [];
proc = 0;
for (const row of rows) {// eslint-disable-line
aChunk.push(row);
idsLen += row.bookId.length;
proc++;
if (idsLen > 20000) {//константа выяснена эмпирическим путем "память/скорость"
await saveSeriesChunk(aChunk);
idsLen = 0;
aChunk = [];
callback({progress: proc/seriesCount});
await utils.sleep(100);
utils.freeMemory();
await db.freeMemory();
}
}
if (aChunk.length) {
await saveSeriesChunk(aChunk);
aChunk = null;
}
//чистка памяти, ибо жрет как не в себя
await db.drop({table: 'book'});//таблица больше не понадобится
await db.drop({table: 'series_temporary'});//таблица больше не понадобится
await db.close({table: 'series'});
await db.freeMemory();
utils.freeMemory();
callback({job: 'done', jobMessage: ''});
}
}
module.exports = DbCreator;

366
server/core/DbSearcher.js Normal file
View File

@@ -0,0 +1,366 @@
//const _ = require('lodash');
const utils = require('./utils');
const maxUtf8Char = String.fromCodePoint(0xFFFFF);
const ruAlphabet = 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя';
const enAlphabet = 'abcdefghijklmnopqrstuvwxyz';
const enruArr = (ruAlphabet + enAlphabet).split('');
class DbSearcher {
constructor(config, db) {
this.config = config;
this.db = db;
this.searchFlag = 0;
this.timer = null;
this.closed = false;
this.periodicCleanCache();//no await
}
getWhere(a) {
const db = this.db;
a = a.toLowerCase();
let where;
//особая обработка префиксов
if (a[0] == '=') {
a = a.substring(1);
where = `@@dirtyIndexLR('value', ${db.esc(a)}, ${db.esc(a)})`;
} else if (a[0] == '*') {
a = a.substring(1);
where = `@@indexIter('value', (v) => (v.indexOf(${db.esc(a)}) >= 0) )`;
} else if (a[0] == '#') {
a = a.substring(1);
where = `@@indexIter('value', (v) => {
const enru = new Set(${db.esc(enruArr)});
return !v || (!enru.has(v[0].toLowerCase()) && v.indexOf(${db.esc(a)}) >= 0);
});`;
} else {
where = `@@dirtyIndexLR('value', ${db.esc(a)}, ${db.esc(a + maxUtf8Char)})`;
}
return where;
}
async selectAuthorIds(query) {
const db = this.db;
let authorIds = new Set();
//сначала выберем все id авторов по фильтру
//порядок id соответсвует ASC-сортировке по author
if (query.author && query.author !== '*') {
const where = this.getWhere(query.author);
const authorRows = await db.select({
table: 'author',
dirtyIdsOnly: true,
where
});
for (const row of authorRows)
authorIds.add(row.id);
} else {//все авторы
if (!db.searchCache.authorIdsAll) {
const authorRows = await db.select({
table: 'author',
dirtyIdsOnly: true,
});
db.searchCache.authorIdsAll = [];
for (const row of authorRows) {
authorIds.add(row.id);
db.searchCache.authorIdsAll.push(row.id);
}
} else {//оптимизация
authorIds = new Set(db.searchCache.authorIdsAll);
}
}
const idsArr = [];
idsArr.push(authorIds);
//серии
if (query.series && query.series !== '*') {
const where = this.getWhere(query.series);
const seriesRows = await db.select({
table: 'series',
map: `(r) => ({authorId: r.authorId})`,
where
});
const ids = new Set();
for (const row of seriesRows) {
for (const id of row.authorId)
ids.add(id);
}
idsArr.push(ids);
}
//названия
if (query.title && query.title !== '*') {
const where = this.getWhere(query.title);
let titleRows = await db.select({
table: 'title',
map: `(r) => ({authorId: r.authorId})`,
where
});
const ids = new Set();
for (const row of titleRows) {
for (const id of row.authorId)
ids.add(id);
}
idsArr.push(ids);
//чистки памяти при тяжелых запросах
if (query.title[0] == '*') {
titleRows = null;
utils.freeMemory();
await db.freeMemory();
}
}
//жанры
if (query.genre) {
const genres = query.genre.split(',');
const ids = new Set();
for (const g of genres) {
const genreRows = await db.select({
table: 'genre',
map: `(r) => ({authorId: r.authorId})`,
where: `@@indexLR('value', ${db.esc(g)}, ${db.esc(g)})`,
});
for (const row of genreRows) {
for (const id of row.authorId)
ids.add(id);
}
}
idsArr.push(ids);
}
//языки
if (query.lang) {
const langs = query.lang.split(',');
const ids = new Set();
for (const l of langs) {
const langRows = await db.select({
table: 'lang',
map: `(r) => ({authorId: r.authorId})`,
where: `@@indexLR('value', ${db.esc(l)}, ${db.esc(l)})`,
});
for (const row of langRows) {
for (const id of row.authorId)
ids.add(id);
}
}
idsArr.push(ids);
}
if (idsArr.length > 1)
authorIds = utils.intersectSet(idsArr);
//сортировка
authorIds = Array.from(authorIds);
authorIds.sort((a, b) => a - b);
return authorIds;
}
async getAuthorIds(query) {
const db = this.db;
if (!db.searchCache)
db.searchCache = {};
let result;
//сначала попробуем найти в кеше
const q = query;
const keyArr = [q.author, q.series, q.title, q.genre, q.lang];
const keyStr = `query-${keyArr.join('')}`;
if (!keyStr) {//пустой запрос
if (db.searchCache.authorIdsAll)
result = db.searchCache.authorIdsAll;
else
result = await this.selectAuthorIds(query);
} else {//непустой запрос
if (this.config.queryCacheEnabled) {
const key = JSON.stringify(keyArr);
const rows = await db.select({table: 'query_cache', where: `@@id(${db.esc(key)})`});
if (rows.length) {//нашли в кеше
await db.insert({
table: 'query_time',
replace: true,
rows: [{id: key, time: Date.now()}],
});
result = rows[0].value;
} else {//не нашли в кеше, ищем в поисковых таблицах
result = await this.selectAuthorIds(query);
await db.insert({
table: 'query_cache',
replace: true,
rows: [{id: key, value: result}],
});
await db.insert({
table: 'query_time',
replace: true,
rows: [{id: key, time: Date.now()}],
});
}
} else {
result = await this.selectAuthorIds(query);
}
}
return result;
}
async search(query) {
if (this.closed)
throw new Error('DbSearcher closed');
this.searchFlag++;
try {
const db = this.db;
const authorIds = await this.getAuthorIds(query);
const totalFound = authorIds.length;
let limit = (query.limit ? query.limit : 100);
limit = (limit > 1000 ? 1000 : limit);
const offset = (query.offset ? query.offset : 0);
//выборка найденных авторов
let result = await db.select({
table: 'author',
map: `(r) => ({id: r.id, author: r.author, bookCount: r.bookCount, bookDelCount: r.bookDelCount})`,
where: `@@id(${db.esc(authorIds.slice(offset, offset + limit))})`
});
return {result, totalFound};
} finally {
this.searchFlag--;
}
}
async getBookList(authorId) {
if (this.closed)
throw new Error('DbSearcher closed');
this.searchFlag++;
try {
const db = this.db;
//выборка автора по authorId
const rows = await db.select({
table: 'author_book',
where: `@@id(${db.esc(authorId)})`
});
let author = '';
let books = '';
if (rows.length) {
author = rows[0].author;
books = rows[0].books;
}
return {author, books};
} finally {
this.searchFlag--;
}
}
async getSeriesBookList(series) {
if (this.closed)
throw new Error('DbSearcher closed');
this.searchFlag++;
try {
const db = this.db;
series = series.toLowerCase();
//выборка серии по названию серии
const rows = await db.select({
table: 'series',
where: `@@dirtyIndexLR('value', ${db.esc(series)}, ${db.esc(series)})`
});
return {books: (rows.length ? rows[0].books : '')};
} finally {
this.searchFlag--;
}
}
async periodicCleanCache() {
this.timer = null;
const cleanInterval = this.config.cacheCleanInterval*60*1000;
try {
const db = this.db;
const oldThres = Date.now() - cleanInterval;
//выберем всех кандидатов на удаление
const rows = await db.select({
table: 'query_time',
where: `
@@iter(@all(), (r) => (r.time < ${db.esc(oldThres)}));
`
});
const ids = [];
for (const row of rows)
ids.push(row.id);
//удаляем
await db.delete({table: 'query_cache', where: `@@id(${db.esc(ids)})`});
await db.delete({table: 'query_time', where: `@@id(${db.esc(ids)})`});
//console.log('Cache clean', ids);
} catch(e) {
console.error(e.message);
} finally {
if (!this.closed) {
this.timer = setTimeout(() => { this.periodicCleanCache(); }, cleanInterval);
}
}
}
async close() {
while (this.searchFlag > 0) {
await utils.sleep(50);
}
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.closed = true;
}
}
module.exports = DbSearcher;

View File

@@ -0,0 +1,127 @@
const https = require('https');
const axios = require('axios');
const utils = require('./utils');
const userAgent = 'Mozilla/5.0 (X11; HasCodingOs 1.0; Linux x64) AppleWebKit/637.36 (KHTML, like Gecko) Chrome/70.0.3112.101 Safari/637.36 HasBrowser/5.0';
class FileDownloader {
constructor(limitDownloadSize = 0) {
this.limitDownloadSize = limitDownloadSize;
}
async load(url, opts, callback, abort) {
let errMes = '';
let options = {
headers: {
'user-agent': userAgent,
timeout: 300*1000,
},
httpsAgent: new https.Agent({
rejectUnauthorized: false // решение проблемы 'unable to verify the first certificate' для некоторых сайтов с валидным сертификатом
}),
responseType: 'stream',
};
if (opts)
options = Object.assign({}, opts, options);
try {
const res = await axios.get(url, options);
let estSize = 0;
if (res.headers['content-length']) {
estSize = res.headers['content-length'];
}
if (this.limitDownloadSize && estSize > this.limitDownloadSize) {
throw new Error('Файл слишком большой');
}
let prevProg = 0;
let transferred = 0;
const download = this.streamToBuffer(res.data, (chunk) => {
transferred += chunk.length;
if (this.limitDownloadSize) {
if (transferred > this.limitDownloadSize) {
errMes = 'Файл слишком большой';
res.request.abort();
}
}
let prog = 0;
if (estSize)
prog = Math.round(transferred/estSize*100);
else
prog = Math.round(transferred/(transferred + 200000)*100);
if (prog != prevProg && callback)
callback(prog);
prevProg = prog;
if (abort && abort()) {
errMes = 'abort';
res.request.abort();
}
});
return await download;
} catch (error) {
errMes = (errMes ? errMes : error.message);
throw new Error(errMes);
}
}
async head(url) {
const options = {
headers: {
'user-agent': userAgent,
timeout: 10*1000,
},
};
const res = await axios.head(url, options);
return res.headers;
}
streamToBuffer(stream, progress, timeout = 30*1000) {
return new Promise((resolve, reject) => {
if (!progress)
progress = () => {};
const _buf = [];
let resolved = false;
let timer = 0;
stream.on('data', (chunk) => {
timer = 0;
_buf.push(chunk);
progress(chunk);
});
stream.on('end', () => {
resolved = true;
timer = timeout;
resolve(Buffer.concat(_buf));
});
stream.on('error', (err) => {
reject(err);
});
stream.on('aborted', () => {
reject(new Error('aborted'));
});
//бодяга с timer и timeout, чтобы гарантировать отсутствие зависания по каким-либо причинам
(async() => {
while (timer < timeout) {
await utils.sleep(1000);
timer += 1000;
}
if (!resolved)
reject(new Error('FileDownloader: timed out'))
})();
});
}
}
module.exports = FileDownloader;

View File

@@ -0,0 +1,36 @@
const fs = require('fs-extra');
const utils = require('./utils');
//поправить в случае, если были критические изменения в DbCreator
//иначе будет рассинхронизация между сервером и клиентом на уровне БД
const dbCreatorVersion = '2';
class InpxHashCreator {
constructor(config) {
this.config = config;
}
async getHash() {
const config = this.config;
let inpxFilterHash = '';
if (await fs.pathExists(config.inpxFilterFile))
inpxFilterHash = await utils.getFileHash(config.inpxFilterFile, 'sha256', 'hex');
const joinedHash = dbCreatorVersion + inpxFilterHash +
await utils.getFileHash(config.inpxFile, 'sha256', 'hex');
return utils.getBufHash(joinedHash, 'sha256', 'hex');
}
async getInpxFileHash() {
return (
await fs.pathExists(this.config.inpxFile) ?
await utils.getFileHash(this.config.inpxFile, 'sha256', 'hex') :
''
);
}
}
module.exports = InpxHashCreator;

139
server/core/InpxParser.js Normal file
View File

@@ -0,0 +1,139 @@
const path = require('path');
const ZipReader = require('./ZipReader');
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';
class InpxParser {
constructor() {
this.inpxInfo = {};
}
async safeExtractToString(zipReader, fileName) {
let result = '';
try {
result = (await zipReader.extractToBuf(fileName)).toString().trim();
} catch (e) {
//quiet
}
return result;
}
async parse(inpxFile, readFileCallback, parsedCallback) {
if (!readFileCallback)
readFileCallback = async() => {};
if (!parsedCallback)
parsedCallback = async() => {};
const zipReader = new ZipReader();
await zipReader.open(inpxFile);
try {
const info = this.inpxInfo;
//посчитаем inp-файлы
const entries = Object.values(zipReader.entries);
const inpFiles = [];
for (const entry of entries) {
if (!entry.isDirectory && path.extname(entry.name) == '.inp')
inpFiles.push(entry.name);
}
//плюс 3 файла .info
await readFileCallback({totalFiles: inpFiles.length + 3});
let current = 0;
//info
await readFileCallback({fileName: collectionInfo, current: ++current});
info.collection = await this.safeExtractToString(zipReader, collectionInfo);
await readFileCallback({fileName: structureInfo, current: ++current});
info.structure = await this.safeExtractToString(zipReader, structureInfo);
await readFileCallback({fileName: versionInfo, current: ++current});
info.version = await this.safeExtractToString(zipReader, versionInfo);
//структура
let inpxStructure = info.structure;
if (!inpxStructure)
inpxStructure = defaultStructure;
inpxStructure = inpxStructure.toLowerCase();
const structure = inpxStructure.split(';');
//парсим 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);
}
if (this.chunk.length) {
await parsedCallback(this.chunk);
}
} finally {
await zipReader.close();
}
}
async parseInp(inpBuf, structure, parsedCallback) {
const structLen = structure.length;
const rows = inpBuf.toString().split('\n');
for (const row of rows) {
let line = row;
if (!line)
continue;
if (line[line.length - 1] == '\x0D')
line = line.substring(0, line.length - 1);
//парсим запись
const parts = line.split('\x04');
const rec = {};
const len = (parts.length > structLen ? structLen : parts.length);
for (let i = 0; i < len; i++) {
if (structure[i])
rec[structure[i]] = parts[i];
}
//специальная обработка некоторых полей
if (rec.author) {
rec.author = rec.author.split(':').map(s => s.replace(/,/g, ' ').trim()).filter(s => s).join(',');
}
if (rec.genre) {
rec.genre = rec.genre.split(':').filter(s => s).join(',');
}
rec.serno = parseInt(rec.serno, 10) || 0;
rec.size = parseInt(rec.size, 10) || 0;
rec.del = parseInt(rec.del, 10) || 0;
rec.insno = parseInt(rec.insno, 10) || 0;
rec.librate = parseInt(rec.librate, 10) || 0;
//пушим
this.chunk.push(rec);
if (this.chunk.length >= 10000) {
await parsedCallback(this.chunk);
this.chunk = [];
}
}
}
get info() {
return this.inpxInfo;
}
}
module.exports = InpxParser;

232
server/core/Logger.js Normal file
View File

@@ -0,0 +1,232 @@
/*
Журналирование с буферизацией вывода
*/
const fs = require('fs-extra');
const ayncExit = new (require('./AsyncExit'))();
const sleep = (ms) => { return new Promise(resolve => setTimeout(resolve, ms)) };
global.LM_OK = 0;
global.LM_INFO = 1;
global.LM_WARN = 2;
global.LM_ERR = 3;
global.LM_FATAL = 4;
global.LM_TOTAL = 5;
const LOG_CACHE_BUFFER_SIZE = 8192;
const LOG_BUFFER_FLUSH_INTERVAL = 200;
const LOG_ROTATE_FILE_LENGTH = 1000000;
const LOG_ROTATE_FILE_DEPTH = 9;
const LOG_ROTATE_FILE_CHECK_INTERVAL = 60000;
let msgTypeToStr = {
[LM_OK]: ' OK',
[LM_INFO]: ' INFO',
[LM_WARN]: ' WARN',
[LM_ERR]: 'ERROR',
[LM_FATAL]: 'FATAL ERROR',
[LM_TOTAL]: 'TOTAL'
};
class BaseLog {
constructor(params) {
this.params = params;
this.exclude = new Set(params.exclude);
this.outputBufferLength = 0;
this.outputBuffer = [];
this.flushing = false;
}
async flush() {
if (this.flushing || !this.outputBufferLength)
return;
this.flushing = true;
this.data = this.outputBuffer;
this.outputBufferLength = 0;
this.outputBuffer = [];
await this.flushImpl(this.data)
.catch(e => { console.error(`Logger error: ${e}`); ayncExit.exit(1); } );
this.flushing = false;
}
log(msgType, message) {
if (this.closed)
return;
if (!this.exclude.has(msgType)) {
this.outputBuffer.push(message);
this.outputBufferLength += message.length;
if (this.outputBufferLength >= LOG_CACHE_BUFFER_SIZE && !this.flushing) {
this.flush();
}
if (!this.iid) {
this.iid = setInterval(() => {
if (!this.flushing) {
clearInterval(this.iid);
this.iid = 0;
this.flush();
}
}, LOG_BUFFER_FLUSH_INTERVAL);
}
}
}
async close() {
if (this.closed)
return;
if (this.iid)
clearInterval(this.iid);
try {
while (this.outputBufferLength) {
await this.flush();
await sleep(1);
}
} catch(e) {
console.log(e);
ayncExit.exit(1);
}
this.outputBufferLength = 0;
this.outputBuffer = [];
this.closed = true;
}
}
class FileLog extends BaseLog {
constructor(params) {
super(params);
this.fileName = params.fileName;
this.fd = fs.openSync(this.fileName, 'a');
this.rcid = 0;
}
async close() {
if (this.closed)
return;
await super.close();
if (this.fd) {
await fs.close(this.fd);
this.fd = null;
}
if (this.rcid)
clearTimeout(this.rcid);
}
async rotateFile(fileName, i) {
let fn = fileName;
if (i > 0)
fn += `.${i}`;
let tn = fileName + '.' + (i + 1);
let exists = await fs.access(tn).then(() => true).catch(() => false);
if (exists) {
if (i >= LOG_ROTATE_FILE_DEPTH - 1) {
await fs.unlink(tn);
} else {
await this.rotateFile(fileName, i + 1);
}
}
await fs.rename(fn, tn);
}
async doFileRotationIfNeeded() {
this.rcid = 0;
let stat = await fs.fstat(this.fd);
if (stat.size > LOG_ROTATE_FILE_LENGTH) {
await fs.close(this.fd);
await this.rotateFile(this.fileName, 0);
this.fd = await fs.open(this.fileName, "a");
}
}
async flushImpl(data) {
if (this.closed)
return;
if (!this.rcid) {
await this.doFileRotationIfNeeded();
this.rcid = setTimeout(() => {
this.rcid = 0;
}, LOG_ROTATE_FILE_CHECK_INTERVAL);
}
if (this.fd)
await fs.write(this.fd, Buffer.from(data.join('')));
}
}
class ConsoleLog extends BaseLog {
async flushImpl(data) {
process.stdout.write(data.join(''));
}
}
//------------------------------------------------------------------
const factory = {
ConsoleLog,
FileLog,
};
class Logger {
constructor(params = null) {
this.handlers = [];
if (params) {
params.forEach((logParams) => {
let className = logParams.log;
let loggerClass = factory[className];
this.handlers.push(new loggerClass(logParams));
});
}
this.closed = false;
ayncExit.onSignal((signal, err) => {
this.log(LM_FATAL, `Signal "${signal}" received, error: "${(err.stack ? err.stack : err)}", exiting...`);
});
ayncExit.addAfter(this.close.bind(this));
}
formatDate(date) {
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ` +
`${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}:${date.getSeconds().toString().padStart(2, '0')}.` +
`${date.getMilliseconds().toString().padStart(3, '0')}`;
}
prepareMessage(msgType, message) {
return this.formatDate(new Date()) + ` ${msgTypeToStr[msgType]}: ${message}\n`;
}
log(msgType, message) {
if (message == null) {
message = msgType;
msgType = LM_INFO;
}
const mes = this.prepareMessage(msgType, message);
if (!this.closed) {
for (let i = 0; i < this.handlers.length; i++)
this.handlers[i].log(msgType, mes);
} else {
console.log(mes);
}
return mes;
}
async close() {
for (let i = 0; i < this.handlers.length; i++)
await this.handlers[i].close();
this.closed = true;
}
}
module.exports = Logger;

80
server/core/RemoteLib.js Normal file
View File

@@ -0,0 +1,80 @@
const fs = require('fs-extra');
const path = require('path');
const utils = require('./utils');
const FileDownloader = require('./FileDownloader');
const WebSocketConnection = require('./WebSocketConnection');
const InpxHashCreator = require('./InpxHashCreator');
const log = new (require('./AppLogger'))().log;//singleton
//singleton
let instance = null;
class RemoteLib {
constructor(config) {
if (!instance) {
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://');
this.down = new FileDownloader(config.maxPayloadSize*1024*1024);
this.inpxHashCreator = new InpxHashCreator(config);
this.inpxFileHash = '';
instance = this;
}
return instance;
}
async wsRequest(query) {
if (this.accessToken)
query.accessToken = this.accessToken;
const response = await this.wsc.message(
await this.wsc.send(query),
120
);
if (response.error)
throw new Error(response.error);
return response;
}
async downloadInpxFile() {
if (!this.inpxFileHash)
this.inpxFileHash = await this.inpxHashCreator.getInpxFileHash();
const response = await this.wsRequest({action: 'get-inpx-file', inpxFileHash: this.inpxFileHash});
if (response.data) {
await fs.writeFile(this.config.inpxFile, response.data, 'base64');
this.inpxFileHash = '';
}
}
async downloadBook(bookPath, downFileName) {
try {
const response = await await this.wsRequest({action: 'get-book-link', bookPath, downFileName});
const link = response.link;
const buf = await this.down.load(`${this.remoteHost}${link}`, {decompress: false});
const publicPath = `${this.config.publicDir}${link}`;
await fs.writeFile(publicPath, buf);
return path.basename(link);
} catch (e) {
log(LM_ERR, `RemoteLib.downloadBook: ${e.message}`);
throw new Error('502 Bad Gateway');
}
}
}
module.exports = RemoteLib;

View File

@@ -0,0 +1,240 @@
const isBrowser = (typeof window !== 'undefined');
const utils = {
sleep: (ms) => { return new Promise(resolve => setTimeout(resolve, ms)); }
};
const cleanPeriod = 5*1000;//5 секунд
class WebSocketConnection {
//messageLifeTime в секундах (проверка каждый cleanPeriod интервал)
constructor(url, openTimeoutSecs = 10, messageLifeTimeSecs = 30, webSocketOptions = {}) {
this.WebSocket = (isBrowser ? WebSocket : require('ws'));
this.url = url;
this.webSocketOptions = webSocketOptions;
this.ws = null;
this.listeners = [];
this.messageQueue = [];
this.messageLifeTime = messageLifeTimeSecs*1000;
this.openTimeout = openTimeoutSecs*1000;
this.requestId = 0;
this.wsErrored = false;
this.closed = false;
this.connecting = false;
this.periodicClean();//no await
}
//рассылаем сообщение и удаляем те обработчики, которые его получили
emit(mes, isError) {
const len = this.listeners.length;
if (len > 0) {
let newListeners = [];
for (const listener of this.listeners) {
let emitted = false;
if (isError) {
listener.onError(mes);
emitted = true;
} else {
if ( (listener.requestId && mes.requestId && listener.requestId === mes.requestId) ||
(!listener.requestId && !mes.requestId) ) {
listener.onMessage(mes);
emitted = true;
}
}
if (!emitted)
newListeners.push(listener);
}
this.listeners = newListeners;
}
return this.listeners.length != len;
}
get isOpen() {
return (this.ws && this.ws.readyState == this.WebSocket.OPEN);
}
processMessageQueue() {
let newMessageQueue = [];
for (const message of this.messageQueue) {
if (!this.emit(message.mes)) {
newMessageQueue.push(message);
}
}
this.messageQueue = newMessageQueue;
}
_open() {
return new Promise((resolve, reject) => { (async() => {
if (this.closed)
reject(new Error('Этот экземпляр класса уничтожен. Пожалуйста, создайте новый.'));
if (this.connecting) {
let i = this.openTimeout/100;
while (i-- > 0 && this.connecting) {
await utils.sleep(100);
}
}
//проверим подключение, и если нет, то подключимся заново
if (this.isOpen) {
resolve(this.ws);
} else {
this.connecting = true;
this.terminate();
if (isBrowser) {
const protocol = (window.location.protocol == 'https:' ? 'wss:' : 'ws:');
const url = this.url || `${protocol}//${window.location.host}/ws`;
this.ws = new this.WebSocket(url);
} else {
this.ws = new this.WebSocket(this.url, this.webSocketOptions);
}
const onopen = () => {
this.connecting = false;
resolve(this.ws);
};
const onmessage = (data) => {
try {
if (isBrowser)
data = data.data;
const mes = JSON.parse(data);
this.messageQueue.push({regTime: Date.now(), mes});
this.processMessageQueue();
} catch (e) {
this.emit(e.message, true);
}
};
const onerror = (e) => {
this.emit(e.message, true);
reject(new Error(e.message));
};
const onclose = (e) => {
this.emit(e.message, true);
reject(new Error(e.message));
};
if (isBrowser) {
this.ws.onopen = onopen;
this.ws.onmessage = onmessage;
this.ws.onerror = onerror;
this.ws.onclose = onclose;
} else {
this.ws.on('open', onopen);
this.ws.on('message', onmessage);
this.ws.on('error', onerror);
this.ws.on('close', onclose);
}
await utils.sleep(this.openTimeout);
reject(new Error('Соединение не удалось'));
}
})() });
}
//timeout в секундах (проверка каждый cleanPeriod интервал)
message(requestId, timeoutSecs = 4) {
return new Promise((resolve, reject) => {
this.listeners.push({
regTime: Date.now(),
requestId,
timeout: timeoutSecs*1000,
onMessage: (mes) => {
resolve(mes);
},
onError: (mes) => {
reject(new Error(mes));
}
});
this.processMessageQueue();
});
}
async send(req, timeoutSecs = 4) {
await this._open();
if (this.isOpen) {
this.requestId = (this.requestId < 1000000 ? this.requestId + 1 : 1);
const requestId = this.requestId;//реентерабельность!!!
this.ws.send(JSON.stringify(Object.assign({requestId}, req)));
let resp = {};
try {
resp = await this.message(requestId, timeoutSecs);
} catch(e) {
this.terminate();
throw new Error('WebSocket не отвечает');
}
if (resp._rok) {
return requestId;
} else {
throw new Error('Запрос не принят сервером');
}
} else {
throw new Error('WebSocket коннект закрыт');
}
}
terminate() {
if (this.ws) {
if (isBrowser) {
this.ws.close();
} else {
this.ws.terminate();
}
}
this.ws = null;
}
close() {
this.terminate();
this.closed = true;
}
async periodicClean() {
while (!this.closed) {
try {
const now = Date.now();
//чистка listeners
let newListeners = [];
for (const listener of this.listeners) {
if (now - listener.regTime < listener.timeout) {
newListeners.push(listener);
} else {
if (listener.onError)
listener.onError('Время ожидания ответа истекло');
}
}
this.listeners = newListeners;
//чистка messageQueue
let newMessageQueue = [];
for (const message of this.messageQueue) {
if (now - message.regTime < this.messageLifeTime) {
newMessageQueue.push(message);
}
}
this.messageQueue = newMessageQueue;
} catch(e) {
//
}
await utils.sleep(cleanPeriod);
}
}
}
module.exports = WebSocketConnection;

571
server/core/WebWorker.js Normal file
View File

@@ -0,0 +1,571 @@
const os = require('os');
const path = require('path');
const fs = require('fs-extra');
const _ = require('lodash');
const ZipReader = require('./ZipReader');
const WorkerState = require('./WorkerState');//singleton
const { JembaDbThread } = require('jembadb');
const DbCreator = require('./DbCreator');
const DbSearcher = require('./DbSearcher');
const InpxHashCreator = require('./InpxHashCreator');
const RemoteLib = require('./RemoteLib');//singleton
const ayncExit = new (require('./AsyncExit'))();
const log = new (require('./AppLogger'))().log;//singleton
const utils = require('./utils');
const genreTree = require('./genres');
//server states
const ssNormal = 'normal';
const ssDbLoading = 'db_loading';
const ssDbCreating = 'db_creating';
const stateToText = {
[ssNormal]: '',
[ssDbLoading]: 'Загрузка поисковой базы',
[ssDbCreating]: 'Создание поисковой базы',
};
const cleanDirPeriod = 60*60*1000;//каждый час
//singleton
let instance = null;
class WebWorker {
constructor(config) {
if (!instance) {
this.config = config;
this.workerState = new WorkerState();
this.remoteLib = null;
if (config.remoteLib) {
this.remoteLib = new RemoteLib(config);
}
this.inpxHashCreator = new InpxHashCreator(config);
this.inpxFileHash = '';
this.wState = this.workerState.getControl('server_state');
this.myState = '';
this.db = null;
this.dbSearcher = null;
ayncExit.add(this.closeDb.bind(this));
this.loadOrCreateDb();//no await
this.periodicLogServerStats();//no await
const dirConfig = [
{
dir: `${this.config.publicDir}/files`,
maxSize: this.config.maxFilesDirSize,
},
];
this.periodicCleanDir(dirConfig);//no await
this.periodicCheckInpx();//no await
instance = this;
}
return instance;
}
checkMyState() {
if (this.myState != ssNormal)
throw new Error('server_busy');
}
setMyState(newState, workerState = {}) {
this.myState = newState;
this.wState.set(Object.assign({}, workerState, {
state: newState,
serverMessage: stateToText[newState]
}));
}
async closeDb() {
if (this.db) {
await this.db.unlock();
this.db = null;
}
}
async createDb(dbPath) {
this.setMyState(ssDbCreating);
log('Searcher DB create start');
const config = this.config;
if (await fs.pathExists(dbPath))
throw new Error(`createDb.pathExists: ${dbPath}`);
const db = new JembaDbThread();
await db.lock({
dbPath,
create: true,
softLock: true,
tableDefaults: {
cacheSize: 5,
},
});
try {
const dbCreator = new DbCreator(config);
await dbCreator.run(db, (state) => {
this.setMyState(ssDbCreating, state);
if (state.fileName)
log(` load ${state.fileName}`);
if (state.recsLoaded)
log(` processed ${state.recsLoaded} records`);
if (state.job)
log(` ${state.job}`);
});
log('Searcher DB successfully created');
} finally {
await db.unlock();
}
}
async loadOrCreateDb(recreate = false) {
this.setMyState(ssDbLoading);
try {
const config = this.config;
const dbPath = `${config.dataDir}/db`;
this.inpxFileHash = await this.inpxHashCreator.getInpxFileHash();
//пересоздаем БД из INPX если нужно
if (config.recreateDb || recreate)
await fs.remove(dbPath);
if (!await fs.pathExists(dbPath)) {
try {
await this.createDb(dbPath);
} catch (e) {
//при ошибке создания БД удалим ее, чтобы не работать с поломанной базой при следующем запуске
await fs.remove(dbPath);
throw e;
}
utils.freeMemory();
}
//загружаем БД
this.setMyState(ssDbLoading);
log('Searcher DB loading');
const db = new JembaDbThread();
await db.lock({
dbPath,
softLock: true,
tableDefaults: {
cacheSize: 5,
},
});
//открываем все таблицы
await db.openAll();
this.dbSearcher = new DbSearcher(config, db);
db.wwCache = {};
this.db = db;
log('Searcher DB ready');
this.logServerStats();
} catch (e) {
log(LM_FATAL, e.message);
ayncExit.exit(1);
} finally {
this.setMyState(ssNormal);
}
}
async recreateDb() {
this.setMyState(ssDbCreating);
if (this.dbSearcher) {
await this.dbSearcher.close();
this.dbSearcher = null;
}
await this.closeDb();
await this.loadOrCreateDb(true);
}
async dbConfig() {
this.checkMyState();
const db = this.db;
if (!db.wwCache.config) {
const rows = await db.select({table: 'config'});
const config = {};
for (const row of rows) {
config[row.id] = row.value;
}
db.wwCache.config = config;
}
return db.wwCache.config;
}
async search(query) {
this.checkMyState();
const config = await this.dbConfig();
const result = await this.dbSearcher.search(query);
return {
author: result.result,
totalFound: result.totalFound,
inpxHash: (config.inpxHash ? config.inpxHash : ''),
};
}
async getBookList(authorId) {
this.checkMyState();
return await this.dbSearcher.getBookList(authorId);
}
async getSeriesBookList(seriesId) {
this.checkMyState();
return await this.dbSearcher.getSeriesBookList(seriesId);
}
async getGenreTree() {
this.checkMyState();
const config = await this.dbConfig();
let result;
const db = this.db;
if (!db.wwCache.genres) {
const genres = _.cloneDeep(genreTree);
const last = genres[genres.length - 1];
const genreValues = new Set();
for (const section of genres) {
for (const g of section.value)
genreValues.add(g.value);
}
//добавим к жанрам те, что нашлись при парсинге
const genreParsed = new Set();
let rows = await db.select({table: 'genre', map: `(r) => ({value: r.value})`});
for (const row of rows) {
genreParsed.add(row.value);
if (!genreValues.has(row.value))
last.value.push({name: row.value, value: row.value});
}
//уберем те, которые не нашлись при парсинге
for (let j = 0; j < genres.length; j++) {
const section = genres[j];
for (let i = 0; i < section.value.length; i++) {
const g = section.value[i];
if (!genreParsed.has(g.value))
section.value.splice(i--, 1);
}
if (!section.value.length)
genres.splice(j--, 1);
}
// langs
rows = await db.select({table: 'lang', map: `(r) => ({value: r.value})`});
const langs = rows.map(r => r.value);
result = {
genreTree: genres,
langList: langs,
inpxHash: (config.inpxHash ? config.inpxHash : ''),
};
db.wwCache.genres = result;
} else {
result = db.wwCache.genres;
}
return result;
}
async extractBook(bookPath) {
const outFile = `${this.config.tempDir}/${utils.randomHexString(30)}`;
const folder = `${this.config.libDir}/${path.dirname(bookPath)}`;
const file = path.basename(bookPath);
const zipReader = new ZipReader();
await zipReader.open(folder);
try {
await zipReader.extractToFile(file, outFile);
return outFile;
} finally {
await zipReader.close();
}
}
async restoreBook(bookPath, downFileName) {
const db = this.db;
let extractedFile = '';
let hash = '';
if (!this.remoteLib) {
extractedFile = await this.extractBook(bookPath);
hash = await utils.getFileHash(extractedFile, 'sha256', 'hex');
} else {
hash = await this.remoteLib.downloadBook(bookPath, downFileName);
}
const link = `/files/${hash}`;
const publicPath = `${this.config.publicDir}${link}`;
if (!await fs.pathExists(publicPath)) {
await fs.ensureDir(path.dirname(publicPath));
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});
} else {
if (extractedFile)
await fs.remove(extractedFile);
await utils.touchFile(publicPath);
}
await db.insert({
table: 'file_hash',
replace: true,
rows: [
{id: bookPath, hash},
{id: hash, bookPath, downFileName}
]
});
return link;
}
async getBookLink(params) {
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}`;
if (!await fs.pathExists(publicPath)) {
link = '';
}
}
if (!link) {
link = await this.restoreBook(bookPath, downFileName)
}
if (!link)
throw new Error('404 Файл не найден');
return {link};
} catch(e) {
log(LM_ERR, `getBookLink error: ${e.message}`);
if (e.message.indexOf('ENOENT') >= 0)
throw new Error('404 Файл не найден');
throw e;
}
}
async restoreBookFile(publicPath) {
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);
return rec.downFileName;
} else {//bookPath не найден
throw new Error('404 Файл не найден');
}
} catch(e) {
log(LM_ERR, `restoreBookFile 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) {
data = false;
}
if (data === null)
data = await fs.readFile(this.config.inpxFile, 'base64');
return {data};
}
logServerStats() {
try {
const memUsage = process.memoryUsage().rss/(1024*1024);//Mb
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})`);
} catch (e) {
log(LM_ERR, e.message);
}
}
async periodicLogServerStats() {
while (1) {// eslint-disable-line
this.logServerStats();
await utils.sleep(60*1000);
}
}
async cleanDir(config) {
const {dir, maxSize} = config;
const list = await fs.readdir(dir);
let size = 0;
let files = [];
//формируем список
for (const filename of list) {
const filePath = `${dir}/${filename}`;
const stat = await fs.stat(filePath);
if (!stat.isDirectory()) {
size += stat.size;
files.push({name: filePath, stat});
}
}
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;
//удаляем
while (i < files.length && size > maxSize) {
const file = files[i];
const oldFile = file.name;
await fs.remove(oldFile);
size -= file.stat.size;
i++;
}
log(LM_WARN, `removed ${i} files`);
}
async periodicCleanDir(dirConfig) {
try {
for (const config of dirConfig)
await fs.ensureDir(config.dir);
let lastCleanDirTime = 0;
while (1) {// eslint-disable-line no-constant-condition
//чистка папок
if (Date.now() - lastCleanDirTime >= cleanDirPeriod) {
for (const config of dirConfig) {
try {
await this.cleanDir(config);
} catch(e) {
log(LM_ERR, e.stack);
}
}
lastCleanDirTime = Date.now();
}
await utils.sleep(60*1000);//интервал проверки 1 минута
}
} catch (e) {
log(LM_FATAL, e.message);
ayncExit.exit(1);
}
}
async periodicCheckInpx() {
const inpxCheckInterval = this.config.inpxCheckInterval;
if (!inpxCheckInterval)
return;
while (1) {// eslint-disable-line no-constant-condition
try {
while (this.myState != ssNormal)
await utils.sleep(1000);
if (this.remoteLib) {
await this.remoteLib.downloadInpxFile();
}
const newInpxHash = await this.inpxHashCreator.getHash();
const dbConfig = await this.dbConfig();
const currentInpxHash = (dbConfig.inpxHash ? dbConfig.inpxHash : '');
if (newInpxHash !== currentInpxHash) {
log('inpx file: changes found, recreating DB');
await this.recreateDb();
} else {
log('inpx file: no changes');
}
} catch(e) {
log(LM_ERR, `periodicCheckInpx: ${e.message}`);
}
await utils.sleep(inpxCheckInterval*60*1000);
}
}
}
module.exports = WebWorker;

View File

@@ -0,0 +1,62 @@
const utils = require('./utils');
const cleanInterval = 3600; //sec
const cleanAfterLastModified = cleanInterval - 60; //sec
let instance = null;
//singleton
class WorkerState {
constructor() {
if (!instance) {
this.states = {};
this.cleanStates();
instance = this;
}
return instance;
}
generateWorkerId() {
return utils.randomHexString(20);
}
getControl(workerId) {
return {
set: state => this.setState(workerId, state),
finish: state => this.finishState(workerId, state),
get: () => this.getState(workerId),
};
}
setState(workerId, state) {
this.states[workerId] = Object.assign({}, this.states[workerId], state, {
workerId,
lastModified: Date.now()
});
}
finishState(workerId, state) {
this.states[workerId] = Object.assign({}, this.states[workerId], state, {
workerId,
state: 'finish',
lastModified: Date.now()
});
}
getState(workerId) {
return this.states[workerId];
}
cleanStates() {
const now = Date.now();
for (let workerID in this.states) {
if ((now - this.states[workerID].lastModified) >= cleanAfterLastModified*1000) {
delete this.states[workerID];
}
}
setTimeout(this.cleanStates.bind(this), cleanInterval*1000);
}
}
module.exports = WorkerState;

58
server/core/ZipReader.js Normal file
View File

@@ -0,0 +1,58 @@
const StreamZip = require('node-stream-zip');
class ZipReader {
constructor() {
this.zip = null;
}
checkState() {
if (!this.zip)
throw new Error('Zip closed');
}
async open(zipFile, zipEntries = true) {
if (this.zip)
throw new Error('Zip file is already open');
const zip = new StreamZip.async({file: zipFile});
if (zipEntries)
this.zipEntries = await zip.entries();
this.zip = zip;
}
get entries() {
this.checkState();
return this.zipEntries;
}
async extractToBuf(entryFilePath) {
this.checkState();
return await this.zip.entryData(entryFilePath);
}
async extractToFile(entryFilePath, outputFile) {
this.checkState();
await this.zip.extract(entryFilePath, outputFile);
}
async extractAllToDir(outputDir) {
this.checkState();
await this.zip.extract(null, outputDir);
}
async close() {
if (this.zip) {
await this.zip.close();
this.zip = null;
this.zipEntries = undefined;
}
}
}
module.exports = ZipReader;

View File

@@ -0,0 +1,454 @@
module.exports = `
#---------- Список жанров fb2 ----------
0.1 Фантастика
0.2 Детективы и Триллеры
0.3 Проза
0.4 Любовные романы
0.5 Приключения
0.6 Литература для детей
0.7 Религия, духовность, эзотерика
0.8 Поэзия
0.9 Драматургия
0.10 Старинное
0.11 Фольклор
0.12 Наука, Образование
0.13 Искусство, Искусствоведение, Дизайн
0.14 Техника
0.15 Компьютеры и Интернет
0.16 Справочная литература
0.17 Документальная литература
0.18 Юмор
0.19 Дом и семья
0.20 Прочее
0.21 Деловая литература
0.22 Учебники и пособия
0.0 Неотсортированное
#---------- 0.1 Фантастика ----------
0.1.1 sf_history;Альтернативная история
0.1.2 sf_action;Боевая фантастика
0.1.3 sf_epic;Эпическая фантастика
0.1.4 sf_heroic;Героическая фантастика
0.1.5 sf_detective;Детективная фантастика
0.1.6 sf_cyberpunk;Киберпанк
0.1.7 sf_space;Космическая фантастика
0.1.8 sf_social;Социально-психологическая фантастика
0.1.9 sf_horror;Ужасы
0.1.10 sf_humor;Юмористическая фантастика
0.1.11 sf_fantasy;Фэнтези
0.1.12 sf;Научная Фантастика
0.1.124 sf_fantasy_city;Городское фэнтези
0.1.131 sf_postapocalyptic;Постапокалипсис
0.1.253 sf_litrpg;ЛитРПГ
0.1.226 sf_etc;Фантастика
0.1.227 russian_fantasy;Славянское фэнтези
0.1.228 sf_technofantasy;Технофэнтези
0.1.229 fairy_fantasy;Мифологическое фэнтези
0.1.230 hronoopera;Хроноопера
0.1.231 sf_mystic;Мистика
0.1.232 sf_stimpank;Стимпанк
0.1.233 modern_tale;Современная сказка
0.1.254 popadancy;Попаданцы
#---------- 0.2 Детективы и Триллеры ----------
0.2.13 det_classic;Классический детектив
0.2.14 det_police;Полицейский детектив
0.2.15 det_action;Боевик
0.2.16 det_irony;Иронический детектив, дамский детективный роман
0.2.17 det_history;Исторический детектив
0.2.18 det_espionage;Шпионский детектив
0.2.19 det_crime;Криминальный детектив
0.2.20 det_political;Политический детектив
0.2.21 det_maniac;Про маньяков
0.2.22 det_hard;Крутой детектив
0.2.23 thriller;Триллер
0.2.24 detective;Детективы
0.2.154 det_su;Советский детектив
#---------- 0.3 Проза ----------
0.3.25 prose;Проза
0.3.26 prose_classic;Классическая проза
0.3.27 prose_history;Историческая проза
0.3.28 prose_contemporary;Современная русская и зарубежная проза
0.3.29 prose_counter;Контркультура
0.3.30 prose_rus_classic;Русская классическая проза
0.3.31 prose_su_classics;Советская классическая проза
0.3.130 prose_military;Проза о войне
0.3.197 foreign_prose;Зарубежная классическая проза
0.3.198 foreign_antique;Средневековая классическая проза
0.3.199 literature_18;Классическая проза XVII-XVIII веков
0.3.200 literature_19;Классическая проза ХIX века
0.3.201 literature_20;Классическая проза ХX века
0.3.202 gothic_novel;Готический роман
0.3.203 prose_magic;Магический реализм
0.3.204 epistolary_fiction;Эпистолярная проза
0.3.205 prose_neformatny;Экспериментальная, неформатная проза
0.3.206 aphorisms;Афоризмы, цитаты
0.3.207 great_story;Роман, повесть
0.3.208 story;Малые литературные формы прозы: рассказы, эссе, новеллы, феерия
0.3.209 prose_abs;Фантасмагория, абсурдистская проза
#---------- 0.4 Любовные романы ----------
0.4.32 love_contemporary;Современные любовные романы
0.4.33 love_history;Исторические любовные романы
0.4.34 love_detective;Остросюжетные любовные романы
0.4.35 love_short;Короткие любовные романы
0.4.36 love_erotica;Эротическая литература
0.4.37 love;Любовные романы
0.4.148 love_sf;Любовное фэнтези, любовно-фантастические романы
0.4.167 love_hard;Порно
#---------- 0.5 Приключения ----------
0.5.39 adv_history;Исторические приключения
0.5.40 adv_indian;Вестерн, про индейцев
0.5.41 adv_maritime;Морские приключения
0.5.42 adv_geo;Путешествия и география
0.5.43 adv_animal;Природа и животные
0.5.44 adventure;Приключения
0.5.50 child_adv;Приключения для детей и подростков
0.5.194 adv_modern;Приключения в современном мире
0.5.195 tale_chivalry;Рыцарский роман
0.5.196 adv_story;Авантюрный роман
#---------- 0.6 Литература для детей ----------
0.6.45 child_tale;Сказки народов мира
0.6.46 child_verse;Стихи для детей
0.6.47 child_prose;Проза для детей
0.6.48 child_sf;Фантастика для детей
0.6.49 child_det;Детская остросюжетная литература
0.6.51 child_education;Детская образовательная литература
0.6.52 children;Детская литература
0.6.155 child_classical;Классическая детская литература
0.6.156 child_tale_rus;Русские сказки
0.6.157 foreign_children;Зарубежная литература для детей
0.6.158 prose_game;Игры, упражнения для детей
#---------- 0.7 Религия, духовность, эзотерика ----------
0.7.128 religion_budda;Буддизм
0.7.64 sci_religion;Религиоведение
0.7.95 religion_esoterics;Эзотерика, эзотерическая литература
0.7.96 religion_self;Самосовершенствование
0.7.97 religion;Религия, религиозная литература
0.7.214 religion_christianity;Христианство
0.7.215 religion_orthodoxy;Православие
0.7.216 religion_protestantism;Протестантизм
0.7.217 religion_catholicism;Католицизм
0.7.218 religion_judaism;Иудаизм
0.7.219 religion_hinduism;Индуизм
0.7.220 religion_islam;Ислам
0.7.221 religion_paganism;Язычество
0.7.222 astrology;Астрология и хиромантия
#---------- 0.8 Поэзия ----------
0.8.53 poetry;Поэзия
0.8.100 humor_verse;Юмористические стихи, басни
0.8.183 poetry_classical;Классическая поэзия
0.8.184 poetry_modern;Современная поэзия
0.8.185 poetry_rus_classical;Классическая русская поэзия
0.8.186 poetry_rus_modern;Современная русская поэзия
0.8.187 poetry_for_classical;Классическая зарубежная поэзия
0.8.188 poetry_for_modern;Современная зарубежная поэзия
0.8.189 poetry_east;Поэзия Востока
0.8.190 lyrics;Лирика
0.8.191 song_poetry;Песенная поэзия
0.8.192 poem;Поэма, эпическая поэзия
0.8.193 palindromes;Визуальная и экспериментальная поэзия, верлибры, палиндромы
#---------- 0.9 Драматургия ----------
0.9.54 dramaturgy;Драматургия
0.9.177 comedy;Комедия
0.9.178 tragedy;Трагедия
0.9.179 drama;Драма
0.9.180 drama_antique;Античная драма
0.9.181 screenplays;Сценарий
0.9.182 vaudeville;Мистерия, буффонада, водевиль
#---------- 0.10 Старинное ----------
0.10.55 antique_ant;Античная литература
0.10.56 antique_european;Европейская старинная литература
0.10.57 antique_russian;Древнерусская литература
0.10.58 antique_east;Древневосточная литература
0.10.60 antique;antique
#---------- 0.11 Фольклор ----------
0.11.59 antique_myths;Мифы. Легенды. Эпос
0.11.235 folklore;Фольклор, загадки folklore
0.11.236 folk_tale;Народные сказки
0.11.237 epic;Былины, эпопея
0.11.238 proverbs;Пословицы, поговорки
0.11.239 folk_songs;Народные песни
0.11.240 child_folklore;Детский фольклор
0.11.241 limerick;Частушки, прибаутки, потешки
#---------- 0.12 Наука, Образование ----------
0.12.61 sci_history;История
0.12.62 sci_psychology;Психология и психотерапия
0.12.65 sci_philosophy;Философия
0.12.66 sci_politics;Политика
0.12.68 sci_juris;Юриспруденция
0.12.69 sci_linguistic;Языкознание, иностранные языки
0.12.70 sci_medicine;Медицина
0.12.71 sci_phys;Физика
0.12.72 sci_math;Математика
0.12.73 sci_chem;Химия
0.12.74 sci_biology;Биология, биофизика, биохимия
0.12.76 science;Научная литература
0.12.126 sci_cosmos;Астрономия и Космос
0.12.125 sci_geo;Геология и география
0.12.122 sci_state;Государство и право
0.12.121 sci_economy;Экономика
0.12.149 sci_medicine_alternative;Альтернативная медицина
0.12.150 sci_philology;Литературоведение
0.12.168 sci_popular;Зарубежная образовательная литература, зарубежная прикладная, научно-популярная литература
0.12.169 military_history;Военная история
0.12.170 sci_social_studies;Обществознание, социология
0.12.171 sci_zoo;Зоология
0.12.172 sci_botany;Ботаника
0.12.173 sci_ecology;Экология
0.12.174 sci_oriental;Востоковедение
0.12.175 sci_theories;Альтернативные науки и научные теории
0.12.176 sci_veterinary;Ветеринария
#---------- 0.13 Искусство, Искусствоведение, Дизайн ----------
0.13.63 sci_culture;Культурология
0.13.127 notes;Партитуры
0.13.91 nonf_criticism;Критика
0.13.92 design;Искусство и Дизайн
0.13.242 music;Музыка
0.13.243 painting;Живопись, альбомы, иллюстрированные каталоги
0.13.244 architecture_book;Скульптура и архитектура
0.13.245 art_world_culture;Мировая художественная культура
0.13.246 cine;Кино
0.13.247 theatre;Театр
0.13.248 art_criticism;Искусствоведение
#---------- 0.14 Техника ----------
0.14.75 sci_tech;Технические науки
0.14.120 sci_build;Строительство и сопромат
0.14.119 sci_radio;Радиоэлектроника
0.14.118 sci_metal;Металлургия
0.14.117 sci_transport;Транспорт и авиация
0.14.223 military_weapon;Военное дело, военная техника и вооружение
0.14.224 auto_business;Автодело
0.14.225 equ_history;История техники
#---------- 0.15 Компьютеры и Интернет ----------
0.15.77 comp_www;ОС и Сети, интернет
0.15.79 comp_hard;Компьютерное 'железо' (аппаратное обеспечение), цифровая обработка сигналов
0.15.81 comp_db;Программирование, программы, базы данных
0.15.83 computers;Зарубежная компьютерная, околокомпьютерная литература
0.15.166 tbg_computers;Учебные пособия, самоучители
#---------- 0.16 Справочная литература ----------
0.16.84 ref_encyc;Энциклопедии
0.16.85 ref_dict;Словари
0.16.86 ref_ref;Справочники
0.16.87 ref_guide;Руководства
0.16.88 reference;Справочная литература
0.16.152 geo_guides;Путеводители, карты, атласы
#---------- 0.17 Документальная литература ----------
0.17.89 nonf_biography;Биографии и Мемуары
0.17.90 nonf_publicism;Публицистика
0.17.93 nonfiction;Документальная литература
0.17.159 nonf_military;Военная документалистика и аналитика
0.17.160 military_special;Военное дело
0.17.161 travel_notes;География, путевые заметки
#---------- 0.18 Юмор ----------
0.18.98 humor_anecdote;Анекдоты
0.18.99 humor_prose;Юмористическая проза
0.18.101 humor;Юмор
0.18.234 humor_satire;Сатира
#---------- 0.19 Дом и семья ----------
0.19.102 home_cooking;Кулинария
0.19.103 home_pets;Домашние животные
0.19.104 home_crafts;Хобби и ремесла
0.19.105 home_entertain;Развлечения
0.19.106 home_health;Здоровье
0.19.107 home_garden;Сад и огород
0.19.108 home_diy;Сделай сам
0.19.109 home_sport;Боевые искусства, спорт
0.19.110 home_sex;Семейные отношения, секс
0.19.111 home;Домоводство
0.19.162 sci_pedagogy;Педагогика, воспитание детей, литература для родителей
0.19.163 auto_regulations;Автомобили и ПДД
0.19.164 home_collecting;Коллекционирование
0.19.165 family;Семейные отношения
#---------- 0.20 Прочее ----------
0.20.112 other;Неотсортированное
0.20.153 periodic;Журналы, газеты
0.20.210 comics;Комиксы
0.20.211 unfinished;Незавершенное
0.20.212 fanfiction;Фанфик
0.20.213 network_literature;Самиздат, сетевая литература
#---------- 0.21 Деловая литература ----------
0.21.132 banking;Финансы
0.21.136 org_behavior;Маркетинг, PR
0.21.141 popular_business;Карьера, кадры
0.21.144 economics_ref;Деловая литература
0.21.147 economics;Экономика
#---------- 0.22 Учебники и пособия ----------
0.22.249 sci_textbook;Учебники и пособия
0.22.250 tbg_school;Школьные учебники и пособия, рефераты, шпаргалки
0.22.251 tbg_secondary;Учебники и пособия для среднего и специального образования
0.22.252 tbg_higher;Учебники и пособия ВУЗов
#---------- 2022-04-28 16:16:05.605170----------
#nonfb2
#---------- Список жанров НЕ-fb2 ----------#
0.1 study;Учебная литература
0.1.0 study_preschool;Дошкольникам
0.1.1 study_scool;Школьникам
0.1.2 study_students;Студентам
0.1.3 study_graduate;Аспирантам
0.2 home;Дом. Быт. Досуг
0.2.0 home_child;Дети. Книги для родителей
0.2.1 home_health;Красота. Здоровье.
0.2.2 home_handiwork;Рукоделие. Домоводство
0.2.3 home_garden;Сад, огород, цветник, дизайн участка
0.2.4 home_cooking;Кулинария
0.2.5 home_pets;Домашние питомцы
0.2.6 home_collecting;Коллекционирование
0.2.7 home_sex;Любовь, эротика
0.2.8 home_diy;Строительство, ремонт
0.2.9 home_crafts;Увлечения. Хобби
0.3 arts;Искусство
0.3.0 arts_albums;Альбомы по искусству. Фотоальбомы
0.3.1 art_visual;Изобразительное искусство. Архитектура
0.3.2 art_sci;Искусствоведение
0.3.3 art_film;Кино. Киноведение
0.3.4 art_dance;Танец. Хореография
0.3.5 art_theater;Театр. Сценическое искусство
0.3.6 design;Дизайн
0.4 languages;Иностранные языки
0.4.0 lang_rus;Русский язык
0.4.1 lag_eng;Английский язык
0.4.1.0 lang_eng_textbook;Учебники, пособия
0.4.1.1 lang_eng_dict;Словари
0.4.1.2 lang_eng_read;Домашнее чтение
0.4.1.3 lang_eng_theor;Теория и история
0.4.1.4 lang_eng_phrase;Разговорники
0.4.2 lang_arab;Арабский язык
0.4.3 lang_east;Восточные языки
0.4.4 lang_euro;Европейские языки
0.4.5 lang_spanish;Испанский язык
0.4.6 lang_it;Итальянский язык
0.4.7 lang_chi;Китайский язык
0.4.8 lang_korean;Корейский язык
0.4.9 lang_lat;Латинский язык
0.4.10 lang_de;Немецкий язык
0.4.11 lang_pl;Польский язык
0.4.12 lang_tu;Турецкий язык
0.4.13 lang_fr;Французский язык
0.4.14 lang_jap;Японский язык
0.4.15 lang_greek;Древнегреческий и другие древние языки
0.5 computers;Компьютерная литература
0.5.0 comp_soft_office;Офисные программы
0.5.1 comp_db;Базы данных
0.5.1.0 comp_db_db2;DB2
0.5.1.1 comp_db_mysql;MySQL
0.5.1.2 comp_db_mssqlserver;MS SQL Server
0.5.1.3 comp_db_oracle;Oracle
0.5.1.4 comp_db_postgresql;PostgreSQL
0.5.1.5 comp_db_sqlite;SQLite
0.5.1.6 comp_db_sybase;Sybase SQL
0.5.1.7 comp_dv_ai;Искуственный интеллект
0.5.1.8 comp_db_exp;Экспертные системы
0.5.2 comp_design;Графика. Дизайн. Мультимедиа
0.5.3 comp_www;Интернет и Web-страницы
0.5.3.0 comp_www_html;HTML
0.5.3.1 comp_www_css;CSS
0.5.3.2 comp_www_cms;CMS
0.5.4 comp_security;Компьютерная безопасность
0.5.5 comp_osnet;Сети
0.5.6 comp_soft;Компьютеры и программы
0.5.7 comp_os;Операционные системы
0.5.7.0 comp_os_windows;Windows
0.5.7.1 comp_os_linux;Linux
0.5.7.2 comp_os_unix;UNIX
0.5.8 comp_soft_dev;Разработка ПО
0.5.8.0 comp_soft_dev_alg;Алгоритмы
0.5.8.1 comp_soft_dev_man;Менеджмент
0.5.8.2 comp_soft_dev_debug;Отладка
0.5.9 comp_soft_cad;Системы проектирования
0.5.10 comp_programming;Языки и системы программирования
0.5.10.0 comp_prog_delphi;Delphi
0.5.10.1 comp_prog_pascal;Pascal
0.5.10.2 comp_prog_c;C
0.5.10.3 comp_prog_cpp;C++
0.5.10.3.0 comp_prog_gnuc;GNU C++
0.5.10.3.1 comp_prog_msvc;MS Visual Studio
0.5.10.3.2 comp_prog_qt;Qt
0.5.10.4 comp_prog_csharp;C#
0.5.10.5 comp_prog_java;Java
0.5.10.6 comp_prog_js;JavaScript
0.5.10.7 comp_prog_php;PHP
0.5.10.8 comp_prog_pyton;Pyton
0.5.10.9 comp_prog_ror;Ruby
0.5.11 comp_dig_photo;Цифровая фотография
0.5.12 comp_exam;Сертификационные экзамены
0.5.13 comp_hard;Железо
0.7 nonfiction;Публицистика
0.7.0 nonf_biography;Биографии, мемуары
0.7.1 nonf_publicism;Публицистика
0.7.2 nonf_criticism;Критика
0.8 Туризм. Фото. Спорт
0.8.0 turism;Путешествия. Туризм
0.8.1 auto;Автомобиль
0.8.3 fish;Рыбалка
0.8.4 hunt;Охота
0.8.5 sport;Спорт
0.9 religion;Религия. Изотерика
0.9.0 religion_esoterics;Эзотерика
0.9.1 religion_self;Самосовершенствование
0.9.2 religion_budda;Буддизм
0.10 science;Наука. Техника
0.10.0 sci_tech;Техника. Технические науки
0.10.0.0 sci_tech_industry;Промышленность
0.10.0.0.0 sci_tech_oil;Нефть, газ
0.10.0.0.1 sci_tech_machinery;Машиностроение
0.10.0.0.2 sci_metal;Металлургия
0.10.0.0.3 sci_tech_print;Полиграфия
0.10.0.0.4 sci_tech_chem;Химическая
0.10.0.1 sci_radio;Радиоэлектроника, радиотехника, связь
0.10.0.2 sci_build;Строительство
0.10.0.3 none;Технические науки
0.10.0.3.0 sci_tech_sopromat;Сопротивление материалов
0.10.0.3.1 sci_tech_theormech;Теория машин
0.10.0.3.2 sci_tech_ref;Справочники
0.10.0.4 sci_transport;Транспорт
0.10.0.5 sci_energy;Энергетика. Электротехника
0.10.1 none;Естественные науки
0.10.1.0 sci_phys;Физика
0.10.1.0.0 sci_phys_acustics;Акустика
0.10.1.0.1 sci_phys_quant;Квантовая механика. Теория поля
0.10.1.0.2 sci_phys_math;Математическая физика
0.10.1.0.3 sci_phys_molecular;Молекулярная физика. Физика газов и жидкостей
0.10.1.0.4 sci_phys_gen;Общие работы по физике
0.10.1.0.5 sci_phys_optics;Оптика
0.10.1.0.6 sci_phys_theor;Теоретическая физика
0.10.1.0.7 sci_phys_thermo;Термодинамика и статистическая физика
0.10.1.0.8 sci_phys_plasma;Физика плазмы
0.10.1.0.9 sci_phys_nuclear;Физика атомного ядра и элементарных частиц
0.10.1.0.10 sci_phys_solidstate;Физика твердого тела. Кристаллография
0.10.1.0.11 sci_phys_em;Электричество и магнетизм
0.10.1.0.12 sci_phys_ref;Энциклопедии, справочники, словари по физике
0.10.1.1 sci_chem;Химические
0.10.1.1.0 sci_hem_general;Общая химия
0.10.1.1.1 sci_orgchem;Органическая химия
0.10.1.1.2 sci_anachem;Аналитическая химия
0.10.1.1.3 sci_physchem;Физическая химия
0.10.1.2 none;Географические
0.10.1.3 sci_biology;Биологические
0.10.1.3.0 sci_biophys;Биофизика
0.10.1.4 sci_math;Математика
0.10.2 none;Ветеринария. Животноводство. Сельское хозяйство
0.10.3 sicial;Общественные и гуманитарные науки
0.10.3.0 sicial_var;Военное дело. Оружие. Спецслужбы
0.10.3.1 sicial_hist;История. Археология. Этнография
0.10.3.2 sicial_lit;Литературоведение. Фольклор
0.10.3.3 sicial_ped;Педагогика
0.10.3.4 sicial_pol;Политика
0.10.3.5 sicial_law;Право. Юриспруденция
0.10.3.6 sicial_psi;Психология
0.10.3.7 sicial_sicial;Социология
0.10.3.8 sicial_smi;Средства массовой информации. Книжное дело
0.10.3.9 sicial_stat;Статистика. Демография
0.10.3.10 sicial_phy;Философия
0.10.3.11 sicial_lang;Языкознание. Филологические науки
0.11 sci_medicine;Медицина
0.11.0 sci_medicine_alternative;Нетрадиционная медицина
0.12 reference;Справочная литература
0.12.0 ref_encyc;Энциклопедии
0.12.1 ref_dict;Словари
0.12.2 ref_ref;Справочники
0.12.3 ref_guide;Руководства
0.12.4 geo_guides;Путеводители
0.13 periodic;Периодика
0.13.0 periodic_newspaper;Газеты
0.13.1 periodic_mag;Журналы
0.0 none;Неотсортированное
`;

View File

@@ -0,0 +1,77 @@
const genresText = require('./genresText.js');
const genres = [];
const nonfb2Genres = [];//костылики
let nonfb2 = false;//костылики
const sec2index = {};
const lines = genresText.split('\n').map(l => l.trim());
let index = 0;
let other;//прочее в конец
const names = new Set();
for (const line of lines) {
if (line.indexOf('#nonfb2') == 0)
nonfb2 = true;
if (!line || line[0] == '#')
continue;
const p = line.indexOf(' ');
const num = line.substring(0, p).trim().split('.');
if (num.length < 2)
continue;
const section = `${num[0]}.${num[1]}`;
if (section == '0.0')
continue;
let name = line.substring(p + 1).trim();
if (!nonfb2) {
if (num.length < 3) {//раздел
if (section == '0.20') {//прочее
other = {name, value: []};
} else {
if (sec2index[section] === undefined) {
if (!genres[index])
genres[index] = {name, value: []};
sec2index[section] = index;
index++;
}
}
} else {//подраздел
const n = name.split(';').map(l => l.trim());
names.add(n[0]);
if (section == '0.20') {//прочее
other.value.push({name: n[1], value: n[0]});
} else {
const i = sec2index[section];
if (i !== undefined) {
genres[i].value.push({name: n[1], value: n[0]});
}
}
}
} else {
const n = name.split(';').map(l => l.trim());
if (!names.has(n[0]))
nonfb2Genres.push({name: n[1], value: n[0]});
names.add(n[0]);
}
}
if (other) {
if (nonfb2Genres.length) {
other.value = other.value.concat(nonfb2Genres);
}
genres.push(other);
}
//console.log(JSON.stringify(genres));
module.exports = genres;

128
server/core/utils.js Normal file
View File

@@ -0,0 +1,128 @@
const fs = require('fs-extra');
const path = require('path');
const zlib = require('zlib');
const crypto = require('crypto');
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function versionText(config) {
return `${config.name} v${config.version}, Node.js ${process.version}`;
}
async function findFiles(callback, dir, recursive = true) {
if (!(callback && dir))
return;
const files = await fs.readdir(dir, { withFileTypes: true });
for (const file of files) {
const found = path.resolve(dir, file.name);
if (file.isDirectory()) {
if (recursive)
await findFiles(callback, found);
} else {
await callback(found);
}
}
}
async function touchFile(filename) {
await fs.utimes(filename, Date.now()/1000, Date.now()/1000);
}
function hasProp(obj, prop) {
return Object.prototype.hasOwnProperty.call(obj, prop);
}
function freeMemory() {
if (global.gc) {
global.gc();
}
}
function getFileHash(filename, hashName, enc) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash(hashName);
const rs = fs.createReadStream(filename);
rs.on('error', reject);
rs.on('data', chunk => hash.update(chunk));
rs.on('end', () => resolve(hash.digest(enc)));
});
}
function getBufHash(buf, hashName, enc) {
const hash = crypto.createHash(hashName);
hash.update(buf);
return hash.digest(enc);
}
function intersectSet(arrSet) {
if (!arrSet.length)
return new Set();
let min = 0;
let size = arrSet[0].size;
for (let i = 1; i < arrSet.length; i++) {
if (arrSet[i].size < size) {
min = i;
size = arrSet[i].size;
}
}
const result = new Set();
for (const elem of arrSet[min]) {
let inAll = true;
for (let i = 0; i < arrSet.length; i++) {
if (i === min)
continue;
if (!arrSet[i].has(elem)) {
inAll = false;
break;
}
}
if (inAll)
result.add(elem);
}
return result;
}
function randomHexString(len) {
return crypto.randomBytes(len).toString('hex')
}
//async
function gzipFile(inputFile, outputFile, level = 1) {
return new Promise((resolve, reject) => {
const gzip = zlib.createGzip({level});
const input = fs.createReadStream(inputFile);
const output = fs.createWriteStream(outputFile);
input.pipe(gzip).pipe(output).on('finish', (err) => {
if (err) reject(err);
else resolve();
});
});
}
function toUnixPath(dir) {
return dir.replace(/\\/g, '/');
}
module.exports = {
sleep,
versionText,
findFiles,
touchFile,
hasProp,
freeMemory,
getFileHash,
getBufHash,
intersectSet,
randomHexString,
gzipFile,
toUnixPath,
};

44
server/createWebApp.js Normal file
View File

@@ -0,0 +1,44 @@
const fs = require('fs-extra');
const webApp = require('../dist/public.json');
const ZipReader = require('./core/ZipReader');
module.exports = async(config) => {
const verFile = `${config.publicDir}/version.txt`;
const zipFile = `${config.tempDir}/public.zip`;
if (await fs.pathExists(verFile)) {
const curPublicVersion = await fs.readFile(verFile, 'utf8');
if (curPublicVersion == config.version)
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
await fs.writeFile(zipFile, webApp.data, {encoding: 'base64'});
const zipReader = new ZipReader();
await zipReader.open(zipFile);
try {
await zipReader.extractAllToDir(config.publicDir);
} finally {
await zipReader.close();
}
//восстановим files
if (tmpFilesDir)
await fs.move(tmpFilesDir, filesDir);
await fs.writeFile(verFile, config.version);
await fs.remove(zipFile);
};

43
server/dev.js Normal file
View File

@@ -0,0 +1,43 @@
const log = new (require('./core/AppLogger'))().log;//singleton
function webpackDevMiddleware(app) {
const webpack = require('webpack');
const wpConfig = require('../build/webpack.dev.config');
const compiler = webpack(wpConfig);
const devMiddleware = require('webpack-dev-middleware');
app.use(devMiddleware(compiler, {
publicPath: wpConfig.output.publicPath,
stats: {colors: true}
}));
let hotMiddleware = require('webpack-hot-middleware');
app.use(hotMiddleware(compiler, {
log: log
}));
}
function logQueries(app) {
app.use(function(req, res, next) {
const start = Date.now();
log(`${req.method} ${req.originalUrl} ${JSON.stringify(req.body ? req.body : '').substr(0, 4000)}`);
//log(`${JSON.stringify(req.headers, null, 2)}`)
res.once('finish', () => {
log(`${Date.now() - start}ms`);
});
next();
});
}
function logErrors(app) {
app.use(function(err, req, res, next) {// eslint-disable-line no-unused-vars
log(LM_ERR, err.stack);
res.status(500).send(err.stack);
});
}
module.exports = {
webpackDevMiddleware,
logQueries,
logErrors
};

236
server/index.js Normal file
View File

@@ -0,0 +1,236 @@
const fs = require('fs-extra');
const path = require('path');
const express = require('express');
const compression = require('compression');
const http = require('http');
const WebSocket = require ('ws');
const utils = require('./core/utils');
const ayncExit = new (require('./core/AsyncExit'))();
let log;
let config;
let argv;
let branch = '';
const argvStrings = ['host', 'port', 'app-dir', 'lib-dir', 'inpx'];
function showHelp(defaultConfig) {
console.log(utils.versionText(config));
console.log(
`Usage: ${config.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
`
);
}
async function init() {
argv = require('minimist')(process.argv.slice(2), {string: argvStrings});
const dataDir = argv['app-dir'];
//config
const configManager = new (require('./config'))();//singleton
await configManager.init(dataDir);
const defaultConfig = configManager.config;
await configManager.load();
config = configManager.config;
branch = config.branch;
//dirs
config.tempDir = `${config.dataDir}/tmp`;
config.logDir = `${config.dataDir}/log`;
config.publicDir = `${config.dataDir}/public`;
configManager.config = config;
await fs.ensureDir(config.dataDir);
await fs.ensureDir(config.tempDir);
await fs.emptyDir(config.tempDir);
//logger
const appLogger = new (require('./core/AppLogger'))();//singleton
await appLogger.init(config);
log = appLogger.log;
//cli
if (argv.help) {
showHelp(defaultConfig);
ayncExit.exit(0);
} else {
log(utils.versionText(config));
log('Initializing');
}
if (argv.host) {
config.server.host = argv.host;
}
if (argv.port) {
config.server.port = argv.port;
}
if (!config.remoteLib) {
const libDir = argv['lib-dir'];
if (libDir) {
if (await fs.pathExists(libDir)) {
config.libDir = libDir;
} else {
throw new Error(`Directory "${libDir}" not exists`);
}
} else {
config.libDir = config.execDir;
}
if (argv.inpx) {
if (await fs.pathExists(argv.inpx)) {
config.inpxFile = argv.inpx;
} else {
throw new Error(`File "${argv.inpx}" not found`);
}
} else {
const inpxFiles = [];
await utils.findFiles((file) => {
if (path.extname(file) == '.inpx')
inpxFiles.push(file);
}, config.libDir, false);
if (inpxFiles.length) {
if (inpxFiles.length == 1) {
config.inpxFile = inpxFiles[0];
} else {
throw new Error(`Found more than one .inpx files: \n${inpxFiles.join('\n')}`);
}
} else {
throw new Error(`No .inpx files found here: ${config.libDir}`);
}
}
} else {
config.inpxFile = `${config.tempDir}/${utils.randomHexString(20)}`;
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;
//web app
if (branch !== 'development') {
const createWebApp = require('./createWebApp');
await createWebApp(config);
}
}
async function main() {
const log = new (require('./core/AppLogger'))().log;//singleton
//server
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server, maxPayload: config.maxPayloadSize*1024*1024 });
let devModule = undefined;
if (branch == 'development') {
const devFileName = './dev.js'; //require ignored by pkg -50Mb executable size
devModule = require(devFileName);
devModule.webpackDevMiddleware(app);
}
app.use(compression({ level: 1 }));
//app.use(express.json({limit: `${config.maxPayloadSize}mb`}));
if (devModule)
devModule.logQueries(app);
initStatic(app, config);
const { WebSocketController } = require('./controllers');
new WebSocketController(wss, config);
if (devModule) {
devModule.logErrors(app);
} else {
app.use(function(err, req, res, next) {// eslint-disable-line no-unused-vars
log(LM_ERR, err.stack);
res.sendStatus(500);
});
}
server.listen(config.server.port, config.server.host, () => {
config.server.ready = true;
log(`Server ready`);
});
}
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) {
if (log)
log(LM_FATAL, (branch == 'development' ? e.stack : e.message));
else
console.error(branch == 'development' ? e.stack : e.message);
ayncExit.exit(1);
}
})();