440 Commits

Author SHA1 Message Date
Book Pauk
d7c6b0e7ab Merge branch 'release/1.2.4' 2022-11-14 16:39:48 +07:00
Book Pauk
94922f3926 Версия 1.2.4 2022-11-14 16:39:24 +07:00
Book Pauk
a580b1eb6d Добавлено отображение постера в отдельном окне 2022-11-14 16:37:11 +07:00
Book Pauk
cd7b8afb29 Рефакторинг 2022-11-14 15:09:13 +07:00
Book Pauk
e634893ff3 Добавил .stop.prevent для событий @click 2022-11-14 14:46:23 +07:00
Book Pauk
fadc7ddc34 Merge tag '1.2.3' into develop
1.2.3
2022-11-13 02:04:13 +07:00
Book Pauk
ed5dc25d94 Merge branch 'release/1.2.3' 2022-11-13 02:04:01 +07:00
Book Pauk
dd11e8c5ad Версия 1.2.3 2022-11-13 02:03:25 +07:00
Book Pauk
2db2b8cff4 Решение проблемы скачивания файлов в режиме "Удаленная библиотека"
(запрашивался не тот файл из-за несовпадения bookId)
2022-11-13 01:59:42 +07:00
Book Pauk
4d3661b758 Мелкая поправка 2022-11-13 00:11:26 +07:00
Book Pauk
891b1e4fe8 Поправка мелкого бага 2022-11-12 23:56:50 +07:00
Book Pauk
d588b16885 Merge tag '1.2.2' into develop
1.2.2

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

Добавлено диалоговое окно "Информация о книге".
Небольшие изменения интерфейса, добавлена кнопка "Клонировать поиск".
2022-11-12 16:56:49 +07:00
Book Pauk
b4da07e924 Merge branch 'release/1.2.1' 2022-11-12 16:56:36 +07:00
Book Pauk
110d145b91 Версия 1.2.1 2022-11-12 16:56:05 +07:00
Book Pauk
fc3d391aa0 Замена moment на dayjs 2022-11-12 16:54:03 +07:00
Book Pauk
4d01901463 Мелкие поправки разметки 2022-11-12 16:38:01 +07:00
Book Pauk
2d380bd98f Убрал дебаг 2022-11-11 22:16:13 +07:00
Book Pauk
2dd67487dc Работа над BookInfoDialog 2022-11-11 22:12:13 +07:00
Book Pauk
a3190e4af3 Добавлены методы XML Inspector 2022-11-11 20:31:41 +07:00
Book Pauk
3d28beddac Merge tag '1.2.0' into develop
1.2.0
2022-11-10 20:27:53 +07:00
Book Pauk
c11e949316 Merge branch 'release/1.2.0' 2022-11-10 20:27:45 +07:00
Book Pauk
4bbaf659b8 Небольшая поправка разметки 2022-11-10 20:26:30 +07:00
Book Pauk
caf3adf884 Версия 1.2.0 2022-11-10 20:14:49 +07:00
Book Pauk
6dfb3f6db9 Работа над BookInfoDialog 2022-11-10 20:10:43 +07:00
Book Pauk
e39611098a Работа над BookInfoDialog 2022-11-10 19:45:26 +07:00
Book Pauk
d7d04fcda8 Работа над BookInfoDialog 2022-11-10 18:57:20 +07:00
Book Pauk
7b2171c269 Работа над BookInfoDialog 2022-11-10 18:05:53 +07:00
Book Pauk
0bb434d415 Работа над BookInfoDialog 2022-11-10 17:55:16 +07:00
Book Pauk
391fb3aa70 Работа над BookInfoDialog 2022-11-10 17:17:56 +07:00
Book Pauk
81d8b476a5 Работа над BookInfoDialog 2022-11-10 14:56:25 +07:00
Book Pauk
79e6ca2d27 Работа над BookInfoDialog 2022-11-10 00:59:47 +07:00
Book Pauk
1d99472ca1 Реструктуризация 2022-11-10 00:51:18 +07:00
Book Pauk
ec6b72868b Переименования 2022-11-10 00:40:44 +07:00
Book Pauk
8ee1b98a12 Поправлена отдача статики 2022-11-09 23:58:08 +07:00
Book Pauk
ffc65ab944 Работа над BookInfoDialog 2022-11-09 19:25:59 +07:00
Book Pauk
7b5061df5f Работа над BookInfoDialog 2022-11-09 18:26:29 +07:00
Book Pauk
2fa48cdde6 Поправлен баг 2022-11-09 18:26:19 +07:00
Book Pauk
28963116c3 Работа над XmlParser 2022-11-09 17:22:18 +07:00
Book Pauk
18da23530b Рефакторинг 2022-11-09 16:47:36 +07:00
Book Pauk
b64c5de5a3 Работа над XmlParser 2022-11-09 16:37:10 +07:00
Book Pauk
f1db203027 Перемещение файлов 2022-11-09 14:47:55 +07:00
Book Pauk
927dade502 Работа над Fb2Parser 2022-11-09 14:45:40 +07:00
Book Pauk
04a8ba8426 Работа над XmlParser 2022-11-09 14:44:00 +07:00
Book Pauk
40f72d17e6 Работа над BookInfoDialog 2022-11-08 22:07:11 +07:00
Book Pauk
4b5949e3bc Работа над XmlParser 2022-11-08 22:02:35 +07:00
Book Pauk
f7994fd9e9 Работа над XmlParser 2022-11-08 16:12:46 +07:00
Book Pauk
9cf9530447 Работа над XmlParser 2022-11-08 15:01:52 +07:00
Book Pauk
98fac2bf11 Работа над XmlParser 2022-11-08 14:42:28 +07:00
Book Pauk
e755ddbbef Работа над XmlParser 2022-11-08 14:07:17 +07:00
Book Pauk
b5c7219e09 Работа над XmlParser 2022-11-08 03:52:00 +07:00
Book Pauk
6a640ba2cd Работа над XmlParser 2022-11-08 02:42:11 +07:00
Book Pauk
d2484659e7 Работа над XmlParser 2022-11-08 02:21:34 +07:00
Book Pauk
8f4dec510c Работа над XmlParser 2022-11-07 21:50:38 +07:00
Book Pauk
e4571faf39 Работа над XmlParser 2022-11-07 21:13:15 +07:00
Book Pauk
a40d9e25b0 Работа над XmlParser 2022-11-07 19:52:29 +07:00
Book Pauk
02f276ca6b Работа над карточкой "Информация о книге" 2022-11-07 16:24:52 +07:00
Book Pauk
2ae7f21bc8 Дополнительные пакеты 2022-11-07 16:24:07 +07:00
Book Pauk
55239159ba Перенос на сервер работы с именами файлов при скачивании 2022-11-06 18:03:03 +07:00
Book Pauk
d9f1912ea2 Работа над BookInfoDialog 2022-11-06 17:15:45 +07:00
Book Pauk
351abe9401 Поправка размера шрифта 2022-11-06 16:39:39 +07:00
Book Pauk
4cde00b337 Поправки интерфейса, работа над информацией о файле 2022-11-06 16:38:01 +07:00
Book Pauk
ba5d7b10b8 Поправлено положение элементов интерфейса,
добавлена кнопка "Клонировать поиск"
2022-11-06 15:47:42 +07:00
Book Pauk
0360098b53 Поправлен баг скроллинга 2022-11-06 14:49:27 +07:00
Book Pauk
32c2d6fef9 Merge tag '1.1.4' into develop
1.1.4
2022-11-03 22:32:13 +07:00
Book Pauk
2c3172d2a9 Merge branch 'release/1.1.4' 2022-11-03 22:32:07 +07:00
Book Pauk
f43a0bde45 1.1.4 2022-11-03 22:31:57 +07:00
Book Pauk
0f7ac5c387 Версия БД: '6' 2022-11-03 22:30:50 +07:00
Book Pauk
a4aa4ae2f0 Рефакторинг 2022-11-03 21:50:08 +07:00
Book Pauk
6e9ff3787e Merge tag '1.1.3' into develop
1.1.3
2022-11-03 21:27:14 +07:00
Book Pauk
cd35acb60e Merge branch 'release/1.1.3' 2022-11-03 21:27:08 +07:00
Book Pauk
94bec6ed42 Верия 1.1.3 2022-11-03 21:26:45 +07:00
Book Pauk
42436fabd3 Исправлен баг "Не качает книги #1", fixed #1 2022-11-03 21:25:10 +07:00
Book Pauk
36c50fd699 Merge tag '1.1.2' into develop
1.1.2
2022-11-01 02:13:49 +07:00
Book Pauk
1a2cad315f Merge branch 'release/1.1.2' 2022-11-01 02:13:43 +07:00
Book Pauk
1d22a129e5 Версия 1.1.2 2022-11-01 02:12:55 +07:00
Book Pauk
2fd18a93e5 Поправлен баг по клику на имени автора 2022-11-01 02:11:30 +07:00
Book Pauk
511f20e9bc Merge tag '1.1.1' into develop
1.1.1
2022-11-01 02:03:24 +07:00
Book Pauk
a7af7342b6 Merge branch 'release/1.1.1' 2022-11-01 02:03:20 +07:00
Book Pauk
2fa8be9cb0 Версия 1.1.1 2022-11-01 02:02:59 +07:00
Book Pauk
9f67b3bf28 Улучшение обработки ошибок в gzipFile 2022-11-01 02:01:15 +07:00
Book Pauk
037d1aa022 Исправления багов скачивания файла в режиме RemoteLib 2022-11-01 01:53:39 +07:00
Book Pauk
b56eeaa024 Merge tag '1.1.0' into develop
1.1.0
2022-11-01 01:11:44 +07:00
Book Pauk
75f5a50d20 Merge branch 'release/1.1.0' 2022-11-01 01:11:39 +07:00
Book Pauk
a38ba2fd9a Поправка бага 2022-11-01 00:34:30 +07:00
Book Pauk
d46d153416 Версия 1.1.0 2022-11-01 00:16:28 +07:00
Book Pauk
e7fe77ef6c Поправка makeTitle 2022-11-01 00:15:49 +07:00
Book Pauk
810920beed Поправки текста 2022-11-01 00:09:00 +07:00
Book Pauk
55984769ea Улучшение выбора даты поступления 2022-10-31 21:07:25 +07:00
Book Pauk
0e4344995c Исправления багов, мелкий рефакторинг 2022-10-31 21:04:36 +07:00
Book Pauk
548f18430d Мелкие доработки 2022-10-31 20:53:29 +07:00
Book Pauk
8982b3eaf0 Работа над диалогом выбора даты 2022-10-31 20:38:35 +07:00
Book Pauk
bcd0308641 "moment": "^2.29.4", 2022-10-31 18:45:49 +07:00
Book Pauk
21ec7df1e7 Добавлено пересоздание поисковой БД при обнаружении повреждений 2022-10-31 16:41:01 +07:00
Book Pauk
35d7e5296f Добавлено описание параметра fullOptimization 2022-10-31 16:29:02 +07:00
Book Pauk
ca812f0eb4 Удален параметр конфига extendedSearch 2022-10-31 16:25:30 +07:00
Book Pauk
e0c856ce8e Поправки багов скроллинга 2022-10-31 16:22:03 +07:00
Book Pauk
820769071d Поправки багов скроллинга 2022-10-31 15:33:58 +07:00
Book Pauk
4ea9b388f0 Улучшение вида списков 2022-10-31 15:00:32 +07:00
Book Pauk
26340bf061 Поправлен баг 2022-10-31 14:11:49 +07:00
Book Pauk
39dc7647bc Поправлен баг 2022-10-31 13:57:10 +07:00
Book Pauk
198a2b9d76 Поправлен баг 2022-10-31 13:47:26 +07:00
Book Pauk
6e9662b409 Небольшие улучшения 2022-10-31 13:35:59 +07:00
Book Pauk
c5bd31e514 Улучшена отзывчивость поисковика при старте 2022-10-31 13:21:10 +07:00
Book Pauk
ea5cf4e3bd Перемещение модуля 2022-10-31 13:11:58 +07:00
Book Pauk
c6080fa423 Мелкая поправка 2022-10-31 13:08:38 +07:00
Book Pauk
f2f22c362d Оптимизация 2022-10-31 01:16:23 +07:00
Book Pauk
55e05d3469 Оптимизация 2022-10-31 00:31:54 +07:00
Book Pauk
87f8567d93 Поправка таймаута 2022-10-31 00:10:34 +07:00
Book Pauk
1659f69aae Merge branch 'feature/new_search' into develop 2022-10-30 17:19:30 +07:00
Book Pauk
f310a70e49 Убрал дебаг 2022-10-30 17:10:58 +07:00
Book Pauk
b4cb08ea62 Работа над новым поиском 2022-10-30 16:57:23 +07:00
Book Pauk
fba7300131 Работа над новым поиском 2022-10-30 16:44:51 +07:00
Book Pauk
c904990eed Работа над новым поиском 2022-10-30 16:04:57 +07:00
Book Pauk
75cb5444b2 Работа над новым поиском 2022-10-30 15:21:10 +07:00
Book Pauk
1210e5bd8a Работа над новым поиском 2022-10-30 15:03:29 +07:00
Book Pauk
70de9e2ab4 dbVersion: '5' 2022-10-30 13:56:43 +07:00
Book Pauk
3f39b55ca0 Работа над новым поиском 2022-10-30 13:55:54 +07:00
Book Pauk
2ba8297594 Начало переделки структуры БД и поиска 2022-10-29 23:40:29 +07:00
Book Pauk
c3724feba0 Добавлено отображение даты поступления 2022-10-28 21:08:32 +07:00
Book Pauk
0b9a25aff0 Мелкие поправки 2022-10-28 20:25:02 +07:00
Book Pauk
96576307c8 Работа над поиском по оценкам 2022-10-28 20:18:30 +07:00
Book Pauk
f51166822b Поправки разметки 2022-10-28 19:56:07 +07:00
Book Pauk
fee3231d10 Работа над поиском по дате поступления и оценке 2022-10-28 19:47:00 +07:00
Book Pauk
52ed97c8de Поправки метки 2022-10-28 18:21:57 +07:00
Book Pauk
52f3938077 Настройка шрифта 2022-10-28 18:11:45 +07:00
Book Pauk
c00754c4ca Удалил ненужное 2022-10-27 19:12:43 +07:00
Book Pauk
78602a082b Поправки разметки 2022-10-27 18:56:27 +07:00
Book Pauk
c33fc407d2 Поправки разметки 2022-10-27 18:48:02 +07:00
Book Pauk
529095e014 Поправки разметки 2022-10-27 18:01:45 +07:00
Book Pauk
8a6852c4ae Поправлен баг 2022-10-27 17:24:47 +07:00
Book Pauk
ecb372ec70 Добавлена возможность сокрытия дополнительных параметров поиска 2022-10-27 16:59:38 +07:00
Book Pauk
87fc08e3bc Добавлен фильтр по удаленным 2022-10-27 15:46:32 +07:00
Book Pauk
b7555fe55d Исправление обработки ошибок вебсокета при запросах 2022-10-27 14:44:05 +07:00
Book Pauk
1669a21add Мелкий рефакторинг, поправки цветов 2022-10-27 14:36:55 +07:00
Book Pauk
7f68b1d68a Поправлен баг 2022-10-26 21:48:07 +07:00
Book Pauk
4cacecc13b Работа над TitleList 2022-10-26 21:32:08 +07:00
Book Pauk
2509b0f742 Доработки 2022-10-26 21:31:29 +07:00
Book Pauk
491c2f3406 Добавлен titleSearch 2022-10-26 17:50:48 +07:00
Book Pauk
20bb9f925a Добавлено формирование title_book 2022-10-26 17:35:45 +07:00
Book Pauk
87369a00a2 Доработка оптимизации 2022-10-26 16:37:04 +07:00
Book Pauk
04f0b17d32 Увеличена dbVersion 2022-10-26 16:22:35 +07:00
Book Pauk
86f6ba5ba0 Мелкая поправка 2022-10-26 16:09:01 +07:00
Book Pauk
dff2f0fcb0 Мелкая поправка 2022-10-26 16:02:40 +07:00
Book Pauk
801bf3aea9 Доработка поиска по разделу "Серии" 2022-10-26 15:58:08 +07:00
Book Pauk
a7db617c14 Дополнен readme 2022-10-26 15:08:47 +07:00
Book Pauk
ed4313b77b Рефакторинг 2022-10-26 01:48:14 +07:00
Book Pauk
d579be5376 Поправка бага 2022-10-26 01:36:45 +07:00
Book Pauk
b7d176c123 Поправки 2022-10-25 19:55:34 +07:00
Book Pauk
cc5d5167a3 Работа над разделом "Серии" 2022-10-25 19:48:20 +07:00
Book Pauk
3a8fa12894 Допиливание DbCreator 2022-10-25 17:42:50 +07:00
Book Pauk
1b503dba8e Использование параметра extendedSearch на клиенте 2022-10-25 17:20:53 +07:00
Book Pauk
53663b2867 Работа над SeriesList 2022-10-25 17:15:25 +07:00
Book Pauk
5c03e06648 Рефакторинг 2022-10-25 16:57:53 +07:00
Book Pauk
b598a43361 Поправка 2022-10-25 16:55:09 +07:00
Book Pauk
d770d499da Добавлена заготовка для seriesSearch 2022-10-25 16:50:56 +07:00
Book Pauk
739f06226f В конфиг добавлен параметр extendedSearch 2022-10-25 16:39:37 +07:00
Book Pauk
03e5a87eb3 Переименования, поправки 2022-10-25 15:58:05 +07:00
Book Pauk
dd5598a695 Переименования 2022-10-25 15:43:40 +07:00
Book Pauk
7553b88b89 Декомпозиция, выделение BaseList, переименования 2022-10-25 15:40:08 +07:00
Book Pauk
5f4b993154 К предыдущему 2022-10-25 14:53:05 +07:00
Book Pauk
6eae46eaa1 Рефакторинг 2022-10-25 14:44:30 +07:00
Book Pauk
e6485ec0a2 Оптимизации, кеширование 2022-10-25 14:43:06 +07:00
Book Pauk
8df8ba5e0e Поправка дебаг-сообщений 2022-10-25 14:41:51 +07:00
Book Pauk
993fe2d142 Мелкие поправки 2022-10-24 21:05:26 +07:00
Book Pauk
d7c1a83785 Добавлена маршрутизация, выбор вида списка: Авторы, Серии, Книги 2022-10-24 20:51:35 +07:00
Book Pauk
f582c34a72 Небольшое улучшение 2022-10-24 19:31:50 +07:00
Book Pauk
fd93d3f523 Рефакторинг 2022-10-24 17:31:21 +07:00
Book Pauk
634d646622 Рефакторинг, небольшие улучшения 2022-10-24 17:23:13 +07:00
Book Pauk
b707f8425e Небольшие улучшения 2022-10-24 17:02:44 +07:00
Book Pauk
bc2a54c4ab Декомпозиция, выделен AuthorList 2022-10-24 16:51:27 +07:00
Book Pauk
63dfdaf2c2 Рефакторинг 2022-10-23 20:51:56 +07:00
Book Pauk
723af3ea8b Поправки багов поиска 2022-10-23 19:26:59 +07:00
Book Pauk
f1267879af Поправки багов поиска 2022-10-23 19:10:13 +07:00
Book Pauk
784dda03fd Поправки багов поиска 2022-10-23 19:05:17 +07:00
Book Pauk
ea6d61ac6f Рефакторинг 2022-10-23 18:43:14 +07:00
Book Pauk
54f0cfec76 Добавлен heavyCalc.terminate() в close 2022-10-23 18:13:05 +07:00
Book Pauk
91d3d4c254 Тяжелые вычисления вынесены в отдельный поток 2022-10-23 18:09:02 +07:00
Book Pauk
ab4d54cc85 Добавлена возможность использования HeavyCalc как singleton 2022-10-23 18:01:20 +07:00
Book Pauk
86aa3511bf Новый модуль HeavyCalc для тяжелых вычислений в отдельном потоке 2022-10-23 16:32:55 +07:00
Book Pauk
8d5792d456 Добавлено отображение версии БД в статистику 2022-10-22 19:50:51 +07:00
Book Pauk
12e5c32578 Мелкий рефакторинг 2022-10-22 13:18:25 +07:00
Book Pauk
19aaba2492 Исправление потенциальных багов при использовании ООП 2022-10-22 00:49:45 +07:00
Book Pauk
1d3a661973 Ускорение процедуры поиска 2022-10-22 00:40:07 +07:00
Book Pauk
6a8f08fca8 "jembadb": "^5.0.2" 2022-10-22 00:32:32 +07:00
Book Pauk
93634b1137 Доработка vueComponent, теперь понимает иерархию классов 2022-10-21 20:18:32 +07:00
Book Pauk
cd72de5014 Рефакторинг 2022-10-21 19:59:19 +07:00
Book Pauk
5a19af3c7d Улучшены подсчет и отображение статистики 2022-10-21 18:52:20 +07:00
Book Pauk
46829a9b08 Адаптация к jembadb 5.0 2022-10-21 18:21:30 +07:00
Book Pauk
87d418a32a Добавлен консольный лог в режиме loggingEnabled: false 2022-10-21 18:09:52 +07:00
Book Pauk
d5a9b77334 "jembadb": "^5.0.1" 2022-10-21 17:30:36 +07:00
Book Pauk
e865070f23 Улучшение обработки ошибок 2022-10-21 13:50:55 +07:00
Book Pauk
294fb35f4d Улучшение загрузки конфига 2022-10-21 13:47:10 +07:00
Book Pauk
1490fc854a Добавлено подсвечивание номера страницы, если изменились критерии поиска 2022-10-21 13:27:28 +07:00
Book Pauk
8b67cd09ad Улучшение отображения списков 2022-10-21 12:41:35 +07:00
Book Pauk
48ebff923e 1.0.7 2022-10-20 22:02:37 +07:00
Book Pauk
7a58b5a3ba Мелкий рефакторинг 2022-10-20 21:57:10 +07:00
Book Pauk
8c1ec1dc93 Улучшение отдачи файлов книг и статики 2022-10-20 21:46:05 +07:00
Book Pauk
f7297ac573 Мелкий рефакторинг 2022-10-20 20:34:21 +07:00
Book Pauk
8e7fda9fbf Удалил ненужный модуль compression 2022-10-20 20:31:07 +07:00
Book Pauk
ba3d6adac6 Улучшение подхвата изменений inpx-файла 2022-10-20 18:03:47 +07:00
Book Pauk
11a6c06997 Поправка 2022-10-20 16:50:16 +07:00
Book Pauk
2d8dc8c04b Улучшение отображения BookView 2022-10-20 16:47:21 +07:00
Book Pauk
3204946fc8 Рефакторинг 2022-10-20 16:05:09 +07:00
Book Pauk
1aaa27e68b jembadb update 2022-10-20 15:51:22 +07:00
Book Pauk
31d5fc6df3 Улучшение отображения списков 2022-10-20 15:09:57 +07:00
Book Pauk
78dc1c87c8 Рефакторинг 2022-10-20 14:27:58 +07:00
Book Pauk
88d49852e0 Рефакторинг 2022-10-20 14:16:44 +07:00
Book Pauk
526683b7ef К предыдущему 2022-10-20 14:05:56 +07:00
Book Pauk
4ca56db142 Рефакторинг 2022-10-20 14:03:49 +07:00
Book Pauk
a556a88ee5 Добавлен подсчет статистики, сортировка серий и названий 2022-10-19 18:09:58 +07:00
Book Pauk
90be2a1447 Поправка формирования заголовка 2022-10-19 16:30:33 +07:00
Book Pauk
e70247e473 Поправлена настройка cacheSize при создании БД 2022-10-19 16:08:25 +07:00
Book Pauk
fba127f7ca Добавлена статистика по количеству файлов книг 2022-10-19 16:04:41 +07:00
Book Pauk
a5a734a333 Рефакторинг и улучшение отображения всех книг серии 2022-10-19 15:15:57 +07:00
Book Pauk
f8416a4aec Поправки багов 2022-10-19 14:30:57 +07:00
Book Pauk
e0e26df5cb Добавлено пояснение для пустого списка в рузультате поиска 2022-10-19 13:48:01 +07:00
Book Pauk
af3bac6a09 В конфиг добавлен параметр dbCacheSize 2022-10-19 13:30:00 +07:00
Book Pauk
43921953a7 Merge tag '1.0.6' into develop
1.0.6
2022-10-17 23:33:10 +07:00
Book Pauk
48eecf7b4c Merge branch 'release/1.0.6' 2022-10-17 23:32:59 +07:00
Book Pauk
97ca49571c 1.0.6 2022-10-17 23:32:36 +07:00
Book Pauk
d9cc0ffa23 Исправлен баг "Malicious entry" - ругался на невалидные имена файлов при извлечении из zip-архива 2022-10-17 23:16:27 +07:00
Book Pauk
de73eec019 Merge tag '1.0.5' into develop
1.0.5
2022-10-17 20:10:15 +07:00
Book Pauk
34311780d2 Merge branch 'release/1.0.5' 2022-10-17 20:10:08 +07:00
Book Pauk
f7e49e62b9 1.0.5 2022-10-17 20:09:52 +07:00
Book Pauk
8281798596 Ускорение выборки из author 2022-10-17 20:08:11 +07:00
Book Pauk
d3917bbc5c Поправлена опечатка 2022-10-17 00:10:27 +07:00
Book Pauk
c7d4522ee9 Merge tag '1.0.4' into develop
1.0.4
2022-10-16 23:22:38 +07:00
Book Pauk
a019adae33 Merge branch 'release/1.0.4' 2022-10-16 23:22:25 +07:00
Book Pauk
90d67b8880 1.0.4 2022-10-16 23:21:59 +07:00
Book Pauk
727a44986d Исправлены цели сборки релиза 2022-10-16 23:20:54 +07:00
Book Pauk
ae8ad29322 Поправлена опечатка 2022-10-16 23:15:50 +07:00
Book Pauk
a9637d1cbd Поправка описания параметров config.json 2022-10-16 22:58:55 +07:00
Book Pauk
f3bc0d2a42 Поправка описания параметров config.json 2022-10-16 22:57:12 +07:00
Book Pauk
65ea6d3d9a Небольшая доработка periodicCleanCache 2022-10-16 22:55:15 +07:00
Book Pauk
4e2760d39e Merge tag '1.0.3' into develop
1.0.3
2022-10-16 18:43:54 +07:00
Book Pauk
894349618c Merge branch 'release/1.0.3' 2022-10-16 18:43:47 +07:00
Book Pauk
2c74afd19b 1.0.3 2022-10-16 18:43:29 +07:00
Book Pauk
9f665eaeac Добавлен сборщик релизов 2022-10-16 18:43:08 +07:00
Book Pauk
96e0df783d Поправил openReleasePage 2022-10-16 18:29:03 +07:00
Book Pauk
54f82d73f3 Редактирование readme 2022-10-16 18:28:24 +07:00
Book Pauk
d6ee223414 Редактирование README 2022-10-16 17:25:09 +07:00
Book Pauk
319d8e7fdf Мелкий рефакторинг 2022-10-16 03:17:06 +07:00
Book Pauk
6bdfdebeaf Улучшение обработки ошибок 2022-10-16 00:40:06 +07:00
Book Pauk
58b1ed5638 Merge tag '1.0.2' into develop
1.0.2
2022-10-16 00:16:21 +07:00
Book Pauk
420c0f2464 Merge branch 'release/1.0.2' 2022-10-16 00:16:14 +07:00
Book Pauk
7e86621fcd Мелкая поправка 2022-10-15 22:02:36 +07:00
Book Pauk
4ddabd1ca9 1.0.2 2022-10-15 22:02:22 +07:00
Book Pauk
061f50b714 Исправлена отдача статики под Windows 2022-10-15 21:51:45 +07:00
Book Pauk
8aab918ac5 Поправил баг 2022-10-15 21:04:56 +07:00
Book Pauk
9aa9261b6a Улучшение работы RemoteLib.downloadInpxFile 2022-10-15 20:59:42 +07:00
Book Pauk
0894a38978 Рефакторинг 2022-10-15 18:59:13 +07:00
Book Pauk
f519bf3f67 Добавлено пояснение в случае неуспешного копирования ссылки 2022-10-15 18:43:14 +07:00
Book Pauk
ecf0aada37 Merge tag '1.0.1' into develop
1.0.1
2022-10-14 21:18:04 +07:00
Book Pauk
eb9bf89854 Merge branch 'release/1.0.1' 2022-10-14 21:17:59 +07:00
Book Pauk
e1418678e0 1.0.1 2022-10-14 21:17:18 +07:00
Book Pauk
ecc7e2b2c3 Исправление багов 2022-10-14 21:16:26 +07:00
Book Pauk
7799bba9f2 Merge tag '1.0.0' into develop
1.0.0
2022-10-14 20:29:20 +07:00
Book Pauk
caad7c3f32 Merge branch 'release/1.0.0' 2022-10-14 20:29:12 +07:00
Book Pauk
2010419da5 Версия 1.0.0 (ура!) 2022-10-14 20:16:54 +07:00
Book Pauk
911ee3f2d7 Улучшение работы ссылки "читать" 2022-10-14 20:05:14 +07:00
Book Pauk
bc5b895cf1 Доработки для встраивания в сетевую библиотеку проекта Liberama 2022-10-14 19:52:01 +07:00
Book Pauk
a2249e8d07 Мелкая поправка 2022-10-14 19:50:56 +07:00
Book Pauk
6eab678109 Мелкая поправка 2022-10-14 17:58:04 +07:00
Book Pauk
ff26b5999d Добавил очистку publicDir 2022-10-14 17:07:48 +07:00
Book Pauk
45705084e5 0.7.0 2022-10-14 17:00:57 +07:00
Book Pauk
2f430d02a4 К предыдущему 2022-10-14 16:58:31 +07:00
Book Pauk
c739c5abbb Улучшено формирование заголовка 2022-10-14 16:56:30 +07:00
Book Pauk
f231bb75bd Добавлена возможность сделать новый поиск по клику на лого 2022-10-14 16:23:09 +07:00
Book Pauk
e22992fb35 Улучшение работы с параметрами URL 2022-10-14 15:46:22 +07:00
Book Pauk
252376412f Поправки вывода в лог 2022-10-14 15:22:40 +07:00
Book Pauk
2ebffbf7f4 0.6.0 2022-10-14 15:22:33 +07:00
Book Pauk
810efb80b7 Поправлен баг 2022-10-14 02:30:25 +07:00
Book Pauk
5791138ff6 Мелкие поправки 2022-10-12 14:10:46 +07:00
Book Pauk
c69d0d77be 0.5.0 2022-10-11 02:05:01 +07:00
Book Pauk
295091d99a Доработка отображения всех книг серии 2022-10-11 02:02:19 +07:00
Book Pauk
58f2483b97 Добавлена возможность отображения всех книг серии 2022-10-10 22:03:59 +07:00
Book Pauk
09b7c38348 Убрал лишнее кеширование 2022-10-10 20:57:30 +07:00
Book Pauk
7cf5e91cc4 Добавлен еще один шаг при создании БД - оптимизация 2022-10-10 19:11:26 +07:00
Book Pauk
6c18c269d9 dbCreatorVersion = '2'; 2022-10-10 17:28:48 +07:00
Book Pauk
5d5ec9c7b0 Поправки вывода консоль текста помощи 2022-10-10 17:25:52 +07:00
Book Pauk
a2bbafd47a К предыдущему 2022-10-10 17:16:55 +07:00
Book Pauk
fc8fb27c41 Мелкие поправки 2022-10-10 17:16:25 +07:00
Book Pauk
a54645b686 Переименование inpx-web-filter.json -> filter.json 2022-10-10 17:03:46 +07:00
Book Pauk
0e2ef4133e Работа над списком книг в серии 2022-10-10 17:02:58 +07:00
Book Pauk
ec96d4af39 Поправлено местоположение inpxFilterFile 2022-10-09 20:58:46 +07:00
Book Pauk
dc7eca9e3d Добавлены cli-параметры --host, --port 2022-10-09 20:55:42 +07:00
Book Pauk
0dc30e730d Рефакторинг, улучшение отображения версии 2022-10-09 20:41:42 +07:00
Book Pauk
578efbd110 Поправил заголовки 2022-10-08 01:59:51 +07:00
Book Pauk
2d817e9c98 Мелкие поправки 2022-10-08 01:31:06 +07:00
Book Pauk
3009d99510 Убрал лишнее 2022-10-07 19:25:58 +07:00
Book Pauk
4817123599 Поправлен баг 2022-10-07 19:19:55 +07:00
Book Pauk
68c4aac16e 0.4.0 2022-10-07 19:08:25 +07:00
Book Pauk
a82392197f Работа над RemoteLib 2022-10-07 19:06:00 +07:00
Book Pauk
e6dfe4d221 Работа над RemoteLib 2022-10-07 17:59:34 +07:00
Book Pauk
8bec2275ae Доработка AsyncExit для выдачи сообщений при сигналах 2022-10-07 17:36:46 +07:00
Book Pauk
248781315a Поправки .gitignore 2022-10-07 16:27:19 +07:00
Book Pauk
612666c723 Поправил цвет панели 2022-10-07 15:06:38 +07:00
Book Pauk
e14c8823d2 Улучшение createWebApp 2022-10-07 15:05:37 +07:00
Book Pauk
9c72651804 В cli добавлен параметр 'unsafe-filter' 2022-10-05 14:59:17 +07:00
Book Pauk
53873910c2 Улучшение фильтра 2022-10-03 16:50:51 +07:00
Book Pauk
904e3e6c2f Добавлена возможность фильтрации по авторам при формировании поисковой БД.
Критерии фильтрации должны находиться в файле inpx-web-filter.json
2022-10-03 16:25:51 +07:00
Book Pauk
f5bb97a081 Поправлен gitignore 2022-10-03 16:25:03 +07:00
Book Pauk
0b95d62fdb Мелкая поправка 2022-10-03 16:24:35 +07:00
Book Pauk
f834703bae Поправки periodicCheckInpx 2022-10-02 16:10:21 +07:00
Book Pauk
fe0f272acc Добавлена периодическая проверка изменений inpx
для автоматического пересоздания поисковой БД
2022-10-02 15:54:40 +07:00
Book Pauk
0946941e59 Добавлено автообновление страницы приложения в браузере при обнаружении новой версии 2022-10-02 15:31:42 +07:00
Book Pauk
e1fb8afa27 Рефакторинг 2022-10-02 14:43:11 +07:00
Book Pauk
0a53648279 Добавлена чистка output перед сборкой 2022-10-01 13:39:25 +07:00
Book Pauk
316aedcfd1 0.3.0 2022-10-01 13:30:35 +07:00
Book Pauk
0af22618ce Добавлена кнопка "Показать еще" при отображении большого количества книг 2022-10-01 13:28:31 +07:00
Book Pauk
ee53b7bb14 Поправки отображения оценок 2022-10-01 12:31:33 +07:00
Book Pauk
12eb77f12e 0.2.0 2022-09-30 17:33:05 +07:00
Book Pauk
d3f1e19b8f Добавлена ссылка "читать" 2022-09-30 17:32:05 +07:00
Book Pauk
55b4cc39d9 Добавлено отображение оценок книг 2022-09-30 17:16:04 +07:00
Book Pauk
b5fe352ca6 Поправлен баг в Firefox 2022-09-30 16:22:14 +07:00
Book Pauk
385e102d7b Добавлена кнопка очистки на жанры 2022-09-29 15:32:23 +07:00
Book Pauk
792c1ca8ae Поправка для dev-режима 2022-09-29 15:18:39 +07:00
Book Pauk
cfa38d21c5 Мелкая поправка 2022-09-29 15:16:08 +07:00
Book Pauk
03e89502d5 Добавлена упаковка web-приложения внутрь исполнимого файла 2022-09-29 13:54:28 +07:00
Book Pauk
bf5140fe0f Поправки 2022-09-27 23:43:56 +07:00
Book Pauk
7d123fed11 Поправки 2022-09-27 16:01:29 +07:00
Book Pauk
8f1c222548 Добавлена памятка, поправки поиска 2022-09-27 15:53:36 +07:00
Book Pauk
6b9287176c Поправки 2022-09-27 14:42:55 +07:00
Book Pauk
d119d217b4 Добавлено отображение статистики по коллекции 2022-09-27 14:32:24 +07:00
Book Pauk
c584738156 Улучшение NumInput 2022-09-27 13:35:42 +07:00
Book Pauk
f7e1b132fc Небольшие поправки 2022-09-27 13:21:19 +07:00
Book Pauk
4548b5c4d9 Поправки 2022-09-27 13:13:47 +07:00
Book Pauk
7b75ec466c Исправление багов 2022-09-26 18:56:30 +07:00
Book Pauk
90c41f3c37 Доработка формы ввода пароля 2022-09-26 18:19:46 +07:00
Book Pauk
0b6b014d5f Добавлена возможность доступа по паролю 2022-09-26 17:27:45 +07:00
Book Pauk
afef0ed04c Улучшено скачивание файлов - добавлено читабельное имя файла 2022-09-26 16:04:26 +07:00
Book Pauk
92303cadc3 Поправлен gitignore 2022-09-26 16:03:59 +07:00
Book Pauk
95a4e96227 Добавлена периодическая чистка /files 2022-09-26 13:27:45 +07:00
Book Pauk
f4625b7d29 Поправка отображения жанров 2022-09-26 12:49:08 +07:00
Book Pauk
66e5985335 Работа над проектом 2022-09-25 17:53:44 +07:00
Book Pauk
e6d0c6e519 Работа над проектом 2022-09-25 17:27:40 +07:00
Book Pauk
7e5ea30579 Работа над проектом 2022-09-25 16:12:54 +07:00
Book Pauk
1cfa787e5a Добавлена настройка "Показывать жанры" 2022-09-25 13:44:58 +07:00
Book Pauk
5e3fe21c25 Добавлен параметр limit в routeQuery 2022-09-25 13:35:23 +07:00
Book Pauk
b6cd6b7c51 Небольшие поправки 2022-09-25 13:18:41 +07:00
Book Pauk
b4c8f5ad72 Работа над проектом 2022-09-23 17:48:15 +07:00
Book Pauk
aaf3c0d076 Работа над проектом 2022-09-23 15:00:25 +07:00
Book Pauk
ef8fd1dd39 Небольшие улучшения 2022-09-22 19:16:23 +07:00
Book Pauk
ff7b0743c6 Добавлена оптимизация запросов в случае поиска по одному автору 2022-09-22 18:26:51 +07:00
Book Pauk
ce5f736a50 Добавлен фильтр на стороне клиента 2022-09-22 17:07:28 +07:00
Book Pauk
567d0bc28e В PageScroller добавлены кнопки "в начало", "в конец" 2022-09-22 16:10:20 +07:00
Book Pauk
88a7e53114 Добавлен favicon 2022-09-20 21:26:20 +07:00
Book Pauk
93a41f9158 Поправки параметров CopyWebpackPlugin 2022-09-20 21:25:30 +07:00
Book Pauk
96b1b4a573 Добавлен линк на github-страницу проекта 2022-09-20 17:43:40 +07:00
Book Pauk
475f210f68 Небольшие поправки 2022-09-15 21:38:08 +07:00
Book Pauk
9d3a1e45da Рефакторинг + добавлено формирование и парсинг параметров в URL 2022-09-15 20:53:28 +07:00
Book Pauk
f362026a21 Поправка разметки 2022-09-12 18:02:01 +07:00
Book Pauk
a46276cf1a Работа над проектом 2022-09-12 17:59:29 +07:00
Book Pauk
00150966e2 Поправки разметки 2022-09-12 16:15:53 +07:00
Book Pauk
dd3685990b Поправки дерева жанров 2022-09-12 16:15:30 +07:00
Book Pauk
75773a3e20 Мелкая поправка 2022-09-02 21:29:01 +07:00
Book Pauk
274dead481 Поправки разметки 2022-09-02 21:16:56 +07:00
Book Pauk
a119abe9cc Поправки разметки 2022-09-02 21:14:47 +07:00
Book Pauk
5aa72fc610 Поправки разметки 2022-09-02 21:01:56 +07:00
Book Pauk
bc46142426 Небольшие поправки 2022-09-02 14:12:21 +07:00
Book Pauk
63a7eddcab Работа над проектом 2022-09-02 13:48:30 +07:00
Book Pauk
6542d17b0b Небольшие поправки 2022-09-02 13:05:31 +07:00
Book Pauk
0cd9384939 Поправки 2022-08-31 19:56:11 +07:00
Book Pauk
fc1b879659 Исправлен баг 2022-08-31 19:55:35 +07:00
Book Pauk
77938aac04 Работа над проектом 2022-08-31 18:51:31 +07:00
Book Pauk
9aae057f32 Добавлена настройка lowMemoryMode на случай, если памяти на машине 2Гб или меньше 2022-08-27 14:47:29 +07:00
Book Pauk
a1cdd6b116 Работа над проектом 2022-08-26 17:37:41 +07:00
Book Pauk
2c51bfaf96 Добавлено кеширование запросов на клиенте 2022-08-26 14:34:50 +07:00
Book Pauk
a6bac669f6 Поправки 2022-08-26 00:51:30 +07:00
Book Pauk
67faa25e8b Работа над проектом 2022-08-26 00:41:08 +07:00
Book Pauk
1e5c97dfac Добавлен параметр "queryCacheEnabled" в конфиг 2022-08-25 23:35:12 +07:00
Book Pauk
6222827593 Работа над проектом 2022-08-25 19:43:27 +07:00
Book Pauk
7a351bbad3 Работа над проектом 2022-08-25 19:21:56 +07:00
Book Pauk
a6b2b102ae Работа над проектом 2022-08-25 19:01:05 +07:00
Book Pauk
cfb8e44c0f Поправка emptyFieldValue 2022-08-25 17:02:29 +07:00
Book Pauk
781114547f Оптимизация работы по памяти 2022-08-25 16:28:58 +07:00
Book Pauk
4b6ad9775b Убрал ошибочные изменения 2022-08-25 03:15:19 +07:00
Book Pauk
9c04535e34 Микрооптимизации 2022-08-25 00:31:23 +07:00
Book Pauk
8c266b634d Убрал лишнее 2022-08-24 23:43:26 +07:00
Book Pauk
eaf5010a34 Оптимизация по памяти 2022-08-24 23:28:21 +07:00
Book Pauk
2f8b4e72b8 Поправки 2022-08-24 22:45:45 +07:00
Book Pauk
22506a91f4 Улучшение поисковой БД 2022-08-24 22:45:24 +07:00
Book Pauk
102e584850 Добавлено кеширование запросов getBookList 2022-08-23 20:14:20 +07:00
Book Pauk
b495a16626 Переименования 2022-08-23 20:09:07 +07:00
Book Pauk
9c0d943d8f Работа над проектом 2022-08-22 01:13:16 +07:00
Book Pauk
de540a1316 К предыдущему 2022-08-21 21:52:31 +07:00
Book Pauk
3564089b7c Работа над проектом 2022-08-21 21:47:31 +07:00
Book Pauk
705fce73f7 Работа над проектом 2022-08-21 21:10:56 +07:00
Book Pauk
c7073635e3 Работа над проектом 2022-08-21 18:44:06 +07:00
Book Pauk
f3d25039bc Работа над проектом 2022-08-21 18:08:50 +07:00
Book Pauk
fc411565b9 Доработки NumInput 2022-08-21 17:00:10 +07:00
Book Pauk
5a6e3ac8b3 Улучшения работы с памятью 2022-08-21 16:17:47 +07:00
Book Pauk
e938b739d5 JembaDb 4.2.0 2022-08-21 16:14:39 +07:00
Book Pauk
27a91d5597 Поправки PageScroller 2022-08-20 21:42:39 +07:00
Book Pauk
909311328e Поправки лимита 2022-08-20 21:39:58 +07:00
Book Pauk
ff5e3b28d0 Добавлен PageScroller 2022-08-20 21:34:20 +07:00
Book Pauk
507c2a0bbd Работа над проектом 2022-08-20 02:48:50 +07:00
Book Pauk
40d6b9c807 Работа над проектом 2022-08-20 02:16:27 +07:00
Book Pauk
d9a03a7e0d Улучшения скроллинга 2022-08-20 01:06:39 +07:00
Book Pauk
0fb86b2174 Доработка DbSearcher 2022-08-20 00:29:09 +07:00
Book Pauk
14adeb7c8d Улучшение поисковой базы 2022-08-19 21:08:59 +07:00
Book Pauk
884a64fe79 Работа над проектом 2022-08-19 20:47:06 +07:00
Book Pauk
32cbde1c4e Работа над проектом 2022-08-19 18:35:01 +07:00
Book Pauk
c0d95115ea Работа над проектом 2022-08-19 18:04:46 +07:00
Book Pauk
0b0a51e5d4 Работа над проектом 2022-08-19 17:16:39 +07:00
Book Pauk
fc38c55eb1 Работа над проектом 2022-08-19 02:27:39 +07:00
Book Pauk
eaf6f5692d Улучшение поисковой БД 2022-08-19 01:31:20 +07:00
Book Pauk
adb1d55141 Работа над приложением 2022-08-19 00:24:05 +07:00
Book Pauk
3877ad15c1 Небольшая доработка 2022-08-18 20:25:01 +07:00
Book Pauk
676e6f6909 Работа над WebWorker и DbSearcher 2022-08-18 18:54:56 +07:00
Book Pauk
61975b6f2b Доработки DbCreator 2022-08-18 18:54:38 +07:00
Book Pauk
587ba2c957 Поправка цели dev 2022-08-18 18:53:27 +07:00
Book Pauk
3850a7d624 Работа над клиентской частью 2022-08-18 18:53:06 +07:00
Book Pauk
41d1dc1441 Работа над WebWorker и DbCreator 2022-08-18 00:41:00 +07:00
Book Pauk
801a4cdbb5 Работа над WebWorker и DbCreator 2022-08-17 20:47:34 +07:00
Book Pauk
a93ae030c4 К предыдущему 2022-08-17 19:59:09 +07:00
Book Pauk
0e0c01e419 Добавил для pkg --options max-old-space-size=4096,expose-gc 2022-08-17 19:57:59 +07:00
Book Pauk
38f63232a3 Работа над WebWorker и DbCreator 2022-08-17 17:53:47 +07:00
Book Pauk
3cfb2beb3d Работа над WebWorker и DbCreator 2022-08-17 03:13:47 +07:00
Book Pauk
f76a8f14ec Работа над DbCreator и WebWorker 2022-08-17 02:40:57 +07:00
Book Pauk
d00cb200a3 Небольшая поправка 2022-08-17 02:40:39 +07:00
Book Pauk
a0f701123d Работа над WebWorker и DbCreator 2022-08-17 01:17:14 +07:00
Book Pauk
425fdf607d Добавил несколько утилитарных функций 2022-08-17 01:15:35 +07:00
Book Pauk
6f8c323557 Добавлен парсинг cli-аргументов 2022-08-17 01:13:09 +07:00
Book Pauk
8476f8b3c8 Добавил execDir в конфиг 2022-08-17 01:02:14 +07:00
Book Pauk
8a4164922f Файл жанров из MHL 2022-08-16 23:18:16 +07:00
Book Pauk
eddfde141e Работа над InpxParser 2022-08-16 20:27:04 +07:00
Book Pauk
2948cfdc27 Начало работы над InpxParser 2022-08-16 18:34:16 +07:00
Book Pauk
50ea7a5ca7 Мелкая поправка 2022-08-16 18:34:03 +07:00
Book Pauk
16e10a84ce Добавлен класс для чтения zip-архивов 2022-08-16 17:34:17 +07:00
Book Pauk
69667a3446 Добавлен node-stream-zip 2022-08-16 16:52:09 +07:00
Book Pauk
1dd5d4e2f8 jembadb 4.0.0 2022-08-16 15:35:53 +07:00
Book Pauk
f5ca8155b4 Поправка минимальной версии node 2022-08-16 15:35:13 +07:00
Book Pauk
78be5a9856 Каркас будущего приложения 2022-08-16 14:54:41 +07:00
Book Pauk
c3a0ce183e Начальная структура директорий, каркас проекта 2022-08-15 18:38:31 +07:00
84 changed files with 28632 additions and 11 deletions

4
.gitignore vendored
View File

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

279
README.md
View File

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

43
build/prepkg.js Normal file
View File

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

31
build/release.js Normal file
View File

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

View File

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

View File

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

View File

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

BIN
client/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

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

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

View File

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

View File

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

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

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

@@ -0,0 +1,213 @@
<template>
<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"
:min="0"
:max="5"
size="18px"
font-size="12px"
:thickness="1"
:color="rateColor"
track-color="grey-4"
readonly
/>
<q-tooltip :delay="500" anchor="top middle" content-style="font-size: 80%" max-width="400px">
Оценка {{ book.librate }}
</q-tooltip>
</div>
<div v-else style="width: 18px" />
</div>
<div v-else class="row justify-center" style="width: 18px">
<q-icon v-if="book.del" class="la la-trash text-bold text-red" size="18px">
<q-tooltip :delay="500" anchor="top middle" content-style="font-size: 80%" max-width="400px">
Удалено
</q-tooltip>
</q-icon>
</div>
</div>
</div>
<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 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">
{{ bookSize }}, {{ book.ext }}
</div>
<div v-if="showInfo" class="q-ml-sm clickable" @click.stop.prevent="emit('bookInfo')">
(инфо)
</div>
<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">
{{ book }}
</div>
</div>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../../vueComponent.js';
import * as utils from '../../../share/utils';
const componentOptions = {
components: {
},
watch: {
settings() {
this.loadSettings();
},
}
};
class BookView {
_options = componentOptions;
_props = {
book: Object,
mode: String,
genreMap: Object,
showReadLink: Boolean,
titleColor: { type: String, default: 'text-blue-10'},
};
showRates = true;
showInfo = true;
showGenres = true;
showDeleted = false;
showDates = false;
created() {
this.loadSettings();
}
loadSettings() {
const settings = this.settings;
this.showRates = settings.showRates;
this.showInfo = settings.showInfo;
this.showGenres = settings.showGenres;
this.showDates = settings.showDates;
this.showDeleted = settings.showDeleted;
}
get settings() {
return this.$store.state.settings;
}
get bookAuthor() {
if (this.book.author) {
let a = this.book.author.split(',');
return a.slice(0, 3).join(', ') + (a.length > 3 ? ' и др.' : '');
}
return '';
}
get bookSeries() {
if (this.book.series) {
return `(Серия: ${this.book.series})`;
}
return '';
}
get bookSize() {
let size = this.book.size/1024;
let unit = 'KB';
if (size > 1024) {
size = size/1024;
unit = 'MB';
}
return `${size.toFixed(0)}${unit}`;
}
get rateColor() {
const rate = (this.book.librate > 5 ? 5 : this.book.librate);
if (rate > 2)
return `green-${(rate - 1)*2}`;
else
return `red-${10 - rate*2}`;
}
get bookGenre() {
let result = [];
const genre = this.book.genre.split(',');
for (const g of genre) {
const name = this.genreMap.get(g);
if (name)
result.push(name);
}
return `(${result.join(' / ')})`;
}
get bookDate() {
if (!this.book.date)
return '';
return utils.sqlDateFormat(this.book.date);
}
emit(action) {
this.$emit('bookEvent', {action, book: this.book});
}
}
export default vueComponent(BookView);
//-----------------------------------------------------------------------------
</script>
<style scoped>
.clickable {
color: blue;
cursor: pointer;
}
.clickable2 {
cursor: pointer;
}
</style>

View File

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

View File

@@ -0,0 +1,75 @@
<template>
<div class="row items-center q-ml-md q-my-xs" style="font-size: 120%">
<div class="q-mr-xs">
Страница
</div>
<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
/>
</div>
<div class="q-ml-xs">
из {{ pageCount }}
</div>
</div>
</template>
<script>
//-----------------------------------------------------------------------------
import vueComponent from '../../vueComponent.js';
import NumInput from '../../share/NumInput.vue';
import * as utils from '../../../share/utils';
const componentOptions = {
components: {
NumInput
},
watch: {
modelValue(newValue) {
this.page = newValue;
},
page(newValue) {
this.$emit('update:modelValue', newValue);
},
}
};
class PageScroller {
_options = componentOptions;
_props = {
modelValue: Number,
disable: Boolean,
pageCount: Number,
};
page = 1;
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);
//-----------------------------------------------------------------------------
</script>
<style scoped>
.trans {
border-radius: 5px;
transition: background-color 0.3s linear;
}
</style>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

View File

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

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.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

16
client/main.js Normal file
View File

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

106
client/quasar.js Normal file
View File

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

41
client/router.js Normal file
View File

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

View File

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

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

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

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

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

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

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

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

@@ -0,0 +1,134 @@
import dayjs from 'dayjs';
import {Buffer} from 'safe-buffer';
//import _ from 'lodash';
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function toHex(buf) {
return Buffer.from(buf).toString('hex');
}
export function keyEventToCode(event) {
let result = [];
let code = event.code;
const modCode = code.substring(0, 3);
if (event.metaKey && modCode != 'Met')
result.push('Meta');
if (event.ctrlKey && modCode != 'Con')
result.push('Ctrl');
if (event.shiftKey && modCode != 'Shi')
result.push('Shift');
if (event.altKey && modCode != 'Alt')
result.push('Alt');
if (modCode == 'Dig') {
code = code.substring(5, 6);
} else if (modCode == 'Key') {
code = code.substring(3, 4);
}
result.push(code);
return result.join('+');
}
export function wordEnding(num, type = 0) {
const endings = [
['ов', '', 'а', 'а', 'а', 'ов', 'ов', 'ов', 'ов', 'ов'],
['й', 'я', 'и', 'и', 'и', 'й', 'й', 'й', 'й', 'й'],
['о', '', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],
['ий', 'ие', 'ия', 'ия', 'ия', 'ий', 'ий', 'ий', 'ий', 'ий'],
['о', 'а', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],
['ок', 'ка', 'ки', 'ки', 'ки', 'ок', 'ок', 'ок', 'ок', 'ок'],
['ых', 'ое', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых', 'ых'],
['о', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],
];
const deci = num % 100;
if (deci > 10 && deci < 20) {
return endings[type][0];
} else {
return endings[type][num % 10];
}
}
export function fallbackCopyTextToClipboard(text) {
let textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
let result = false;
try {
result = document.execCommand('copy');
} catch (e) {
console.error(e);
}
document.body.removeChild(textArea);
return result;
}
export async function copyTextToClipboard(text) {
if (!navigator.clipboard) {
return fallbackCopyTextToClipboard(text);
}
let result = false;
try {
await navigator.clipboard.writeText(text);
result = true;
} catch (e) {
console.error(e);
}
return result;
}
/*
export function formatDate(d, format = 'normal') {
switch (format) {
case 'normal':
return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.${d.getFullYear()} ` +
`${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
case 'coDate':
return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
case 'coMonth':
return `${(d.getMonth() + 1).toString().padStart(2, '0')}`;
case 'noDate':
return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.${d.getFullYear()}`;
default:
throw new Error('formatDate: unknown date format');
}
}
export function parseDate(sqlDate) {
const d = sqlDate.split('-');
const result = new Date();
result.setDate(parseInt(d[2], 10));
result.setMonth(parseInt(d[1], 10) - 1);
result.setYear(parseInt(d[0], 10));
return result;
}
*/
export function isDigit(c) {
return !isNaN(parseInt(c, 10));
}
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])));
}

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

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

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

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

15230
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1 @@
development

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

@@ -0,0 +1,47 @@
const path = require('path');
const pckg = require('../../package.json');
const execDir = path.resolve(__dirname, '..');
module.exports = {
branch: 'unknown',
version: pckg.version,
name: pckg.name,
execDir,
accessPassword: '',
bookReadLink: '',
loggingEnabled: true,
//поправить в случае, если были критические изменения в DbCreator или InpxParser
//иначе будет рассинхронизация между сервером и клиентом на уровне БД
dbVersion: '7',
dbCacheSize: 5,
maxPayloadSize: 500,//in MB
maxFilesDirSize: 1024*1024*1024,//1Gb
queryCacheEnabled: true,
cacheCleanInterval: 60,//minutes
inpxCheckInterval: 60,//minutes
lowMemoryMode: false,
fullOptimization: false,
webConfigParams: ['name', 'version', 'branch', 'bookReadLink', 'dbVersion'],
allowRemoteLib: false,
remoteLib: false,
/*
allowRemoteLib: true, // на сервере
remoteLib: { // на клиенте
accessPassword: '',
url: 'wss://remoteInpxWeb.ru',
},
*/
server: {
host: '0.0.0.0',
port: '22380',
},
};

View File

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

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

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,64 @@
const fs = require('fs-extra');
const Logger = require('./Logger');
let instance = null;
//singleton
class AppLogger {
constructor() {
if (!instance) {
this.inited = false;
this.logFileName = '';
this.errLogFileName = '';
this.fatalLogFileName = '';
instance = this;
}
return instance;
}
async init(config) {
if (this.inited)
throw new Error('already inited');
let loggerParams = null;
if (config.loggingEnabled) {
await fs.ensureDir(config.logDir);
this.logFileName = `${config.logDir}/${config.name}.log`;
this.errLogFileName = `${config.logDir}/${config.name}.err.log`;
this.fatalLogFileName = `${config.logDir}/${config.name}.fatal.log`;
loggerParams = [
{log: 'ConsoleLog'},
{log: 'FileLog', fileName: this.logFileName},
{log: 'FileLog', fileName: this.errLogFileName, exclude: [LM_OK, LM_INFO, LM_TOTAL]},
{log: 'FileLog', fileName: this.fatalLogFileName, exclude: [LM_OK, LM_INFO, LM_WARN, LM_ERR, LM_TOTAL]},//LM_FATAL only
];
} else {
loggerParams = [
{log: 'ConsoleLog'},
];
}
this._logger = new Logger(loggerParams);
this.inited = true;
return this.logger;
}
get logger() {
if (!this.inited)
throw new Error('not inited');
return this._logger;
}
get log() {
const l = this.logger;
return l.log.bind(l);
}
}
module.exports = AppLogger;

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

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

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

@@ -0,0 +1,641 @@
const fs = require('fs-extra');
const InpxParser = require('./InpxParser');
const InpxHashCreator = require('./InpxHashCreator');
const utils = require('./utils');
const emptyFieldValue = '?';
class DbCreator {
constructor(config) {
this.config = config;
}
async loadInpxFilter() {
const inpxFilterFile = this.config.inpxFilterFile;
if (await fs.pathExists(inpxFilterFile)) {
let filter = await fs.readFile(inpxFilterFile, 'utf8');
filter = JSON.parse(filter);
if (filter.includeAuthors) {
filter.includeAuthors = filter.includeAuthors.map(a => a.toLowerCase());
filter.includeSet = new Set(filter.includeAuthors);
}
if (filter.excludeAuthors) {
filter.excludeAuthors = filter.excludeAuthors.map(a => a.toLowerCase());
filter.excludeSet = new Set(filter.excludeAuthors);
}
return filter;
} else {
return false;
}
}
//процедура формировани БД несколько усложнена, в целях экономии памяти
async run(db, callback) {
const config = this.config;
callback({jobStepCount: 5});
callback({job: 'load inpx', jobMessage: 'Загрузка INPX', jobStep: 1, progress: 0});
//временная таблица
await db.create({
table: 'book',
cacheSize: (config.lowMemoryMode ? 5 : 500),
});
//поисковые таблицы, позже сохраним в БД
let authorMap = new Map();//авторы
let authorArr = [];
let seriesMap = new Map();//серии
let seriesArr = [];
let titleMap = new Map();//названия
let titleArr = [];
let genreMap = new Map();//жанры
let genreArr = [];
let langMap = new Map();//языки
let langArr = [];
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;
let bookCount = 0;
let noAuthorBookCount = 0;
let bookDelCount = 0;
//stuff
let recsLoaded = 0;
callback({recsLoaded});
let chunkNum = 0;
//фильтр
const inpxFilter = await this.loadInpxFilter();
let filter = () => true;
if (inpxFilter) {
let recFilter = () => true;
if (inpxFilter.filter) {
if (config.allowUnsafeFilter)
recFilter = new Function(`'use strict'; return ${inpxFilter.filter}`)();
else
throw new Error(`Unsafe property 'filter' detected in ${this.config.inpxFilterFile}. Please specify '--unsafe-filter' param if you know what you're doing.`);
}
filter = (rec) => {
let author = rec.author;
if (!author)
author = emptyFieldValue;
author = author.toLowerCase();
let excluded = false;
if (inpxFilter.excludeSet) {
const authors = author.split(',');
for (const a of authors) {
if (inpxFilter.excludeSet.has(a)) {
excluded = true;
break;
}
}
}
return recFilter(rec)
&& (!inpxFilter.includeSet || inpxFilter.includeSet.has(author))
&& !excluded
;
};
}
//вспомогательные функции
const splitAuthor = (author) => {
if (!author)
author = emptyFieldValue;
const result = author.split(',');
if (result.length > 1)
result.push(author);
return result;
}
let totalFiles = 0;
const readFileCallback = async(readState) => {
callback(readState);
if (readState.totalFiles)
totalFiles = readState.totalFiles;
if (totalFiles)
callback({progress: (readState.current || 0)/totalFiles});
};
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) || uidSet.has(rec._uid)) {
rec.id = 0;
filtered = true;
continue;
}
rec.id = ++id;
uidSet.add(rec._uid);
if (!rec.del) {
bookCount++;
if (!rec.author)
noAuthorBookCount++;
} else {
bookDelCount++;
}
parseBookRec(rec);
}
let saveChunk = [];
if (filtered) {
saveChunk = chunk.filter(r => r.id);
} else {
saveChunk = chunk;
}
await db.insert({table: 'book', rows: saveChunk});
recsLoaded += chunk.length;
callback({recsLoaded});
if (chunkNum++ % 10 == 0 && config.lowMemoryMode)
utils.freeMemory();
};
//парсинг
const parser = new InpxParser();
await parser.parse(config.inpxFile, readFileCallback, parsedCallback);
//чистка памяти, ибо жрет как не в себя
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();
//отсортируем таблицы выдадим им правильные 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,
bookCount,
bookCountAll: bookCount + bookDelCount,
bookDelCount,
noAuthorBookCount,
titleCount: titleArr.length,
seriesCount: seriesArr.length,
genreCount: genreArr.length,
langCount: langArr.length,
};
//console.log(stats);
//сохраним поисковые таблицы
const chunkSize = 10000;
const saveTable = async(table, arr, nullArr, indexType = 'string') => {
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, type: indexType, depth: 1000000},
});
//вставка в БД по кусочкам, экономим память
for (let i = 0; i < arr.length; i += chunkSize) {
const chunk = arr.slice(i, i + chunkSize);
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(10);
}
callback({progress: i/arr.length});
}
nullArr();
await db.close({table});
utils.freeMemory();
await db.freeMemory();
};
//author
callback({job: 'author save', jobMessage: 'Сохранение индекса авторов', jobStep: 3, progress: 0});
await saveTable('author', authorArr, () => {authorArr = null});
//series
callback({job: 'series save', jobMessage: 'Сохранение индекса серий', jobStep: 4, progress: 0});
await saveTable('series', seriesArr, () => {seriesArr = null});
//title
callback({job: 'title save', jobMessage: 'Сохранение индекса названий', jobStep: 5, progress: 0});
await saveTable('title', titleArr, () => {titleArr = null});
//genre
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
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'});
await db.create({table: 'query_time'});
//кэш-таблица имен файлов и их хешей
await db.create({table: 'file_hash'});
//-- завершающие шаги --------------------------------
await db.open({
table: 'book',
cacheSize: (config.lowMemoryMode ? 5 : 500),
});
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);
});
callback({job: 'stats count', jobMessage: 'Подсчет статистики', jobStep: 9, progress: 0});
await this.countStats(db, callback, stats);
//чистка памяти, ибо жрет как не в себя
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`;
const toId = `${from}_id`;
await db.open({table: from});
await db.create({table: to});
let bookId2RecId = new Map();
const saveChunk = async(chunk) => {
const ids = [];
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);
}
}
if (config.fullOptimization) {
ids.sort((a, b) => a - b);// обязательно, иначе будет тормозить - особенности JembaDb
const rows = await db.select({table: 'book', where: `@@id(${db.esc(ids)})`});
const bookArr = new Map();
for (const row of rows)
bookArr.set(row.id, row);
for (const 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;
}
await db.insert({
table: to,
rows: chunk,
});
}
};
const rows = await db.select({table: from, count: true});
const fromLength = rows[0].count;
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);
}
const ids = new Set();
let bookIdsLen = 0;
let id = iter.next();
while (!id.done) {
ids.add(id.value);
const row = @row(id.value);
bookIdsLen += row.bookIds.length;
if (bookIdsLen >= 50000)
break;
id = iter.next();
}
return ids;
`
});
if (chunk.length) {
await saveChunk(chunk);
processed += chunk.length;
callback({progress: 0.5*processed/fromLength});
} else
break;
if (this.config.lowMemoryMode) {
await utils.sleep(10);
utils.freeMemory();
await db.freeMemory();
}
}
await db.close({table: to});
await db.close({table: from});
await db.create({table: toId});
const chunkSize = 50000;
let idRows = [];
let proc = 0;
for (const [id, value] of bookId2RecId) {
idRows.push({id, value});
if (idRows.length >= chunkSize) {
await db.insert({table: toId, rows: idRows});
idRows = [];
proc += chunkSize;
callback({progress: 0.5 + 0.5*proc/bookId2RecId.size});
}
}
if (idRows.length)
await db.insert({table: toId, rows: idRows});
await db.close({table: toId});
bookId2RecId = null;
utils.freeMemory();
}
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;
}
}
module.exports = DbCreator;

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

@@ -0,0 +1,734 @@
//const _ = require('lodash');
const LockQueue = require('./LockQueue');
const utils = require('./utils');
const maxMemCacheSize = 100;
const maxLimit = 1000;
const emptyFieldValue = '?';
const maxUtf8Char = String.fromCodePoint(0xFFFFF);
const ruAlphabet = 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя';
const enAlphabet = 'abcdefghijklmnopqrstuvwxyz';
const enruArr = (ruAlphabet + enAlphabet).split('');
class DbSearcher {
constructor(config, db) {
this.config = config;
this.db = db;
this.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) {
const db = this.db;
a = a.toLowerCase();
let where;
//особая обработка префиксов
if (a[0] == '=') {
a = a.substring(1);
where = `@dirtyIndexLR('value', ${db.esc(a)}, ${db.esc(a)})`;
} else if (a[0] == '*') {
a = a.substring(1);
where = `@indexIter('value', (v) => (v !== ${db.esc(emptyFieldValue)} && v.indexOf(${db.esc(a)}) >= 0) )`;
} else if (a[0] == '#') {
a = a.substring(1);
where = `@indexIter('value', (v) => {
const enru = new Set(${db.esc(enruArr)});
return !v || (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)})`;
}
return where;
}
async selectBookIds(query) {
const db = this.db;
const idsArr = [];
const tableBookIds = async(table, where) => {
const rows = await db.select({
table,
rawResult: true,
where: `
const ids = ${where};
const result = new Set();
for (const id of ids) {
const row = @unsafeRow(id);
for (const bookId of row.bookIds)
result.add(bookId);
}
return Array.from(result);
`
});
return rows[0].rawResult;
};
//авторы
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);
}
idsArr.push(ids);
}
//серии
if (query.series && query.series !== '*') {
const key = `book-ids-series-${query.series}`;
let ids = await this.getCached(key);
if (ids === null) {
ids = await tableBookIds('series', this.getWhere(query.series));
await this.putCached(key, ids);
}
idsArr.push(ids);
}
//названия
if (query.title && query.title !== '*') {
const key = `book-ids-title-${query.title}`;
let ids = await this.getCached(key);
if (ids === null) {
ids = await tableBookIds('title', this.getWhere(query.title));
await this.putCached(key, ids);
}
idsArr.push(ids);
}
//жанры
if (query.genre) {
const key = `book-ids-genre-${query.genre}`;
let ids = await this.getCached(key);
if (ids === null) {
const genreRows = await db.select({
table: 'genre',
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 Array.from(result);
`
});
ids = genreRows[0].rawResult;
await this.putCached(key, ids);
}
idsArr.push(ids);
}
//языки
if (query.lang) {
const key = `book-ids-lang-${query.lang}`;
let ids = await this.getCached(key);
if (ids === null) {
const langRows = await db.select({
table: 'lang',
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 Array.from(result);
`
});
ids = langRows[0].rawResult;
await this.putCached(key, ids);
}
idsArr.push(ids);
}
//удаленные
if (query.del !== undefined) {
const key = `book-ids-del-${query.del}`;
let ids = await this.getCached(key);
if (ids === null) {
ids = await tableBookIds('del', `@indexLR('value', ${db.esc(query.del)}, ${db.esc(query.del)})`);
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 Array.from(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 Array.from(inter);
} else if (idsArr.length == 1) {
return idsArr[0];
} else {
return false;
}
}
async fillBookIdMap(from) {
if (this.bookIdMap[from])
return this.bookIdMap[from];
await this.lock.get();
try {
const db = this.db;
const map = new Map();
const table = `${from}_id`;
await db.open({table});
let rows = await db.select({table});
await db.close({table});
for (const row of rows) {
if (!row.value.length)
continue;
if (row.value.length > 1)
map.set(row.id, row.value);
else
map.set(row.id, row.value[0]);
}
this.bookIdMap[from] = map;
rows = null;
await db.freeMemory();
utils.freeMemory();
return this.bookIdMap[from];
} finally {
this.lock.ret();
}
}
async fillBookIdMapAll() {
await this.fillBookIdMap('author');
await this.fillBookIdMap('series');
await this.fillBookIdMap('title');
}
async filterTableIds(tableIds, from, query) {
let result = tableIds;
//т.к. авторы у книги идут списком, то дополнительно фильтруем
if (from == 'author' && query.author && query.author !== '*') {
const key = `filter-ids-author-${query.author}`;
let authorIds = await this.getCached(key);
if (authorIds === null) {
const rows = await this.db.select({
table: 'author',
rawResult: true,
where: `return Array.from(${this.getWhere(query.author)})`
});
authorIds = rows[0].rawResult;
await this.putCached(key, authorIds);
}
//пересечение tableIds и authorIds
result = [];
const authorIdsSet = new Set(authorIds);
for (const id of tableIds)
if (authorIdsSet.has(id))
result.push(id);
}
return result;
}
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);
}
if (bookIds) {
const tableIdsSet = new Set();
const bookIdMap = await this.fillBookIdMap(from);
let proc = 0;
let nextProc = 0;
for (const bookId of bookIds) {
const tableIdValue = bookIdMap.get(bookId);
if (!tableIdValue)
continue;
if (Array.isArray(tableIdValue)) {
for (const tableId of tableIdValue) {
tableIdsSet.add(tableId);
proc++;
}
} else {
tableIdsSet.add(tableIdValue);
proc++;
}
//прерываемся иногда, чтобы не блокировать Event Loop
if (proc >= nextProc) {
nextProc += 10000;
await utils.processLoop();
}
}
tableIds = Array.from(tableIdsSet);
} else {
const rows = await db.select({
table: from,
rawResult: true,
where: `return Array.from(@all())`
});
tableIds = rows[0].rawResult;
}
tableIds = await this.filterTableIds(tableIds, from, query);
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 ids = await this.selectTableIds(from, query);
const totalFound = ids.length;
let limit = (query.limit ? query.limit : 100);
limit = (limit > maxLimit ? maxLimit : limit);
const offset = (query.offset ? query.offset : 0);
//выборка найденных значений
const found = await db.select({
table: from,
map: `(r) => ({id: r.id, ${from}: r.name, bookCount: r.bookCount, bookDelCount: r.bookDelCount})`,
where: `@@id(${db.esc(ids.slice(offset, offset + limit))})`
});
//для 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 getAuthorBookList(authorId) {
if (this.closed)
throw new Error('DbSearcher closed');
if (!authorId)
return {author: '', books: ''};
this.searchFlag++;
try {
//выборка книг автора по authorId
const rows = await this.restoreBooks('author', [authorId])
let author = '';
let books = '';
if (rows.length) {
author = rows[0].name;
books = rows[0].books;
}
return {author, books: (books && books.length ? JSON.stringify(books) : '')};
} finally {
this.searchFlag--;
}
}
async getSeriesBookList(series) {
if (this.closed)
throw new Error('DbSearcher closed');
if (!series)
return {books: ''};
this.searchFlag++;
try {
const db = this.db;
series = series.toLowerCase();
//выборка серии по названию серии
let rows = await db.select({
table: 'series',
rawResult: true,
where: `return Array.from(@dirtyIndexLR('value', ${db.esc(series)}, ${db.esc(series)}))`
});
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.config.queryCacheEnabled)
return null;
let result = null;
const db = this.db;
const memCache = this.memCache;
if (memCache.has(key)) {//есть в недавних
result = memCache.get(key);
//изменим порядок ключей, для последующей правильной чистки старых
memCache.delete(key);
memCache.set(key, result);
} else {//смотрим в таблице
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;
memCache.set(key, result);
if (memCache.size > maxMemCacheSize) {
//удаляем самый старый ключ-значение
for (const k of memCache.keys()) {
memCache.delete(k);
break;
}
}
}
}
return result;
}
async putCached(key, value) {
if (!this.config.queryCacheEnabled)
return;
const db = this.db;
const memCache = this.memCache;
memCache.set(key, value);
if (memCache.size > maxMemCacheSize) {
//удаляем самый старый ключ-значение
for (const k of memCache.keys()) {
memCache.delete(k);
break;
}
}
//кладем в таблицу асинхронно
(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;
if (!cleanInterval)
return;
try {
const db = this.db;
const oldThres = Date.now() - cleanInterval;
//выберем всех кандидатов на удаление
const rows = await db.select({
table: 'query_time',
where: `
@@iter(@all(), (r) => (r.time < ${db.esc(oldThres)}));
`
});
const ids = [];
for (const row of rows)
ids.push(row.id);
//удаляем
await db.delete({table: 'query_cache', where: `@@id(${db.esc(ids)})`});
await db.delete({table: 'query_time', where: `@@id(${db.esc(ids)})`});
//console.log('Cache clean', ids);
} catch(e) {
console.error(e.message);
} finally {
if (!this.closed) {
this.timer = setTimeout(() => { this.periodicCleanCache(); }, cleanInterval);
}
}
}
async close() {
while (this.searchFlag > 0) {
await utils.sleep(50);
}
this.searchCache = null;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.closed = true;
}
}
module.exports = DbSearcher;

View File

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

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

@@ -0,0 +1,32 @@
const fs = require('fs-extra');
const utils = require('./utils');
class InpxHashCreator {
constructor(config) {
this.config = config;
}
async getHash() {
const config = this.config;
let inpxFilterHash = '';
if (await fs.pathExists(config.inpxFilterFile))
inpxFilterHash = await utils.getFileHash(config.inpxFilterFile, 'sha256', 'hex');
const joinedHash = this.config.dbVersion + inpxFilterHash +
await utils.getFileHash(config.inpxFile, 'sha256', 'hex');
return utils.getBufHash(joinedHash, 'sha256', 'hex');
}
async getInpxFileHash() {
return (
await fs.pathExists(this.config.inpxFile) ?
await utils.getFileHash(this.config.inpxFile, 'sha256', 'hex') :
''
);
}
}
module.exports = InpxHashCreator;

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

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

53
server/core/LockQueue.js Normal file
View File

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

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

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

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

@@ -0,0 +1,80 @@
const fs = require('fs-extra');
const path = require('path');
const utils = require('./utils');
const FileDownloader = require('./FileDownloader');
const WebSocketConnection = require('./WebSocketConnection');
const InpxHashCreator = require('./InpxHashCreator');
const log = new (require('./AppLogger'))().log;//singleton
//singleton
let instance = null;
class RemoteLib {
constructor(config) {
if (!instance) {
this.config = config;
this.wsc = new WebSocketConnection(config.remoteLib.url, 10, 30, {rejectUnauthorized: false});
if (config.remoteLib.accessPassword)
this.accessToken = utils.getBufHash(config.remoteLib.accessPassword, 'sha256', 'hex');
this.remoteHost = config.remoteLib.url.replace(/^ws:\/\//, 'http://').replace(/^wss:\/\//, 'https://');
this.down = new FileDownloader(config.maxPayloadSize*1024*1024);
this.inpxHashCreator = new InpxHashCreator(config);
this.inpxFileHash = '';
instance = this;
}
return instance;
}
async wsRequest(query) {
if (this.accessToken)
query.accessToken = this.accessToken;
const response = await this.wsc.message(
await this.wsc.send(query),
120
);
if (response.error)
throw new Error(response.error);
return response;
}
async downloadInpxFile() {
if (!this.inpxFileHash)
this.inpxFileHash = await this.inpxHashCreator.getInpxFileHash();
const response = await this.wsRequest({action: 'get-inpx-file', inpxFileHash: this.inpxFileHash});
if (response.data) {
await fs.writeFile(this.config.inpxFile, response.data, 'base64');
this.inpxFileHash = '';
}
}
async downloadBook(bookUid) {
try {
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.publicFilesDir}${link}`;
await fs.writeFile(publicPath, buf);
return path.basename(link);
} catch (e) {
log(LM_ERR, `RemoteLib.downloadBook: ${e.message}`);
throw new Error('502 Bad Gateway');
}
}
}
module.exports = RemoteLib;

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

31
server/createWebApp.js Normal file
View File

@@ -0,0 +1,31 @@
const fs = require('fs-extra');
const webApp = require('../dist/public.json');
const ZipReader = require('./core/ZipReader');
module.exports = async(config) => {
const verFile = `${config.publicDir}/version.txt`;
const zipFile = `${config.tempDir}/public.zip`;
if (await fs.pathExists(verFile)) {
const curPublicVersion = await fs.readFile(verFile, 'utf8');
if (curPublicVersion == config.version)
return;
}
await fs.remove(config.publicDir);
//извлекаем новый webApp
await fs.writeFile(zipFile, webApp.data, {encoding: 'base64'});
const zipReader = new ZipReader();
await zipReader.open(zipFile);
try {
await zipReader.extractAllToDir(config.publicDir);
} finally {
await zipReader.close();
}
await fs.writeFile(verFile, config.version);
await fs.remove(zipFile);
};

43
server/dev.js Normal file
View File

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

247
server/index.js Normal file
View File

@@ -0,0 +1,247 @@
const fs = require('fs-extra');
const path = require('path');
const express = require('express');
const http = require('http');
const WebSocket = require ('ws');
const utils = require('./core/utils');
const ayncExit = new (require('./core/AsyncExit'))();
let log;
let config;
let argv;
let branch = '';
const argvStrings = ['host', 'port', 'app-dir', 'lib-dir', 'inpx'];
function showHelp(defaultConfig) {
console.log(utils.versionText(config));
console.log(
`Usage: ${config.name} [options]
Options:
--help Print ${config.name} command line options
--host=<ip> Set web server host, default: ${defaultConfig.server.host}
--port=<port> Set web server port, default: ${defaultConfig.server.port}
--app-dir=<dirpath> Set application working directory, default: <execDir>/.${config.name}
--lib-dir=<dirpath> Set library directory, default: the same as ${config.name} executable's
--inpx=<filepath> Set INPX collection file, default: the one that found in library dir
--recreate Force recreation of the search database on start
`
);
}
async function init() {
argv = require('minimist')(process.argv.slice(2), {string: argvStrings});
const dataDir = argv['app-dir'];
//config
const configManager = new (require('./config'))();//singleton
await configManager.init(dataDir);
const defaultConfig = configManager.config;
await configManager.load();
config = configManager.config;
branch = config.branch;
//dirs
config.tempDir = `${config.dataDir}/tmp`;
config.logDir = `${config.dataDir}/log`;
config.publicDir = `${config.dataDir}/public`;
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);
//logger
const appLogger = new (require('./core/AppLogger'))();//singleton
await appLogger.init(config);
log = appLogger.log;
//cli
if (argv.help) {
showHelp(defaultConfig);
ayncExit.exit(0);
} else {
log(utils.versionText(config));
log('Initializing');
}
if (argv.host) {
config.server.host = argv.host;
}
if (argv.port) {
config.server.port = argv.port;
}
if (!config.remoteLib) {
const libDir = argv['lib-dir'];
if (libDir) {
if (await fs.pathExists(libDir)) {
config.libDir = libDir;
} else {
throw new Error(`Directory "${libDir}" not exists`);
}
} else {
config.libDir = config.execDir;
}
if (argv.inpx) {
if (await fs.pathExists(argv.inpx)) {
config.inpxFile = argv.inpx;
} else {
throw new Error(`File "${argv.inpx}" not found`);
}
} else {
const inpxFiles = [];
await utils.findFiles((file) => {
if (path.extname(file) == '.inpx')
inpxFiles.push(file);
}, config.libDir, false);
if (inpxFiles.length) {
if (inpxFiles.length == 1) {
config.inpxFile = inpxFiles[0];
} else {
throw new Error(`Found more than one .inpx files: \n${inpxFiles.join('\n')}`);
}
} else {
throw new Error(`No .inpx files found here: ${config.libDir}`);
}
}
} else {
config.inpxFile = `${config.dataDir}/remote.inpx`;
const RemoteLib = require('./core/RemoteLib');//singleton
const remoteLib = new RemoteLib(config);
await remoteLib.downloadInpxFile();
}
config.recreateDb = argv.recreate || false;
config.inpxFilterFile = `${config.dataDir}/filter.json`;
config.allowUnsafeFilter = argv['unsafe-filter'] || false;
//web app
if (branch !== 'development') {
const createWebApp = require('./createWebApp');
await createWebApp(config);
}
}
async function main() {
const log = new (require('./core/AppLogger'))().log;//singleton
//server
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server, maxPayload: config.maxPayloadSize*1024*1024 });
let devModule = undefined;
if (branch == 'development') {
const devFileName = './dev.js'; //require ignored by pkg -50Mb executable size
devModule = require(devFileName);
devModule.webpackDevMiddleware(app);
}
if (devModule)
devModule.logQueries(app);
initStatic(app, config);
const { WebSocketController } = require('./controllers');
new WebSocketController(wss, config);
if (devModule) {
devModule.logErrors(app);
} else {
app.use(function(err, req, res, next) {// eslint-disable-line no-unused-vars
log(LM_ERR, err.stack);
res.sendStatus(500);
});
}
server.listen(config.server.port, config.server.host, () => {
config.server.ready = true;
log(`Server ready`);
});
}
function initStatic(app, config) {
/*
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(filesPath) === 0)
) {
return next();
}
if (path.extname(req.path) == '') {
const bookFile = `${config.publicFilesDir}${req.path}`;
const bookFileDesc = `${bookFile}.d.json`;
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);
}
if (downFileName)
res.downFileName = downFileName;
}
return next();
});
//заголовки при отдаче
app.use(config.filesPathStatic, express.static(config.filesDir, {
setHeaders: (res) => {
if (res.downFileName) {
res.set('Content-Encoding', 'gzip');
res.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(res.downFileName)}`);
}
},
}));
app.use(express.static(config.publicDir));
}
(async() => {
try {
await init();
await main();
} catch (e) {
const mes = (branch == 'development' ? e.stack : e.message);
if (log)
log(LM_FATAL, mes);
else
console.error(mes);
ayncExit.exit(1);
}
})();