280 Commits
1.0.4 ... 1.3.1

Author SHA1 Message Date
Book Pauk
9bc92a7346 Merge branch 'release/1.3.1' 2022-11-26 01:03:13 +07:00
Book Pauk
53264da6bd Версия 1.3.1 2022-11-26 01:02:44 +07:00
Book Pauk
53e328ac2d Поправлен versionText 2022-11-26 00:05:35 +07:00
Book Pauk
59c2d73c05 Merge tag '1.3.0' into develop
1.3.0
2022-11-24 21:11:10 +07:00
Book Pauk
fc729218ba Merge branch 'release/1.3.0' 2022-11-24 21:11:05 +07:00
Book Pauk
15778eb3e4 Версия 1.3.0 2022-11-24 20:59:43 +07:00
Book Pauk
ad1a6560fa Добавлены цели для сборки релиза macos 2022-11-24 20:58:33 +07:00
Book Pauk
7fa203eaae Поправлен readme 2022-11-24 20:50:04 +07:00
Book Pauk
74d8cd3f94 Добавлен basic-auth для opds 2022-11-24 20:37:35 +07:00
Book Pauk
fd29532cf1 + "express-basic-auth": "^1.2.1" 2022-11-24 20:14:07 +07:00
Book Pauk
1dc169d14b Работа над opds 2022-11-24 19:52:51 +07:00
Book Pauk
870f95a51f Работа над opds 2022-11-24 19:08:34 +07:00
Book Pauk
72ab94291c Рефакторинг 2022-11-24 18:29:14 +07:00
Book Pauk
8de33fbd9a Работа над opds 2022-11-24 17:50:35 +07:00
Book Pauk
fd9bc45fb1 Работа над opds 2022-11-24 17:20:11 +07:00
Book Pauk
e356b87494 Работа над opds 2022-11-24 16:54:56 +07:00
Book Pauk
4371e1a641 Работа над opds 2022-11-24 16:36:40 +07:00
Book Pauk
95da605cb9 Рефакторинг 2022-11-24 16:04:27 +07:00
Book Pauk
6dfa551b97 Работа над opds 2022-11-23 20:57:41 +07:00
Book Pauk
6a3b919f5f Работа над opds 2022-11-23 19:17:08 +07:00
Book Pauk
cac8e7c721 Поправки багов 2022-11-23 18:08:31 +07:00
Book Pauk
410aa01ac9 Поправки багов 2022-11-23 17:31:47 +07:00
Book Pauk
a8ed8b29e5 Работа над opds 2022-11-23 17:03:33 +07:00
Book Pauk
5a04e4f0c7 Работа над opds 2022-11-23 14:59:29 +07:00
Book Pauk
a6d9df7dec Работа над opds 2022-11-23 14:38:23 +07:00
Book Pauk
8cf370c79d Работа над opds 2022-11-23 01:21:29 +07:00
Book Pauk
35925dbc6e Работа над opds 2022-11-22 20:09:00 +07:00
Book Pauk
d0e79b0abb + "he": "^1.2.0" 2022-11-22 19:55:54 +07:00
Book Pauk
aba0c206f8 Работа над opds 2022-11-20 19:52:10 +07:00
Book Pauk
037b42a5b4 Работа над opds 2022-11-20 19:22:54 +07:00
Book Pauk
8a71c4040c Начата работа над opds 2022-11-20 17:47:15 +07:00
Book Pauk
e685f136e1 Поправка мелкого бага 2022-11-18 20:45:38 +07:00
Book Pauk
b8b40e8cb0 Поправлен readme 2022-11-17 18:00:05 +07:00
Book Pauk
7e9f446079 В релиз добавлен readme.html 2022-11-17 17:56:46 +07:00
Book Pauk
13c3c98c63 Поправил readme 2022-11-17 17:53:47 +07:00
Book Pauk
1b70259ea7 "showdown": "^2.1.0" 2022-11-17 17:53:28 +07:00
Book Pauk
a840fb7233 Поправка отображения Inpx инфо 2022-11-17 17:08:06 +07:00
Book Pauk
1ba54c1237 Добавлено описание параметров конфига queryCacheMemSize, queryCacheDiskSize 2022-11-17 16:58:09 +07:00
Book Pauk
412335c0f1 В конфиг добавлены параметры queryCacheMemSize, queryCacheDiskSize 2022-11-17 16:51:12 +07:00
Book Pauk
6b91c43655 "jembadb": "^5.1.3" 2022-11-17 14:01:40 +07:00
Book Pauk
4b4865b6ed Переход на Uint32Array 2022-11-16 20:37:18 +07:00
Book Pauk
d5931138e3 Переход на Uint32Array 2022-11-16 20:27:53 +07:00
Book Pauk
3d1385da6e "jembadb": "^5.1.2" 2022-11-16 20:08:28 +07:00
Book Pauk
5630feba36 "jembadb": "^5.1.1" 2022-11-16 19:32:51 +07:00
Book Pauk
64a301eda1 "jembadb": "^5.1.0" 2022-11-16 18:56:46 +07:00
Book Pauk
044ab1ab26 Поправлена обработка ошибок 2022-11-15 00:16:45 +07:00
Book Pauk
d6260e3433 Оптимизация использования памяти при загрузке маппингов 2022-11-15 00:03:10 +07:00
Book Pauk
fb2eb62a98 Merge tag '1.2.4' into develop
1.2.4
2022-11-14 16:39:53 +07:00
Book Pauk
d7c6b0e7ab Merge branch 'release/1.2.4' 2022-11-14 16:39:48 +07:00
Book Pauk
94922f3926 Версия 1.2.4 2022-11-14 16:39:24 +07:00
Book Pauk
a580b1eb6d Добавлено отображение постера в отдельном окне 2022-11-14 16:37:11 +07:00
Book Pauk
cd7b8afb29 Рефакторинг 2022-11-14 15:09:13 +07:00
Book Pauk
e634893ff3 Добавил .stop.prevent для событий @click 2022-11-14 14:46:23 +07:00
Book Pauk
fadc7ddc34 Merge tag '1.2.3' into develop
1.2.3
2022-11-13 02:04:13 +07:00
Book Pauk
ed5dc25d94 Merge branch 'release/1.2.3' 2022-11-13 02:04:01 +07:00
Book Pauk
dd11e8c5ad Версия 1.2.3 2022-11-13 02:03:25 +07:00
Book Pauk
2db2b8cff4 Решение проблемы скачивания файлов в режиме "Удаленная библиотека"
(запрашивался не тот файл из-за несовпадения bookId)
2022-11-13 01:59:42 +07:00
Book Pauk
4d3661b758 Мелкая поправка 2022-11-13 00:11:26 +07:00
Book Pauk
891b1e4fe8 Поправка мелкого бага 2022-11-12 23:56:50 +07:00
Book Pauk
d588b16885 Merge tag '1.2.2' into develop
1.2.2

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

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

View File

@@ -2,19 +2,24 @@ inpx-web
========
Веб-сервер для поиска по .inpx-коллекции.
Выглядит это так: https://lib.omnireader.ru
Выглядит следующим образом: [https://lib.omnireader.ru](https://lib.omnireader.ru)
.inpx - индексный файл для импорта\экспорта информации из базы данных сетевых библиотек
в базу каталогизатора [MyHomeLib](https://alex80.github.io/mhl/)
или [freeLib](http://sourceforge.net/projects/freelibdesign)
или [LightLib](https://lightlib.azurewebsites.net)
Просто поместите приложение `inpx-web` в папку с .inpx-файлом и файлами библиотеки (zip-архивами) и запустите.
Сервер будет доступен по адресу http://127.0.0.1:12380
[Установка](#usage): просто поместить приложение `inpx-web` в папку с .inpx-файлом и файлами библиотеки (zip-архивами) и запустить.
После открытия веб-приложения в бразуере, для быстрого понимания того, как работает поиск, воспользуйтесь памяткой (кнопка со знаком вопроса).
По умолчанию, веб-сервер будет доступен по адресу [http://127.0.0.1:12380](http://127.0.0.1:12380)
##
OPDS-сервер доступен по адресу [http://127.0.0.1:12380/opds](http://127.0.0.1:12380/opds)
Для указания местоположения .inpx-файла или папки с файлами библиотеки, воспользуйтесь [параметрами командной строки](#cli).
Дополнительные параметры сервера настраиваются в [конфигурационном файле](#config).
##
* [Возможности программы](#capabilities)
* [Использование](#usage)
* [Параметры командной строки](#cli)
@@ -28,6 +33,7 @@ inpx-web
<a id="capabilities" />
## Возможности программы
- веб-интерфейс и OPDS-сервер
- поиск по автору, серии, названию и пр.
- скачивание книги, копирование ссылки или открытие в читалке
- возможность указать рабочий каталог при запуске, а также расположение .inpx и файлов библиотеки
@@ -42,7 +48,12 @@ inpx-web
## Использование
Поместите приложение `inpx-web` в папку с .inpx-файлом и файлами библиотеки и запустите.
По умолчанию сервер будет доступен по адресу http://127.0.0.1:12380
Там же, при первом запуске, будет создана рабочая директория `.inpx-web`, в которой хранится
конфигурационный файл `config.json`, файлы базы данных, журналы и прочее.
По умолчанию веб-интерфейс будет доступен по адресу [http://127.0.0.1:12380](http://127.0.0.1:12380)
OPDS-сервер доступен по адресу [http://127.0.0.1:12380/opds](http://127.0.0.1:12380/opds)
<a id="cli" />
@@ -78,13 +89,25 @@ Options:
// включить(true)/выключить(false) журналирование
"loggingEnabled": true,
// максимальный размер кеша каждой таблицы в БД, в блоках (требуется примерно 1-10Мб памяти на один блок)
// если надо кешировать всю БД, можно поставить значение от 1000 и больше
"dbCacheSize": 5,
// максимальный размер в байтах директории закешированных файлов в <раб.дир>/public/files
// чистка каждый час
"maxFilesDirSize": 1073741824,
// включить(true)/выключить(false) кеширование запросов на сервере
// включить(true)/выключить(false) серверное кеширование запросов на диске и в памяти
"queryCacheEnabled": true,
// размер кеша запросов в оперативной памяти (количество)
// 0 - отключить кеширование запросов в оперативной памяти
"queryCacheMemSize": 50,
// размер кеша запросов на диске (количество)
// 0 - отключить кеширование запросов на диске
"queryCacheDiskSize": 500,
// периодичность чистки кеша запросов на сервере, в минутах
// 0 - отключить чистку
"cacheCleanInterval": 60,
@@ -99,6 +122,10 @@ Options:
// во столько же раз увеличивается время создания
"lowMemoryMode": false,
// включить(true)/выключить(false) полную оптимизацию поисковой БД
// ускоряет работу поиска, но увеличивает размер БД в 2-3 раза при импорте INPX
"fullOptimization": false,
// включить(true)/выключить(false) режим "Удаленная библиотека" (сервер)
"allowRemoteLib": false,
@@ -110,6 +137,14 @@ Options:
"server": {
"host": "0.0.0.0",
"port": "12380"
},
// настройки opds-сервера
// user, password используются для Basic HTTP authentication
"opds": {
"enabled": true,
"user": "",
"password": ""
}
}
```
@@ -142,7 +177,7 @@ Options:
```
Если сервер работает по протоколу `http://`, то указываем протокол `ws://`, а для `https://` соответственно `wss://`.
Пароль не обязателен, но необходим в случае, если сервер тоже "смотрит" в интернет, для ограничения доступа к его веб-интерфесу.
Пароль не обязателен, но необходим в случае, если сервер тоже "смотрит" в интернет, для ограничения доступа к его веб-интерфейсу.
При указании `"remoteLib": {...}` настройки командной строки --inpx и --lib-dir игнорируются,
т.к. файлы .inpx-индекса и библиотеки используются удаленно.
@@ -150,7 +185,7 @@ Options:
### Фильтр по авторам и книгам
При создании поисковой БД во время загрузки и парсинга .inpx-файла, имеется возможность
При создании поисковой БД, во время загрузки и парсинга .inpx-файла, имеется возможность
отфильтровать авторов и книги, задав определенные критерии. Для этого небходимо создать
в рабочей директории (там же, где `config.json`) файл `filter.json` следующего вида:
```json
@@ -165,7 +200,7 @@ Options:
"excludeAuthors": ["Имя автора"]
}
```
При создании поисковой БД, авторы и книги из `includeAuthors` будут добавлены, а из `excludeAuthors` исключены.
При фильтрации, авторы и их книги из `includeAuthors` будут оставлены, а из `excludeAuthors` исключены.
Использование совместно `includeAuthors` и `excludeAuthors` имеет мало смысла, поэтому для включения
определенных авторов можно использовать только `includeAuthors`:
```json
@@ -245,17 +280,12 @@ cd inpx-web
npm i
```
#### Для платформы Windows
#### Релизы
```sh
npm run build:win
npm run release
```
#### Для платформы Linux
```sh
npm run build:linux
```
Результат сборки будет доступен в каталоге `dist/linux|win` в виде исполнимого (standalone) файла.
Результат сборки будет доступен в каталоге `dist/release`
<a id="development" />

View File

@@ -2,6 +2,8 @@ const fs = require('fs-extra');
const path = require('path');
const { execSync } = require('child_process');
const showdown = require('showdown');
const platform = process.argv[2];
const distDir = path.resolve(__dirname, '../dist');
@@ -10,11 +12,17 @@ const publicDir = `${tmpDir}/public`;
const outDir = `${distDir}/${platform}`;
async function build() {
if (platform != 'linux' && platform != 'win')
if (platform != 'linux' && platform != 'win' && platform != 'macos')
throw new Error(`Unknown platform: ${platform}`);
await fs.emptyDir(outDir);
//добавляем readme в релиз
let readme = await fs.readFile(path.resolve(__dirname, '../README.md'), 'utf-8');
const converter = new showdown.Converter();
readme = converter.makeHtml(readme);
await fs.writeFile(`${outDir}/readme.html`, readme);
// перемещаем public на место
if (await fs.pathExists(publicDir)) {

View File

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

View File

@@ -35,24 +35,22 @@ import vueComponent from '../vueComponent.js';
import wsc from './webSocketConnection';
import * as utils from '../../share/utils';
import * as cryptoUtils from '../../share/cryptoUtils';
import LockQueue from '../../share/LockQueue';
import LockQueue from '../../../server/core/LockQueue';
import packageJson from '../../../package.json';
const rotor = '|/-\\';
const stepBound = [
0,
0,// jobStep = 1
18,// jobStep = 2
20,// jobStep = 3
60,// jobStep = 4
72,// jobStep = 5
72,// jobStep = 6
74,// jobStep = 7
75,// jobStep = 8
79,// jobStep = 9
79,// jobStep = 10
80,// jobStep = 11
100,// jobStep = 12
40,// jobStep = 2
50,// jobStep = 3
54,// jobStep = 4
58,// jobStep = 5
69,// jobStep = 6
69,// jobStep = 7
70,// jobStep = 8
95,// jobStep = 9
100,// jobStep = 10
];
const componentOptions = {
@@ -185,80 +183,64 @@ class Api {
}
async request(params, timeoutSecs = 10) {
let errCount = 0;
while (1) {// eslint-disable-line
if (this.accessToken)
params.accessToken = this.accessToken;
try {
if (this.accessToken)
params.accessToken = this.accessToken;
const response = await wsc.message(await wsc.send(params), timeoutSecs);
const response = await wsc.message(await wsc.send(params), timeoutSecs);
if (response && response.error == 'need_access_token') {
await this.showPasswordDialog();
} else if (response && response.error == 'server_busy') {
await this.showBusyDialog();
} else {
return response;
if (response && response.error == 'need_access_token') {
await this.showPasswordDialog();
} else if (response && response.error == 'server_busy') {
await this.showBusyDialog();
} else {
if (response.error) {
throw new Error(response.error);
}
return response;
}
errCount = 0;
} catch(e) {
errCount++;
if (e.message !== 'WebSocket не отвечает' || errCount > 10) {
errCount = 0;
throw e;
}
await utils.sleep(100);
}
}
}
async search(query) {
const response = await this.request({action: 'search', query});
if (response.error) {
throw new Error(response.error);
}
return response;
async search(from, query) {
return await this.request({action: 'search', from, query}, 30);
}
async getBookList(authorId) {
const response = await this.request({action: 'get-book-list', authorId});
if (response.error) {
throw new Error(response.error);
}
return response;
async getAuthorBookList(authorId) {
return await this.request({action: 'get-author-book-list', authorId});
}
async getSeriesBookList(series) {
const response = await this.request({action: 'get-series-book-list', series});
if (response.error) {
throw new Error(response.error);
}
return response;
return await this.request({action: 'get-series-book-list', series});
}
async getGenreTree() {
const response = await this.request({action: 'get-genre-tree'});
if (response.error) {
throw new Error(response.error);
}
return response;
return await this.request({action: 'get-genre-tree'});
}
async getBookLink(params) {
const response = await this.request(Object.assign({action: 'get-book-link'}, params), 120);
async getBookLink(bookUid) {
return await this.request({action: 'get-book-link', bookUid}, 120);
}
if (response.error) {
throw new Error(response.error);
}
return response;
async getBookInfo(bookUid) {
return await this.request({action: 'get-book-info', bookUid}, 120);
}
async getConfig() {
const response = await this.request({action: 'get-config'});
if (response.error) {
throw new Error(response.error);
}
return response;
return await this.request({action: 'get-config'});
}
}

View File

@@ -121,7 +121,7 @@ body, html, #app {
padding: 0;
width: 100%;
height: 100%;
font: normal 12px GameDefault;
font: normal 13px Web Default;
}
.dborder {
@@ -133,6 +133,15 @@ body, html, #app {
animation: rotating 2s linear infinite;
}
.q-dialog__inner--minimized {
padding: 10px !important;
}
.q-dialog__inner--minimized > div {
max-height: 100% !important;
max-width: 800px !important;
}
@keyframes rotating {
from {
transform: rotate(0deg);
@@ -142,9 +151,13 @@ body, html, #app {
}
@font-face {
font-family: 'GameDefault';
src: url('fonts/web-default.woff') format('woff'),
url('fonts/web-default.ttf') format('truetype');
font-family: 'Web Default';
src: url('fonts/web-default.ttf') format('truetype');
}
@font-face {
font-family: 'Verdana';
font-weight: bold;
src: url('fonts/web-default-bold.ttf') format('truetype');
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,151 @@
<template>
<Dialog ref="dialog" v-model="dialogVisible">
<template #header>
<div class="row items-center" style="font-size: 110%">
<q-icon class="q-mr-sm text-green" name="la la-cog" size="28px"></q-icon>
Настройки
</div>
</template>
<div class="q-mx-md column" style="min-width: 300px; font-size: 120%;">
<div class="row items-center q-ml-sm">
<div class="q-mr-sm">
Результатов на странице
</div>
<q-select
v-model="limit" :options="limitOptions" class="bg-white"
dropdown-icon="la la-angle-down la-sm"
outlined dense emit-value map-options
/>
</div>
<q-checkbox v-model="showCounts" size="36px" label="Показывать количество" />
<q-checkbox v-model="showRates" size="36px" label="Показывать оценки" />
<q-checkbox v-model="showInfo" size="36px" label="Показывать кнопку (инфо)" />
<q-checkbox v-model="showGenres" size="36px" label="Показывать жанры" />
<q-checkbox v-model="showDates" size="36px" label="Показывать даты поступления" />
<q-checkbox v-model="showDeleted" size="36px" label="Показывать удаленные" />
<q-checkbox v-model="abCacheEnabled" size="36px" label="Кешировать запросы" />
</div>
<template #footer>
<q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="okClick">
OK
</q-btn>
</template>
</Dialog>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../../vueComponent.js';
import Dialog from '../../share/Dialog.vue';
const componentOptions = {
components: {
Dialog
},
watch: {
modelValue(newValue) {
this.dialogVisible = newValue;
},
dialogVisible(newValue) {
this.$emit('update:modelValue', newValue);
},
settings() {
this.loadSettings();
},
limit(newValue) {
this.commit('setSettings', {'limit': newValue});
},
showCounts(newValue) {
this.commit('setSettings', {'showCounts': newValue});
},
showRates(newValue) {
this.commit('setSettings', {'showRates': newValue});
},
showInfo(newValue) {
this.commit('setSettings', {'showInfo': newValue});
},
showGenres(newValue) {
this.commit('setSettings', {'showGenres': newValue});
},
showDates(newValue) {
this.commit('setSettings', {'showDates': newValue});
},
showDeleted(newValue) {
this.commit('setSettings', {'showDeleted': newValue});
},
abCacheEnabled(newValue) {
this.commit('setSettings', {'abCacheEnabled': newValue});
},
}
};
class SettingsDialog {
_options = componentOptions;
_props = {
modelValue: Boolean,
};
dialogVisible = false;
//settings
limit = 20;
showCounts = true;
showRates = true;
showInfo = true;
showGenres = true;
showDates = true;
showDeleted = false;
abCacheEnabled = true;
limitOptions = [
{label: '10', value: 10},
{label: '20', value: 20},
{label: '50', value: 50},
{label: '100', value: 100},
{label: '200', value: 200},
{label: '500', value: 500},
{label: '1000', value: 1000},
];
created() {
this.commit = this.$store.commit;
this.loadSettings();
}
mounted() {
}
get settings() {
return this.$store.state.settings;
}
loadSettings() {
const settings = this.settings;
this.limit = settings.limit;
this.showCounts = settings.showCounts;
this.showRates = settings.showRates;
this.showInfo = settings.showInfo;
this.showGenres = settings.showGenres;
this.showDates = settings.showDates;
this.showDeleted = settings.showDeleted;
this.abCacheEnabled = settings.abCacheEnabled;
}
okClick() {
this.dialogVisible = false;
}
}
export default vueComponent(SettingsDialog);
//-----------------------------------------------------------------------------
</script>
<style scoped>
</style>

View File

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

View File

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

Binary file not shown.

View File

@@ -4,6 +4,7 @@
<i :class="icon" :style="`font-size: ${iconSize}px; margin-top: ${imt}px`" />
<slot></slot>
</div>
<slot name="tooltip"></slot>
</div>
</template>

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import dayjs from 'dayjs';
import {Buffer} from 'safe-buffer';
//import _ from 'lodash';
@@ -38,7 +39,11 @@ export function wordEnding(num, type = 0) {
['ов', '', 'а', 'а', 'а', 'ов', 'ов', 'ов', 'ов', 'ов'],
['й', 'я', 'и', 'и', 'и', 'й', 'й', 'й', 'й', 'й'],
['о', '', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],
['ий', 'ие', 'ия', 'ия', 'ия', 'ий', 'ий', 'ий', 'ий', 'ий']
['ий', 'ие', 'ия', 'ия', 'ия', 'ий', 'ий', 'ий', 'ий', 'ий'],
['о', 'а', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],
['ок', 'ка', 'ки', 'ки', 'ки', 'ок', 'ок', 'ок', 'ок', 'ок'],
['ых', 'ое', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых'],
['о', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],
];
const deci = num % 100;
if (deci > 10 && deci < 20) {
@@ -82,15 +87,48 @@ export async function copyTextToClipboard(text) {
return result;
}
export function makeValidFilename(filename, repl = '_') {
let f = filename.replace(/[\x00\\/:*"<>|]/g, repl); // eslint-disable-line no-control-regex
f = f.trim();
while (f.length && (f[f.length - 1] == '.' || f[f.length - 1] == '_')) {
f = f.substring(0, f.length - 1);
}
/*
export function formatDate(d, format = 'normal') {
switch (format) {
case 'normal':
return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.${d.getFullYear()} ` +
`${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
case 'coDate':
return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
case 'coMonth':
return `${(d.getMonth() + 1).toString().padStart(2, '0')}`;
case 'noDate':
return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.${d.getFullYear()}`;
if (f)
return f;
else
throw new Error('Invalid filename');
default:
throw new Error('formatDate: unknown date format');
}
}
export function parseDate(sqlDate) {
const d = sqlDate.split('-');
const result = new Date();
result.setDate(parseInt(d[2], 10));
result.setMonth(parseInt(d[1], 10) - 1);
result.setYear(parseInt(d[0], 10));
return result;
}
*/
export function isDigit(c) {
return !isNaN(parseInt(c, 10));
}
export function dateFormat(date, format = 'DD.MM.YYYY') {
return dayjs(date).format(format);
}
export function sqlDateFormat(date, format = 'DD.MM.YYYY') {
return dayjs(date, 'YYYY-MM-DD').format(format);
}
export function isManualDate(date) {
return date && (date[0] == ',' || (isDigit(date[0]) && isDigit(date[1])));
}

View File

@@ -3,12 +3,15 @@ const state = {
config: {},
settings: {
accessToken: '',
extendedParams: false,
limit: 20,
expanded: [],
expandedAuthor: [],
expandedSeries: [],
showCounts: true,
showRate: true,
showRates: true,
showInfo: true,
showGenres: true,
showDates: false,
showDeleted: false,
abCacheEnabled: true,
langDefault: '',

306
package-lock.json generated
View File

@@ -1,21 +1,25 @@
{
"name": "inpx-web",
"version": "1.0.4",
"version": "1.3.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "inpx-web",
"version": "1.0.4",
"version": "1.3.1",
"hasInstallScript": true,
"license": "CC0-1.0",
"dependencies": {
"@quasar/extras": "^1.15.0",
"axios": "^0.27.2",
"compression": "^1.7.4",
"chardet": "^1.5.0",
"dayjs": "^1.11.6",
"express": "^4.18.1",
"express-basic-auth": "^1.2.1",
"fs-extra": "^10.1.0",
"jembadb": "^4.2.0",
"he": "^1.2.0",
"iconv-lite": "^0.6.3",
"jembadb": "^5.1.4",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"minimist": "^1.2.6",
@@ -47,6 +51,7 @@
"html-webpack-plugin": "^5.5.0",
"mini-css-extract-plugin": "^2.6.1",
"pkg": "^5.8.0",
"showdown": "^2.1.0",
"terser-webpack-plugin": "^5.3.3",
"vue-eslint-parser": "^9.0.3",
"vue-loader": "^17.0.0",
@@ -2572,6 +2577,22 @@
}
]
},
"node_modules/basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
"dependencies": {
"safe-buffer": "5.1.2"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/basic-auth/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@@ -2645,6 +2666,17 @@
"ms": "2.0.0"
}
},
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/body-parser/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -2736,14 +2768,6 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true
},
"node_modules/bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
"integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -2817,6 +2841,11 @@
"node": ">=4"
}
},
"node_modules/chardet": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-1.5.0.tgz",
"integrity": "sha512-Nj3VehbbFs/1ZnJJJaL3ztEf3Nu5Fs6YV/NBs6lyz/iDDHUU+X9QNk5QgPy1/5Rjtb/cGVf+NyazP7kVEJqKRg=="
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
@@ -2954,52 +2983,6 @@
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
"dev": true
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz",
"integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==",
"dependencies": {
"accepts": "~1.3.5",
"bytes": "3.0.0",
"compressible": "~2.0.16",
"debug": "2.6.9",
"on-headers": "~1.0.2",
"safe-buffer": "5.1.2",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/compression/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3469,6 +3452,11 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.20.tgz",
"integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA=="
},
"node_modules/dayjs": {
"version": "1.11.6",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.6.tgz",
"integrity": "sha512-zZbY5giJAinCG+7AGaw0wIhNZ6J8AhWuSXKvuc1KAyMiRsvGQWqh4L+MomvhdAYjN+lqvVCMq1I41e3YHvXkyQ=="
},
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -4240,6 +4228,14 @@
"node": ">= 0.10.0"
}
},
"node_modules/express-basic-auth": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/express-basic-auth/-/express-basic-auth-1.2.1.tgz",
"integrity": "sha512-L6YQ1wQ/mNjVLAmK3AG1RK6VkokA1BIY6wmiH304Xtt/cLTps40EusZsU1Uop+v9lTDPxdtzbFmdXfFO3KEnwA==",
"dependencies": {
"basic-auth": "^2.0.1"
}
},
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -4732,7 +4728,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"dev": true,
"bin": {
"he": "bin/he"
}
@@ -4835,11 +4830,11 @@
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
@@ -5077,9 +5072,9 @@
}
},
"node_modules/jembadb": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/jembadb/-/jembadb-4.2.0.tgz",
"integrity": "sha512-wnqUwaZSWU99hJYHPBhXJVRYHA1aQVjpt5fDHMuXaz7VWZqK9DhLgNDIKD9z8czICz56ECTR2xlVBpDgBnuQVA==",
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/jembadb/-/jembadb-5.1.4.tgz",
"integrity": "sha512-VGg800ZhEXDdWCJ1y2ZcgRqdBWo2g+/55LusBh3r9S3ruKJeRkwVv4nGqHXbtdW60QJNsSwUyQbzcku8zhzvXw==",
"engines": {
"node": ">=16.16.0"
}
@@ -5811,14 +5806,6 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -7065,6 +7052,17 @@
"node": ">= 0.8"
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -7505,6 +7503,31 @@
"node": ">=8"
}
},
"node_modules/showdown": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz",
"integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==",
"dev": true,
"dependencies": {
"commander": "^9.0.0"
},
"bin": {
"showdown": "bin/showdown.js"
},
"funding": {
"type": "individual",
"url": "https://www.paypal.me/tiviesantos"
}
},
"node_modules/showdown/node_modules/commander": {
"version": "9.4.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz",
"integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==",
"dev": true,
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
@@ -10710,6 +10733,21 @@
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true
},
"basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
"requires": {
"safe-buffer": "5.1.2"
},
"dependencies": {
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
}
}
},
"big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@@ -10772,6 +10810,14 @@
"ms": "2.0.0"
}
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"requires": {
"safer-buffer": ">= 2.1.2 < 3"
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -10832,11 +10878,6 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true
},
"bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
"integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw=="
},
"call-bind": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -10891,6 +10932,11 @@
"supports-color": "^5.3.0"
}
},
"chardet": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-1.5.0.tgz",
"integrity": "sha512-Nj3VehbbFs/1ZnJJJaL3ztEf3Nu5Fs6YV/NBs6lyz/iDDHUU+X9QNk5QgPy1/5Rjtb/cGVf+NyazP7kVEJqKRg=="
},
"chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
@@ -11006,48 +11052,6 @@
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
"dev": true
},
"compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"requires": {
"mime-db": ">= 1.43.0 < 2"
}
},
"compression": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz",
"integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==",
"requires": {
"accepts": "~1.3.5",
"bytes": "3.0.0",
"compressible": "~2.0.16",
"debug": "2.6.9",
"on-headers": "~1.0.2",
"safe-buffer": "5.1.2",
"vary": "~1.1.2"
},
"dependencies": {
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"requires": {
"ms": "2.0.0"
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
}
}
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -11383,6 +11387,11 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.20.tgz",
"integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA=="
},
"dayjs": {
"version": "1.11.6",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.6.tgz",
"integrity": "sha512-zZbY5giJAinCG+7AGaw0wIhNZ6J8AhWuSXKvuc1KAyMiRsvGQWqh4L+MomvhdAYjN+lqvVCMq1I41e3YHvXkyQ=="
},
"debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -11963,6 +11972,14 @@
}
}
},
"express-basic-auth": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/express-basic-auth/-/express-basic-auth-1.2.1.tgz",
"integrity": "sha512-L6YQ1wQ/mNjVLAmK3AG1RK6VkokA1BIY6wmiH304Xtt/cLTps40EusZsU1Uop+v9lTDPxdtzbFmdXfFO3KEnwA==",
"requires": {
"basic-auth": "^2.0.1"
}
},
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -12340,8 +12357,7 @@
"he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"dev": true
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
},
"html-entities": {
"version": "2.3.3",
@@ -12412,11 +12428,11 @@
}
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"requires": {
"safer-buffer": ">= 2.1.2 < 3"
"safer-buffer": ">= 2.1.2 < 3.0.0"
}
},
"icss-utils": {
@@ -12578,9 +12594,9 @@
"dev": true
},
"jembadb": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/jembadb/-/jembadb-4.2.0.tgz",
"integrity": "sha512-wnqUwaZSWU99hJYHPBhXJVRYHA1aQVjpt5fDHMuXaz7VWZqK9DhLgNDIKD9z8czICz56ECTR2xlVBpDgBnuQVA=="
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/jembadb/-/jembadb-5.1.4.tgz",
"integrity": "sha512-VGg800ZhEXDdWCJ1y2ZcgRqdBWo2g+/55LusBh3r9S3ruKJeRkwVv4nGqHXbtdW60QJNsSwUyQbzcku8zhzvXw=="
},
"jest-worker": {
"version": "27.5.1",
@@ -13118,11 +13134,6 @@
"ee-first": "1.1.1"
}
},
"on-headers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA=="
},
"once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -13970,6 +13981,14 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"requires": {
"safer-buffer": ">= 2.1.2 < 3"
}
}
}
},
@@ -14311,6 +14330,23 @@
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true
},
"showdown": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz",
"integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==",
"dev": true,
"requires": {
"commander": "^9.0.0"
},
"dependencies": {
"commander": {
"version": "9.4.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz",
"integrity": "sha512-5EEkTNyHNGFPD2H+c/dXXfQZYa/scCKasxWcXJaWnNJ99pnQN9Vnmqow+p+PlFPE63Q6mThaZws1T+HxfpgtPw==",
"dev": true
}
}
},
"side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "inpx-web",
"version": "1.0.4",
"version": "1.3.1",
"author": "Book Pauk <bookpauk@gmail.com>",
"license": "CC0-1.0",
"repository": "bookpauk/inpx-web",
@@ -12,8 +12,9 @@
"build:client": "webpack --config build/webpack.prod.config.js",
"build:linux": "npm run build:client && node build/prepkg.js linux && pkg -t node16-linux-x64 -C GZip --options max-old-space-size=4096,expose-gc -o dist/linux/inpx-web .",
"build:win": "npm run build:client && node build/prepkg.js win && pkg -t node16-win-x64 -C GZip --options max-old-space-size=4096,expose-gc -o dist/win/inpx-web .",
"build:macos": "npm run build:client && node build/prepkg.js macos && pkg -t node16-macos-x64 -C GZip --options max-old-space-size=4096,expose-gc -o dist/macos/inpx-web .",
"build:client-dev": "webpack --config build/webpack.dev.config.js",
"build:all": "npm run build:linux && npm run build:win",
"build:all": "npm run build:linux && npm run build:win && npm run build:macos",
"release": "npm run build:all && node build/release.js",
"postinstall": "npm run build:client-dev"
},
@@ -38,6 +39,7 @@
"html-webpack-plugin": "^5.5.0",
"mini-css-extract-plugin": "^2.6.1",
"pkg": "^5.8.0",
"showdown": "^2.1.0",
"terser-webpack-plugin": "^5.3.3",
"vue-eslint-parser": "^9.0.3",
"vue-loader": "^17.0.0",
@@ -51,10 +53,14 @@
"dependencies": {
"@quasar/extras": "^1.15.0",
"axios": "^0.27.2",
"compression": "^1.7.4",
"chardet": "^1.5.0",
"dayjs": "^1.11.6",
"express": "^4.18.1",
"express-basic-auth": "^1.2.1",
"fs-extra": "^10.1.0",
"jembadb": "^4.2.0",
"he": "^1.2.0",
"iconv-lite": "^0.6.3",
"jembadb": "^5.1.4",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"minimist": "^1.2.6",

View File

@@ -14,14 +14,22 @@ module.exports = {
bookReadLink: '',
loggingEnabled: true,
//поправить в случае, если были критические изменения в DbCreator или InpxParser
//иначе будет рассинхронизация между сервером и клиентом на уровне БД
dbVersion: '8',
dbCacheSize: 5,
maxPayloadSize: 500,//in MB
maxFilesDirSize: 1024*1024*1024,//1Gb
queryCacheEnabled: true,
queryCacheMemSize: 50,
queryCacheDiskSize: 500,
cacheCleanInterval: 60,//minutes
inpxCheckInterval: 60,//minutes
lowMemoryMode: false,
fullOptimization: false,
webConfigParams: ['name', 'version', 'branch', 'bookReadLink'],
webConfigParams: ['name', 'version', 'branch', 'bookReadLink', 'dbVersion'],
allowRemoteLib: false,
remoteLib: false,
@@ -37,5 +45,11 @@ module.exports = {
host: '0.0.0.0',
port: '22380',
},
//opds: false,
opds: {
enabled: true,
user: '',
password: '',
},
};

View File

@@ -8,14 +8,19 @@ const propsToSave = [
'accessPassword',
'bookReadLink',
'loggingEnabled',
'dbCacheSize',
'maxFilesDirSize',
'queryCacheEnabled',
'queryCacheMemSize',
'queryCacheDiskSize',
'cacheCleanInterval',
'inpxCheckInterval',
'lowMemoryMode',
'fullOptimization',
'allowRemoteLib',
'remoteLib',
'server',
'opds',
];
let instance = null;
@@ -82,15 +87,28 @@ class ConfigManager {
}
async load() {
if (!this.inited)
throw new Error('not inited');
if (!await fs.pathExists(this.userConfigFile)) {
await this.save();
return;
}
try {
if (!this.inited)
throw new Error('not inited');
const data = await fs.readFile(this.userConfigFile, 'utf8');
this.config = JSON.parse(data);
if (await fs.pathExists(this.userConfigFile)) {
const data = JSON.parse(await fs.readFile(this.userConfigFile, 'utf8'));
const config = _.pick(data, propsToSave);
this.config = config;
//сохраним конфиг, если не все атрибуты присутствуют в файле конфига
for (const prop of propsToSave)
if (!Object.prototype.hasOwnProperty.call(config, prop)) {
await this.save();
break;
}
} else {
await this.save();
}
} catch(e) {
throw new Error(`Error while loading "${this.userConfigFile}": ${e.message}`);
}
}
async save() {

View File

@@ -76,14 +76,16 @@ class WebSocketController {
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-author-book-list':
await this.getAuthorBookList(req, ws); break;
case 'get-series-book-list':
await this.getSeriesBookList(req, ws); break;
case 'get-genre-tree':
await this.getGenreTree(req, ws); break;
case 'get-book-link':
await this.getBookLink(req, ws); break;
case 'get-book-info':
await this.getBookInfo(req, ws); break;
case 'get-inpx-file':
await this.getInpxFile(req, ws); break;
@@ -107,7 +109,7 @@ class WebSocketController {
ws.send(message);
if (this.isDevelopment) {
log(`WebSocket-OUT: ${message.substr(0, 4000)}`);
log(`WebSocket-OUT: ${message.substr(0, 200)}`);
}
}
@@ -136,25 +138,21 @@ class WebSocketController {
async search(req, ws) {
if (!req.query)
throw new Error(`query is empty`);
if (!req.from)
throw new Error(`from is empty`);
const result = await this.webWorker.search(req.query);
const result = await this.webWorker.search(req.from, req.query);
this.send(result, req, ws);
}
async getBookList(req, ws) {
if (!utils.hasProp(req, 'authorId'))
throw new Error(`authorId is empty`);
const result = await this.webWorker.getBookList(req.authorId);
async getAuthorBookList(req, ws) {
const result = await this.webWorker.getAuthorBookList(req.authorId);
this.send(result, req, ws);
}
async getSeriesBookList(req, ws) {
if (!utils.hasProp(req, 'series'))
throw new Error(`series is empty`);
const result = await this.webWorker.getSeriesBookList(req.series);
this.send(result, req, ws);
@@ -167,12 +165,19 @@ class WebSocketController {
}
async getBookLink(req, ws) {
if (!utils.hasProp(req, 'bookPath'))
throw new Error(`bookPath is empty`);
if (!utils.hasProp(req, 'downFileName'))
throw new Error(`downFileName is empty`);
if (!utils.hasProp(req, 'bookUid'))
throw new Error(`bookUid is empty`);
const result = await this.webWorker.getBookLink({bookPath: req.bookPath, downFileName: req.downFileName});
const result = await this.webWorker.getBookLink(req.bookUid);
this.send(result, req, ws);
}
async getBookInfo(req, ws) {
if (!utils.hasProp(req, 'bookUid'))
throw new Error(`bookUid is empty`);
const result = await this.webWorker.getBookInfo(req.bookUid);
this.send(result, req, ws);
}

View File

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

View File

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

View File

@@ -1,7 +1,11 @@
const fs = require('fs-extra');
//const _ = require('lodash');
const LockQueue = require('./LockQueue');
const utils = require('./utils');
const maxLimit = 1000;
const emptyFieldValue = '?';
const maxUtf8Char = String.fromCodePoint(0xFFFFF);
const ruAlphabet = 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя';
const enAlphabet = 'abcdefghijklmnopqrstuvwxyz';
@@ -10,13 +14,27 @@ const enruArr = (ruAlphabet + enAlphabet).split('');
class DbSearcher {
constructor(config, db) {
this.config = config;
this.queryCacheMemSize = this.config.queryCacheMemSize;
this.queryCacheDiskSize = this.config.queryCacheDiskSize;
this.queryCacheEnabled = this.config.queryCacheEnabled
&& (this.queryCacheMemSize > 0 || this.queryCacheDiskSize > 0);
this.db = db;
this.lock = new LockQueue();
this.searchFlag = 0;
this.timer = null;
this.closed = false;
this.memCache = new Map();
this.bookIdMap = {};
this.periodicCleanCache();//no await
this.fillBookIdMapAll();//no await
}
queryKey(q) {
return JSON.stringify([q.author, q.series, q.title, q.genre, q.lang, q.del, q.date, q.librate]);
}
getWhere(a) {
@@ -28,75 +46,72 @@ class DbSearcher {
//особая обработка префиксов
if (a[0] == '=') {
a = a.substring(1);
where = `@@dirtyIndexLR('value', ${db.esc(a)}, ${db.esc(a)})`;
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) )`;
where = `@indexIter('value', (v) => (v !== ${db.esc(emptyFieldValue)} && v.indexOf(${db.esc(a)}) >= 0) )`;
} else if (a[0] == '#') {
a = a.substring(1);
where = `@@indexIter('value', (v) => {
where = `@indexIter('value', (v) => {
const enru = new Set(${db.esc(enruArr)});
return !v || (!enru.has(v[0].toLowerCase()) && v.indexOf(${db.esc(a)}) >= 0);
});`;
return !v || (v !== ${db.esc(emptyFieldValue)} && !enru.has(v[0]) && v.indexOf(${db.esc(a)}) >= 0);
})`;
} else {
where = `@@dirtyIndexLR('value', ${db.esc(a)}, ${db.esc(a + maxUtf8Char)})`;
where = `@dirtyIndexLR('value', ${db.esc(a)}, ${db.esc(a + maxUtf8Char)})`;
}
return where;
}
async selectAuthorIds(query) {
async selectBookIds(query) {
const db = this.db;
let authorIds = new Set();
const idsArr = [];
//сначала выберем все id авторов по фильтру
//порядок id соответсвует ASC-сортировке по author
if (query.author && query.author !== '*') {
const where = this.getWhere(query.author);
const tableBookIds = async(table, where) => {
const rows = await db.select({
table,
rawResult: true,
where: `
const ids = ${where};
const authorRows = await db.select({
table: 'author',
dirtyIdsOnly: true,
where
const result = new Set();
for (const id of ids) {
const row = @unsafeRow(id);
for (const bookId of row.bookIds)
result.add(bookId);
}
return new Uint32Array(result);
`
});
for (const row of authorRows)
authorIds.add(row.id);
} else {//все авторы
if (!db.searchCache.authorIdsAll) {
const authorRows = await db.select({
table: 'author',
dirtyIdsOnly: true,
});
return rows[0].rawResult;
};
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);
//авторы
if (query.author && query.author !== '*') {
const key = `book-ids-author-${query.author}`;
let ids = await this.getCached(key);
if (ids === null) {
ids = await tableBookIds('author', this.getWhere(query.author));
await this.putCached(key, ids);
}
}
const idsArr = [];
idsArr.push(authorIds);
idsArr.push(ids);
}
//серии
if (query.series && query.series !== '*') {
const where = this.getWhere(query.series);
const key = `book-ids-series-${query.series}`;
let ids = await this.getCached(key);
const seriesRows = await db.select({
table: 'series',
map: `(r) => ({authorId: r.authorId})`,
where
});
if (ids === null) {
ids = await tableBookIds('series', this.getWhere(query.series));
const ids = new Set();
for (const row of seriesRows) {
for (const id of row.authorId)
ids.add(id);
await this.putCached(key, ids);
}
idsArr.push(ids);
@@ -104,45 +119,49 @@ class DbSearcher {
//названия
if (query.title && query.title !== '*') {
const where = this.getWhere(query.title);
const key = `book-ids-title-${query.title}`;
let ids = await this.getCached(key);
let titleRows = await db.select({
table: 'title',
map: `(r) => ({authorId: r.authorId})`,
where
});
if (ids === null) {
ids = await tableBookIds('title', this.getWhere(query.title));
const ids = new Set();
for (const row of titleRows) {
for (const id of row.authorId)
ids.add(id);
await this.putCached(key, ids);
}
idsArr.push(ids);
//чистки памяти при тяжелых запросах
if (query.title[0] == '*') {
titleRows = null;
utils.freeMemory();
await db.freeMemory();
}
}
//жанры
if (query.genre) {
const genres = query.genre.split(',');
const key = `book-ids-genre-${query.genre}`;
let ids = await this.getCached(key);
const ids = new Set();
for (const g of genres) {
if (ids === null) {
const genreRows = await db.select({
table: 'genre',
map: `(r) => ({authorId: r.authorId})`,
where: `@@indexLR('value', ${db.esc(g)}, ${db.esc(g)})`,
rawResult: true,
where: `
const genres = ${db.esc(query.genre.split(','))};
const ids = new Set();
for (const g of genres) {
for (const id of @indexLR('value', g, g))
ids.add(id);
}
const result = new Set();
for (const id of ids) {
const row = @unsafeRow(id);
for (const bookId of row.bookIds)
result.add(bookId);
}
return new Uint32Array(result);
`
});
for (const row of genreRows) {
for (const id of row.authorId)
ids.add(id);
}
ids = genreRows[0].rawResult;
await this.putCached(key, ids);
}
idsArr.push(ids);
@@ -150,143 +169,470 @@ class DbSearcher {
//языки
if (query.lang) {
const langs = query.lang.split(',');
const key = `book-ids-lang-${query.lang}`;
let ids = await this.getCached(key);
const ids = new Set();
for (const l of langs) {
if (ids === null) {
const langRows = await db.select({
table: 'lang',
map: `(r) => ({authorId: r.authorId})`,
where: `@@indexLR('value', ${db.esc(l)}, ${db.esc(l)})`,
rawResult: true,
where: `
const langs = ${db.esc(query.lang.split(','))};
const ids = new Set();
for (const l of langs) {
for (const id of @indexLR('value', l, l))
ids.add(id);
}
const result = new Set();
for (const id of ids) {
const row = @unsafeRow(id);
for (const bookId of row.bookIds)
result.add(bookId);
}
return new Uint32Array(result);
`
});
for (const row of langRows) {
for (const id of row.authorId)
ids.add(id);
}
ids = langRows[0].rawResult;
await this.putCached(key, ids);
}
idsArr.push(ids);
}
if (idsArr.length > 1)
authorIds = utils.intersectSet(idsArr);
//удаленные
if (query.del !== undefined) {
const key = `book-ids-del-${query.del}`;
let ids = await this.getCached(key);
//сортировка
authorIds = Array.from(authorIds);
authorIds.sort((a, b) => a - b);
if (ids === null) {
ids = await tableBookIds('del', `@indexLR('value', ${db.esc(query.del)}, ${db.esc(query.del)})`);
return authorIds;
await this.putCached(key, ids);
}
idsArr.push(ids);
}
//дата поступления
if (query.date) {
const key = `book-ids-date-${query.date}`;
let ids = await this.getCached(key);
if (ids === null) {
let [from = '', to = ''] = query.date.split(',');
ids = await tableBookIds('date', `@indexLR('value', ${db.esc(from)} || undefined, ${db.esc(to)} || undefined)`);
await this.putCached(key, ids);
}
idsArr.push(ids);
}
//оценка
if (query.librate) {
const key = `book-ids-librate-${query.librate}`;
let ids = await this.getCached(key);
if (ids === null) {
const dateRows = await db.select({
table: 'librate',
rawResult: true,
where: `
const rates = ${db.esc(query.librate.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n)))};
const ids = new Set();
for (const rate of rates) {
for (const id of @indexLR('value', rate, rate))
ids.add(id);
}
const result = new Set();
for (const id of ids) {
const row = @unsafeRow(id);
for (const bookId of row.bookIds)
result.add(bookId);
}
return new Uint32Array(result);
`
});
ids = dateRows[0].rawResult;
await this.putCached(key, ids);
}
idsArr.push(ids);
}
if (idsArr.length > 1) {
//ищем пересечение множеств
let proc = 0;
let nextProc = 0;
let inter = new Set(idsArr[0]);
for (let i = 1; i < idsArr.length; i++) {
const newInter = new Set();
for (const id of idsArr[i]) {
if (inter.has(id))
newInter.add(id);
//прерываемся иногда, чтобы не блокировать Event Loop
proc++;
if (proc >= nextProc) {
nextProc += 10000;
await utils.processLoop();
}
}
inter = newInter;
}
return new Uint32Array(inter);
} else if (idsArr.length == 1) {
return idsArr[0];
} else {
return false;
}
}
async getAuthorIds(query) {
const db = this.db;
async fillBookIdMap(from) {
if (this.bookIdMap[from])
return this.bookIdMap[from];
if (!db.searchCache)
db.searchCache = {};
await this.lock.get();
try {
const data = await fs.readFile(`${this.config.dataDir}/db/${from}_id.map`, 'utf-8');
let result;
const idMap = JSON.parse(data);
idMap.arr = new Uint32Array(idMap.arr);
idMap.map = new Map(idMap.map);
//сначала попробуем найти в кеше
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);
this.bookIdMap[from] = idMap;
} else {//непустой запрос
if (this.config.queryCacheEnabled) {
const key = JSON.stringify(keyArr);
const rows = await db.select({table: 'query_cache', where: `@@id(${db.esc(key)})`});
return this.bookIdMap[from];
} finally {
this.lock.ret();
}
}
if (rows.length) {//нашли в кеше
await db.insert({
table: 'query_time',
replace: true,
rows: [{id: key, time: Date.now()}],
});
async fillBookIdMapAll() {
try {
await this.fillBookIdMap('author');
await this.fillBookIdMap('series');
await this.fillBookIdMap('title');
} catch (e) {
throw new Error(`DbSearcher.fillBookIdMapAll error: ${e.message}`)
}
}
result = rows[0].value;
} else {//не нашли в кеше, ищем в поисковых таблицах
result = await this.selectAuthorIds(query);
async tableIdsFilter(from, query) {
//т.к. авторы у книги идут списком (т.е. одна книга относиться сразу к нескольким авторам),
//то в выборку по bookId могут попасть авторы, которые отсутствуют в критерии query.author,
//поэтому дополнительно фильтруем
let result = null;
if (from == 'author' && query.author && query.author !== '*') {
const key = `filter-ids-author-${query.author}`;
let authorIds = await this.getCached(key);
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);
if (authorIds === null) {
const rows = await this.db.select({
table: 'author',
rawResult: true,
where: `return new Uint32Array(${this.getWhere(query.author)})`
});
authorIds = rows[0].rawResult;
await this.putCached(key, authorIds);
}
result = new Set(authorIds);
}
return result;
}
async search(query) {
async selectTableIds(from, query) {
const db = this.db;
const queryKey = this.queryKey(query);
const tableKey = `${from}-table-ids-${queryKey}`;
let tableIds = await this.getCached(tableKey);
if (tableIds === null) {
const bookKey = `book-ids-${queryKey}`;
let bookIds = await this.getCached(bookKey);
if (bookIds === null) {
bookIds = await this.selectBookIds(query);
await this.putCached(bookKey, bookIds);
}
//id книг (bookIds) нашли, теперь надо их смаппировать в id таблицы from (авторов, серий, названий)
if (bookIds) {
//т.к. авторы у книги идут списком, то дополнительно фильтруем
const filter = await this.tableIdsFilter(from, query);
const tableIdsSet = new Set();
const idMap = await this.fillBookIdMap(from);
let proc = 0;
let nextProc = 0;
for (const bookId of bookIds) {
const tableId = idMap.arr[bookId];
if (tableId) {
if (!filter || filter.has(tableId))
tableIdsSet.add(tableId);
proc++;
} else {
const tableIdArr = idMap.map.get(bookId);
if (tableIdArr) {
for (const tableId of tableIdArr) {
if (!filter || filter.has(tableId))
tableIdsSet.add(tableId);
proc++;
}
}
}
//прерываемся иногда, чтобы не блокировать Event Loop
if (proc >= nextProc) {
nextProc += 10000;
await utils.processLoop();
}
}
tableIds = new Uint32Array(tableIdsSet);
} else {//bookIds пустой - критерии не заданы, значит берем все id из from
const rows = await db.select({
table: from,
rawResult: true,
where: `return new Uint32Array(@all())`
});
tableIds = rows[0].rawResult;
}
//сортируем по id
//порядок id соответствует ASC-сортировке по строковому значению из from (имя автора, назание серии, название книги)
tableIds.sort((a, b) => a - b);
await this.putCached(tableKey, tableIds);
}
return tableIds;
}
async restoreBooks(from, ids) {
const db = this.db;
const bookTable = `${from}_book`;
const rows = await db.select({
table: bookTable,
where: `@@id(${db.esc(ids)})`
});
if (rows.length == ids.length)
return rows;
//далее восстановим книги из book в <from>_book
const idsSet = new Set(rows.map(r => r.id));
//недостающие
const tableIds = [];
for (const id of ids) {
if (!idsSet.has(id))
tableIds.push(id);
}
const tableRows = await db.select({
table: from,
where: `@@id(${db.esc(tableIds)})`
});
//список недостающих bookId
const bookIds = [];
for (const row of tableRows) {
for (const bookId of row.bookIds)
bookIds.push(bookId);
}
//выбираем книги
const books = await db.select({
table: 'book',
where: `@@id(${db.esc(bookIds)})`
});
const booksMap = new Map();
for (const book of books)
booksMap.set(book.id, book);
//распределяем
for (const row of tableRows) {
const books = [];
for (const bookId of row.bookIds) {
const book = booksMap.get(bookId);
if (book)
books.push(book);
}
rows.push({id: row.id, name: row.name, books});
}
await db.insert({table: bookTable, ignore: true, rows});
return rows;
}
async search(from, query) {
if (this.closed)
throw new Error('DbSearcher closed');
if (!['author', 'series', 'title'].includes(from))
throw new Error(`Unknown value for param 'from'`);
this.searchFlag++;
try {
const db = this.db;
const authorIds = await this.getAuthorIds(query);
const ids = await this.selectTableIds(from, query);
const totalFound = authorIds.length;
const totalFound = ids.length;
let limit = (query.limit ? query.limit : 100);
limit = (limit > 1000 ? 1000 : limit);
limit = (limit > maxLimit ? maxLimit : 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))})`
const slice = ids.slice(offset, offset + limit);
//выборка найденных значений
const found = await db.select({
table: from,
map: `(r) => ({id: r.id, ${from}: r.name, bookCount: r.bookCount, bookDelCount: r.bookDelCount})`,
where: `@@id(${db.esc(Array.from(slice))})`
});
return {result, totalFound};
//для title восстановим books
if (from == 'title') {
const bookIds = found.map(r => r.id);
const rows = await this.restoreBooks(from, bookIds);
const rowsMap = new Map();
for (const row of rows)
rowsMap.set(row.id, row);
for (const f of found) {
const b = rowsMap.get(f.id);
if (b)
f.books = b.books;
}
}
return {found, totalFound};
} finally {
this.searchFlag--;
}
}
async getBookList(authorId) {
async opdsQuery(from, query) {
if (this.closed)
throw new Error('DbSearcher closed');
if (!['author', 'series', 'title'].includes(from))
throw new Error(`Unknown value for param 'from'`);
this.searchFlag++;
try {
const db = this.db;
//выборка автора по authorId
const rows = await db.select({
table: 'author_book',
where: `@@id(${db.esc(authorId)})`
});
const depth = query.depth || 1;
const queryKey = this.queryKey(query);
const opdsKey = `${from}-opds-d${depth}-${queryKey}`;
let result = await this.getCached(opdsKey);
let author = '';
if (result === null) {
const ids = await this.selectTableIds(from, query);
const totalFound = ids.length;
//группировка по name длиной depth
const found = await db.select({
table: from,
rawResult: true,
where: `
const depth = ${db.esc(depth)};
const group = new Map();
const ids = ${db.esc(Array.from(ids))};
for (const id of ids) {
const row = @unsafeRow(id);
const s = row.value.substring(0, depth);
let g = group.get(s);
if (!g) {
g = {id: row.id, name: row.name, value: s, count: 0};
group.set(s, g);
}
g.count++;
}
const result = Array.from(group.values());
result.sort((a, b) => a.value.localeCompare(b.value));
return result;
`
});
result = {found: found[0].rawResult, totalFound};
await this.putCached(opdsKey, result);
}
return result;
} finally {
this.searchFlag--;
}
}
async getAuthorBookList(authorId, author) {
if (this.closed)
throw new Error('DbSearcher closed');
if (!authorId && !author)
return {author: '', books: ''};
this.searchFlag++;
try {
const db = this.db;
if (!authorId) {
//восстановим authorId
authorId = 0;
author = author.toLowerCase();
const rows = await db.select({
table: 'author',
rawResult: true,
where: `return Array.from(@dirtyIndexLR('value', ${db.esc(author)}, ${db.esc(author)}))`
});
if (rows.length && rows[0].rawResult.length)
authorId = rows[0].rawResult[0];
}
//выборка книг автора по authorId
const rows = await this.restoreBooks('author', [authorId]);
let authorName = '';
let books = '';
if (rows.length) {
author = rows[0].author;
authorName = rows[0].name;
books = rows[0].books;
}
return {author, books};
return {author: authorName, books: (books && books.length ? JSON.stringify(books) : '')};
} finally {
this.searchFlag--;
}
@@ -296,24 +642,124 @@ class DbSearcher {
if (this.closed)
throw new Error('DbSearcher closed');
if (!series)
return {books: ''};
this.searchFlag++;
try {
const db = this.db;
series = series.toLowerCase();
//выборка серии по названию серии
const rows = await db.select({
let rows = await db.select({
table: 'series',
where: `@@dirtyIndexLR('value', ${db.esc(series)}, ${db.esc(series)})`
rawResult: true,
where: `return Array.from(@dirtyIndexLR('value', ${db.esc(series)}, ${db.esc(series)}))`
});
return {books: (rows.length ? rows[0].books : '')};
let books;
if (rows.length && rows[0].rawResult.length) {
//выборка книг серии
const bookRows = await this.restoreBooks('series', [rows[0].rawResult[0]])
if (bookRows.length)
books = bookRows[0].books;
}
return {books: (books && books.length ? JSON.stringify(books) : '')};
} finally {
this.searchFlag--;
}
}
async getCached(key) {
if (!this.queryCacheEnabled)
return null;
let result = null;
const db = this.db;
const memCache = this.memCache;
if (this.queryCacheMemSize > 0 && memCache.has(key)) {//есть в недавних
result = memCache.get(key);
//изменим порядок ключей, для последующей правильной чистки старых
memCache.delete(key);
memCache.set(key, result);
} else if (this.queryCacheDiskSize > 0) {//смотрим в таблице
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;
//заполняем кеш в памяти
if (this.queryCacheMemSize > 0) {
memCache.set(key, result);
if (memCache.size > this.queryCacheMemSize) {
//удаляем самый старый ключ-значение
for (const k of memCache.keys()) {
memCache.delete(k);
break;
}
}
}
}
}
return result;
}
async putCached(key, value) {
if (!this.queryCacheEnabled)
return;
const db = this.db;
if (this.queryCacheMemSize > 0) {
const memCache = this.memCache;
memCache.set(key, value);
if (memCache.size > this.queryCacheMemSize) {
//удаляем самый старый ключ-значение
for (const k of memCache.keys()) {
memCache.delete(k);
break;
}
}
}
if (this.queryCacheDiskSize > 0) {
//кладем в таблицу асинхронно
(async() => {
try {
await db.insert({
table: 'query_cache',
replace: true,
rows: [{id: key, value}],
});
await db.insert({
table: 'query_time',
replace: true,
rows: [{id: key, time: Date.now()}],
});
} catch(e) {
console.error(`putCached: ${e.message}`);
}
})();
}
}
async periodicCleanCache() {
this.timer = null;
const cleanInterval = this.config.cacheCleanInterval*60*1000;
@@ -321,21 +767,37 @@ class DbSearcher {
return;
try {
if (!this.queryCacheEnabled || this.queryCacheDiskSize <= 0)
return;
const db = this.db;
const oldThres = Date.now() - cleanInterval;
let rows = await db.select({table: 'query_time', count: true});
const delCount = rows[0].count - this.queryCacheDiskSize;
//выберем всех кандидатов на удаление
const rows = await db.select({
if (delCount < 1)
return;
//выберем delCount кандидатов на удаление
rows = await db.select({
table: 'query_time',
rawResult: true,
where: `
@@iter(@all(), (r) => (r.time < ${db.esc(oldThres)}));
const delCount = ${delCount};
const rows = [];
@unsafeIter(@all(), (r) => {
rows.push(r);
return false;
});
rows.sort((a, b) => a.time - b.time);
return rows.slice(0, delCount).map(r => r.id);
`
});
const ids = [];
for (const row of rows)
ids.push(row.id);
const ids = rows[0].rawResult;
//удаляем
await db.delete({table: 'query_cache', where: `@@id(${db.esc(ids)})`});
@@ -356,6 +818,8 @@ class DbSearcher {
await utils.sleep(50);
}
this.searchCache = null;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;

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

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

View File

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

View File

@@ -1,4 +1,5 @@
const path = require('path');
const crypto = require('crypto');
const ZipReader = require('./ZipReader');
const collectionInfo = 'collection.info';
@@ -70,9 +71,8 @@ class InpxParser {
this.chunk = [];
for (const inpFile of inpFiles) {
await readFileCallback({fileName: inpFile, current: ++current});
const buf = await zipReader.extractToBuf(inpFile);
await this.parseInp(buf, structure, parsedCallback);
await this.parseInp(zipReader, inpFile, structure, parsedCallback);
}
if (this.chunk.length) {
@@ -84,10 +84,13 @@ class InpxParser {
}
}
async parseInp(inpBuf, structure, parsedCallback) {
const structLen = structure.length;
async parseInp(zipReader, inpFile, structure, parsedCallback) {
const inpBuf = await zipReader.extractToBuf(inpFile);
const rows = inpBuf.toString().split('\n');
const defaultFolder = `${path.basename(inpFile, '.inp')}.zip`;
const structLen = structure.length;
for (const row of rows) {
let line = row;
if (!line)
@@ -96,9 +99,13 @@ class InpxParser {
if (line[line.length - 1] == '\x0D')
line = line.substring(0, line.length - 1);
const rec = {};
//уникальный идентификатор записи
const sha256 = crypto.createHash('sha256');
rec._uid = sha256.update(line).digest('base64');
//парсим запись
const parts = line.split('\x04');
const rec = {};
const len = (parts.length > structLen ? structLen : parts.length);
for (let i = 0; i < len; i++) {
@@ -115,6 +122,9 @@ class InpxParser {
rec.genre = rec.genre.split(':').filter(s => s).join(',');
}
if (!rec.folder)
rec.folder = defaultFolder;
rec.serno = parseInt(rec.serno, 10) || 0;
rec.size = parseInt(rec.size, 10) || 0;
rec.del = parseInt(rec.del, 10) || 0;

View File

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

View File

@@ -58,14 +58,14 @@ class RemoteLib {
}
}
async downloadBook(bookPath, downFileName) {
async downloadBook(bookUid) {
try {
const response = await await this.wsRequest({action: 'get-book-link', bookPath, downFileName});
const response = await await this.wsRequest({action: 'get-book-link', bookUid});
const link = response.link;
const buf = await this.down.load(`${this.remoteHost}${link}`, {decompress: false});
const publicPath = `${this.config.publicDir}${link}`;
const publicPath = `${this.config.publicFilesDir}${link}`;
await fs.writeFile(publicPath, buf);

View File

@@ -5,7 +5,7 @@ const _ = require('lodash');
const ZipReader = require('./ZipReader');
const WorkerState = require('./WorkerState');//singleton
const { JembaDbThread } = require('jembadb');
const { JembaDb, JembaDbThread } = require('jembadb');
const DbCreator = require('./DbCreator');
const DbSearcher = require('./DbSearcher');
const InpxHashCreator = require('./InpxHashCreator');
@@ -15,6 +15,7 @@ const ayncExit = new (require('./AsyncExit'))();
const log = new (require('./AppLogger'))().log;//singleton
const utils = require('./utils');
const genreTree = require('./genres');
const Fb2Helper = require('./fb2/Fb2Helper');
//server states
const ssNormal = 'normal';
@@ -44,6 +45,7 @@ class WebWorker {
}
this.inpxHashCreator = new InpxHashCreator(config);
this.fb2Helper = new Fb2Helper();
this.inpxFileHash = '';
this.wState = this.workerState.getControl('server_state');
@@ -58,8 +60,8 @@ class WebWorker {
const dirConfig = [
{
dir: `${this.config.publicDir}/files`,
maxSize: this.config.maxFilesDirSize,
dir: config.filesDir,
maxSize: config.maxFilesDirSize,
},
];
@@ -108,7 +110,7 @@ class WebWorker {
softLock: true,
tableDefaults: {
cacheSize: 5,
cacheSize: config.dbCacheSize,
},
});
@@ -132,7 +134,7 @@ class WebWorker {
}
}
async loadOrCreateDb(recreate = false) {
async loadOrCreateDb(recreate = false, iteration = 0) {
this.setMyState(ssDbLoading);
try {
@@ -141,18 +143,35 @@ class WebWorker {
this.inpxFileHash = await this.inpxHashCreator.getInpxFileHash();
//пересоздаем БД из INPX если нужно
//проверим полный InxpHash (включая фильтр и версию БД)
//для этого заглянем в конфиг внутри БД, если он есть
if (!(config.recreateDb || recreate) && await fs.pathExists(dbPath)) {
const newInpxHash = await this.inpxHashCreator.getHash();
const tmpDb = new JembaDb();
await tmpDb.lock({dbPath, softLock: true});
try {
await tmpDb.open({table: 'config'});
const rows = await tmpDb.select({table: 'config', where: `@@id('inpxHash')`});
if (!rows.length || newInpxHash !== rows[0].value)
throw new Error('inpx file: changes found on start, recreating DB');
} catch (e) {
log(LM_WARN, e.message);
recreate = true;
} finally {
await tmpDb.unlock();
}
}
//удалим БД если нужно
if (config.recreateDb || recreate)
await fs.remove(dbPath);
//пересоздаем БД из INPX если нужно
if (!await fs.pathExists(dbPath)) {
try {
await this.createDb(dbPath);
} catch (e) {
//при ошибке создания БД удалим ее, чтобы не работать с поломанной базой при следующем запуске
await fs.remove(dbPath);
throw e;
}
await this.createDb(dbPath);
utils.freeMemory();
}
@@ -160,32 +179,49 @@ class WebWorker {
this.setMyState(ssDbLoading);
log('Searcher DB loading');
const db = new JembaDbThread();
const db = new JembaDbThread();//в отдельном потоке
await db.lock({
dbPath,
softLock: true,
tableDefaults: {
cacheSize: 5,
cacheSize: config.dbCacheSize,
},
});
//открываем все таблицы
await db.openAll();
try {
//открываем таблицы
await db.openAll({exclude: ['author_id', 'series_id', 'title_id', 'book']});
const bookCacheSize = 500;
await db.open({
table: 'book',
cacheSize: (config.lowMemoryMode || config.dbCacheSize > bookCacheSize ? config.dbCacheSize : bookCacheSize)
});
} catch(e) {
log(LM_ERR, `Database error: ${e.message}`);
if (iteration < 1) {
log('Recreating DB');
await this.loadOrCreateDb(true, iteration + 1);
} else
throw e;
return;
}
//поисковый движок
this.dbSearcher = new DbSearcher(config, db);
//stuff
db.wwCache = {};
this.db = db;
log('Searcher DB ready');
this.setMyState(ssNormal);
log('Searcher DB ready');
this.logServerStats();
} catch (e) {
log(LM_FATAL, e.message);
ayncExit.exit(1);
} finally {
this.setMyState(ssNormal);
}
}
@@ -220,29 +256,33 @@ class WebWorker {
return db.wwCache.config;
}
async search(query) {
async search(from, query) {
this.checkMyState();
const result = await this.dbSearcher.search(from, query);
const config = await this.dbConfig();
const result = await this.dbSearcher.search(query);
result.inpxHash = (config.inpxHash ? config.inpxHash : '');
return {
author: result.result,
totalFound: result.totalFound,
inpxHash: (config.inpxHash ? config.inpxHash : ''),
};
return result;
}
async getBookList(authorId) {
async opdsQuery(from, query) {
this.checkMyState();
return await this.dbSearcher.getBookList(authorId);
return await this.dbSearcher.opdsQuery(from, query);
}
async getSeriesBookList(seriesId) {
async getAuthorBookList(authorId, author) {
this.checkMyState();
return await this.dbSearcher.getSeriesBookList(seriesId);
return await this.dbSearcher.getAuthorBookList(authorId, author);
}
async getSeriesBookList(series) {
this.checkMyState();
return await this.dbSearcher.getSeriesBookList(series);
}
async getGenreTree() {
@@ -320,7 +360,7 @@ class WebWorker {
}
}
async restoreBook(bookPath, downFileName) {
async restoreBook(bookUid, bookPath, downFileName) {
const db = this.db;
let extractedFile = '';
@@ -330,23 +370,30 @@ class WebWorker {
extractedFile = await this.extractBook(bookPath);
hash = await utils.getFileHash(extractedFile, 'sha256', 'hex');
} else {
hash = await this.remoteLib.downloadBook(bookPath, downFileName);
hash = await this.remoteLib.downloadBook(bookUid);
}
const link = `/files/${hash}`;
const publicPath = `${this.config.publicDir}${link}`;
const link = `${this.config.filesPathStatic}/${hash}`;
const bookFile = `${this.config.filesDir}/${hash}`;
const bookFileDesc = `${bookFile}.d.json`;
if (!await fs.pathExists(publicPath)) {
await fs.ensureDir(path.dirname(publicPath));
if (!await fs.pathExists(bookFile) || !await fs.pathExists(bookFileDesc)) {
if (!await fs.pathExists(bookFile) && extractedFile) {
const tmpFile = `${this.config.tempDir}/${utils.randomHexString(30)}`;
await utils.gzipFile(extractedFile, tmpFile, 4);
await fs.remove(extractedFile);
await fs.move(tmpFile, bookFile, {overwrite: true});
} else {
await utils.touchFile(bookFile);
}
const tmpFile = `${this.config.tempDir}/${utils.randomHexString(30)}`;
await utils.gzipFile(extractedFile, tmpFile, 4);
await fs.remove(extractedFile);
await fs.move(tmpFile, publicPath, {overwrite: true});
await fs.writeFile(bookFileDesc, JSON.stringify({bookPath, downFileName}));
} else {
if (extractedFile)
await fs.remove(extractedFile);
await utils.touchFile(publicPath);
await utils.touchFile(bookFile);
await utils.touchFile(bookFileDesc);
}
await db.insert({
@@ -361,35 +408,54 @@ class WebWorker {
return link;
}
async getBookLink(params) {
async getBookLink(bookUid) {
this.checkMyState();
const {bookPath, downFileName} = params;
try {
const db = this.db;
let link = '';
//найдем bookPath и downFileName
let rows = await db.select({table: 'book', where: `@@hash('_uid', ${db.esc(bookUid)})`});
if (!rows.length)
throw new Error('404 Файл не найден');
const book = rows[0];
let downFileName = book.file;
const author = book.author.split(',');
const at = [author[0], book.title];
downFileName = utils.makeValidFileNameOrEmpty(at.filter(r => r).join(' - '))
|| utils.makeValidFileNameOrEmpty(at[0])
|| utils.makeValidFileNameOrEmpty(at[1])
|| downFileName;
downFileName = downFileName.substring(0, 100);
const ext = `.${book.ext}`;
if (downFileName.substring(downFileName.length - ext.length) != ext)
downFileName += ext;
const bookPath = `${book.folder}/${book.file}${ext}`;
//найдем хеш
const rows = await db.select({table: 'file_hash', where: `@@id(${db.esc(bookPath)})`});
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}`;
const bookFile = `${this.config.filesDir}/${hash}`;
const bookFileDesc = `${bookFile}.d.json`;
if (!await fs.pathExists(publicPath)) {
link = '';
if (await fs.pathExists(bookFile) && await fs.pathExists(bookFileDesc)) {
link = `${this.config.filesPathStatic}/${hash}`;
}
}
if (!link) {
link = await this.restoreBook(bookPath, downFileName)
link = await this.restoreBook(bookUid, bookPath, downFileName)
}
if (!link)
throw new Error('404 Файл не найден');
return {link};
return {link, bookPath, downFileName};
} catch(e) {
log(LM_ERR, `getBookLink error: ${e.message}`);
if (e.message.indexOf('ENOENT') >= 0)
@@ -398,46 +464,78 @@ class WebWorker {
}
}
async restoreBookFile(publicPath) {
async getBookInfo(bookUid) {
this.checkMyState();
try {
const db = this.db;
const hash = path.basename(publicPath);
//найдем bookPath и downFileName
const rows = await db.select({table: 'file_hash', where: `@@id(${db.esc(hash)})`});
if (rows.length) {//нашли по хешу
const rec = rows[0];
await this.restoreBook(rec.bookPath, rec.downFileName);
let bookInfo = await this.getBookLink(bookUid);
const hash = path.basename(bookInfo.link);
const bookFile = `${this.config.filesDir}/${hash}`;
const bookFileInfo = `${bookFile}.i.json`;
return rec.downFileName;
} else {//bookPath не найден
let rows = await db.select({table: 'book', where: `@@hash('_uid', ${db.esc(bookUid)})`});
if (!rows.length)
throw new Error('404 Файл не найден');
const book = rows[0];
const restoreBookInfo = async(info) => {
const result = {};
result.book = book;
result.cover = '';
result.fb2 = false;
let parser = null;
if (book.ext == 'fb2') {
const {fb2, cover, coverExt} = await this.fb2Helper.getDescAndCover(bookFile);
parser = fb2;
result.fb2 = fb2.rawNodes;
if (cover) {
result.cover = `${this.config.filesPathStatic}/${hash}${coverExt}`;
await fs.writeFile(`${bookFile}${coverExt}`, cover);
}
}
Object.assign(info, result);
await fs.writeFile(bookFileInfo, JSON.stringify(info));
if (this.config.branch === 'development') {
await fs.writeFile(`${bookFile}.dev`, `${JSON.stringify(info, null, 2)}\n\n${parser ? parser.toString({format: true}) : ''}`);
}
};
if (!await fs.pathExists(bookFileInfo)) {
await restoreBookInfo(bookInfo);
} else {
await utils.touchFile(bookFileInfo);
const info = await fs.readFile(bookFileInfo, 'utf-8');
const tmpInfo = JSON.parse(info);
//проверим существование файла обложки, восстановим если нету
let coverFile = '';
if (tmpInfo.cover)
coverFile = `${this.config.publicFilesDir}${tmpInfo.cover}`;
if (book.id != tmpInfo.book.id || (coverFile && !await fs.pathExists(coverFile))) {
await restoreBookInfo(bookInfo);
} else {
bookInfo = tmpInfo;
}
}
return {bookInfo};
} catch(e) {
log(LM_ERR, `restoreBookFile error: ${e.message}`);
log(LM_ERR, `getBookInfo error: ${e.message}`);
if (e.message.indexOf('ENOENT') >= 0)
throw new Error('404 Файл не найден');
throw e;
}
}
async getDownFileName(publicPath) {
this.checkMyState();
const db = this.db;
const hash = path.basename(publicPath);
//найдем downFileName
const rows = await db.select({table: 'file_hash', where: `@@id(${db.esc(hash)})`});
if (rows.length) {//downFileName найден по хешу
return rows[0].downFileName;
} else {//bookPath не найден
throw new Error('404 Файл не найден');
}
}
async getInpxFile(params) {
let data = null;
if (params.inpxFileHash && this.inpxFileHash && params.inpxFileHash === this.inpxFileHash) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,181 @@
const BasePage = require('./BasePage');
class AuthorPage extends BasePage {
constructor(config) {
super(config);
this.id = 'author';
this.title = 'Авторы';
}
sortBooks(bookList) {
//схлопывание серий
const books = [];
const seriesSet = new Set();
for (const book of bookList) {
if (book.series) {
if (!seriesSet.has(book.series)) {
books.push({
type: 'series',
book
});
seriesSet.add(book.series);
}
} else {
books.push({
type: 'book',
book
});
}
}
//сортировка
books.sort((a, b) => {
if (a.type == 'series') {
return (b.type == 'series' ? a.book.series.localeCompare(b.book.series) : -1);
} else {
return (b.type == 'book' ? a.book.title.localeCompare(b.book.title) : 1);
}
});
return books;
}
sortSeriesBooks(seriesBooks) {
seriesBooks.sort((a, b) => {
const dserno = (a.serno || Number.MAX_VALUE) - (b.serno || Number.MAX_VALUE);
const dtitle = a.title.localeCompare(b.title);
const dext = a.ext.localeCompare(b.ext);
return (dserno ? dserno : (dtitle ? dtitle : dext));
});
return seriesBooks;
}
async body(req) {
const result = {};
const query = {
author: req.query.author || '',
series: req.query.series || '',
genre: req.query.genre || '',
del: 0,
all: req.query.all || '',
depth: 0,
};
query.depth = query.author.length + 1;
if (query.author == '___others') {
query.author = '';
query.depth = 1;
query.others = true;
}
const entry = [];
if (query.series) {
//книги по серии
const bookList = await this.webWorker.getSeriesBookList(query.series);
if (bookList.books) {
let books = JSON.parse(bookList.books);
const booksAll = this.filterBooks(books, {del: 0});
const filtered = (query.all ? booksAll : this.filterBooks(books, query));
const sorted = this.sortSeriesBooks(filtered);
if (booksAll.length > filtered.length) {
entry.push(
this.makeEntry({
id: 'all_series_books',
title: '[Все книги серии]',
link: this.navLink({
href: `/${this.id}?author=${encodeURIComponent(query.author)}` +
`&series=${encodeURIComponent(query.series)}&all=1`}),
})
);
}
for (const book of sorted) {
const title = `${book.serno ? `${book.serno}. `: ''}${book.title || 'Без названия'} (${book.ext})`;
const e = {
id: book._uid,
title,
link: this.acqLink({href: `/book?uid=${encodeURIComponent(book._uid)}`}),
};
if (query.all) {
e.content = {
'*ATTRS': {type: 'text'},
'*TEXT': this.bookAuthor(book.author),
}
}
entry.push(
this.makeEntry(e)
);
}
}
} else if (query.author && query.author[0] == '=') {
//книги по автору
const bookList = await this.webWorker.getAuthorBookList(0, query.author.substring(1));
if (bookList.books) {
let books = JSON.parse(bookList.books);
books = this.sortBooks(this.filterBooks(books, query));
for (const b of books) {
if (b.type == 'series') {
entry.push(
this.makeEntry({
id: b.book._uid,
title: `Серия: ${b.book.series}`,
link: this.navLink({
href: `/${this.id}?author=${encodeURIComponent(query.author)}` +
`&series=${encodeURIComponent(b.book.series)}&genre=${encodeURIComponent(query.genre)}`}),
})
);
} else {
const title = `${b.book.title || 'Без названия'} (${b.book.ext})`;
entry.push(
this.makeEntry({
id: b.book._uid,
title,
link: this.acqLink({href: `/book?uid=${encodeURIComponent(b.book._uid)}`}),
})
);
}
}
}
} else {
if (query.depth == 1 && !query.genre && !query.others) {
entry.push(
this.makeEntry({
id: 'select_genre',
title: '[Выбрать жанр]',
link: this.navLink({href: `/genre?from=${this.id}`}),
})
);
}
//навигация по каталогу
const queryRes = await this.opdsQuery('author', query, '[Остальные авторы]');
for (const rec of queryRes) {
entry.push(
this.makeEntry({
id: rec.id,
title: this.bookAuthor(rec.title),
link: this.navLink({href: `/${this.id}?author=${rec.q}&genre=${encodeURIComponent(query.genre)}`}),
})
);
}
}
result.entry = entry;
return this.makeBody(result, req);
}
}
module.exports = AuthorPage;

View File

@@ -0,0 +1,347 @@
const _ = require('lodash');
const he = require('he');
const WebWorker = require('../WebWorker');//singleton
const XmlParser = require('../xml/XmlParser');
const spaceChar = String.fromCodePoint(0x00B7);
const emptyFieldValue = '?';
const maxUtf8Char = String.fromCodePoint(0xFFFFF);
const ruAlphabet = 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя';
const enAlphabet = 'abcdefghijklmnopqrstuvwxyz';
const enruArr = (ruAlphabet + enAlphabet).split('');
const enru = new Set(enruArr);
class BasePage {
constructor(config) {
this.config = config;
this.webWorker = new WebWorker(config);
this.rootTag = 'feed';
this.opdsRoot = config.opdsRoot;
}
makeEntry(entry = {}) {
if (!entry.id)
throw new Error('makeEntry: no id');
if (!entry.title)
throw new Error('makeEntry: no title');
entry.title = he.escape(entry.title);
const result = {
updated: (new Date()).toISOString().substring(0, 19) + 'Z',
};
return Object.assign(result, entry);
}
myEntry() {
return this.makeEntry({
id: this.id,
title: this.title,
link: this.navLink({href: `/${this.id}`}),
});
}
makeLink(attrs) {
attrs.href = he.escape(attrs.href);
return {'*ATTRS': attrs};
}
navLink(attrs) {
return this.makeLink({
href: (attrs.hrefAsIs ? attrs.href : `${this.opdsRoot}${attrs.href || ''}`),
rel: attrs.rel || 'subsection',
type: 'application/atom+xml;profile=opds-catalog;kind=navigation',
});
}
acqLink(attrs) {
return this.makeLink({
href: (attrs.hrefAsIs ? attrs.href : `${this.opdsRoot}${attrs.href || ''}`),
rel: attrs.rel || 'subsection',
type: 'application/atom+xml;profile=opds-catalog;kind=acquisition',
});
}
downLink(attrs) {
if (!attrs.href)
throw new Error('downLink: no href');
if (!attrs.type)
throw new Error('downLink: no type');
return this.makeLink({
href: attrs.href,
rel: 'http://opds-spec.org/acquisition',
type: attrs.type,
});
}
imgLink(attrs) {
if (!attrs.href)
throw new Error('imgLink: no href');
return this.makeLink({
href: attrs.href,
rel: `http://opds-spec.org/image${attrs.thumb ? '/thumbnail' : ''}`,
type: attrs.type || 'image/jpeg',
});
}
baseLinks(req, selfAcq = false) {
const result = [
this.makeLink({href: `${this.opdsRoot}/opensearch`, rel: 'search', type: 'application/opensearchdescription+xml'}),
this.makeLink({href: `${this.opdsRoot}/search?term={searchTerms}`, rel: 'search', type: 'application/atom+xml'}),
this.navLink({rel: 'start'}),
];
if (selfAcq) {
result.push(this.acqLink({rel: 'self', href: req.originalUrl, hrefAsIs: true}));
} else {
result.push(this.navLink({rel: 'self', href: req.originalUrl, hrefAsIs: true}));
}
return result;
}
makeBody(content, req) {
const base = this.makeEntry({id: this.id, title: this.title});
base['*ATTRS'] = {
'xmlns': 'http://www.w3.org/2005/Atom',
'xmlns:dc': 'http://purl.org/dc/terms/',
'xmlns:opds': 'http://opds-spec.org/2010/catalog',
};
if (!content.link)
base.link = this.baseLinks(req);
const xml = new XmlParser();
const xmlObject = {};
xmlObject[this.rootTag] = Object.assign(base, content);
xml.fromObject(xmlObject);
return xml.toString({format: true});
}
async body() {
throw new Error('Body not implemented');
}
// -- stuff -------------------------------------------
async search(from, query) {
const result = [];
const queryRes = await this.webWorker.search(from, query);
for (const row of queryRes.found) {
const rec = {
id: row.id,
title: (row[from] || 'Без автора'),
q: `=${encodeURIComponent(row[from])}`,
};
result.push(rec);
}
return result;
}
async opdsQuery(from, query, otherTitle = '[Другие]', prevLen = 0) {
const queryRes = await this.webWorker.opdsQuery(from, query);
let count = 0;
for (const row of queryRes.found)
count += row.count;
const others = [];
let result = [];
if (count <= 50) {
//конец навигации
return await this.search(from, query);
} else {
let len = 0;
for (const row of queryRes.found) {
const value = row.value;
len += value.length;
let rec;
if (row.count == 1) {
rec = {
id: row.id,
title: row.name,
q: `=${encodeURIComponent(row.name)}`,
};
} else {
rec = {
id: row.id,
title: `${value.toUpperCase().replace(/ /g, spaceChar)}~`,
q: encodeURIComponent(value),
};
}
if (query.depth > 1 || enru.has(value[0])) {
result.push(rec);
} else {
others.push(rec);
}
}
if (query[from] && query.depth > 1 && result.length < 10 && len > prevLen) {
//рекурсия, с увеличением глубины, для облегчения навигации
const newQuery = _.cloneDeep(query);
newQuery.depth++;
return await this.opdsQuery(from, newQuery, otherTitle, len);
}
}
if (!query.others && others.length)
result.unshift({id: 'other', title: otherTitle, q: '___others'});
return (!query.others ? result : others);
}
//скопировано из BaseList.js, часть функционала не используется
filterBooks(books, query) {
const s = query;
const splitAuthor = (author) => {
if (!author) {
author = emptyFieldValue;
}
const result = author.split(',');
if (result.length > 1)
result.push(author);
return result;
};
const filterBySearch = (bookValue, searchValue) => {
if (!searchValue)
return true;
if (!bookValue)
bookValue = emptyFieldValue;
bookValue = bookValue.toLowerCase();
searchValue = searchValue.toLowerCase();
//особая обработка префиксов
if (searchValue[0] == '=') {
searchValue = searchValue.substring(1);
return bookValue.localeCompare(searchValue) == 0;
} else if (searchValue[0] == '*') {
searchValue = searchValue.substring(1);
return bookValue !== emptyFieldValue && bookValue.indexOf(searchValue) >= 0;
} else if (searchValue[0] == '#') {
searchValue = searchValue.substring(1);
return !bookValue || (bookValue !== emptyFieldValue && !enru.has(bookValue[0]) && bookValue.indexOf(searchValue) >= 0);
} else {
//where = `@dirtyIndexLR('value', ${db.esc(a)}, ${db.esc(a + maxUtf8Char)})`;
return bookValue.localeCompare(searchValue) >= 0 && bookValue.localeCompare(searchValue + maxUtf8Char) <= 0;
}
};
return books.filter((book) => {
//author
let authorFound = false;
const authors = splitAuthor(book.author);
for (const a of authors) {
if (filterBySearch(a, s.author)) {
authorFound = true;
break;
}
}
//genre
let genreFound = !s.genre;
if (!genreFound) {
const searchGenres = new Set(s.genre.split(','));
const bookGenres = book.genre.split(',');
for (let g of bookGenres) {
if (!g)
g = emptyFieldValue;
if (searchGenres.has(g)) {
genreFound = true;
break;
}
}
}
//lang
let langFound = !s.lang;
if (!langFound) {
const searchLang = new Set(s.lang.split(','));
langFound = searchLang.has(book.lang || emptyFieldValue);
}
//date
let dateFound = !s.date;
if (!dateFound) {
const date = this.queryDate(s.date).split(',');
let [from = '0000-00-00', to = '9999-99-99'] = date;
dateFound = (book.date >= from && book.date <= to);
}
//librate
let librateFound = !s.librate;
if (!librateFound) {
const searchLibrate = new Set(s.librate.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n)));
librateFound = searchLibrate.has(book.librate);
}
return (this.showDeleted || !book.del)
&& authorFound
&& filterBySearch(book.series, s.series)
&& filterBySearch(book.title, s.title)
&& genreFound
&& langFound
&& dateFound
&& librateFound
;
});
}
bookAuthor(author) {
if (author) {
let a = author.split(',');
return a.slice(0, 3).join(', ') + (a.length > 3 ? ' и др.' : '');
}
return '';
}
async getGenres() {
let result;
if (!this.genres) {
const res = await this.webWorker.getGenreTree();
result = {
genreTree: res.genreTree,
genreMap: new Map(),
genreSection: new Map(),
};
for (const section of result.genreTree) {
result.genreSection.set(section.name, section.value);
for (const g of section.value)
result.genreMap.set(g.value, g.name);
}
this.genres = result;
} else {
result = this.genres;
}
return result;
}
}
module.exports = BasePage;

View File

@@ -0,0 +1,206 @@
const path = require('path');
const _ = require('lodash');
const he = require('he');
const dayjs = require('dayjs');
const BasePage = require('./BasePage');
const Fb2Parser = require('../fb2/Fb2Parser');
class BookPage extends BasePage {
constructor(config) {
super(config);
this.id = 'book';
this.title = 'Книга';
}
formatSize(size) {
size = size/1024;
let unit = 'KB';
if (size > 1024) {
size = size/1024;
unit = 'MB';
}
return `${size.toFixed(1)} ${unit}`;
}
inpxInfo(bookRec) {
const mapping = [
{name: 'fileInfo', label: 'Информация о файле', value: [
{name: 'folder', label: 'Папка'},
{name: 'file', label: 'Файл'},
{name: 'size', label: 'Размер'},
{name: 'date', label: 'Добавлен'},
{name: 'del', label: 'Удален'},
{name: 'libid', label: 'LibId'},
{name: 'insno', label: 'InsideNo'},
]},
{name: 'titleInfo', label: 'Общая информация', value: [
{name: 'author', label: 'Автор(ы)'},
{name: 'title', label: 'Название'},
{name: 'series', label: 'Серия'},
{name: 'genre', label: 'Жанр'},
{name: 'librate', label: 'Оценка'},
{name: 'lang', label: 'Язык книги'},
{name: 'keywords', label: 'Ключевые слова'},
]},
];
const valueToString = (value, nodePath, b) => {//eslint-disable-line no-unused-vars
if (nodePath == 'fileInfo/file')
return `${value}.${b.ext}`;
if (nodePath == 'fileInfo/size')
return `${this.formatSize(value)} (${value.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1 ')} Bytes)`;
if (nodePath == 'fileInfo/date')
return dayjs(value, 'YYYY-MM-DD').format('DD.MM.YYYY');
if (nodePath == 'fileInfo/del')
return (value ? 'Да' : null);
if (nodePath == 'fileInfo/insno')
return (value ? value : null);
if (nodePath == 'titleInfo/author')
return value.split(',').join(', ');
if (nodePath == 'titleInfo/librate' && !value)
return null;
if (typeof(value) === 'string') {
return value;
}
return (value.toString ? value.toString() : '');
};
let result = [];
const book = _.cloneDeep(bookRec);
book.series = [book.series, book.serno].filter(v => v).join(' #');
for (const item of mapping) {
const itemOut = {name: item.name, label: item.label, value: []};
for (const subItem of item.value) {
const subItemOut = {
name: subItem.name,
label: subItem.label,
value: valueToString(book[subItem.name], `${item.name}/${subItem.name}`, book)
};
if (subItemOut.value)
itemOut.value.push(subItemOut);
}
if (itemOut.value.length)
result.push(itemOut);
}
return result;
}
htmlInfo(title, infoList) {
let info = '';
for (const part of infoList) {
if (part.value.length)
info += `<h3>${part.label}</h3>`;
for (const rec of part.value)
info += `<p>${rec.label}: ${rec.value}</p>`;
}
if (info)
info = `<h2>${title}</h2>${info}`;
return info;
}
async body(req) {
const result = {};
result.link = this.baseLinks(req, true);
const bookUid = req.query.uid;
const entry = [];
if (bookUid) {
const {bookInfo} = await this.webWorker.getBookInfo(bookUid);
if (bookInfo) {
const {genreMap} = await this.getGenres();
const fileFormat = `${bookInfo.book.ext}+zip`;
//entry
const e = this.makeEntry({
id: bookUid,
title: bookInfo.book.title || 'Без названия',
});
e['dc:language'] = bookInfo.book.lang;
e['dc:format'] = fileFormat;
//genre
const genre = bookInfo.book.genre.split(',');
for (const g of genre) {
const genreName = genreMap.get(g);
if (genreName) {
if (!e.category)
e.category = [];
e.category.push({
'*ATTRS': {term: genreName, label: genreName},
});
}
}
let content = '';
let ann = '';
let info = '';
//fb2 info
if (bookInfo.fb2) {
const parser = new Fb2Parser(bookInfo.fb2);
const infoObj = parser.bookInfo();
if (infoObj.titleInfo) {
if (infoObj.titleInfo.author.length) {
e.author = infoObj.titleInfo.author.map(a => ({name: a}));
}
ann = infoObj.titleInfo.annotationHtml || '';
const infoList = parser.bookInfoList(infoObj);
info += this.htmlInfo('Fb2 инфо', infoList);
}
}
//content
info += this.htmlInfo('Inpx инфо', this.inpxInfo(bookInfo.book));
content = `${ann}${info}`;
if (content) {
e.content = {
'*ATTRS': {type: 'text/html'},
'*TEXT': he.escape(content),
};
}
//links
e.link = [ this.downLink({href: bookInfo.link, type: `application/${fileFormat}`}) ];
if (bookInfo.cover) {
let coverType = 'image/jpeg';
if (path.extname(bookInfo.cover) == '.png')
coverType = 'image/png';
e.link.push(this.imgLink({href: bookInfo.cover, type: coverType}));
e.link.push(this.imgLink({href: bookInfo.cover, type: coverType, thumb: true}));
}
entry.push(e);
}
}
result.entry = entry;
return this.makeBody(result, req);
}
}
module.exports = BookPage;

View File

@@ -0,0 +1,72 @@
const BasePage = require('./BasePage');
class GenrePage extends BasePage {
constructor(config) {
super(config);
this.id = 'genre';
this.title = 'Жанры';
}
async body(req) {
const result = {};
const query = {
from: req.query.from || '',
section: req.query.section || '',
};
const entry = [];
if (query.from) {
if (query.section) {
//выбираем подразделы
const {genreSection} = await this.getGenres();
const section = genreSection.get(query.section);
if (section) {
let id = 0;
const all = [];
for (const g of section) {
all.push(g.value);
entry.push(
this.makeEntry({
id: ++id,
title: g.name,
link: this.navLink({href: `/${encodeURIComponent(query.from)}?genre=${encodeURIComponent(g.value)}`}),
})
);
}
entry.unshift(
this.makeEntry({
id: 'whole_section',
title: '[Весь раздел]',
link: this.navLink({href: `/${encodeURIComponent(query.from)}?genre=${encodeURIComponent(all.join(','))}`}),
})
);
}
} else {
//выбираем разделы
const {genreTree} = await this.getGenres();
let id = 0;
for (const section of genreTree) {
entry.push(
this.makeEntry({
id: ++id,
title: section.name,
link: this.navLink({href: `/genre?from=${encodeURIComponent(query.from)}&section=${encodeURIComponent(section.name)}`}),
})
);
}
}
}
result.entry = entry;
return this.makeBody(result, req);
}
}
module.exports = GenrePage;

View File

@@ -0,0 +1,45 @@
const BasePage = require('./BasePage');
const XmlParser = require('../xml/XmlParser');
class OpensearchPage extends BasePage {
constructor(config) {
super(config);
this.id = 'opensearch';
this.title = 'opensearch';
}
async body() {
const xml = new XmlParser();
const xmlObject = {};
/*
<?xml version="1.0" encoding="utf-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>inpx-web</ShortName>
<Description>Поиск по каталогу</Description>
<InputEncoding>UTF-8</InputEncoding>
<OutputEncoding>UTF-8</OutputEncoding>
<Url type="application/atom+xml;profile=opds-catalog;kind=navigation" template="/opds/search?term={searchTerms}"/>
</OpenSearchDescription>
*/
xmlObject['OpenSearchDescription'] = {
'*ATTRS': {xmlns: 'http://a9.com/-/spec/opensearch/1.1/'},
ShortName: 'inpx-web',
Description: 'Поиск по каталогу',
InputEncoding: 'UTF-8',
OutputEncoding: 'UTF-8',
Url: {
'*ATTRS': {
type: 'application/atom+xml;profile=opds-catalog;kind=navigation',
template: `${this.opdsRoot}/search?term={searchTerms}`,
},
},
}
xml.fromObject(xmlObject);
return xml.toString({format: true});
}
}
module.exports = OpensearchPage;

View File

@@ -0,0 +1,39 @@
const BasePage = require('./BasePage');
const AuthorPage = require('./AuthorPage');
const SeriesPage = require('./SeriesPage');
const TitlePage = require('./TitlePage');
class RootPage extends BasePage {
constructor(config) {
super(config);
this.id = 'root';
this.title = '';
this.authorPage = new AuthorPage(config);
this.seriesPage = new SeriesPage(config);
this.titlePage = new TitlePage(config);
}
async body(req) {
const result = {};
if (!this.title) {
const dbConfig = await this.webWorker.dbConfig();
const collection = dbConfig.inpxInfo.collection.split('\n');
this.title = collection[0].trim();
if (!this.title)
this.title = 'Неизвестная коллекция';
}
result.entry = [
this.authorPage.myEntry(),
this.seriesPage.myEntry(),
this.titlePage.myEntry(),
];
return this.makeBody(result, req);
}
}
module.exports = RootPage;

View File

@@ -0,0 +1,83 @@
const BasePage = require('./BasePage');
class SearchPage extends BasePage {
constructor(config) {
super(config);
this.id = 'search';
this.title = 'Поиск';
}
async body(req) {
const result = {};
const query = {
type: req.query.type || '',
term: req.query.term || '',
page: parseInt(req.query.page, 10) || 1,
};
let entry = [];
if (query.type) {
if (['author', 'series', 'title'].includes(query.type)) {
const from = query.type;
const page = query.page;
const limit = 100;
const offset = (page - 1)*limit;
const queryRes = await this.webWorker.search(from, {[from]: query.term, del: 0, offset, limit});
const found = queryRes.found;
for (let i = 0; i < found.length; i++) {
if (i >= limit)
break;
const row = found[i];
entry.push(
this.makeEntry({
id: row.id,
title: row[from],
link: this.navLink({href: `/${from}?${from}==${encodeURIComponent(row[from])}`}),
}),
);
}
if (queryRes.totalFound > offset + found.length) {
entry.push(
this.makeEntry({
id: 'next_page',
title: '[Следующая страница]',
link: this.navLink({href: `/${this.id}?type=${from}&term=${encodeURIComponent(query.term)}&page=${page + 1}`}),
}),
);
}
}
} else {
//корневой раздел
entry = [
this.makeEntry({
id: 'search_author',
title: 'Поиск авторов',
link: this.navLink({href: `/${this.id}?type=author&term=${encodeURIComponent(query.term)}`}),
}),
this.makeEntry({
id: 'search_series',
title: 'Поиск серий',
link: this.navLink({href: `/${this.id}?type=series&term=${encodeURIComponent(query.term)}`}),
}),
this.makeEntry({
id: 'search_title',
title: 'Поиск книг',
link: this.navLink({href: `/${this.id}?type=title&term=${encodeURIComponent(query.term)}`}),
}),
]
}
result.entry = entry;
return this.makeBody(result, req);
}
}
module.exports = SearchPage;

View File

@@ -0,0 +1,114 @@
const BasePage = require('./BasePage');
class SeriesPage extends BasePage {
constructor(config) {
super(config);
this.id = 'series';
this.title = 'Серии';
}
sortSeriesBooks(seriesBooks) {
seriesBooks.sort((a, b) => {
const dserno = (a.serno || Number.MAX_VALUE) - (b.serno || Number.MAX_VALUE);
const dtitle = a.title.localeCompare(b.title);
const dext = a.ext.localeCompare(b.ext);
return (dserno ? dserno : (dtitle ? dtitle : dext));
});
return seriesBooks;
}
async body(req) {
const result = {};
const query = {
series: req.query.series || '',
genre: req.query.genre || '',
del: 0,
all: req.query.all || '',
depth: 0,
};
query.depth = query.series.length + 1;
if (query.series == '___others') {
query.series = '';
query.depth = 1;
query.others = true;
}
const entry = [];
if (query.series && query.series[0] == '=') {
//книги по серии
const bookList = await this.webWorker.getSeriesBookList(query.series.substring(1));
if (bookList.books) {
let books = JSON.parse(bookList.books);
const booksAll = this.filterBooks(books, {del: 0});
const filtered = (query.all ? booksAll : this.filterBooks(books, query));
const sorted = this.sortSeriesBooks(filtered);
if (booksAll.length > filtered.length) {
entry.push(
this.makeEntry({
id: 'all_series_books',
title: '[Все книги серии]',
link: this.navLink({
href: `/${this.id}?series=${encodeURIComponent(query.series)}&all=1`}),
})
);
}
for (const book of sorted) {
const title = `${book.serno ? `${book.serno}. `: ''}${book.title || 'Без названия'} (${book.ext})`;
const e = {
id: book._uid,
title,
link: this.acqLink({href: `/book?uid=${encodeURIComponent(book._uid)}`}),
};
if (query.all) {
e.content = {
'*ATTRS': {type: 'text'},
'*TEXT': this.bookAuthor(book.author),
}
}
entry.push(
this.makeEntry(e)
);
}
}
} else {
if (query.depth == 1 && !query.genre && !query.others) {
entry.push(
this.makeEntry({
id: 'select_genre',
title: '[Выбрать жанр]',
link: this.navLink({href: `/genre?from=${this.id}`}),
})
);
}
//навигация по каталогу
const queryRes = await this.opdsQuery('series', query, '[Остальные серии]');
for (const rec of queryRes) {
entry.push(
this.makeEntry({
id: rec.id,
title: rec.title,
link: this.navLink({href: `/${this.id}?series=${rec.q}&genre=${encodeURIComponent(query.genre)}`}),
})
);
}
}
result.entry = entry;
return this.makeBody(result, req);
}
}
module.exports = SeriesPage;

View File

@@ -0,0 +1,84 @@
const BasePage = require('./BasePage');
class TitlePage extends BasePage {
constructor(config) {
super(config);
this.id = 'title';
this.title = 'Книги';
}
async body(req) {
const result = {};
const query = {
title: req.query.title || '',
genre: req.query.genre || '',
del: 0,
depth: 0,
};
query.depth = query.title.length + 1;
if (query.title == '___others') {
query.title = '';
query.depth = 1;
query.others = true;
}
const entry = [];
if (query.title && query.title[0] == '=') {
//книги по названию
const res = await this.webWorker.search('title', query);
if (res.found.length) {
const books = res.found[0].books || [];
const filtered = this.filterBooks(books, query);
for (const book of filtered) {
const title = `${book.serno ? `${book.serno}. `: ''}${book.title || 'Без названия'} (${book.ext})`;
entry.push(
this.makeEntry({
id: book._uid,
title,
link: this.acqLink({href: `/book?uid=${encodeURIComponent(book._uid)}`}),
content: {
'*ATTRS': {type: 'text'},
'*TEXT': this.bookAuthor(book.author),
},
})
);
}
}
} else {
if (query.depth == 1 && !query.genre && !query.others) {
entry.push(
this.makeEntry({
id: 'select_genre',
title: '[Выбрать жанр]',
link: this.navLink({href: `/genre?from=${this.id}`}),
})
);
}
//навигация по каталогу
const queryRes = await this.opdsQuery('title', query, '[Остальные названия]');
for (const rec of queryRes) {
entry.push(
this.makeEntry({
id: rec.id,
title: rec.title,
link: this.navLink({href: `/${this.id}?title=${rec.q}&genre=${encodeURIComponent(query.genre)}`}),
})
);
}
}
result.entry = entry;
return this.makeBody(result, req);
}
}
module.exports = TitlePage;

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

@@ -0,0 +1,82 @@
const basicAuth = require('express-basic-auth');
const RootPage = require('./RootPage');
const AuthorPage = require('./AuthorPage');
const SeriesPage = require('./SeriesPage');
const TitlePage = require('./TitlePage');
const GenrePage = require('./GenrePage');
const BookPage = require('./BookPage');
const OpensearchPage = require('./OpensearchPage');
const SearchPage = require('./SearchPage');
module.exports = function(app, config) {
if (!config.opds || !config.opds.enabled)
return;
const opdsRoot = '/opds';
config.opdsRoot = opdsRoot;
const root = new RootPage(config);
const author = new AuthorPage(config);
const series = new SeriesPage(config);
const title = new TitlePage(config);
const genre = new GenrePage(config);
const book = new BookPage(config);
const opensearch = new OpensearchPage(config);
const search = new SearchPage(config);
const routes = [
['', root],
['/root', root],
['/author', author],
['/series', series],
['/title', title],
['/genre', genre],
['/book', book],
['/opensearch', opensearch],
['/search', search],
];
const pages = new Map();
for (const r of routes) {
pages.set(`${opdsRoot}${r[0]}`, r[1]);
}
const opds = async(req, res, next) => {
try {
const page = pages.get(req.path);
if (page) {
res.set('Content-Type', 'application/atom+xml; charset=utf-8');
const result = await page.body(req, res);
if (result !== false)
res.send(result);
} else {
next();
}
} catch (e) {
res.status(500).send({error: e.message});
if (config.branch == 'development') {
console.error({error: e.message, url: req.originalUrl});
}
}
};
const opdsPaths = [opdsRoot, `${opdsRoot}/*`];
if (config.opds.password) {
if (!config.opds.user)
throw new Error('User must not be empty if password set');
app.use(opdsPaths, basicAuth({
users: {[config.opds.user]: config.opds.password},
challenge: true,
}));
}
app.get(opdsPaths, opds);
};

View File

@@ -7,8 +7,12 @@ function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function processLoop() {
return new Promise(resolve => setImmediate(resolve));
}
function versionText(config) {
return `${config.name} v${config.version}, Node.js ${process.version}`;
return `${config.name} v${config.version}, Node.js ${process.version}, ${process.platform}`;
}
async function findFiles(callback, dir, recursive = true) {
@@ -101,19 +105,78 @@ function gzipFile(inputFile, outputFile, level = 1) {
const input = fs.createReadStream(inputFile);
const output = fs.createWriteStream(outputFile);
input.pipe(gzip).pipe(output).on('finish', (err) => {
input.on('error', reject)
.pipe(gzip).on('error', reject)
.pipe(output).on('error', reject)
.on('finish', (err) => {
if (err) reject(err);
else resolve();
});
});
}
function gunzipFile(inputFile, outputFile) {
return new Promise((resolve, reject) => {
const gzip = zlib.createGunzip();
const input = fs.createReadStream(inputFile);
const output = fs.createWriteStream(outputFile);
input.on('error', reject)
.pipe(gzip).on('error', reject)
.pipe(output).on('error', reject)
.on('finish', (err) => {
if (err) reject(err);
else resolve();
});
});
}
function gzipBuffer(buf) {
return new Promise((resolve, reject) => {
zlib.gzip(buf, {level: 1}, (err, result) => {
if (err) reject(err);
resolve(result);
});
});
}
function gunzipBuffer(buf) {
return new Promise((resolve, reject) => {
zlib.gunzip(buf, (err, result) => {
if (err) reject(err);
resolve(result);
});
});
}
function toUnixPath(dir) {
return dir.replace(/\\/g, '/');
}
function makeValidFileName(fileName, repl = '_') {
let f = fileName.replace(/[\x00\\/:*"<>|]/g, repl); // eslint-disable-line no-control-regex
f = f.trim();
while (f.length && (f[f.length - 1] == '.' || f[f.length - 1] == '_')) {
f = f.substring(0, f.length - 1);
}
if (f)
return f;
else
throw new Error('Invalid filename');
}
function makeValidFileNameOrEmpty(fileName) {
try {
return makeValidFileName(fileName);
} catch(e) {
return '';
}
}
module.exports = {
sleep,
processLoop,
versionText,
findFiles,
touchFile,
@@ -124,5 +187,10 @@ module.exports = {
intersectSet,
randomHexString,
gzipFile,
gunzipFile,
gzipBuffer,
gunzipBuffer,
toUnixPath,
makeValidFileName,
makeValidFileNameOrEmpty,
};

View File

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

View File

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

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

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

View File

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

View File

@@ -2,7 +2,6 @@ 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');
@@ -50,9 +49,14 @@ async function init() {
config.tempDir = `${config.dataDir}/tmp`;
config.logDir = `${config.dataDir}/log`;
config.publicDir = `${config.dataDir}/public`;
config.publicFilesDir = `${config.dataDir}/public-files`;
config.filesPathStatic = `/book`;
config.filesDir = `${config.publicFilesDir}${config.filesPathStatic}`;
configManager.config = config;
await fs.ensureDir(config.dataDir);
await fs.ensureDir(config.filesDir);
await fs.ensureDir(config.tempDir);
await fs.emptyDir(config.tempDir);
@@ -114,7 +118,7 @@ async function init() {
}
}
} else {
config.inpxFile = `${config.tempDir}/${utils.randomHexString(20)}`;
config.inpxFile = `${config.dataDir}/remote.inpx`;
const RemoteLib = require('./core/RemoteLib');//singleton
const remoteLib = new RemoteLib(config);
await remoteLib.downloadInpxFile();
@@ -147,11 +151,11 @@ async function main() {
devModule.webpackDevMiddleware(app);
}
app.use(compression({ level: 1 }));
//app.use(express.json({limit: `${config.maxPayloadSize}mb`}));
if (devModule)
devModule.logQueries(app);
const opds = require('./core/opds');
opds(app, config);
initStatic(app, config);
const { WebSocketController } = require('./controllers');
@@ -173,52 +177,60 @@ async function main() {
}
function initStatic(app, config) {
const WebWorker = require('./core/WebWorker');//singleton
const webWorker = new WebWorker(config);
/*
publicFilesDir = `${config.dataDir}/public-files`;
filesPathStatic = `/book`;
filesDir = `${config.publicFilesDir}${config.filesPathStatic}`;
*/
const filesPath = `${config.filesPathStatic}/`;
//загрузка или восстановление файлов в /files, при необходимости
app.use(async(req, res, next) => {
if ((req.method !== 'GET' && req.method !== 'HEAD') ||
!(req.path.indexOf('/files/') === 0)
!(req.path.indexOf(filesPath) === 0)
) {
return next();
}
const publicPath = `${config.publicDir}${req.path}`;
if (path.extname(req.path) == '') {
const bookFile = `${config.publicFilesDir}${req.path}`;
const bookFileDesc = `${bookFile}.d.json`;
let downFileName = '';
//восстановим
try {
if (!await fs.pathExists(publicPath)) {
downFileName = await webWorker.restoreBookFile(publicPath);
} else {
downFileName = await webWorker.getDownFileName(publicPath);
let downFileName = '';
//восстановим из json-файла описания
try {
if (await fs.pathExists(bookFile) && await fs.pathExists(bookFileDesc)) {
await utils.touchFile(bookFile);
await utils.touchFile(bookFileDesc);
let desc = await fs.readFile(bookFileDesc, 'utf8');
desc = JSON.parse(desc);
downFileName = desc.downFileName;
} else {
await fs.remove(bookFile);
await fs.remove(bookFileDesc);
}
} catch(e) {
log(LM_ERR, e.message);
}
} catch(e) {
//quiet
}
if (downFileName)
res.downFileName = downFileName;
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) {
app.use(config.filesPathStatic, express.static(config.filesDir, {
setHeaders: (res) => {
if (res.downFileName) {
res.set('Content-Encoding', 'gzip');
if (res.downFileName)
res.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(res.downFileName)}`);
res.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(res.downFileName)}`);
}
},
}));
app.use(express.static(config.publicDir));
}
(async() => {
@@ -226,10 +238,11 @@ function initStatic(app, config) {
await init();
await main();
} catch (e) {
const mes = (branch == 'development' ? e.stack : e.message);
if (log)
log(LM_FATAL, (branch == 'development' ? e.stack : e.message));
log(LM_FATAL, mes);
else
console.error(branch == 'development' ? e.stack : e.message);
console.error(mes);
ayncExit.exit(1);
}