Compare commits

...

133 Commits

Author SHA1 Message Date
Book Pauk
428b507257 Merge branch 'release/0.12.1' 2022-09-01 21:10:52 +07:00
Book Pauk
043dab0731 Версия 0.12.1 2022-09-01 21:08:56 +07:00
Book Pauk
a7b4d9c0d8 Добавлена форма доната 2022-09-01 21:05:22 +07:00
Book Pauk
6f9c95e351 Переход на node 16, актуализация пакетов 2022-09-01 15:36:28 +07:00
Book Pauk
7a53063ea8 Исправление багов 2022-09-01 15:31:16 +07:00
Book Pauk
ec4d5cac4f Поправлен баг 2022-08-16 23:40:40 +07:00
Book Pauk
f8557cba88 Исправление багов 2022-08-05 02:25:45 +07:00
Book Pauk
5dead039f5 Дебаг 2022-08-05 01:09:47 +07:00
Book Pauk
ea38392df4 Дебаг 2022-08-05 00:57:18 +07:00
Book Pauk
0cc9d90a94 Поправлен мелкий баг 2022-08-05 00:31:56 +07:00
Book Pauk
8c7b86c458 Поправлен баг 2022-08-05 00:16:54 +07:00
Book Pauk
0e29546fc5 Добавлены таймауты 2022-08-04 23:53:46 +07:00
Book Pauk
c9fa90d07c Поправлен donate-адрес 2022-08-04 15:08:43 +07:00
Book Pauk
7d8e0525b1 Активировал DonateHelpPage 2022-08-04 15:03:48 +07:00
Book Pauk
ddf69876a6 Добавлено сообщение при изменении чекбокса проверки обновления 2022-08-04 13:23:32 +07:00
Book Pauk
1d78e75e38 Merge tag '0.12.0-2' into develop
0.12.0-2
2022-08-03 15:58:49 +07:00
Book Pauk
7ed58fe3c6 Merge branch 'release/0.12.0-2' 2022-08-03 15:58:42 +07:00
Book Pauk
058c79570b Поправки багов 2022-08-03 15:52:48 +07:00
Book Pauk
ec8fbcdf38 Исправление багов 2022-08-03 15:34:24 +07:00
Book Pauk
76673295bf Добавлена автоотмена проверки обновлений книг по истечении заданного количества дней 2022-08-03 14:57:01 +07:00
Book Pauk
084401b9c3 Мелкие поправки 2022-08-03 14:53:58 +07:00
Book Pauk
49038b10f7 Улучшение обработки ошибок 2022-07-29 17:45:33 +07:00
Book Pauk
45ea26810a Улучшение fillCheckQueue 2022-07-28 20:22:38 +07:00
Book Pauk
18c8b2d803 Мелкие поправки 2022-07-28 18:50:56 +07:00
Book Pauk
f4a7482b3b Улучшение парсинга head-запроса 2022-07-28 18:38:49 +07:00
Book Pauk
32dff128f4 Улучшение парсинга head-запроса 2022-07-28 18:04:47 +07:00
Book Pauk
a00b2d6574 Исправлен баг 2022-07-27 23:29:52 +07:00
Book Pauk
10c6e7d522 Merge tag '0.12.0-1' into develop
0.12.0-1
2022-07-27 21:33:56 +07:00
Book Pauk
df6a256d51 Merge branch 'release/0.12.0-1' 2022-07-27 21:33:49 +07:00
Book Pauk
fbdb74ee68 Поправка текста 2022-07-27 21:33:22 +07:00
Book Pauk
9ad7250da0 Merge tag '0.12.0' into develop
0.12.0
2022-07-27 21:10:04 +07:00
Book Pauk
8c86984ea1 Merge branch 'release/0.12.0' 2022-07-27 21:09:59 +07:00
Book Pauk
834b3f6210 Версия 0.12.0 2022-07-27 21:09:42 +07:00
Book Pauk
105b8d5042 Мелкие поправки 2022-07-27 21:02:26 +07:00
Book Pauk
7ca8fd9ca1 Доработки отправки bookUrls 2022-07-27 20:50:39 +07:00
Book Pauk
0067c2800a Дебаг 2022-07-27 20:37:56 +07:00
Book Pauk
688c8796f4 Поправлен баг 2022-07-27 19:00:25 +07:00
Book Pauk
56af65742b Улучшение настроек для BookUpdateChecker 2022-07-27 18:49:51 +07:00
Book Pauk
629ad26d40 Доработки BookUpdateChecker 2022-07-27 17:55:29 +07:00
Book Pauk
4b0e499c10 Работа над BookUpdateChecker 2022-07-27 17:28:02 +07:00
Book Pauk
4697b46cba Работа над BookUpdateChecker 2022-07-27 16:50:24 +07:00
Book Pauk
7f17e7daed Работа над BookUpdateChecker 2022-07-27 15:40:46 +07:00
Book Pauk
a1fcb7597b Работа над BookUpdateChecker 2022-07-27 14:08:59 +07:00
Book Pauk
35e46d0685 Работа над BookUpdateChecker 2022-07-27 12:44:10 +07:00
Book Pauk
e2c0f3658b Улучшения ServerStorage 2022-07-27 11:42:39 +07:00
Book Pauk
a3541ec16a Работа над BookUpdateChecker 2022-07-26 20:37:49 +07:00
Book Pauk
08d0d3e7f3 Работа над BookUpdateChecker 2022-07-26 20:12:44 +07:00
Book Pauk
2c47b2bee3 Работа над BookUpdateChecker 2022-07-26 18:43:42 +07:00
Book Pauk
e6008b5ec4 Работа над BookUpdateChecker 2022-07-26 17:30:34 +07:00
Book Pauk
e214ddf8d5 Работа над BookUpdateChecker 2022-07-26 00:41:07 +07:00
Book Pauk
52927c6188 Работа над BookUpdateChecker 2022-07-26 00:11:15 +07:00
Book Pauk
92ca9dd983 Работа над BookUpdateChecker 2022-07-25 23:27:38 +07:00
Book Pauk
ed8be34c12 Работа над BookUpdateChecker 2022-07-25 17:52:57 +07:00
Book Pauk
93bddfd05e Переход на vuex-persist вместо vuex-persistedstate 2022-07-25 17:03:29 +07:00
Book Pauk
8c99101bb3 Обновление пакетов 2022-07-25 16:41:07 +07:00
Book Pauk
d874f9ded4 Актуализация пакетов 2022-07-25 16:30:38 +07:00
Book Pauk
d7be4d3d94 Окончательное избавление от sqlite в пользу jembadb 2022-07-25 16:12:15 +07:00
Book Pauk
a2fa312839 Merge tag '0.11.8-7' into develop
0.11.8-7
2022-07-19 00:52:43 +07:00
Book Pauk
f7e1e09928 Merge branch 'release/0.11.8-7' 2022-07-19 00:52:36 +07:00
Book Pauk
f0832b07cb Исправление привнесенного бага 2022-07-19 00:50:44 +07:00
Book Pauk
7c253df291 Merge tag '0.11.8-6' into develop
0.11.8-6
2022-07-19 00:36:00 +07:00
Book Pauk
bb7cd9cbde Merge branch 'release/0.11.8-6' 2022-07-19 00:35:55 +07:00
Book Pauk
56c4182985 Небольшой тюнинг 2022-07-19 00:35:12 +07:00
Book Pauk
cb6c7536bf Небольшой тюнинг 2022-07-19 00:32:52 +07:00
Book Pauk
fbfe8cbda0 Решение проблемы невалидного tls-сертификата 2022-07-19 00:27:54 +07:00
Book Pauk
6129d2d7eb Небольшие поправки 2022-07-19 00:14:18 +07:00
Book Pauk
16b30c922a Улучшение работы с удаленным хранилищем 2022-07-18 23:54:25 +07:00
Book Pauk
c42ad66be6 Merge tag '0.11.8-5' into develop
0.11.8-5
2022-07-17 21:15:37 +07:00
Book Pauk
f36c13fea1 Merge branch 'release/0.11.8-5' 2022-07-17 21:15:31 +07:00
Book Pauk
4fd9d579e0 Небольшие доработки remoteSent, оптимизация отправки файлов 2022-07-17 21:10:52 +07:00
Book Pauk
e65a8a13ea Рефакторинг 2022-07-17 20:04:23 +07:00
Book Pauk
6ddb97d43e Тюнинг таймаутов 2022-07-17 17:11:34 +07:00
Book Pauk
89082603de Merge tag '0.11.8-4' into develop
0.11.8-4
2022-07-17 16:54:15 +07:00
Book Pauk
a9a3227433 Merge branch 'release/0.11.8-4' 2022-07-17 16:53:59 +07:00
Book Pauk
60cb3514b2 Тюнинг таймаутов 2022-07-17 16:53:12 +07:00
Book Pauk
4aeaa05f0b Merge tag '0.11.8-3' into develop
0.11.8-3
2022-07-17 15:58:34 +07:00
Book Pauk
9c06552278 Merge branch 'release/0.11.8-3' 2022-07-17 15:58:28 +07:00
Book Pauk
000f8dde82 Переход на RemoteStorage 2022-07-17 15:43:12 +07:00
Book Pauk
9ffc218002 Поправка 2022-07-16 21:36:50 +07:00
Book Pauk
68a188f099 Конфиг nginx 2022-07-16 21:10:33 +07:00
Book Pauk
8829bb3810 Конфиг nginx 2022-07-16 21:07:16 +07:00
Book Pauk
5164d2f536 Merge tag '0.11.8-2' into develop
0.11.8-2
2022-07-16 21:02:05 +07:00
Book Pauk
451538fcf7 Merge branch 'release/0.11.8-2' 2022-07-16 21:01:56 +07:00
Book Pauk
82a02ef339 Удаление более ненужной функциональности 2022-07-16 20:48:50 +07:00
Book Pauk
b834d4951f Обработка ошибок 2022-07-16 20:40:21 +07:00
Book Pauk
edc3b669be Добавлено восстановление файлов из webdav 2022-07-16 20:35:34 +07:00
Book Pauk
522826311d Переделка механизма чистки папок и отправки через RemoteWebDavStorage 2022-07-16 20:24:37 +07:00
Book Pauk
e69b9951d5 Отключил проверку валидности tls-сертификата 2022-07-16 18:43:09 +07:00
Book Pauk
c6300222ea Мелкий рефакторинг 2022-07-16 17:54:27 +07:00
Book Pauk
5aa6ee899c Изменение механизма работы с /tmp и /upload (начало) 2022-07-16 17:35:32 +07:00
Book Pauk
4b76f97d2b Поправки конфигов nginx 2022-07-16 15:45:52 +07:00
Book Pauk
5ccfe71c55 Начало работы над BookUpdateChecker 2022-07-16 13:16:57 +07:00
Book Pauk
97fc902cdb Поправлен баг 2022-07-15 23:53:54 +07:00
Book Pauk
7e935951d7 Поправка разметки 2022-07-15 23:17:30 +07:00
Book Pauk
810c6d68d2 Поправка разметки 2022-07-15 23:14:09 +07:00
Book Pauk
003dc70f4f Merge tag '0.11.8-1' into develop
0.11.8-1
2022-07-15 18:14:12 +07:00
Book Pauk
371ff64a95 Merge branch 'release/0.11.8-1' 2022-07-15 18:14:06 +07:00
Book Pauk
b0de5adbf3 Добавлена возможность скачивать обои 2022-07-15 18:11:24 +07:00
Book Pauk
d1d2b07c33 Поправки разметки 2022-07-15 17:42:19 +07:00
Book Pauk
d9b2444c1a Улучшен механизм загрузки обложек 2022-07-15 17:36:49 +07:00
Book Pauk
e7fae27031 Убрал отладку 2022-07-15 17:17:00 +07:00
Book Pauk
eb0c7b0a32 Отладка 2022-07-15 17:11:58 +07:00
Book Pauk
3d7ad0dd9a Небюольшие оптимизации загрузки обложек 2022-07-15 17:05:17 +07:00
Book Pauk
ae04feb311 Merge tag '0.11.8' into develop
0.11.8
2022-07-15 02:11:03 +07:00
Book Pauk
7b59f911ef Merge branch 'release/0.11.8' 2022-07-15 02:10:58 +07:00
Book Pauk
d3444da647 Поправки разметки 2022-07-15 01:58:42 +07:00
Book Pauk
66738d0c9c К предыдущему 2022-07-15 01:51:28 +07:00
Book Pauk
7e187acd68 Версия 0.11.8 2022-07-15 01:50:17 +07:00
Book Pauk
c751372a54 Добавлен resizeImage 2022-07-15 01:38:25 +07:00
Book Pauk
7fc98fc7da Добавление отображения обложки (coverpage) в окне загруженных файлов 2022-07-15 00:47:24 +07:00
Book Pauk
b56f45694e Добавлен coversStorage для хранения coverpage 2022-07-15 00:45:56 +07:00
Book Pauk
091ca521ef Новые upload-методы 2022-07-15 00:45:09 +07:00
Book Pauk
c7a17b0a76 Добавлена синхронизация файлов обоев 2022-07-14 20:14:40 +07:00
Book Pauk
26468b996a Мелкая поправка 2022-07-14 20:12:37 +07:00
Book Pauk
c4e240d87c Увеличил maxPayloadSize 2022-07-14 20:11:17 +07:00
Book Pauk
04713f47c8 Небольшие поправки 2022-07-14 16:14:25 +07:00
Book Pauk
37ab3493db Merge tag '0.11.7-6' into develop
0.11.7-6
2022-07-14 03:52:50 +07:00
Book Pauk
a4cb3c628e Merge branch 'release/0.11.7-6' 2022-07-14 03:52:44 +07:00
Book Pauk
8492da8a13 Небольшое улучшение 2022-07-14 03:51:59 +07:00
Book Pauk
98d7c64a56 Исправление багов 2022-07-14 03:34:55 +07:00
Book Pauk
25f121e5ed Merge tag '0.11.7-5' into develop
0.11.7-5
2022-07-14 01:57:36 +07:00
Book Pauk
4c8797c99c Merge branch 'release/0.11.7-5' 2022-07-14 01:57:30 +07:00
Book Pauk
1155aa285d Лишние пробелы 2022-07-14 01:57:03 +07:00
Book Pauk
239bbb8263 Добавлено восстановление из архива 2022-07-14 01:55:09 +07:00
Book Pauk
e6b9330108 Добавление работы с архивом 2022-07-14 01:17:09 +07:00
Book Pauk
935b767c2e Поправил поведение buttonActiveClass 2022-07-14 00:31:24 +07:00
Book Pauk
8acf3295b5 Поправил разметку 2022-07-14 00:31:09 +07:00
Book Pauk
48c3a12fa0 Улучшение парсинга плохих fb2 2022-07-14 00:30:27 +07:00
Book Pauk
a1dea514b7 Поправка разметки 2022-07-13 23:47:55 +07:00
Book Pauk
d4788439cb Merge tag '0.11.7-4' into develop
0.11.7-4
2022-07-13 16:38:10 +07:00
Book Pauk
0a60ad354c Merge branch 'release/0.11.7-4' 2022-07-13 16:38:04 +07:00
Book Pauk
c565a20344 Поправки разметки 2022-07-13 16:37:47 +07:00
Book Pauk
735ee88f0b Merge tag '0.11.7-3' into develop
0.11.7-3
2022-07-13 16:34:22 +07:00
65 changed files with 4480 additions and 4546 deletions

View File

@@ -1,43 +1,43 @@
# Liberama
Браузерная онлайн-читалка книг и децентрализованная библиотека.
Читалка <img src="https://omnireader.ru/favicon.ico" width="14px"/>[OmniReader](https://omnireader.ru) является частью данного проекта, размещенной на VPS:
![](docs/assets/face.jpg)
![](docs/assets/reader.jpg)
## VPS
Для разворачивания читалки на чистом VPS с нуля смотрите [docs/omnireader.ru](docs/omnireader.ru/README.md)
## Сборка проекта
Необходима версия node.js не ниже 14.
```
$ git clone https://github.com/bookpauk/liberama
$ cd liberama
$ npm i
```
### Windows
```
$ npm run build:win
```
### Linux
```
$ npm run build:linux
```
Результат сборки будет доступен в каталоге `dist/linux|win` в виде исполнимого (standalone) файла
### Разработка
```
$ npm run dev
```
## Помочь проекту
* bitcoin: 3EbgZ7MK1UVaN38Gty5DCBtS4PknM4Ut85
* litecoin: MP39Riec4oSNB3XMjiquKoLWxbufRYNXxZ
* monero: 8BQPnvHcPSHM5gMQsmuypDgx9NNsYqwXKfDDuswEyF2Q2ewQSfd2pkK6ydH2wmMyq2JViZvy9DQ35hLMx7g72mFWNJTPtnz
# Liberama
Браузерная онлайн-читалка книг и децентрализованная библиотека.
Читалка <img src="https://omnireader.ru/favicon.ico" width="14px"/>[OmniReader](https://omnireader.ru) является частью данного проекта, размещенной на VPS:
![](docs/assets/face.jpg)
![](docs/assets/reader.jpg)
## VPS
Для разворачивания читалки на чистом VPS с нуля смотрите [docs/omnireader.ru](docs/omnireader.ru/README.md)
## Сборка проекта
Необходима версия node.js не ниже 14.
```
$ git clone https://github.com/bookpauk/liberama
$ cd liberama
$ npm i
```
### Windows
```
$ npm run build:win
```
### Linux
```
$ npm run build:linux
```
Результат сборки будет доступен в каталоге `dist/linux|win` в виде исполнимого (standalone) файла
### Разработка
```
$ npm run dev
```
## Помочь проекту
* bitcoin: bc1q3tyumaj648pp2e69jalsez2lnt462ttc33nup9
* litecoin: MP39Riec4oSNB3XMjiquKoLWxbufRYNXxZ
* monero: 8BQPnvHcPSHM5gMQsmuypDgx9NNsYqwXKfDDuswEyF2Q2ewQSfd2pkK6ydH2wmMyq2JViZvy9DQ35hLMx7g72mFWNJTPtnz

View File

@@ -23,24 +23,6 @@ async function main() {
await fs.ensureDir(tempDownloadDir);
//sqlite3
const sqliteRemoteUrl = 'https://mapbox-node-binary.s3.amazonaws.com/sqlite3/v5.0.2/napi-v3-linux-x64.tar.gz';
const sqliteDecompressedFilename = `${tempDownloadDir}/napi-v3-linux-x64/node_sqlite3.node`;
if (!await fs.pathExists(sqliteDecompressedFilename)) {
// Скачиваем node_sqlite3.node для винды, т.к. pkg не включает его в сборку
const res = await axios.get(sqliteRemoteUrl, {responseType: 'stream'})
await pipeline(res.data, fs.createWriteStream(`${tempDownloadDir}/sqlite.tar.gz`));
console.log(`done downloading ${sqliteRemoteUrl}`);
//распаковываем
console.log(await decomp.unpackTarZZ(`${tempDownloadDir}/sqlite.tar.gz`, tempDownloadDir));
console.log('files decompressed');
}
// копируем в дистрибутив
await fs.copy(sqliteDecompressedFilename, `${outDir}/node_sqlite3.node`);
console.log(`copied ${sqliteDecompressedFilename} to ${outDir}/node_sqlite3.node`);
//ipfs
const ipfsDecompressedFilename = `${tempDownloadDir}/go-ipfs/ipfs`;
if (!await fs.pathExists(ipfsDecompressedFilename)) {

View File

@@ -23,24 +23,6 @@ async function main() {
await fs.ensureDir(tempDownloadDir);
//sqlite3
const sqliteRemoteUrl = 'https://mapbox-node-binary.s3.amazonaws.com/sqlite3/v5.0.2/napi-v3-win32-x64.tar.gz';
const sqliteDecompressedFilename = `${tempDownloadDir}/napi-v3-win32-x64/node_sqlite3.node`;
if (!await fs.pathExists(sqliteDecompressedFilename)) {
// Скачиваем node_sqlite3.node для винды, т.к. pkg не включает его в сборку
const res = await axios.get(sqliteRemoteUrl, {responseType: 'stream'})
await pipeline(res.data, fs.createWriteStream(`${tempDownloadDir}/sqlite.tar.gz`));
console.log(`done downloading ${sqliteRemoteUrl}`);
//распаковываем
console.log(await decomp.unpackTarZZ(`${tempDownloadDir}/sqlite.tar.gz`, tempDownloadDir));
console.log('files decompressed');
}
// копируем в дистрибутив
await fs.copy(sqliteDecompressedFilename, `${outDir}/node_sqlite3.node`);
console.log(`copied ${sqliteDecompressedFilename} to ${outDir}/node_sqlite3.node`);
//ipfs
const ipfsDecompressedFilename = `${tempDownloadDir}/go-ipfs/ipfs.exe`;
if (!await fs.pathExists(ipfsDecompressedFilename)) {

View File

@@ -9,7 +9,7 @@ class Misc {
async loadConfig() {
const query = {params: [
'name', 'version', 'mode', 'maxUploadFileSize', 'useExternalBookConverter', 'acceptFileExt', 'branch',
'name', 'version', 'mode', 'maxUploadFileSize', 'useExternalBookConverter', 'acceptFileExt', 'bucEnabled', 'branch',
]};
try {

View File

@@ -1,5 +1,6 @@
import axios from 'axios';
import * as utils from '../share/utils';
import * as cryptoUtils from '../share/cryptoUtils';
import wsc from './webSocketConnection';
const api = axios.create({
@@ -119,32 +120,7 @@ class Reader {
estSize = response.headers['content-length'];
}
} catch (e) {
//восстановим при необходимости файл на сервере из удаленного облака
let response = null
try {
response = await wsc.message(await wsc.send({action: 'reader-restore-cached-file', path: url}));
} catch (e) {
console.error(e);
//если с WebSocket проблема, работаем по http
response = await api.post('/restore-cached-file', {path: url});
response = response.data;
}
if (response.state == 'error') {
throw new Error(response.error);
}
const workerId = response.workerId;
if (!workerId)
throw new Error('Неверный ответ api');
response = await this.getWorkerStateFinish(workerId);
if (response.state == 'error') {
throw new Error(response.error);
}
if (response.size && estSize < 0) {
estSize = response.size;
}
//
}
return estSize;
@@ -174,11 +150,10 @@ class Reader {
return await axios.get(url, options);
}
async uploadFile(file, maxUploadFileSize, callback) {
if (!maxUploadFileSize)
maxUploadFileSize = 10*1024*1024;
async uploadFile(file, maxUploadFileSize = 10*1024*1024, callback) {
if (file.size > maxUploadFileSize)
throw new Error(`Размер файла превышает ${maxUploadFileSize} байт`);
let formData = new FormData();
formData.append('file', file, file.name);
@@ -225,6 +200,46 @@ class Reader {
return response;
}
makeUrlFromBuf(buf) {
const key = utils.toHex(cryptoUtils.sha256(buf));
return `disk://${key}`;
}
async uploadFileBuf(buf, url) {
if (!url)
url = this.makeUrlFromBuf(buf);
let response;
try {
await axios.head(url.replace('disk://', '/upload/'), {headers: {'Cache-Control': 'no-cache'}});
response = await wsc.message(await wsc.send({action: 'upload-file-touch', url}));
} catch (e) {
response = await wsc.message(await wsc.send({action: 'upload-file-buf', buf}));
}
if (response.error)
throw new Error(response.error);
return response;
}
async getUploadedFileBuf(url) {
url = url.replace('disk://', '/upload/');
return (await axios.get(url)).data;
}
async checkBuc(bookUrls) {
const response = await wsc.message(await wsc.send({action: 'check-buc', bookUrls}));
if (response.error)
throw new Error(response.error);
if (!response.data)
throw new Error(`response.data is empty`);
return response.data;
}
}
export default new Reader();

View File

@@ -238,7 +238,7 @@ class App {
const url = s[1] || '';
const q = utils.parseQuery(s[0] || '');
if (url) {
q.url = decodeURIComponent(url);
q.url = url;
}
window.history.replaceState({}, '', '/');

View File

@@ -1,70 +1,17 @@
<template>
<div class="page">
<div class="box">
<div class="column items-center" style="width: 500px">
<p class="p">
Вы можете пожертвовать на развитие проекта любую сумму:
Здесь вы можете пожертвовать на развитие проекта:
</p>
<div class="address">
<img class="logo" src="./assets/yoomoney.png">
<q-btn class="q-ml-sm q-px-sm" dense no-caps @click="donateYooMoney">
Пожертвовать
</q-btn><br>
<div class="para">
{{ yooAddress }}
<q-icon class="copy-icon" name="la la-copy" @click="copyAddress(yooAddress, 'Кошелёк ЮMoney')">
<q-tooltip :delay="1000" anchor="top middle" self="center middle" content-style="font-size: 80%">
Скопировать
</q-tooltip>
</q-icon>
</div>
</div>
<!--div class="address">
<img class="logo" src="./assets/paypal.png">
<div class="para">
{{ paypalAddress }}
<q-icon class="copy-icon" name="la la-copy" @click="copyAddress(paypalAddress, 'Paypal-адрес')">
<q-tooltip :delay="1000" anchor="top middle" self="center middle" content-style="font-size: 80%">
Скопировать
</q-tooltip>
</q-icon>
</div>
</div-->
<q-btn no-caps class="q-my-lg" color="green-8" size="14px" style="width: 200px" @click="makeDonation">
<q-icon class="q-mr-xs" name="la la-donate" size="24px" />
Поддержать проект
</q-btn>
<div class="address">
<img class="logo" src="./assets/bitcoin.png">
<div class="para">
{{ bitcoinAddress }}
<q-icon class="copy-icon" name="la la-copy" @click="copyAddress(bitcoinAddress, 'Bitcoin-адрес')">
<q-tooltip :delay="1000" anchor="top middle" self="center middle" content-style="font-size: 80%">
Скопировать
</q-tooltip>
</q-icon>
</div>
</div>
<div class="address">
<img class="logo" src="./assets/litecoin.png">
<div class="para">
{{ litecoinAddress }}
<q-icon class="copy-icon" name="la la-copy" @click="copyAddress(litecoinAddress, 'Litecoin-адрес')">
<q-tooltip :delay="1000" anchor="top middle" self="center middle" content-style="font-size: 80%">
Скопировать
</q-tooltip>
</q-icon>
</div>
</div>
<div class="address">
<img class="logo" src="./assets/monero.png">
<div class="para">
{{ moneroAddress }}
<q-icon class="copy-icon" name="la la-copy" @click="copyAddress(moneroAddress, 'Monero-адрес')">
<q-tooltip :delay="1000" anchor="top middle" self="center middle" content-style="font-size: 80%">
Скопировать
</q-tooltip>
</q-icon>
</div>
<div style="font-size: 60%">
* Ваш донат является подарком автору проекта
</div>
</div>
</div>
@@ -74,28 +21,14 @@
//-----------------------------------------------------------------------------
import vueComponent from '../../../vueComponent.js';
import {copyTextToClipboard} from '../../../../share/utils';
import * as utils from '../../../../share/utils';
class DonateHelpPage {
yooAddress = '410018702323056';
paypalAddress = 'bookpauk@gmail.com';
bitcoinAddress = '3EbgZ7MK1UVaN38Gty5DCBtS4PknM4Ut85';
litecoinAddress = 'MP39Riec4oSNB3XMjiquKoLWxbufRYNXxZ';
moneroAddress = '8BQPnvHcPSHM5gMQsmuypDgx9NNsYqwXKfDDuswEyF2Q2ewQSfd2pkK6ydH2wmMyq2JViZvy9DQ35hLMx7g72mFWNJTPtnz';
created() {
}
donateYooMoney() {
window.open(`https://yoomoney.ru/to/${this.yooAddress}`, '_blank');
}
async copyAddress(address, prefix) {
const result = await copyTextToClipboard(address);
if (result)
this.$root.notify.success(`${prefix} ${address} успешно скопирован в буфер обмена`);
else
this.$root.notify.error('Копирование не удалось');
makeDonation() {
utils.makeDonation();
}
}
@@ -116,31 +49,4 @@ export default vueComponent(DonateHelpPage);
padding: 0;
text-indent: 20px;
}
.box {
max-width: 550px;
overflow-wrap: break-word;
}
.address {
padding-top: 10px;
margin-top: 20px;
}
.para {
margin: 10px 10px 10px 40px;
}
.logo {
width: 130px;
position: relative;
top: 10px;
}
.copy-icon {
margin-left: 10px;
cursor: pointer;
font-size: 120%;
color: blue;
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -1,5 +1,5 @@
<template>
<Window @close="close">
<Window @close="close" style="z-index: 200">
<template #header>
Справка
</template>
@@ -36,14 +36,14 @@ import CommonHelpPage from './CommonHelpPage/CommonHelpPage.vue';
import HotkeysHelpPage from './HotkeysHelpPage/HotkeysHelpPage.vue';
import MouseHelpPage from './MouseHelpPage/MouseHelpPage.vue';
import VersionHistoryPage from './VersionHistoryPage/VersionHistoryPage.vue';
//import DonateHelpPage from './DonateHelpPage/DonateHelpPage.vue';
import DonateHelpPage from './DonateHelpPage/DonateHelpPage.vue';
const pages = {
'CommonHelpPage': CommonHelpPage,
'HotkeysHelpPage': HotkeysHelpPage,
'MouseHelpPage': MouseHelpPage,
'VersionHistoryPage': VersionHistoryPage,
//'DonateHelpPage': DonateHelpPage,
'DonateHelpPage': DonateHelpPage,
};
const tabs = [
@@ -51,7 +51,7 @@ const tabs = [
['MouseHelpPage', 'Мышь/тачскрин'],
['HotkeysHelpPage', 'Клавиатура'],
['VersionHistoryPage', 'История версий'],
//['DonateHelpPage', 'Помочь проекту'],
['DonateHelpPage', 'Помочь проекту'],
];
const componentOptions = {
@@ -80,7 +80,7 @@ class HelpPage {
}
activateDonateHelpPage() {
//this.selectedTab = 'DonateHelpPage';
this.selectedTab = 'DonateHelpPage';
}
activateVersionHistoryHelpPage() {

View File

@@ -57,7 +57,7 @@
<div class="col column justify-end items-center no-wrap overflow-hidden">
<span v-if="mode == 'omnireader'" class="bottom-span clickable" @click="findBook">Найти книгу</span>
<span class="bottom-span clickable" @click="openHelp">Справка</span>
<!--span class="bottom-span clickable" @click="openDonate">Помочь проекту</span-->
<span class="bottom-span clickable" @click="openDonate">Помочь проекту</span>
<span v-if="version == clientVersion" class="bottom-span">v{{ version }}</span>
<span v-else class="bottom-span">Версия сервера {{ version }}, версия клиента {{ clientVersion }}, необходимо обновить страницу</span>

View File

@@ -100,6 +100,12 @@
</q-tooltip>
</button>
<button v-show="showToolButton['recentBooks']" ref="recentBooks" v-ripple class="tool-button" :class="buttonActiveClass('recentBooks')" @click="buttonClick('recentBooks')">
<div v-show="bothBucEnabled && needBookUpdateCount > 0" style="position: absolute">
<div class="need-book-update-count">
{{ needBookUpdateCount }}
</div>
</div>
<q-icon name="la la-book-open" size="32px" />
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
{{ rstore.readerActions['recentBooks'] }}
@@ -156,7 +162,7 @@
></SearchPage>
<CopyTextPage v-if="copyTextActive" ref="copyTextPage" @do-action="doAction"></CopyTextPage>
<LibsPage v-show="hidden" ref="libsPage" @load-book="loadBook" @libs-close="libsClose" @do-action="doAction"></LibsPage>
<RecentBooksPage v-show="recentBooksActive" ref="recentBooksPage" @load-book="loadBook" @recent-books-close="recentBooksClose"></RecentBooksPage>
<RecentBooksPage v-show="recentBooksActive" ref="recentBooksPage" @load-book="loadBook" @recent-books-close="recentBooksClose" @update-count-changed="updateCountChanged"></RecentBooksPage>
<SettingsPage v-show="settingsActive" ref="settingsPage" @do-action="doAction"></SettingsPage>
<HelpPage v-if="helpActive" ref="helpPage" @do-action="doAction"></HelpPage>
<ClickMapPage v-show="clickMapActive" ref="clickMapPage"></ClickMapPage>
@@ -194,6 +200,7 @@ import ReaderDialogs from './ReaderDialogs/ReaderDialogs.vue';
import bookManager from './share/bookManager';
import wallpaperStorage from './share/wallpaperStorage';
import coversStorage from './share/coversStorage';
import dynamicCss from '../../share/dynamicCss';
import rstore from '../../store/modules/reader';
@@ -308,6 +315,10 @@ class Reader {
donationVisible = false;
dualPageMode = false;
bucEnabled = false;
bucSetOnNew = false;
needBookUpdateCount = 0;
created() {
this.rstore = rstore;
this.loading = true;
@@ -356,6 +367,32 @@ class Reader {
}
}, 200);
this.debouncedRecentBooksPageUpdate = _.debounce(async() => {
if (this.recentBooksActive) {
await this.$refs.recentBooksPage.updateTableData();
}
}, 100);
this.recentItemKeys = [];
this.debouncedSaveRecent = _.debounce(async() => {
let timer = setTimeout(() => {
if (!this.offlineModeActive)
this.$root.notify.error('Таймаут соединения');
}, 10000);
try {
const itemKeys = this.recentItemKeys;
this.recentItemKeys = [];
//сохранение в удаленном хранилище
await this.$refs.serverStorage.saveRecent(itemKeys);
} catch (e) {
if (!this.offlineModeActive)
this.$root.notify.error(e.message);
} finally {
clearTimeout(timer);
}
}, 500, {maxWait: 1000});
document.addEventListener('fullscreenchange', () => {
this.fullScreenActive = (document.fullscreenElement !== null);
});
@@ -366,6 +403,8 @@ class Reader {
mounted() {
(async() => {
await wallpaperStorage.init();
await coversStorage.init();
await bookManager.init(this.settings);
bookManager.addEventListener(this.bookManagerEvent);
@@ -391,16 +430,30 @@ class Reader {
this.updateRoute();
await this.$refs.dialogs.init();
this.$refs.recentBooksPage.init();
})();
//проверки обновлений читалки
(async() => {
this.isFirstNeedUpdateNotify = true;
//вечный цикл, запрашиваем периодически конфиг для проверки выхода новой версии читалки
while (true) {// eslint-disable-line no-constant-condition
while (1) {// eslint-disable-line no-constant-condition
await this.checkNewVersionAvailable();
await utils.sleep(3600*1000); //каждый час
await utils.sleep(60*60*1000); //каждый час
}
//дальше кода нет
//дальше хода нет
})();
//проверки обновлений книг
(async() => {
await utils.sleep(15*1000); //подождем неск. секунд перед первым запросом
//вечный цикл, запрашиваем периодически обновления
while (1) {// eslint-disable-line no-constant-condition
await this.checkBuc();
await utils.sleep(70*60*1000); //каждые 70 минут
}
//дальше хода нет
})();
}
@@ -422,6 +475,11 @@ class Reader {
this.pdfQuality = settings.pdfQuality;
this.dualPageMode = settings.dualPageMode;
this.userWallpapers = settings.userWallpapers;
this.bucEnabled = settings.bucEnabled;
this.bucSizeDiff = settings.bucSizeDiff;
this.bucSetOnNew = settings.bucSetOnNew;
this.bucCancelEnabled = settings.bucCancelEnabled;
this.bucCancelDays = settings.bucCancelDays;
this.readerActionByKeyCode = utils.userHotKeysObjectSwap(settings.userHotKeys);
this.$root.readerActionByKeyEvent = (event) => {
@@ -450,22 +508,47 @@ class Reader {
//wallpaper css
async loadWallpapers() {
const wallpaperDataLength = await wallpaperStorage.getLength();
if (wallpaperDataLength !== this.wallpaperDataLength) {//оптимизация
this.wallpaperDataLength = wallpaperDataLength;
if (!_.isEqual(this.userWallpapers, this.prevUserWallpapers)) {//оптимизация
this.prevUserWallpapers = _.cloneDeep(this.userWallpapers);
let newCss = '';
let updated = false;
const wallpaperExists = new Set();
for (const wp of this.userWallpapers) {
const data = await wallpaperStorage.getData(wp.cssClass);
wallpaperExists.add(wp.cssClass);
let data = await wallpaperStorage.getData(wp.cssClass);
if (!data) {
//здесь будем восстанавливать данные с сервера
const url = `disk://${wp.cssClass.replace('user-paper', '')}`;
try {
data = await readerApi.getUploadedFileBuf(url);
await wallpaperStorage.setData(wp.cssClass, data);
updated = true;
} catch (e) {
console.error(e);
}
}
if (data) {
newCss += `.${wp.cssClass} {background: url(${data}) center; background-size: 100% 100%;}`;
}
}
//почистим wallpaperStorage
for (const key of await wallpaperStorage.getKeys()) {
if (!wallpaperExists.has(key)) {
await wallpaperStorage.removeData(key);
}
}
//обновим settings, если загружали обои из /upload/
if (updated) {
const newSettings = _.cloneDeep(this.settings);
newSettings.needUpdateSettingsView = (newSettings.needUpdateSettingsView < 10 ? newSettings.needUpdateSettingsView + 1 : 0);
this.commit('reader/setSettings', newSettings);
}
dynamicCss.replace('wallpapers', newCss);
}
}
@@ -494,6 +577,92 @@ class Reader {
}
}
async checkBuc() {
if (!this.bothBucEnabled)
return;
try {
const sorted = bookManager.getSortedRecent();
//выберем все кандидиаты на обновление
const updateUrls = new Set();
for (const book of sorted) {
if (!book.deleted && book.checkBuc && book.url && book.url.indexOf('disk://') !== 0)
updateUrls.add(book.url);
}
//теперь по кусочкам запросим сервер
const arr = Array.from(updateUrls);
const bucSize = {};
const chunkSize = 100;
for (let i = 0; i < arr.length; i += chunkSize) {
const chunk = arr.slice(i, i + chunkSize);
const data = await readerApi.checkBuc(chunk);
for (const item of data) {
bucSize[item.id] = item.size;
}
await utils.sleep(1000);//чтобы не ддосить сервер
}
const checkSetTime = {};
//проставим новые размеры у книг
for (const book of sorted) {
if (book.deleted)
continue;
//размер 0 считаем отсутствующим
if (book.url && bucSize[book.url] && bucSize[book.url] !== book.bucSize) {
book.bucSize = bucSize[book.url];
await bookManager.recentSetItem(book);
}
//подготовка к следующему шагу, ищем книгу по url с максимальной датой установки checkBucTime/loadTime
//от этой даты будем потом отсчитывать bucCancelDays
if (updateUrls.has(book.url)) {
let rec = checkSetTime[book.url] || {time: 0, loadTime: 0};
const time = (book.checkBucTime ? book.checkBucTime : (rec.loadTime || 0));
if (time > rec.time || (time == rec.time && (book.loadTime > rec.loadTime)))
rec = {time, loadTime: book.loadTime, key: book.key};
checkSetTime[book.url] = rec;
}
}
//bucCancelEnabled и bucCancelDays
//снимем флаг checkBuc у необновлявшихся bucCancelDays
if (this.bucCancelEnabled) {
for (const rec of Object.values(checkSetTime)) {
if (rec.time && Date.now() - rec.time > this.bucCancelDays*24*3600*1000) {
const book = await bookManager.getRecentBook({key: rec.key});
const needBookUpdate =
book.checkBuc
&& book.bucSize
&& utils.hasProp(book, 'downloadSize')
&& book.bucSize !== book.downloadSize
&& (book.bucSize - book.downloadSize >= this.bucSizeDiff)
;
if (book && !needBookUpdate) {
await bookManager.setCheckBuc(book, undefined);//!!!
}
}
}
}
await this.$refs.recentBooksPage.updateTableData();
} catch (e) {
console.error(e);
}
}
updateCountChanged(event) {
this.needBookUpdateCount = event.needBookUpdateCount;
}
checkSetStorageAccessKey() {
const q = this.$route.query;
@@ -552,7 +721,7 @@ class Reader {
return;
const recent = this.mostRecentBook();
const pos = (recent && recent.bookPos && this.allowUrlParamBookPos ? `__p=${recent.bookPos}&` : '');
const url = (recent ? `url=${recent.url}` : '');
const url = (recent ? `url=${encodeURIComponent(recent.url)}` : '');
if (isNewRoute)
this.$router.push(`/reader?${pos}${url}`).catch(() => {});
else
@@ -572,6 +741,10 @@ class Reader {
return versionHistory[0].version;
}
get bothBucEnabled() {
return this.$store.state.config.bucEnabled && this.bucEnabled;
}
get routeParamUrl() {
let result = '';
const path = this.$route.fullPath;
@@ -620,27 +793,12 @@ class Reader {
}
if (eventName == 'recent-changed') {
if (this.recentBooksActive) {
await this.$refs.recentBooksPage.updateTableData();
}
this.debouncedRecentBooksPageUpdate();
//сохранение в serverStorage
if (value) {
await utils.sleep(500);
let timer = setTimeout(() => {
if (!this.offlineModeActive)
this.$root.notify.error('Таймаут соединения');
}, 10000);
try {
await this.$refs.serverStorage.saveRecent(value);
} catch (e) {
if (!this.offlineModeActive)
this.$root.notify.error(e.message);
} finally {
clearTimeout(timer);
}
if (value && this.recentItemKeys.indexOf(value) < 0) {
this.recentItemKeys.push(value);
this.debouncedSaveRecent();
}
}
}
@@ -998,7 +1156,6 @@ class Reader {
classResult = classDisabled;
break;
case 'refresh':
case 'recentBooks':
if (!this.mostRecentBookReactive)
classResult = classDisabled;
break;
@@ -1108,6 +1265,7 @@ class Reader {
wasOpened = (wasOpened ? _.cloneDeep(wasOpened) : {});
wasOpened = Object.assign(wasOpened, {
url: (opts.url !== undefined ? opts.url : wasOpened.url),
path: (opts.path !== undefined ? opts.path : wasOpened.path),
bookPos: (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPos),
bookPosSeen: (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPosSeen),
@@ -1133,6 +1291,7 @@ class Reader {
this.checkBookPosPercent();
this.activateClickMapPage();//no await
this.$refs.recentBooksPage.updateTableData();//no await
return;
}
@@ -1209,9 +1368,13 @@ class Reader {
delete wasOpened.loadTime;
// добавляем в историю
await bookManager.setRecentBook(Object.assign(wasOpened, addedBook));
const recentBook = await bookManager.setRecentBook(Object.assign(wasOpened, addedBook));
if (this.bucSetOnNew) {
await bookManager.setCheckBuc(recentBook, true);
}
this.mostRecentBook();
this.addAction(wasOpened.bookPos);
this.addAction(recentBook.bookPos);
this.updateRoute(true);
this.loaderActive = false;
@@ -1223,6 +1386,7 @@ class Reader {
this.checkBookPosPercent();
this.activateClickMapPage();//no await
this.$refs.recentBooksPage.updateTableData();//no await
} catch (e) {
progress.hide(); this.progressActive = false;
this.loaderActive = true;
@@ -1573,4 +1737,16 @@ export default vueComponent(Reader);
.clear {
color: rgba(0,0,0,0);
}
.need-book-update-count {
position: relative;
padding: 2px 6px 2px 6px;
left: 27px;
top: 22px;
background-color: blue;
border-radius: 10px;
color: white;
z-index: 10;
font-size: 80%;
}
</style>

View File

@@ -18,56 +18,51 @@
</template>
</Dialog>
<Dialog ref="dialog2" v-model="donationVisible">
<template #header>
Здравствуйте, уважаемые читатели!
</template>
<q-dialog ref="dialog2" v-model="donationVisible" style="z-index: 100" no-route-dismiss no-esc-dismiss no-backdrop-dismiss>
<div class="column bg-white no-wrap q-pa-md">
<div class="row justify-center q-mb-md" style="font-size: 110%">
Здравствуйте, дорогие читатели!
</div>
<div style="word-break: normal">
Стартовала ежегодная акция "Оплатим хостинг вместе".<br><br>
<div class="q-mx-md column" style="word-break: normal">
<div>
Вот уже много лет мы все вместе пользуемся нашей любимой читалкой.<br><br>
Для оплаты годового хостинга читалки, необходимо собрать около 2000 рублей.
В настоящий момент у автора эта сумма есть в наличии. Однако будет справедливо, если каждый
сможет проголосовать рублем за то, чтобы читалка так и оставалась:
Напоминаем вам, что проект является некоммерческим и обладает такими
достоинствами, как:
<ul>
<li>непрерывно улучшаемой</li>
<li>без рекламы</li>
<li>без регистрации</li>
<li>Open Source</li>
</ul>
<ul>
<li>все функции читалки открыты и доступны совершенно бесплатно</li>
<li>в проекте отсутствует какая-либо реклама или баннеры</li>
<li>нет никакой регистрации и монетизации</li>
<li>нет сбора персональных данных</li>
<li>открытый исходный код</li>
<li>проект постепенно улучшается, по мере возможности</li>
</ul>
Автор также обращается с просьбой о помощи в распространении
<a href="https://omnireader.ru" target="_blank">ссылки</a>
<q-icon class="copy-icon" name="la la-copy" @click="copyLink('https://omnireader.ru')">
<q-tooltip :delay="1000" anchor="top middle" self="center middle" content-style="font-size: 80%">
Скопировать
</q-tooltip>
</q-icon>
на читалку через тематические форумы, соцсети, мессенджеры и пр.
Чем нас больше, тем легче оставаться на плаву и тем больше мотивации у разработчика, чтобы продолжать работать над проектом.
Однако на оплату хостинга читалки и сервера обновлений автор тратит свои
собственные средства, а также тратит свое время и силы на улучшение проекта.
<br><br>
Поддержим же материально наш ресурс, чтобы и дальше спокойно существовать и развиваться:
</div>
<br><br>
Если соберется бóльшая сумма, то разработка децентрализованной библиотеки для свободного обмена книгами будет по возможности ускорена.
<br><br>
P.S. При необходимости можно воспользоваться подходящим обменником на <a href="https://www.bestchange.ru" target="_blank">bestchange.ru</a>
<q-btn style="margin: 10px 50px 10px 50px" color="green-8" size="14px" no-caps @click="makeDonation">
<q-icon class="q-mr-xs" name="la la-donate" size="24px" />
Поддержать проект
</q-btn>
<br><br>
<div class="row justify-center">
<!--q-btn class="q-px-sm" color="primary" dense no-caps @click="openDonate">
Помочь проекту
</q-btn-->
<q-btn style="margin: 0 50px 20px 50px" size="14px" no-caps @click="donationDialogRemind">
Напомнить в следующем месяце
</q-btn>
<div class="row justify-center">
<div class="q-px-sm clickable" style="font-size: 80%" @click="openDonate">
Помочь проекту можно в любое время
</div>
</div>
</div>
</div>
<template #footer>
<span class="clickable row justify-end" style="font-size: 60%; color: grey" @click="donationDialogDisable">Больше не показывать</span>
<br>
<q-btn class="q-px-sm" dense no-caps @click="donationDialogRemind">
Напомнить позже
</q-btn>
</template>
</Dialog>
</q-dialog>
<Dialog ref="dialog3" v-model="urlHelpVisible">
<template #header>
@@ -134,7 +129,7 @@ class ReaderDialogs {
loadSettings() {
const settings = this.settings;
this.showWhatsNewDialog = settings.showWhatsNewDialog;
this.showDonationDialog2020 = settings.showDonationDialog2020;
this.showDonationDialog = settings.showDonationDialog;
}
async showWhatsNew() {
@@ -149,9 +144,9 @@ class ReaderDialogs {
}
async showDonation() {
const today = utils.formatDate(new Date(), 'coDate');
const today = utils.formatDate(new Date(), 'coMonth');
if ((this.mode == 'omnireader' || this.mode == 'liberama.top') && today < '2020-03-01' && this.showDonationDialog2020 && this.donationRemindDate != today) {
if ((this.mode == 'omnireader' || this.mode == 'liberama.top') && this.showDonationDialog && this.donationRemindDate != today) {
await utils.sleep(3000);
this.donationVisible = true;
}
@@ -166,20 +161,17 @@ class ReaderDialogs {
this.urlHelpVisible = false;
}
donationDialogDisable() {
this.donationVisible = false;
if (this.showDonationDialog2020) {
this.commit('reader/setSettings', { showDonationDialog2020: false });
}
}
donationDialogRemind() {
this.donationVisible = false;
this.commit('reader/setDonationRemindDate', utils.formatDate(new Date(), 'coDate'));
this.commit('reader/setDonationRemindDate', utils.formatDate(new Date(), 'coMonth'));
}
makeDonation() {
utils.makeDonation();
this.donationDialogRemind();
}
openDonate() {
this.donationVisible = false;
this.$emit('donate-toggle');
}

View File

@@ -7,6 +7,32 @@
</span>
</template>
<template #buttons>
<div
v-show="needBookUpdateCount > 0"
class="row justify-center items-center"
:class="{'header-button-update': !showNeedBookUpdateOnly, 'header-button-update-pressed': showNeedBookUpdateOnly}"
@mousedown.stop @click="showNeedBookUpdateOnlyToggle"
>
<span style="font-size: 90%">{{ needBookUpdateCount }} обновлен{{ wordEnding(needBookUpdateCount, 3) }}</span>
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
{{ (needBookUpdateCount ? 'Скрыть обновления' : 'Показать обновления') }}
</q-tooltip>
</div>
<div
class="row justify-center items-center"
:class="{'header-button': !showArchive, 'header-button-pressed': showArchive}"
@mousedown.stop @click="showArchiveToggle"
>
<q-icon class="q-mr-xs" name="la la-archive" size="20px" />
<span style="font-size: 90%">Архив</span>
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
{{ (showArchive ? 'Скрыть архивные' : 'Показать архивные') }}
</q-tooltip>
</div>
</template>
<a ref="download" style="display: none;" target="_blank"></a>
<div id="vs-container" ref="vsContainer" class="recent-books-scroll col">
@@ -91,8 +117,17 @@
</div>
<div class="row-part column justify-center items-stretch" style="width: 80px">
<div class="col row justify-center items-center clickable" @click="loadBook(item)">
<q-icon name="la la-book" size="40px" style="color: #dddddd" />
<div class="col row justify-center items-center clickable" style="padding: 0 2px 0 2px" @click="loadBook(item, bothBucEnabled && item.needBookUpdate)">
<div v-show="isLoadedCover(item.coverPageUrl)" style="height: 80px" v-html="getCoverHtml(item.coverPageUrl)" />
<q-icon v-show="!isLoadedCover(item.coverPageUrl)" name="la la-book" size="40px" style="color: #dddddd" />
<div
v-show="bothBucEnabled && item.needBookUpdate"
class="column justify-center"
style="position: absolute; background-color: rgba(255, 255, 255, 0.5); border-radius: 40px;"
>
<q-icon name="la la-sync" size="60px" style="color: blue" />
</div>
</div>
<div v-show="!showSameBook && item.group && item.group.length > 0" class="row justify-center" style="font-size: 70%">
@@ -100,12 +135,21 @@
</div>
</div>
<div class="row-part column items-stretch clickable break-word" :style="{ 'width': (350 - 40*(+item.inGroup)) + 'px' }" style="font-size: 75%" @click="loadBook(item)">
<div class="col" style="border: 1px solid #cccccc; border-bottom: 0; padding: 4px" :style="{ 'width': (340 - 40*(+item.inGroup)) + 'px' }">
<div class="text-green-10" style="font-size: 105%">
<div class="row-part column items-stretch clickable break-word" @click="loadBook(item)">
<div
class="col" style="border: 1px solid #cccccc; border-bottom: 0; padding: 4px; line-height: 140%;"
:style="{ 'width': (380 - 40*(+item.inGroup)) + 'px' }"
>
<div class="text-green-10" style="font-size: 80%">
{{ item.desc.author }}
</div>
<div>{{ item.desc.title }}</div>
<div style="font-size: 75%">
{{ item.desc.title }}
</div>
<div v-show="bothBucEnabled && item.needBookUpdate" style="font-size: 75%; color: blue;">
Размер: {{ item.bucSize - item.downloadSize > 0 ? '+' : '' }}{{ item.bucSize - item.downloadSize }}
({{ item.downloadSize }} &rarr; {{ item.bucSize }})
</div>
</div>
<div class="row" style="font-size: 10px">
@@ -113,7 +157,7 @@
{{ item.desc.textLen }}
</div>
<div class="row items-center row-info-top" :style="`width: ${(220 - 40*(+item.inGroup))}px; padding: 1px`">
<div class="row items-center row-info-top" :style="`width: ${(260 - 40*(+item.inGroup))}px; padding: 1px`">
<div class="read-bar" :style="`width: ${100*item.readPart}%`"></div>
</div>
@@ -124,7 +168,7 @@
</div>
</div>
<div class="row" style="font-size: 10px" :style="{ 'width': (340 - 40*(+item.inGroup)) + 'px' }">
<div class="row" style="font-size: 10px" :style="{ 'width': (380 - 40*(+item.inGroup)) + 'px' }">
<div class="row justify-center items-center row-info-bottom" style="width: 30px">
{{ item.num }}
</div>
@@ -141,21 +185,61 @@
</div>
</div>
<div class="row-part column justify-center" style="width: 80px; font-size: 75%">
<div>
<a v-show="isUrl(item.url)" :href="item.url" target="_blank">Оригинал</a><br><br>
<a :href="item.path" @click.prevent="downloadBook(item.path, item.fullTitle)">Скачать FB2</a>
<div
class="row-part column"
style="width: 90px;"
>
<div
class="col column justify-center"
style="font-size: 75%; padding-left: 6px; border: 1px solid #cccccc; border-left: 0;"
>
<div style="margin: 25px 0 0 5px">
<a v-show="isUrl(item.url)" :href="item.url" target="_blank">Оригинал</a><br><br>
<a :href="item.path" @click.prevent="downloadBook(item.path, item.fullTitle)">Скачать FB2</a>
</div>
</div>
</div>
<div class="row-part column justify-center">
<q-btn
dense
style="width: 30px; height: 30px; padding: 7px 0 7px 0; margin-left: 4px"
<div
class="del-button self-end row justify-center items-center clickable"
@click="handleDel(item.key)"
>
<q-icon class="la la-times" size="14px" />
</q-btn>
<q-icon class="la la-times" size="12px" />
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
{{ (showArchive ? 'Удалить окончательно' : 'Перенести в архив') }}
</q-tooltip>
</div>
<div
v-show="showArchive"
class="restore-button self-start row justify-center items-center clickable"
@click="handleRestore(item.key)"
>
<q-icon class="la la-arrow-left" size="14px" />
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
Восстановить из архива
</q-tooltip>
</div>
<div
v-show="bothBucEnabled && item.showCheckBuc"
class="buc-checkbox self-start"
>
<q-checkbox
v-model="item.checkBuc"
size="xs"
style="position: relative; top: -3px; left: -3px;"
@update:model-value="checkBucChange(item)"
>
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
<div v-if="item.checkBuc === undefined">
Проверка обновлений отключена автоматически<br>т.к. книга не обновлялась {{ bucCancelDays }} дней
</div>
<div v-else>
{{ (item.checkBuc ? 'Проверка обновлений книги включена' : 'Проверка обновлений книги отключена') }}
</div>
</q-tooltip>
</q-checkbox>
</div>
</div>
</div>
</q-virtual-scroll>
@@ -175,6 +259,7 @@ import LockQueue from '../../../share/LockQueue';
import Window from '../../share/Window.vue';
import bookManager from '../share/bookManager';
import readerApi from '../../../api/reader';
import coversStorage from '../share/coversStorage';
const componentOptions = {
components: {
@@ -190,6 +275,12 @@ const componentOptions = {
settings() {
this.loadSettings();
},
needBookUpdateCount() {
if (this.needBookUpdateCount == 0)
this.showNeedBookUpdateOnly = false;
this.$emit('update-count-changed', {needBookUpdateCount: this.needBookUpdateCount});
}
},
};
class RecentBooksPage {
@@ -200,6 +291,17 @@ class RecentBooksPage {
tableData = [];
sortMethod = '';
showSameBook = false;
bucEnabled = false;
bucSizeDiff = 0;
bucSetOnNew = false;
bucCancelDays = 0;
needBookUpdateCount = 0;
showArchive = false;
showNeedBookUpdateOnly = false;
covers = {};
coversLoadFunc = {};
created() {
this.commit = this.$store.commit;
@@ -225,6 +327,7 @@ class RecentBooksPage {
this.showBar();
await this.updateTableData();
await this.scrollToActiveBook();
//await this.scrollRefresh();
})();
}
@@ -232,12 +335,20 @@ class RecentBooksPage {
const settings = this.settings;
this.showSameBook = settings.recentShowSameBook;
this.sortMethod = settings.recentSortMethod || 'loadTimeDesc';
this.bucEnabled = settings.bucEnabled;
this.bucSizeDiff = settings.bucSizeDiff;
this.bucSetOnNew = settings.bucSetOnNew;
this.bucCancelDays = settings.bucCancelDays;
}
get settings() {
return this.$store.state.reader.settings;
}
get bothBucEnabled() {
return this.$store.state.config.bucEnabled && this.bucEnabled;
}
async updateTableData() {
if (!this.inited)
return;
@@ -251,7 +362,7 @@ class RecentBooksPage {
//подготовка полей
for (const book of sorted) {
if (book.deleted)
if ((!this.showArchive && book.deleted) || (this.showArchive && book.deleted != 1))
continue;
let d = new Date();
@@ -275,9 +386,14 @@ class RecentBooksPage {
let title = bt.bookTitle;
title = (title ? `"${title}"`: '');
const author = (bt.author ? bt.author : (bt.bookTitle ? bt.bookTitle : (book.uploadFileName ? book.uploadFileName : book.url)));
const author = (bt.author ? bt.author : (bt.bookTitle ? bt.bookTitle : (book.uploadFileName ? book.uploadFileName : book.url))) || '';
result.push({
key: book.key,
url: book.url,
path: book.path,
deleted: book.deleted,
touchTime,
loadTime,
desc: {
@@ -287,14 +403,25 @@ class RecentBooksPage {
textLen,
},
readPart,
url: book.url,
path: book.path,
fullTitle: bt.fullTitle,
key: book.key,
sameBookKey: book.sameBookKey,
active: (activeBook.key == book.key),
activeParent: false,
inGroup: false,
coverPageUrl: book.coverPageUrl,
showCheckBuc: !this.showArchive && utils.hasProp(book, 'downloadSize') && book.url.indexOf('disk://') !== 0,
checkBuc: book.checkBuc,
needBookUpdate: (
!this.showArchive
&& book.checkBuc
&& book.bucSize
&& utils.hasProp(book, 'downloadSize')
&& book.bucSize !== book.downloadSize
&& (book.bucSize - book.downloadSize >= this.bucSizeDiff)
),
bucSize: book.bucSize,
downloadSize: book.downloadSize,
//для сортировки
loadTimeRaw,
@@ -313,12 +440,15 @@ class RecentBooksPage {
//фильтрация
const search = this.search;
if (search) {
const lowerSearch = search.toLowerCase();
result = result.filter(item => {
return !search ||
item.touchTime.includes(search) ||
item.loadTime.includes(search) ||
item.desc.title.toLowerCase().includes(search.toLowerCase()) ||
item.desc.author.toLowerCase().includes(search.toLowerCase())
return !search
|| item.touchTime.includes(search)
|| item.loadTime.includes(search)
|| item.desc.title.toLowerCase().includes(lowerSearch)
|| item.desc.author.toLowerCase().includes(lowerSearch)
;
});
}
@@ -351,6 +481,7 @@ class RecentBooksPage {
}
//группировка
let nbuCount = 0;
const groups = {};
const parents = {};
let newResult = [];
@@ -367,13 +498,20 @@ class RecentBooksPage {
if (book.active)
parents[book.sameBookKey].activeParent = true;
book.showCheckBuc = false;
book.needBookUpdate = false;
groups[book.sameBookKey].push(book);
}
} else {
newResult.push(book);
}
if (book.needBookUpdate)
nbuCount++;
}
result = newResult;
this.needBookUpdateCount = nbuCount;
//showSameBook
if (this.showSameBook) {
@@ -390,6 +528,11 @@ class RecentBooksPage {
result = newResult;
}
//showNeedBookUpdateOnly
if (this.showNeedBookUpdateOnly) {
result = result.filter(item => item.needBookUpdate);
}
//другие стадии
//.....
@@ -407,7 +550,9 @@ class RecentBooksPage {
wordEnding(num, type = 0) {
const endings = [
['ов', '', 'а', 'а', 'а', 'ов', 'ов', 'ов', 'ов', 'ов'],
['й', 'я', 'и', 'и', 'и', 'й', 'й', 'й', 'й', 'й']
['й', 'я', 'и', 'и', 'и', 'й', 'й', 'й', 'й', 'й'],
['о', '', 'о', 'о', 'о', 'о', 'о', 'о', 'о', 'о'],
['ий', 'ие', 'ия', 'ия', 'ия', 'ий', 'ий', 'ий', 'ий', 'ий']
];
const deci = num % 100;
if (deci > 10 && deci < 20) {
@@ -419,7 +564,7 @@ class RecentBooksPage {
get header() {
const len = (this.tableData ? this.tableData.length : 0);
return `${(this.search ? 'Найдено' : 'Всего')} ${len} файл${this.wordEnding(len)}`;
return `${(this.search || this.showNeedBookUpdateOnly ? `Найден${this.wordEnding(len, 2)}` : 'Всего')} ${len} файл${this.wordEnding(len)}${this.showArchive ? ' в архиве' : ''}`;
}
async downloadBook(fb2path, fullTitle) {
@@ -445,15 +590,27 @@ class RecentBooksPage {
}
async handleDel(key) {
await bookManager.delRecentBook({key});
//this.updateTableData();//обновление уже происходит Reader.bookManagerEvent
if (!bookManager.mostRecentBook())
this.close();
if (!this.showArchive) {
await bookManager.delRecentBook({key});
this.$root.notify.info('Перенесено в архив');
} else {
if (await this.$root.stdDialog.confirm('Подтвердите удаление из архива:', ' ')) {
await bookManager.delRecentBook({key}, 2);
this.$root.notify.info('Удалено безвозвратно');
}
}
}
loadBook(row) {
this.$emit('load-book', {url: row.url, path: row.path});
async handleRestore(key) {
await bookManager.restoreRecentBook({key});
this.$root.notify.info('Восстановлено из архива');
}
async loadBook(item, force = false) {
if (item.deleted)
await this.handleRestore(item.key);
this.$emit('load-book', {url: item.url, path: item.path, force});
this.close();
}
@@ -510,6 +667,8 @@ class RecentBooksPage {
}
async scrollToActiveBook() {
await this.$nextTick();
this.lockScroll = true;
try {
let activeIndex = -1;
@@ -555,6 +714,16 @@ class RecentBooksPage {
}
}
async scrollRefresh() {
this.lockScroll = true;
await utils.sleep(100);
try {
this.$refs.virtualScroll.refresh();
} finally {
await utils.sleep(100);
this.lockScroll = false;
}
}
get sortMethodOptions() {
return [
@@ -569,6 +738,13 @@ class RecentBooksPage {
];
}
showArchiveToggle() {
this.showArchive = !this.showArchive;
this.showNeedBookUpdateOnly = false;
this.updateTableData();
}
close() {
this.$emit('recent-books-close');
}
@@ -579,6 +755,80 @@ class RecentBooksPage {
}
return true;
}
makeCoverHtml(data) {
return `<img src="${data}" style="height: 100%; width: 100%; object-fit: contain" />`;
}
isLoadedCover(coverPageUrl) {
if (!coverPageUrl)
return false;
let loadedCover = this.covers[coverPageUrl];
if (loadedCover == 'error')
return false;
if (!loadedCover) {
(async() => {
if (this.coversLoadFunc[coverPageUrl])
return;
this.coversLoadFunc[coverPageUrl] = (async() => {
//сначала заглянем в storage
let data = await coversStorage.getData(coverPageUrl);
if (data) {
this.covers[coverPageUrl] = this.makeCoverHtml(data);
} else {//иначе идем на сервер
try {
data = await readerApi.getUploadedFileBuf(coverPageUrl);
await coversStorage.setData(coverPageUrl, data);
this.covers[coverPageUrl] = this.makeCoverHtml(data);
} catch (e) {
console.error(e);
this.covers[coverPageUrl] = 'error';
}
}
});
try {
await this.coversLoadFunc[coverPageUrl]();
} finally {
this.coversLoadFunc[coverPageUrl] = null;
}
})();
}
return (loadedCover != undefined);
}
getCoverHtml(coverPageUrl) {
if (coverPageUrl && this.covers[coverPageUrl])
return this.covers[coverPageUrl];
else
return '';
}
async checkBucChange(item) {
const book = await bookManager.getRecentBook(item);
if (book) {
await bookManager.setCheckBuc(book, item.checkBuc);
this.$root.notify.info(item.checkBuc
? 'Проверка обновлений книги включена'
: 'Проверка обновлений книги отключена'
);
}
}
showNeedBookUpdateOnlyToggle() {
this.showNeedBookUpdateOnly = !this.showNeedBookUpdateOnly;
this.showArchive = false;
this.updateTableData();
}
}
export default vueComponent(RecentBooksPage);
@@ -606,7 +856,7 @@ export default vueComponent(RecentBooksPage);
}
.row-part {
padding: 4px 4px 4px 4px;
padding: 4px 0px 4px 0px;
}
.clickable {
@@ -614,7 +864,6 @@ export default vueComponent(RecentBooksPage);
}
.break-word {
line-height: 180%;
overflow-wrap: break-word;
word-wrap: break-word;
white-space: normal;
@@ -653,14 +902,14 @@ export default vueComponent(RecentBooksPage);
line-height: 110%;
border-left: 1px solid #cccccc;
border-bottom: 1px solid #cccccc;
height: 12px;
height: 14px;
}
.row-info-top {
line-height: 110%;
border: 1px solid #cccccc;
border-right: 0;
height: 12px;
height: 14px;
}
.time-info, .row-info-top {
@@ -669,6 +918,64 @@ export default vueComponent(RecentBooksPage);
.read-bar {
height: 6px;
background-color: #bbbbbb;
background-color: #b8b8b8;
}
.del-button {
width: 25px;
height: 20px;
position: absolute;
border-left: 1px solid #cccccc;
border-bottom: 1px solid #cccccc;
border-radius: 0 0 0 10px;
margin: 1px;
}
.del-button:hover {
color: white;
background-color: #FF3030;
}
.restore-button {
width: 25px;
height: 20px;
position: absolute;
border-right: 1px solid #cccccc;
border-bottom: 1px solid #cccccc;
border-radius: 0 0 10px 0;
margin: 1px;
}
.restore-button:hover {
color: white;
background-color: #00bb00;
}
.header-button, .header-button-pressed {
width: 80px;
height: 30px;
cursor: pointer;
color: #555555;
}
.header-button-update, .header-button-update-pressed {
width: 120px;
height: 30px;
cursor: pointer;
color: white;
}
.header-button:hover, .header-button-update:hover {
color: white;
background-color: #39902F;
}
.header-button-pressed, .header-button-update-pressed {
color: black;
background-color: yellow;
}
.buc-checkbox {
position: absolute;
}
</style>

View File

@@ -12,6 +12,7 @@ import bookManager from '../share/bookManager';
import readerApi from '../../../api/reader';
import * as utils from '../../../share/utils';
import * as cryptoUtils from '../../../share/cryptoUtils';
import LockQueue from '../../../share/LockQueue';
import localForage from 'localforage';
const ssCacheStore = localForage.createInstance({
@@ -48,6 +49,8 @@ class ServerStorage {
this.keyInited = false;
this.commit = this.$store.commit;
this.prevServerStorageKey = null;
this.lock = new LockQueue(100);
this.$root.generateNewServerStorageKey = () => {this.generateNewServerStorageKey()};
this.debouncedSaveSettings = _.debounce(() => {
@@ -542,14 +545,16 @@ class ServerStorage {
return true;
}
async saveRecent(itemKey, recurse) {
while (!this.inited || this.savingRecent)
async saveRecent(itemKeys, recurse) {
while (!this.inited)
await utils.sleep(100);
if (!this.keyInited || !this.serverSyncEnabled || this.savingRecent)
if (!this.keyInited || !this.serverSyncEnabled)
return;
this.savingRecent = true;
let needRecurseCall = false;
await this.lock.get();
try {
const bm = bookManager;
@@ -559,22 +564,29 @@ class ServerStorage {
//newRecentMod
let newRecentMod = {};
if (itemKey && this.cachedRecentPatch.data[itemKey] && this.prevItemKey == itemKey) {
let oneItemKey = null;
if (itemKeys && itemKeys.length == 1)
oneItemKey = itemKeys[0];
if (oneItemKey && this.cachedRecentPatch.data[oneItemKey] && this.prevItemKey == oneItemKey) {
newRecentMod = _.cloneDeep(this.cachedRecentMod);
newRecentMod.rev++;
newRecentMod.data.key = itemKey;
newRecentMod.data.mod = utils.getObjDiff(this.cachedRecentPatch.data[itemKey], bm.recent[itemKey]);
newRecentMod.data.key = oneItemKey;
newRecentMod.data.mod = utils.getObjDiff(this.cachedRecentPatch.data[oneItemKey], bm.recent[oneItemKey]);
needSaveRecentMod = true;
}
this.prevItemKey = itemKey;
this.prevItemKey = oneItemKey;
//newRecentPatch
let newRecentPatch = {};
if (itemKey && !needSaveRecentMod) {
if (itemKeys && !needSaveRecentMod) {
newRecentPatch = _.cloneDeep(this.cachedRecentPatch);
newRecentPatch.rev++;
newRecentPatch.data[itemKey] = _.cloneDeep(bm.recent[itemKey]);
for (const key of itemKeys) {
newRecentPatch.data[key] = _.cloneDeep(bm.recent[key]);
}
const applyMod = this.cachedRecentMod.data;
if (applyMod && applyMod.key && newRecentPatch.data[applyMod.key])
@@ -587,11 +599,7 @@ class ServerStorage {
//newRecent
let newRecent = {};
if (!itemKey || (needSaveRecentPatch && Object.keys(newRecentPatch.data).length > 10)) {
//ждем весь bm.recent
/*while (!bookManager.loaded)
await utils.sleep(100);*/
if (!itemKeys || (needSaveRecentPatch && Object.keys(newRecentPatch.data).length > 10)) {
newRecent = {rev: this.cachedRecent.rev + 1, data: _.cloneDeep(bm.recent)};
newRecentPatch = {rev: this.cachedRecentPatch.rev + 1, data: {}};
newRecentMod = {rev: this.cachedRecentMod.rev + 1, data: {}};
@@ -625,10 +633,8 @@ class ServerStorage {
if (res)
this.warning(`Последние изменения отменены. Данные синхронизированы с сервером.`);
if (!recurse && itemKey) {
this.savingRecent = false;
await this.saveRecent(itemKey, true);
return;
if (!recurse && itemKeys) {
needRecurseCall = true;
}
} else if (result.state == 'success') {
if (needSaveRecent && newRecent.rev)
@@ -639,8 +645,11 @@ class ServerStorage {
await this.setCachedRecentMod(newRecentMod);
}
} finally {
this.savingRecent = false;
this.lock.ret();
}
if (needRecurseCall)
await this.saveRecent(itemKeys, true);
}
async storageCheck(items) {

View File

@@ -1,19 +1,21 @@
<template>
<Window ref="window" height="140px" max-width="600px" :top-shift="-50" @close="close">
<Window ref="window" height="125px" max-width="600px" :top-shift="-50" @close="close">
<template #header>
Установить позицию
</template>
<div id="set-position-slider" class="slider q-px-md">
<q-slider
v-model="sliderValue"
thumb-path="M 2, 10 a 8.5,8.5 0 1,0 17,0 a 8.5,8.5 0 1,0 -17,0"
:max="sliderMax"
label
:label-value="(sliderMax ? (sliderValue/sliderMax*100).toFixed(2) + '%' : 0)"
color="primary"
/>
<div class="col column justify-center">
<div id="set-position-slider" class="slider q-px-md column justify-center">
<q-slider
v-model="sliderValue"
thumb-path="M 2, 10 a 8.5,8.5 0 1,0 17,0 a 8.5,8.5 0 1,0 -17,0"
:max="sliderMax"
label
:label-value="(sliderMax ? (sliderValue/sliderMax*100).toFixed(2) + '%' : 0)"
color="primary"
/>
</div>
</div>
</Window>
</template>
@@ -76,7 +78,8 @@ export default vueComponent(SetPositionPage);
<style scoped>
.slider {
margin: 20px;
margin: 0 20px 0 20px;
height: 35px;
background-color: #efefef;
border-radius: 15px;
}

View File

@@ -43,25 +43,14 @@
<div class="item row">
<div class="label-6">Уведомление</div>
<q-checkbox size="xs" v-model="showNeedUpdateNotify">
Показывать уведомление о новой версии
<q-checkbox size="xs" v-model="showDonationDialog">
Показывать форму доната
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
Напоминать о необходимости обновления страницы<br>
при появлении новой версии читалки
Показывать диалог для сбора пожертвований
</q-tooltip>
</q-checkbox>
</div>
<!--div class="item row">
<div class="label-6">Уведомление</div>
<q-checkbox size="xs" v-model="showDonationDialog2020">
Показывать "Оплатим хостинг вместе"
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
Показывать уведомление "Оплатим хостинг вместе"
</q-tooltip>
</q-checkbox>
</div-->
<!---------------------------------------------->
<div class="part-header">Другое</div>

View File

@@ -5,6 +5,8 @@
</template>
<div class="col row">
<a ref="download" style="display: none;" target="_blank"></a>
<div class="full-height">
<q-tabs
ref="tabs"
@@ -28,6 +30,7 @@
<q-tab class="tab" name="keys" icon="la la-gamepad" label="Управление" />
<q-tab class="tab" name="pagemove" icon="la la-school" label="Листание" />
<q-tab class="tab" name="convert" icon="la la-magic" label="Конвертир." />
<q-tab class="tab" name="update" icon="la la-sync" label="Обновление" />
<q-tab class="tab" name="others" icon="la la-list-ul" label="Прочее" />
<q-tab class="tab" name="reset" icon="la la-broom" label="Сброс" />
<div v-show="tabsScrollable" class="q-pt-lg" />
@@ -97,6 +100,10 @@
<div v-if="selectedTab == 'convert'" class="fit tab-panel">
@@include('./ConvertTab.inc');
</div>
<!-- Обновление ------------------------------------------------------------------>
<div v-if="selectedTab == 'update'" class="fit tab-panel">
@@include('./UpdateTab.inc');
</div>
<!-- Прочее ---------------------------------------------------------------------->
<div v-if="selectedTab == 'others'" class="fit tab-panel">
@@include('./OthersTab.inc');
@@ -124,6 +131,7 @@ import NumInput from '../../share/NumInput.vue';
import UserHotKeys from './UserHotKeys/UserHotKeys.vue';
import wallpaperStorage from '../share/wallpaperStorage';
import readerApi from '../../../api/reader';
import rstore from '../../../store/modules/reader';
import defPalette from './defPalette';
@@ -310,6 +318,10 @@ class SettingsPage {
return this.$store.state.reader.profiles;
}
get configBucEnabled() {
return this.$store.state.config.bucEnabled;
}
get currentProfileOptions() {
const profNames = Object.keys(this.profiles)
profNames.sort();
@@ -636,8 +648,17 @@ class SettingsPage {
if (index < 0)
newUserWallpapers.push({label, cssClass});
if (!wallpaperStorage.keyExists(cssClass))
if (!wallpaperStorage.keyExists(cssClass)) {
await wallpaperStorage.setData(cssClass, data);
//отправим data на сервер в файл `/upload/${key}`
try {
//const res =
await readerApi.uploadFileBuf(data);
//console.log(res);
} catch (e) {
console.error(e);
}
}
this.userWallpapers = newUserWallpapers;
this.wallpaper = cssClass;
@@ -664,6 +685,27 @@ class SettingsPage {
}
}
async downloadWallpaper() {
if (this.wallpaper.indexOf('user-paper') != 0)
return;
try {
const d = this.$refs.download;
const dataUrl = await wallpaperStorage.getData(this.wallpaper);
if (!dataUrl)
throw new Error('Файл обоев не найден');
d.href = dataUrl;
d.download = `wallpaper-#${this.wallpaper.replace('user-paper', '').substring(0, 4)}`;
d.click();
} catch (e) {
this.$root.stdDialog.alert(e.message, 'Ошибка', {color: 'negative'});
}
}
keyHook(event) {
if (!this.$root.stdDialog.active && event.type == 'keydown' && event.key == 'Escape') {
this.close();

View File

@@ -0,0 +1,76 @@
<!---------------------------------------------->
<div class="part-header">Обновление читалки</div>
<div class="item row">
<div class="label-6"></div>
<q-checkbox size="xs" v-model="showNeedUpdateNotify">
Проверять наличие новой версии
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
Напоминать о необходимости обновления страницы<br>
при появлении новой версии читалки
</q-tooltip>
</q-checkbox>
</div>
<!---------------------------------------------->
<div class="part-header">Обновление книг</div>
<div v-show="!configBucEnabled" class="item row">
<div class="label-6"></div>
<div>Сервер обновлений временно не работает</div>
</div>
<div v-show="configBucEnabled" class="item row">
<div class="label-6"></div>
<q-checkbox size="xs" v-model="bucEnabled">
Проверять обновления книг
</q-checkbox>
</div>
<div v-show="configBucEnabled && bucEnabled" class="item row">
<div class="label-6"></div>
<div class="col-5 column justify-center items-end q-pr-xs">Разница размеров</div>
<div class="col row">
<NumInput class="col-left" v-model="bucSizeDiff" />
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
Уведомлять о наличии обновления книги в списке загруженных<br>
при указанной разнице в размерах старого и нового файлов.<br>
Разница указывается в байтах и может быть отрицательной.
</q-tooltip>
</div>
</div>
<div v-show="configBucEnabled && bucEnabled" class="item row">
<div class="label-6"></div>
<q-checkbox size="xs" v-model="bucSetOnNew">
Автопроверка для вновь загружаемых
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
Автоматически устанавливать флаг проверки<br>
обновлений для всех вновь загружаемых книг
</q-tooltip>
</q-checkbox>
</div>
<div v-show="configBucEnabled && bucEnabled" class="item row">
<div class="label-6"></div>
<q-checkbox size="xs" v-model="bucCancelEnabled">
Отменять проверку через {{ bucCancelDays }} дней{{ (bucCancelEnabled ? ':' : '') }}
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
Снимать флаг проверки с книги, если не было<br>
обновлений в течение {{ bucCancelDays }} дней
</q-tooltip>
</q-checkbox>
</div>
<div v-show="configBucEnabled && bucEnabled && bucCancelEnabled" class="item row">
<div class="label-6"></div>
<div class="col-5"></div>
<div class="col row">
<NumInput class="col-left" v-model="bucCancelDays" :min="1" :max="10000"/>
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
Снимать флаг проверки с книги, если не было<br>
обновлений в течение {{ bucCancelDays }} дней
</q-tooltip>
</div>
</div>

View File

@@ -102,6 +102,11 @@
Удалить выбранные обои
</q-tooltip>
</q-btn>
<q-btn v-show="wallpaper.indexOf('user-paper') === 0" class="q-ml-sm" round dense color="blue" icon="la la-file-download" @click.stop="downloadWallpaper">
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
Скачать выбранные обои
</q-tooltip>
</q-btn>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import sax from '../../../../server/core/sax';
import * as utils from '../../../share/utils';
const maxImageLineCount = 100;
const maxParaLength = 10000;
const maxParaTextLength = 10000;
// defaults
@@ -84,6 +85,7 @@ export default class BookParser {
let binaryId = '';
let binaryType = '';
let dimPromises = [];
this.coverPageId = '';
//оглавление
this.contents = [];
@@ -228,6 +230,7 @@ export default class BookParser {
};
const growParagraph = (text, len, textRaw) => {
//начальный параграф
if (paraIndex < 0) {
newParagraph();
growParagraph(text, len);
@@ -253,6 +256,14 @@ export default class BookParser {
}
const p = para[paraIndex];
//ограничение на размер параграфа
if (p.length > maxParaLength) {
newParagraph();
growParagraph(text, len);
return;
}
p.length += len;
p.text += text;
paraOffset += len;
@@ -279,7 +290,7 @@ export default class BookParser {
const href = attrs.href.value;
const alt = (attrs.alt && attrs.alt.value ? attrs.alt.value : '');
const {id, local} = this.imageHrefToId(href);
if (href[0] == '#') {//local
if (local) {//local
imageNum++;
if (inPara && !this.sets.showInlineImagesInCenter && !center)
@@ -291,6 +302,11 @@ export default class BookParser {
if (inPara && this.sets.showInlineImagesInCenter)
newParagraph();
//coverpage
if (path == '/fictionbook/description/title-info/coverpage/image') {
this.coverPageId = id;
}
} else {//external
imageNum++;

View File

@@ -2,8 +2,10 @@ import localForage from 'localforage';
import path from 'path-browserify';
import _ from 'lodash';
import * as utils from '../../../share/utils';
import BookParser from './BookParser';
import readerApi from '../../../api/reader';
import coversStorage from './coversStorage';
import * as utils from '../../../share/utils';
const maxDataSize = 500*1024*1024;//compressed bytes
const maxRecentLength = 5000;
@@ -232,6 +234,10 @@ class BookManager {
async addBook(newBook, callback) {
let meta = {url: newBook.url, path: newBook.path};
if (newBook.downloadSize !== undefined && newBook.downloadSize >= 0)
meta.downloadSize = newBook.downloadSize;
meta.key = this.keyFromPath(meta.path);
meta.addTime = Date.now();//время добавления в кеш
@@ -345,9 +351,38 @@ class BookManager {
const parsed = new BookParser(this.settings);
const parsedMeta = await parsed.parse(data, callback);
//cover page
let coverPageUrl = '';
if (parsed.coverPageId && parsed.binary[parsed.coverPageId]) {
const bin = parsed.binary[parsed.coverPageId];
let dataUrl = `data:${bin.type};base64,${bin.data}`;
try {
dataUrl = await utils.resizeImage(dataUrl, 160, 160, 0.94);
} catch (e) {
console.error(e);
}
coverPageUrl = readerApi.makeUrlFromBuf(dataUrl);
//далее асинхронно
(async() => {
//отправим dataUrl на сервер в /upload
try {
await readerApi.uploadFileBuf(dataUrl, coverPageUrl);
} catch (e) {
console.error(e);
}
//сохраним в storage
await coversStorage.setData(coverPageUrl, dataUrl);
})();
}
const result = Object.assign({}, meta, parsedMeta, {
length: data.length,
textLength: parsed.textLength,
coverPageUrl,
parsed
});
@@ -433,9 +468,9 @@ class BookManager {
return this.recent[value.key];
}
async delRecentBook(value) {
async delRecentBook(value, delFlag = 1) {
const item = this.recent[value.key];
item.deleted = 1;
item.deleted = delFlag;
if (this.recentLastKey == value.key) {
await this.recentSetLastKey(null);
@@ -445,6 +480,38 @@ class BookManager {
this.emit('recent-deleted', value.key);
}
async restoreRecentBook(value) {
const item = this.recent[value.key];
item.deleted = 0;
await this.recentSetItem(item);
}
async setCheckBuc(value, checkBuc) {
const item = this.recent[value.key];
const updateItems = [];
if (item) {
if (item.sameBookKey !== undefined) {
const sorted = this.getSortedRecent();
for (const book of sorted) {
if (!book.deleted && book.sameBookKey === item.sameBookKey)
updateItems.push(book);
}
} else {
updateItems.push(item);
}
}
const now = Date.now();
for (const book of updateItems) {
book.checkBuc = checkBuc;
if (checkBuc)
book.checkBucTime = now;
await this.recentSetItem(book);
}
}
async cleanRecentBooks() {
const sorted = this.getSortedRecent();

View File

@@ -0,0 +1,61 @@
import localForage from 'localforage';
//import _ from 'lodash';
import * as utils from '../../../share/utils';
const maxDataSize = 100*1024*1024;
const coversStore = localForage.createInstance({
name: 'coversStorage'
});
class CoversStorage {
constructor() {
}
async init() {
this.cleanCovers(); //no await
}
async setData(key, data) {
await coversStore.setItem(key, {addTime: Date.now(), data});
}
async getData(key) {
const item = await coversStore.getItem(key);
return (item ? item.data : undefined);
}
async removeData(key) {
await coversStore.removeItem(key);
}
async cleanCovers() {
await utils.sleep(10000);
while (1) {// eslint-disable-line no-constant-condition
let size = 0;
let min = Date.now();
let toDel = null;
for (const key of (await coversStore.keys())) {
const item = await coversStore.getItem(key);
size += item.data.length;
if (item.addTime < min) {
toDel = key;
min = item.addTime;
}
}
if (size > maxDataSize && toDel) {
await this.removeData(toDel);
} else {
break;
}
}
}
}
export default new CoversStorage();

View File

@@ -32,6 +32,10 @@ class WallpaperStorage {
this.cachedKeys = await wpStore.keys();
}
async getKeys() {
return await wpStore.keys();
}
keyExists(key) {//не асинхронная
return this.cachedKeys.includes(key);
}

View File

@@ -1,4 +1,50 @@
export const versionHistory = [
{
version: '0.12.1',
releaseDate: '2022-09-01',
showUntil: '2022-08-30',
content:
`
<ul>
<li>добавлена форма для доната</li>
<li>исправления багов</li>
</ul>
`
},
{
version: '0.12.0',
releaseDate: '2022-07-27',
showUntil: '2022-08-03',
content:
`
<ul>
<li>запущен сервер проверки обновлений книг:</li>
<ul>
<li>проверка обновления той или иной книги настраивается в списке загруженных (чекбокс)</li>
<li>для того, чтобы чекбокс появился у ранее загруженной, необходимо принудительно обновить книгу</li>
<li>в настройках можно указать разницу размеров, при которой требуется делать уведомление</li>
</ul>
</ul>
`
},
{
version: '0.11.8',
releaseDate: '2022-07-14',
showUntil: '2022-07-13',
content:
`
<ul>
<li>добавлено отображение и синхронизация обложек в окне загруженных книг</li>
<li>добавлена синхронизация обоев</li>
</ul>
`
},
{
version: '0.11.7',
releaseDate: '2022-07-12',

View File

@@ -45,6 +45,8 @@ export function formatDate(d, format) {
`${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')}-${d.getDate().toString().padStart(2, '0')}`;
case 'noDate':
return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.${d.getFullYear()}`;
}
@@ -363,4 +365,54 @@ export function getBookTitle(fb2) {
]).join(' - ');
return result;
}
}
export function resizeImage(dataUrl, toWidth, toHeight, quality = 0.9) {
return new Promise ((resolve, reject) => { (async() => {
const img = new Image();
let resolved = false;
img.onload = () => {
try {
let width = img.width;
let height = img.height;
if (width > height) {
if (width > toWidth) {
height = height * (toWidth / width);
width = toWidth;
}
} else {
if (height > toHeight) {
width = width * (toHeight / height);
height = toHeight;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
const result = canvas.toDataURL('image/jpeg', quality);
resolved = true;
resolve(result);
} catch (e) {
reject(e);
return;
}
};
img.onerror = reject;
img.src = dataUrl;
await sleep(1000);
if (!resolved)
reject('Не удалось изменить размер');
})().catch(reject); });
}
export function makeDonation() {
window.open('https://donatty.com/liberama', '_blank');
}

View File

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

View File

@@ -180,17 +180,28 @@ const settingDefaults = {
showServerStorageMessages: true,
showWhatsNewDialog: true,
showDonationDialog2020: true,
showDonationDialog: true,
showNeedUpdateNotify: true,
fontShifts: {},
showToolButton: {},
toolBarHideOnScroll: true,
toolBarHideOnScroll: false,
userHotKeys: {},
userWallpapers: [],
recentShowSameBook: false,
recentSortMethod: '',
//Book Update Checker
bucEnabled: true, // общее включение/выключение проверки обновлений
bucSizeDiff: 1, // разница в размерах файла, при которой показывать наличие обновления
bucSetOnNew: true, // автоматически включать проверку обновлений для вновь загружаемых файлов
bucCancelEnabled: true, // вкл/выкл отмену проверки книг через bucCancelDays
bucCancelDays: 90, // количество дней, через которое отменяется проверка книги, при условии отсутствия обновлений за это время
//для SettingsPage
needUpdateSettingsView: 0,
};
for (const font of fonts)

View File

@@ -6,6 +6,7 @@ server {
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
server_name beta.liberama.top;
set $liberama http://127.0.0.1:34082;
client_max_body_size 50m;
proxy_read_timeout 1h;
@@ -15,15 +16,20 @@ server {
gzip_proxied expired no-cache no-store private auth;
gzip_types *;
location @liberama {
proxy_pass $liberama;
}
location /api {
proxy_pass http://127.0.0.1:34082;
proxy_pass $liberama;
}
location /ws {
proxy_pass http://127.0.0.1:34082;
proxy_pass $liberama;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 600s;
}
location / {
@@ -32,6 +38,11 @@ server {
location /tmp {
types { } default_type "application/xml; charset=utf-8";
add_header Content-Encoding gzip;
try_files $uri @liberama;
}
location /upload {
try_files $uri @liberama;
}
location ~* \.(?:manifest|appcache|html)$ {
@@ -50,6 +61,7 @@ server {
server {
listen 80;
server_name b.beta.liberama.top;
set $liberama http://127.0.0.1:34082;
client_max_body_size 50m;
proxy_read_timeout 1h;
@@ -59,15 +71,20 @@ server {
gzip_proxied expired no-cache no-store private auth;
gzip_types *;
location @liberama {
proxy_pass $liberama;
}
location /api {
proxy_pass http://127.0.0.1:34082;
proxy_pass $liberama;
}
location /ws {
proxy_pass http://127.0.0.1:34082;
proxy_pass $liberama;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 600s;
}
location / {
@@ -76,6 +93,11 @@ server {
location /tmp {
types { } default_type "application/xml; charset=utf-8";
add_header Content-Encoding gzip;
try_files $uri @liberama;
}
location /upload {
try_files $uri @liberama;
}
location ~* \.(?:manifest|appcache|html)$ {

View File

@@ -6,6 +6,7 @@ server {
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
server_name beta.omnireader.ru;
set $liberama http://127.0.0.1:34081;
client_max_body_size 50m;
proxy_read_timeout 1h;
@@ -15,15 +16,20 @@ server {
gzip_proxied expired no-cache no-store private auth;
gzip_types *;
location @liberama {
proxy_pass $liberama;
}
location /api {
proxy_pass http://127.0.0.1:34081;
proxy_pass $liberama;
}
location /ws {
proxy_pass http://127.0.0.1:34081;
proxy_pass $liberama;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 600s;
}
location / {
@@ -32,6 +38,11 @@ server {
location /tmp {
types { } default_type "application/xml; charset=utf-8";
add_header Content-Encoding gzip;
try_files $uri @liberama;
}
location /upload {
try_files $uri @liberama;
}
location ~* \.(?:manifest|appcache|html)$ {

View File

@@ -1,6 +1,7 @@
server {
listen 80;
server_name beta.omnireader.ru;
set $liberama http://127.0.0.1:34081;
client_max_body_size 50m;
proxy_read_timeout 1h;
@@ -10,15 +11,20 @@ server {
gzip_proxied expired no-cache no-store private auth;
gzip_types *;
location @liberama {
proxy_pass $liberama;
}
location /api {
proxy_pass http://127.0.0.1:34081;
proxy_pass $liberama;
}
location /ws {
proxy_pass http://127.0.0.1:34081;
proxy_pass $liberama;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 600s;
}
location / {
@@ -27,6 +33,11 @@ server {
location /tmp {
types { } default_type "application/xml; charset=utf-8";
add_header Content-Encoding gzip;
try_files $uri @liberama;
}
location /upload {
try_files $uri @liberama;
}
location ~* \.(?:manifest|appcache|html)$ {

View File

@@ -17,6 +17,7 @@ server {
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
server_name liberama.top;
set $liberama http://127.0.0.1:55081;
client_max_body_size 100m;
proxy_read_timeout 1h;
@@ -26,12 +27,16 @@ server {
gzip_proxied expired no-cache no-store private auth;
gzip_types *;
location @liberama {
proxy_pass $liberama;
}
location /api {
proxy_pass http://127.0.0.1:55081;
proxy_pass $liberama;
}
location /ws {
proxy_pass http://127.0.0.1:55081;
proxy_pass $liberama;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@@ -44,6 +49,11 @@ server {
location /tmp {
types { } default_type "application/xml; charset=utf-8";
add_header Content-Encoding gzip;
try_files $uri @liberama;
}
location /upload {
try_files $uri @liberama;
}
location ~* \.(?:manifest|appcache|html)$ {
@@ -62,6 +72,7 @@ server {
server {
listen 80;
server_name b.liberama.top;
set $liberama http://127.0.0.1:55081;
client_max_body_size 100m;
proxy_read_timeout 1h;
@@ -71,15 +82,20 @@ server {
gzip_proxied expired no-cache no-store private auth;
gzip_types *;
location @liberama {
proxy_pass $liberama;
}
location /api {
proxy_pass http://127.0.0.1:55081;
proxy_pass $liberama;
}
location /ws {
proxy_pass http://127.0.0.1:55081;
proxy_pass $liberama;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 600s;
}
location / {
@@ -88,6 +104,11 @@ server {
location /tmp {
types { } default_type "application/xml; charset=utf-8";
add_header Content-Encoding gzip;
try_files $uri @liberama;
}
location /upload {
try_files $uri @liberama;
}
location ~* \.(?:manifest|appcache|html)$ {

View File

@@ -6,6 +6,7 @@ server {
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
server_name omnireader.ru;
set $liberama http://127.0.0.1:44081;
client_max_body_size 100m;
proxy_read_timeout 1h;
@@ -15,12 +16,16 @@ server {
gzip_proxied expired no-cache no-store private auth;
gzip_types *;
location @liberama {
proxy_pass $liberama;
}
location /api {
proxy_pass http://127.0.0.1:44081;
proxy_pass $liberama;
}
location /ws {
proxy_pass http://127.0.0.1:44081;
proxy_pass $liberama;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@@ -33,6 +38,11 @@ server {
location /tmp {
types { } default_type "application/xml; charset=utf-8";
add_header Content-Encoding gzip;
try_files $uri @liberama;
}
location /upload {
try_files $uri @liberama;
}
location ~* \.(?:manifest|appcache|html)$ {

View File

@@ -1,6 +1,7 @@
server {
listen 80;
server_name omnireader.ru;
set $liberama http://127.0.0.1:44081;
client_max_body_size 50m;
proxy_read_timeout 1h;
@@ -10,12 +11,16 @@ server {
gzip_proxied expired no-cache no-store private auth;
gzip_types *;
location @liberama {
proxy_pass $liberama;
}
location /api {
proxy_pass http://127.0.0.1:44081;
proxy_pass $liberama;
}
location /ws {
proxy_pass http://127.0.0.1:44081;
proxy_pass $liberama;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@@ -27,6 +32,11 @@ server {
location /tmp {
types { } default_type "application/xml; charset=utf-8";
add_header Content-Encoding gzip;
try_files $uri @liberama;
}
location /upload {
try_files $uri @liberama;
}
location ~* \.(?:manifest|appcache|html)$ {

5371
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,17 @@
{
"name": "Liberama",
"version": "0.11.7",
"version": "0.12.1",
"author": "Book Pauk <bookpauk@gmail.com>",
"license": "CC0-1.0",
"repository": "bookpauk/liberama",
"engines": {
"node": ">=14.4.0"
"node": ">=16.16.0"
},
"scripts": {
"dev": "nodemon --inspect --ignore server/public --ignore server/data --ignore client --exec 'node server'",
"build:client": "webpack --config build/webpack.prod.config.js",
"build:linux": "npm run build:client && node build/linux && pkg -t node14-linux-x64 -C GZip -o dist/linux/liberama .",
"build:win": "npm run build:client && node build/win && pkg -t node14-win-x64 -C GZip -o dist/win/liberama .",
"build:linux": "npm run build:client && node build/linux && pkg -t node16-linux-x64 -C GZip -o dist/linux/liberama .",
"build:win": "npm run build:client && node build/win && pkg -t node16-win-x64 -C GZip -o dist/win/liberama .",
"lint": "eslint --ext=.js,.vue client server",
"build:client-dev": "webpack --config build/webpack.dev.config.js",
"postinstall": "npm run build:client-dev && node build/linux"
@@ -21,67 +21,64 @@
"scripts": "server/config/*.js"
},
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/eslint-parser": "^7.16.3",
"@babel/eslint-plugin": "^7.14.5",
"@babel/plugin-proposal-decorators": "^7.16.0",
"@babel/preset-env": "^7.16.0",
"@babel/core": "^7.18.13",
"@babel/eslint-parser": "^7.18.9",
"@babel/eslint-plugin": "^7.18.10",
"@babel/plugin-proposal-decorators": "^7.18.10",
"@babel/preset-env": "^7.18.10",
"@vue/compiler-sfc": "^3.2.22",
"babel-loader": "^8.2.3",
"babel-loader": "^8.2.5",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.5.1",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^4.0.0",
"eslint": "^8.19.0",
"eslint-plugin-vue": "^9.2.0",
"eslint": "^8.23.0",
"eslint-plugin-vue": "^9.4.0",
"html-webpack-plugin": "^5.5.0",
"mini-css-extract-plugin": "^2.4.4",
"pkg": "^5.5.1",
"terser-webpack-plugin": "^5.2.5",
"mini-css-extract-plugin": "^2.6.1",
"pkg": "^5.8.0",
"terser-webpack-plugin": "^5.3.6",
"vue-eslint-parser": "^9.0.3",
"vue-loader": "^17.0.0",
"vue-style-loader": "^4.1.3",
"webpack": "^5.64.1",
"webpack-cli": "^4.9.1",
"webpack-dev-middleware": "^5.2.1",
"webpack-hot-middleware": "^2.25.1",
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0",
"webpack-dev-middleware": "^5.3.3",
"webpack-hot-middleware": "^2.25.2",
"webpack-merge": "^5.8.0",
"workbox-webpack-plugin": "^6.4.1"
"workbox-webpack-plugin": "^6.5.4"
},
"dependencies": {
"@quasar/extras": "^1.12.0",
"@vue/compat": "^3.2.21",
"@quasar/extras": "^1.15.2",
"@vue/compat": "^3.2.38",
"axios": "^0.27.2",
"base-x": "^4.0.0",
"chardet": "^1.4.0",
"compression": "^1.7.4",
"express": "^4.17.1",
"express": "^4.18.1",
"fg-loadcss": "^3.1.0",
"fs-extra": "^10.1.0",
"he": "^1.2.0",
"iconv-lite": "^0.6.3",
"jembadb": "^3.0.8",
"jembadb": "^4.2.0",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"minimist": "^1.2.5",
"minimist": "^1.2.6",
"multer": "^1.4.5-lts.1",
"pako": "^2.0.4",
"path-browserify": "^1.0.1",
"pidusage": "^3.0.0",
"quasar": "^2.7.5",
"quasar": "^2.7.7",
"safe-buffer": "^5.2.1",
"sanitize-html": "^2.5.3",
"sanitize-html": "^2.7.1",
"sjcl": "^1.0.8",
"sql-template-strings": "^2.2.2",
"sqlite": "^4.0.23",
"sqlite3": "^5.0.2",
"tar-fs": "^2.1.1",
"unbzip2-stream": "^1.4.3",
"vue": "^3.2.37",
"vue-router": "^4.1.1",
"vue-router": "^4.1.5",
"vuex": "^4.0.2",
"vuex-persistedstate": "^4.1.0",
"webdav": "^4.7.0",
"ws": "^8.2.3",
"vuex-persist": "^3.1.3",
"webdav": "^4.11.0",
"ws": "^8.8.1",
"zip-stream": "^4.1.0"
}
}

View File

@@ -23,46 +23,61 @@ module.exports = {
useExternalBookConverter: false,
acceptFileExt: '.fb2, .fb3, .html, .txt, .zip, .bz2, .gz, .rar, .epub, .mobi, .rtf, .doc, .docx, .pdf, .djvu, .jpg, .jpeg, .png',
webConfigParams: ['name', 'version', 'mode', 'maxUploadFileSize', 'useExternalBookConverter', 'acceptFileExt', 'branch'],
db: [
{
poolName: 'app',
connCount: 20,
fileName: 'app.sqlite',
},
{
poolName: 'readerStorage',
connCount: 20,
fileName: 'reader-storage.sqlite',
}
],
webConfigParams: ['name', 'version', 'mode', 'maxUploadFileSize', 'useExternalBookConverter', 'acceptFileExt', 'bucEnabled', 'branch'],
jembaDb: [
{
serverMode: ['reader', 'omnireader', 'liberama.top'],
dbName: 'app',
thread: true,
openAll: true,
},
{
serverMode: ['reader', 'omnireader', 'liberama.top'],
dbName: 'reader-storage',
thread: true,
openAll: true,
}
},
{
serverMode: 'book_update_checker',
dbName: 'book-update-server',
thread: true,
openAll: true,
},
],
servers: [
{
serverName: '1',
mode: 'normal', //'none', 'normal', 'site', 'reader', 'omnireader', 'liberama.top'
mode: 'normal', //'none', 'normal', 'site', 'reader', 'omnireader', 'liberama.top', 'book_update_checker'
ip: '0.0.0.0',
port: '33080',
},
/*{
serverName: '2',
mode: 'book_update_checker', //'none', 'normal', 'site', 'reader', 'omnireader', 'liberama.top', 'book_update_checker'
isHttps: true,
keysFile: 'server',
ip: '0.0.0.0',
port: '33443',
accessToken: '',
}*/
],
remoteWebDavStorage: false,
remoteStorage: false,
/*
remoteWebDavStorage: {
url: '127.0.0.1:1900',
username: '',
password: '',
remoteStorage: {
url: 'wss://127.0.0.1:11900',
accessToken: '',
},
*/
bucEnabled: false,
bucServer: false,
/*
bucServer: {
url: 'wss://127.0.0.1:33443',
accessToken: '',
}
*/
};

View File

@@ -10,7 +10,9 @@ const propsToSave = [
'useExternalBookConverter',
'servers',
'remoteWebDavStorage',
'remoteStorage',
'bucEnabled',
'bucServer',
];
let instance = null;

View File

@@ -0,0 +1,126 @@
const WebSocket = require('ws');
//const _ = require('lodash');
const BUCServer = require('../core/BookUpdateChecker/BUCServer');
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 BookUpdateCheckerController {
constructor(wss, config) {
this.config = config;
this.isDevelopment = (config.branch == 'development');
this.accessToken = config.accessToken;
this.bucServer = new BUCServer(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(`BUC-WebSocket-IN: ${message.substr(0, 4000)}`);
}
req = JSON.parse(message);
ws.lastActivity = Date.now();
//pong for WebSocketConnection
this.send({_rok: 1}, req, ws);
if (req.accessToken !== this.accessToken)
throw new Error('Access denied');
switch (req.action) {
case 'test':
await this.test(req, ws); break;
case 'get-buc':
await this.getBuc(req, ws); break;
case 'update-buc':
await this.updateBuc(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(`BUC-WebSocket-OUT: ${message.substr(0, 4000)}`);
}
}
}
//Actions ------------------------------------------------------------------
async test(req, ws) {
this.send({message: 'Liberama project is awesome'}, req, ws);
}
async getBuc(req, ws) {
if (!req.fromCheckTime)
throw new Error(`key 'fromCheckTime' is empty`);
await this.bucServer.getBuc(req.fromCheckTime, (rows) => {
this.send({state: 'get', rows}, req, ws);
});
this.send({state: 'finish'}, req, ws);
}
async updateBuc(req, ws) {
if (!req.bookUrls)
throw new Error(`key 'bookUrls' is empty`);
if (!Array.isArray(req.bookUrls))
throw new Error(`key 'bookUrls' must be array`);
await this.bucServer.updateBuc(req.bookUrls);
this.send({state: 'success'}, req, ws);
}
}
module.exports = BookUpdateCheckerController;

View File

@@ -68,24 +68,6 @@ class ReaderController extends BaseController {
res.status(400).send({error});
return false;
}
async restoreCachedFile(req, res) {
const request = req.body;
let error = '';
try {
if (!request.path)
throw new Error(`key 'path' is empty`);
const workerId = this.readerWorker.restoreCachedFile(request.path);
const state = this.workerState.getState(workerId);
return (state ? state : {});
} catch (e) {
error = e.message;
}
//bad request
res.status(400).send({error});
return false;
}
}
module.exports = ReaderController;

View File

@@ -4,6 +4,7 @@ const _ = require('lodash');
const ReaderWorker = require('../core/Reader/ReaderWorker');//singleton
const JembaReaderStorage = require('../core/Reader/JembaReaderStorage');//singleton
const WorkerState = require('../core/WorkerState');//singleton
const BUCClient = require('../core/BookUpdateChecker/BUCClient');//singleton
const log = new (require('../core/AppLogger'))().log;//singleton
const utils = require('../core/utils');
@@ -19,12 +20,20 @@ class WebSocketController {
this.readerWorker = new ReaderWorker(config);
this.workerState = new WorkerState();
if (config.bucEnabled) {
this.bucClient = new BUCClient(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);
@@ -66,10 +75,14 @@ class WebSocketController {
await this.workerGetState(req, ws); break;
case 'worker-get-state-finish':
await this.workerGetStateFinish(req, ws); break;
case 'reader-restore-cached-file':
await this.readerRestoreCachedFile(req, ws); break;
case 'reader-storage':
await this.readerStorageDo(req, ws); break;
case 'upload-file-buf':
await this.uploadFileBuf(req, ws); break;
case 'upload-file-touch':
await this.uploadFileTouch(req, ws); break;
case 'check-buc':
await this.checkBuc(req, ws); break;
default:
throw new Error(`Action not found: ${req.action}`);
@@ -149,15 +162,6 @@ class WebSocketController {
}
}
async readerRestoreCachedFile(req, ws) {
if (!req.path)
throw new Error(`key 'path' is empty`);
const workerId = this.readerWorker.restoreCachedFile(req.path);
const state = this.workerState.getState(workerId);
this.send((state ? state : {}), req, ws);
}
async readerStorageDo(req, ws) {
if (!req.body)
throw new Error(`key 'body' is empty`);
@@ -168,6 +172,35 @@ class WebSocketController {
this.send(await this.readerStorage.doAction(req.body), req, ws);
}
async uploadFileBuf(req, ws) {
if (!req.buf)
throw new Error(`key 'buf' is empty`);
this.send({url: await this.readerWorker.saveFileBuf(req.buf)}, req, ws);
}
async uploadFileTouch(req, ws) {
if (!req.url)
throw new Error(`key 'url' is empty`);
this.send({url: await this.readerWorker.uploadFileTouch(req.url)}, req, ws);
}
async checkBuc(req, ws) {
if (!this.config.bucEnabled)
throw new Error('BookUpdateChecker disabled');
if (!req.bookUrls)
throw new Error(`key 'bookUrls' is empty`);
if (!Array.isArray(req.bookUrls))
throw new Error(`key 'bookUrls' must be array`);
const data = await this.bucClient.checkBuc(req.bookUrls);
this.send({state: 'success', data}, req, ws);
}
}
module.exports = WebSocketController;

View File

@@ -3,4 +3,5 @@ module.exports = {
ReaderController: require('./ReaderController'),
WorkerController: require('./WorkerController'),
WebSocketController: require('./WebSocketController'),
BookUpdateCheckerController: require('./BookUpdateCheckerController'),
}

View File

@@ -0,0 +1,262 @@
const WebSocketConnection = require('../WebSocketConnection');
const JembaConnManager = require('../../db/JembaConnManager');//singleton
const ayncExit = new (require('../AsyncExit'))();
const utils = require('../utils');
const log = new (require('../AppLogger'))().log;//singleton
const minuteMs = 60*1000;
const hourMs = 60*minuteMs;
const dayMs = 24*hourMs;
let instance = null;
//singleton
class BUCClient {
constructor(config) {
if (!instance) {
this.config = config;
this.connManager = new JembaConnManager();
this.appDb = this.connManager.db['app'];
this.wsc = new WebSocketConnection(config.bucServer.url, 10, 30, {rejectUnauthorized: false});
this.accessToken = config.bucServer.accessToken;
//константы
if (this.config.branch !== 'development') {
this.cleanQueryInterval = 300*dayMs;//интервал очистки устаревших
this.syncPeriod = 1*hourMs;//период синхронизации с сервером BUC
this.sendBookUrlsPeriod = 1*minuteMs;//период отправки BookUrls на сервер BUC
} else {
this.cleanQueryInterval = 300*dayMs;//интервал очистки устаревших
this.syncPeriod = 1*minuteMs;//период синхронизации с сервером BUC
this.sendBookUrlsPeriod = 1*1000;//период отправки BookUrls на сервер BUC
}
this.fromCheckTime = 1;
this.bookUrls = new Set();
this.main();//no await
instance = this;
}
return instance;
}
async wsRequest(query) {
const response = await this.wsc.message(
await this.wsc.send(Object.assign({accessToken: this.accessToken}, query), 60),
60
);
if (response.error)
throw new Error(response.error);
return response;
}
async wsGetBuc(fromCheckTime, callback) {
const requestId = await this.wsc.send({accessToken: this.accessToken, action: 'get-buc', fromCheckTime}, 60);
while (1) {//eslint-disable-line
const res = await this.wsc.message(requestId, 60);
if (res.state == 'get') {
await callback(res.rows);
} else {
break;
}
}
}
async wsUpdateBuc(bookUrls) {
return await this.wsRequest({action: 'update-buc', bookUrls});
}
async checkBuc(bookUrls) {
const db = this.appDb;
for (const url of bookUrls)
this.bookUrls.add(url);
const rows = await db.select({
table: 'buc',
map: `(r) => ({id: r.id, size: r.size})`,
where: `@@id(${db.esc(bookUrls)})`,
});
return rows;
}
async findMaxCheckTime() {
const db = this.appDb;
let result = 1;
//одним куском, возможно будет жрать память
const rows = await db.select({
table: 'buc',
where: `
const result = new Set();
let max = 0;
let maxId = null;
@iter(@all(), (row) => {
if (row.checkTime > max) {
max = row.checkTime;
maxId = row.id;
}
});
if (maxId)
result.add(maxId);
return result;
`
});
if (rows.length)
result = rows[0].checkTime;
return result;
}
async periodicSendBookUrls() {
while (1) {//eslint-disable-line
try {
//отправим this.bookUrls
if (this.bookUrls.size) {
log(`client: remote update buc begin`);
const arr = Array.from(this.bookUrls);
this.bookUrls = new Set();
const chunkSize = 100;
let updated = 0;
for (let i = 0; i < arr.length; i += chunkSize) {
const chunk = arr.slice(i, i + chunkSize);
const res = await this.wsUpdateBuc(chunk);
if (!res.error && res.state == 'success') {
//update success
updated += chunk.length;
} else {
for (const url of chunk) {
this.bookUrls.add(url);
}
log(LM_ERR, `update-buc error: ${(res.error ? res.error : `wrong state "${res.state}"`)}`);
}
}
log(`client: remote update buc end, updated ${updated} urls`);
}
} catch (e) {
log(LM_ERR, e.stack);
}
await utils.sleep(this.sendBookUrlsPeriod);
}
}
async periodicSync() {
const db = this.appDb;
while (1) {//eslint-disable-line
try {
//почистим нашу таблицу 'buc'
log(`client: clean 'buc' table begin`);
const cleanTime = Date.now() - this.cleanQueryInterval;
while (1) {//eslint-disable-line
//выборка всех по кусочкам
const rows = await db.select({
table: 'buc',
where: `
let iter = @getItem('clean');
if (!iter) {
iter = @all();
@setItem('clean', iter);
}
const ids = new Set();
let id = iter.next();
while (!id.done) {
ids.add(id.value);
if (ids.size >= 1000)
break;
id = iter.next();
}
return ids;
`
});
if (rows.length) {
const toDelIds = [];
for (const row of rows)
if (row.queryTime <= cleanTime)
toDelIds.push(row.id);
//удаление
const res = await db.delete({
table: 'buc',
where: `@@id(${db.esc(toDelIds)})`,
});
log(`client: clean 'buc' deleted ${res.deleted}`);
} else {
break;
}
}
await db.select({
table: 'buc',
where: `
@delItem('clean');
return new Set();
`
});
log(`client: clean 'buc' table end`);
//синхронизация с сервером BUC
log(`client: sync 'buc' table begin`);
this.fromCheckTime -= 30*minuteMs;//минус полчаса на всякий случай
await this.wsGetBuc(this.fromCheckTime, async(rows) => {
for (const row of rows) {
if (row.checkTime > this.fromCheckTime)
this.fromCheckTime = row.checkTime;
}
const res = await db.insert({
table: 'buc',
replace: true,
rows
});
log(`client: sync 'buc' table, inserted ${res.inserted} rows, replaced ${res.replaced}`);
});
log(`client: sync 'buc' table end`);
} catch (e) {
log(LM_ERR, e.stack);
}
await utils.sleep(this.syncPeriod);
}
}
async main() {
try {
if (!this.config.bucEnabled)
throw new Error('BookUpdateChecker disabled');
this.fromCheckTime = await this.findMaxCheckTime();
this.periodicSendBookUrls();//no await
this.periodicSync();//no await
log(`BUC Client Worker started`);
} catch (e) {
log(LM_FATAL, e.stack);
ayncExit.exit(1);
}
}
}
module.exports = BUCClient;

View File

@@ -0,0 +1,355 @@
const fs = require('fs-extra');
const FileDownloader = require('../FileDownloader');
const JembaConnManager = require('../../db/JembaConnManager');//singleton
const ayncExit = new (require('../AsyncExit'))();
const utils = require('../utils');
const log = new (require('../AppLogger'))().log;//singleton
const minuteMs = 60*1000;
const hourMs = 60*minuteMs;
const dayMs = 24*hourMs;
let instance = null;
//singleton
class BUCServer {
constructor(config) {
if (!instance) {
this.config = config;
//константы
if (this.config.branch !== 'development') {
this.maxCheckQueueLength = 10000;//максимальная длина checkQueue
this.fillCheckQueuePeriod = 1*minuteMs;//период пополнения очереди
this.periodicCheckWait = 500;//пауза, если нечего делать
this.cleanQueryInterval = 300*dayMs;//интервал очистки устаревших
this.oldQueryInterval = 14*dayMs;//интервал устаревания запроса на обновление
this.checkingInterval = 5*hourMs;//интервал проверки обновления одного и того же файла
this.sameHostCheckInterval = 1000;//интервал проверки файла на том же сайте, не менее
} else {
this.maxCheckQueueLength = 10;//максимальная длина checkQueue
this.fillCheckQueuePeriod = 10*1000;//период пополнения очереди
this.periodicCheckWait = 500;//пауза, если нечего делать
this.cleanQueryInterval = 300*dayMs;//интервал очистки устаревших
this.oldQueryInterval = 30*dayMs;//интервал устаревания запроса на обновление
this.checkingInterval = 30*1000;//интервал проверки обновления одного и того же файла
this.sameHostCheckInterval = 1000;//интервал проверки файла на том же сайте, не менее
}
this.config.tempDownloadDir = `${config.tempDir}/download`;
fs.ensureDirSync(this.config.tempDownloadDir);
this.down = new FileDownloader(config.maxUploadFileSize);
this.connManager = new JembaConnManager();
this.db = this.connManager.db['book-update-server'];
this.checkQueue = [];
this.hostChecking = {};
this.main(); //no await
instance = this;
}
return instance;
}
async getBuc(fromCheckTime, callback) {
const db = this.db;
const iterName = utils.randomHexString(30);
while (1) {//eslint-disable-line
const rows = await db.select({
table: 'buc',
where: `
let iter = @getItem(${db.esc(iterName)});
if (!iter) {
iter = @dirtyIndexLR('checkTime', ${db.esc(fromCheckTime)});
iter = iter.values();
@setItem(${db.esc(iterName)}, iter);
}
const ids = new Set();
let id = iter.next();
while (!id.done) {
ids.add(id.value);
if (ids.size >= 100)
break;
id = iter.next();
}
return ids;
`
});
if (rows.length)
callback(rows);
else
break;
}
await db.select({
table: 'buc',
where: `
@delItem(${db.esc(iterName)});
return new Set();
`
});
}
async updateBuc(bookUrls) {
const db = this.db;
const now = Date.now();
const rows = await db.select({
table: 'buc',
map: `(r) => ({id: r.id})`,
where: `@@id(${db.esc(bookUrls)})`
});
const exists = new Set();
for (const row of rows) {
exists.add(row.id);
}
const toUpdateIds = [];
const toInsertRows = [];
for (let id of bookUrls) {
if (!id)
continue;
if (id.length > 1000) {
id = id.substring(0, 1000);
}
if (exists.has(id)) {
toUpdateIds.push(id);
} else {
toInsertRows.push({
id,
queryTime: now,
checkTime: 0, // 0 - never checked
etag: '',
modTime: '',
size: 0,
checkSum: '', //sha256
state: 0, // 0 - not processing, 1 - processing
error: '',
});
}
}
if (toUpdateIds.length) {
await db.update({
table: 'buc',
mod: `(r) => r.queryTime = ${db.esc(now)}`,
where: `@@id(${db.esc(toUpdateIds)})`
});
}
if (toInsertRows.length) {
await db.insert({
table: 'buc',
ignore: true,
rows: toInsertRows,
});
}
}
async fillCheckQueue() {
const db = this.db;
while (1) {//eslint-disable-line
try {
let now = Date.now();
//чистка совсем устаревших
let rows = await db.select({
table: 'buc',
where: `@@dirtyIndexLR('queryTime', undefined, ${db.esc(now - this.cleanQueryInterval)})`
});
if (rows.length) {
const ids = rows.map((r) => r.id);
const res = await db.delete({
table: 'buc',
where: `@@id(${db.esc(ids)})`,
});
log(LM_WARN, `clean 'buc' table: deleted ${res.deleted}`);
}
rows = await db.select({table: 'buc', count: true});
log(LM_WARN, `'buc' table size: ${rows[0].count}`);
now = Date.now();
//выборка кандидатов
rows = await db.select({
table: 'buc',
where: `
@@and(
@dirtyIndexLR('queryTime', ${db.esc(now - this.oldQueryInterval)}),
@dirtyIndexLR('checkTime', undefined, ${db.esc(now - this.checkingInterval)}),
@flag('notProcessing')
);
`
});
//формирование checkQueue
if (rows.length) {
const ids = [];
const rowsToPush = [];
//сначала выберем сколько надо
for (const row of rows) {
if (this.checkQueue.length + rowsToPush.length >= this.maxCheckQueueLength)
break;
rowsToPush.push(row);
ids.push(row.id);
}
//установим у них флаг "в обработке"
await db.update({
table: 'buc',
mod: `(r) => r.state = 1`,
where: `@@id(${db.esc(ids)})`
});
//пушим в очередь, после этого их обработает periodicCheck
for (const row of rowsToPush) {
this.checkQueue.push(row);
log(LM_INFO, ` add ${row.id}`);
}
log(LM_WARN, `checkQueue: added ${ids.length} recs, total ${this.checkQueue.length}`);
}
} catch(e) {
log(LM_ERR, e.stack);
}
await utils.sleep(this.fillCheckQueuePeriod);
}
}
async periodicCheck() {
const db = this.db;
while (1) {//eslint-disable-line
try {
if (!this.checkQueue.length)
await utils.sleep(this.periodicCheckWait);
if (!this.checkQueue.length)
continue;
const row = this.checkQueue.shift();
const url = new URL(row.id);
//только если обращались к тому же хосту не ранее sameHostCheckInterval миллисекунд назад
if (!this.hostChecking[url.hostname]) {
this.hostChecking[url.hostname] = true;
try {
let unchanged = true;
let hash = '';
const headers = await this.down.head(row.id);
const etag = headers['etag'] || '';
const modTime = headers['last-modified'] || '';
let size = parseInt(headers['content-length'], 10) || 0;
//log(row.id);
//log(`etag: ${etag}, modTime: ${modTime}, size: ${size}`)
if ((!etag || !row.etag || (etag !== row.etag))
&& (!modTime || !row.modTime || (modTime !== row.modTime))
&& (!size || !row.size || (size !== row.size))
) {
const downdata = await this.down.load(row.id);
size = downdata.length;
hash = await utils.getBufHash(downdata, 'sha256', 'hex');
unchanged = false;
}
await db.update({
table: 'buc',
mod: `(r) => {
r.checkTime = ${db.esc(Date.now())};
r.etag = ${(unchanged ? 'r.etag' : db.esc(etag))};
r.modTime = ${(unchanged ? 'r.modTime' : db.esc(modTime))};
r.size = ${(unchanged ? 'r.size' : db.esc(size))};
r.checkSum = ${(unchanged ? 'r.checkSum' : db.esc(hash))};
r.state = 0;
r.error = '';
}`,
where: `@@id(${db.esc(row.id)})`
});
if (unchanged) {
log(`checked ${row.id} > unchanged`);
} else {
log(`checked ${row.id} > size ${size}`);
}
} catch (e) {
await db.update({
table: 'buc',
mod: `(r) => {
r.checkTime = ${db.esc(Date.now())};
r.state = 0;
r.error = ${db.esc(e.message)};
}`,
where: `@@id(${db.esc(row.id)})`
});
log(LM_ERR, `error ${row.id} > ${e.stack ? e.stack : e.message}`);
} finally {
(async() => {
await utils.sleep(this.sameHostCheckInterval);
this.hostChecking[url.hostname] = false;
})();
}
} else {
this.checkQueue.push(row);
}
} catch(e) {
log(LM_ERR, e.stack);
}
await utils.sleep(10);
}
}
async main() {
try {
//обнуляем все статусы
await this.db.update({table: 'buc', mod: `(r) => r.state = 0`});
this.fillCheckQueue();//no await
//10 потоков
for (let i = 0; i < 10; i++)
this.periodicCheck();//no await
log(`-------------------------`);
log(`BUC Server Worker started`);
log(`-------------------------`);
} catch (e) {
log(LM_FATAL, e.stack);
ayncExit.exit(1);
}
}
}
module.exports = BUCServer;

View File

@@ -1,4 +1,7 @@
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) {
@@ -10,7 +13,8 @@ class FileDownloader {
const options = {
headers: {
'user-agent': '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'
'user-agent': userAgent,
timeout: 300*1000,
},
responseType: 'stream',
};
@@ -23,7 +27,7 @@ class FileDownloader {
estSize = res.headers['content-length'];
}
if (estSize > this.limitDownloadSize) {
if (this.limitDownloadSize && estSize > this.limitDownloadSize) {
throw new Error('Файл слишком большой');
}
@@ -62,25 +66,54 @@ class FileDownloader {
}
}
streamToBuffer(stream, progress) {
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', () => resolve(Buffer.concat(_buf)));
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'))
})();
});
}
}

View File

@@ -6,12 +6,16 @@ const WorkerState = require('../WorkerState');//singleton
const FileDownloader = require('../FileDownloader');
const FileDecompressor = require('../FileDecompressor');
const BookConverter = require('./BookConverter');
const RemoteWebDavStorage = require('../RemoteWebDavStorage');
const RemoteStorage = require('../RemoteStorage');
const JembaConnManager = require('../../db/JembaConnManager');//singleton
const ayncExit = new (require('../AsyncExit'))();
const utils = require('../utils');
const log = new (require('../AppLogger'))().log;//singleton
const cleanDirPeriod = 60*60*1000;//1 раз в час
const cleanDirPeriod = 60*60*1000;//каждый час
const remoteSendPeriod = 119*1000;//примерно раз 2 минуты
const queue = new LimitedQueue(5, 100, 2*60*1000 + 15000);//2 минуты ожидание подвижек
let instance = null;
@@ -33,15 +37,37 @@ class ReaderWorker {
this.decomp = new FileDecompressor(3*config.maxUploadFileSize);
this.bookConverter = new BookConverter(this.config);
this.remoteWebDavStorage = false;
if (config.remoteWebDavStorage) {
this.remoteWebDavStorage = new RemoteWebDavStorage(
Object.assign({maxContentLength: 3*config.maxUploadFileSize}, config.remoteWebDavStorage)
this.connManager = new JembaConnManager();
this.appDb = this.connManager.db['app'];
this.remoteStorage = false;
if (config.remoteStorage) {
this.remoteStorage = new RemoteStorage(
Object.assign({maxContentLength: 3*config.maxUploadFileSize}, config.remoteStorage)
);
}
this.periodicCleanDir(this.config.tempPublicDir, this.config.maxTempPublicDirSize, cleanDirPeriod);
this.periodicCleanDir(this.config.uploadDir, this.config.maxUploadPublicDirSize, cleanDirPeriod);
this.dirConfigArr = [
{
dir: this.config.tempPublicDir,
remoteDir: '/tmp',
maxSize: this.config.maxTempPublicDirSize,
moveToRemote: true,
},
{
dir: this.config.uploadDir,
remoteDir: '/upload',
maxSize: this.config.maxUploadPublicDirSize,
moveToRemote: true,
}
];
//преобразуем в объект для большего удобства
this.dirConfig = {};
for (const configRec of this.dirConfigArr)
this.dirConfig[configRec.remoteDir] = configRec;
this.remoteFilesToSend = [];
this.periodicCleanDir();//no await
instance = this;
}
@@ -54,7 +80,6 @@ class ReaderWorker {
let decompDir = '';
let downloadedFilename = '';
let isUploaded = false;
let isRestored = false;
let convertFilename = '';
const overLoadMes = 'Слишком большая очередь загрузки. Пожалуйста, попробуйте позже.';
@@ -80,6 +105,7 @@ class ReaderWorker {
const tempFilename2 = utils.randomHexString(30);
const decompDirname = utils.randomHexString(30);
let downloadSize = -1;
//download or use uploaded
if (url.indexOf('disk://') != 0) {//download
const downdata = await this.down.load(url, (progress) => {
@@ -87,6 +113,8 @@ class ReaderWorker {
}, q.abort);
downloadedFilename = `${this.config.tempDownloadDir}/${tempFilename}`;
downloadSize = downdata.length;
await fs.writeFile(downloadedFilename, downdata);
} else {//uploaded file
const fileHash = url.substr(7);
@@ -94,8 +122,7 @@ class ReaderWorker {
if (!await fs.pathExists(downloadedFilename)) {
//если удалено из upload, попробуем восстановить из удаленного хранилища
try {
downloadedFilename = await this.restoreRemoteFile(fileHash);
isRestored = true;
await this.restoreRemoteFile(fileHash, '/upload');
} catch(e) {
throw new Error('Файл не найден на сервере (возможно был удален как устаревший). Пожалуйста, загрузите файл с диска на сервер заново.');
}
@@ -142,34 +169,19 @@ class ReaderWorker {
//finish
const finishFilename = path.basename(compFilename);
wState.finish({path: `/tmp/${finishFilename}`, size: stat.size});
//лениво сохраним compFilename в удаленном хранилище
if (this.remoteWebDavStorage) {
(async() => {
await utils.sleep(20*1000);
try {
//log(`remoteWebDavStorage.putFile ${path.basename(compFilename)}`);
await this.remoteWebDavStorage.putFile(compFilename);
} catch (e) {
log(LM_ERR, e.stack);
}
})();
}
const result = {path: `/tmp/${finishFilename}`, size: stat.size};
if (downloadSize >= 0)
result.downloadSize = downloadSize;
//лениво сохраним downloadedFilename в tmp и в удаленном хранилище в случае isUploaded
if (this.remoteWebDavStorage && isUploaded && !isRestored) {
(async() => {
await utils.sleep(30*1000);
try {
//сжимаем файл в tmp, если там уже нет с тем же именем-sha256
const compDownloadedFilename = await this.decomp.gzipFileIfNotExists(downloadedFilename, this.config.tempPublicDir, true);
await this.remoteWebDavStorage.putFile(compDownloadedFilename);
} catch (e) {
log(LM_ERR, e.stack);
}
})();
}
wState.finish(result);
//асинхронно через 30 сек добавим в очередь на отправку
//т.к. gzipFileIfNotExists может переупаковать файл
(async() => {
await utils.sleep(30*1000);
this.pushRemoteSend(compFilename, '/tmp');
})();
} catch (e) {
log(LM_ERR, e.stack);
@@ -211,6 +223,7 @@ class ReaderWorker {
if (!await fs.pathExists(outFilename)) {
await fs.move(file.path, outFilename);
this.pushRemoteSend(outFilename, '/upload');
} else {
await utils.touchFile(outFilename);
await fs.remove(file.path);
@@ -219,14 +232,42 @@ class ReaderWorker {
return `disk://${hash}`;
}
async restoreRemoteFile(filename) {
async saveFileBuf(buf) {
const hash = await utils.getBufHash(buf, 'sha256', 'hex');
const outFilename = `${this.config.uploadDir}/${hash}`;
if (!await fs.pathExists(outFilename)) {
await fs.writeFile(outFilename, buf);
this.pushRemoteSend(outFilename, '/upload');
} else {
await utils.touchFile(outFilename);
}
return `disk://${hash}`;
}
async uploadFileTouch(url) {
const outFilename = `${this.config.uploadDir}/${url.replace('disk://', '')}`;
await utils.touchFile(outFilename);
return url;
}
async restoreRemoteFile(filename, remoteDir) {
let targetDir = '';
if (this.dirConfig[remoteDir])
targetDir = this.dirConfig[remoteDir].dir;
else
throw new Error(`restoreRemoteFile: unknown remoteDir value (${remoteDir})`);
const basename = path.basename(filename);
const targetName = `${this.config.tempPublicDir}/${basename}`;
const targetName = `${targetDir}/${basename}`;
if (!await fs.pathExists(targetName)) {
let found = false;
if (this.remoteWebDavStorage) {
found = await this.remoteWebDavStorage.getFileSuccess(targetName);
if (this.remoteStorage) {
found = await this.remoteStorage.getFileSuccess(targetName, remoteDir);
}
if (!found) {
@@ -237,83 +278,170 @@ class ReaderWorker {
return targetName;
}
restoreCachedFile(filename) {
const workerId = this.workerState.generateWorkerId();
const wState = this.workerState.getControl(workerId);
wState.set({state: 'start'});
(async() => {
try {
wState.set({state: 'download', step: 1, totalSteps: 1, path: filename, progress: 0});
const targetName = await this.restoreRemoteFile(filename);
const stat = await fs.stat(targetName);
const basename = path.basename(filename);
wState.finish({path: `/tmp/${basename}`, size: stat.size, progress: 100});
} catch (e) {
if (e.message.indexOf('404') < 0)
log(LM_ERR, e.stack);
wState.set({state: 'error', error: e.message});
}
})();
return workerId;
}
async periodicCleanDir(dir, maxSize, timeout) {
try {
const list = await fs.readdir(dir);
let size = 0;
let files = [];
for (const name of list) {
const stat = await fs.stat(`${dir}/${name}`);
if (!stat.isDirectory()) {
size += stat.size;
files.push({name, stat});
}
}
log(`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;
let j = 0;
while (i < files.length && size > maxSize) {
const file = files[i];
const oldFile = `${dir}/${file.name}`;
let remoteSuccess = true;
//отправляем только this.config.tempPublicDir
if (this.remoteWebDavStorage && dir === this.config.tempPublicDir) {
remoteSuccess = false;
try {
//log(`remoteWebDavStorage.putFile ${path.basename(oldFile)}`);
await this.remoteWebDavStorage.putFile(oldFile);
remoteSuccess = true;
} catch (e) {
log(LM_ERR, e.stack);
}
}
//реально удаляем только если сохранили в хранилище
if (remoteSuccess || size > maxSize*1.2) {
await fs.remove(oldFile);
j++;
}
size -= file.stat.size;
i++;
}
log(`removed ${j} files`);
} catch(e) {
log(LM_ERR, e.stack);
} finally {
setTimeout(() => {
this.periodicCleanDir(dir, maxSize, timeout);
}, timeout);
pushRemoteSend(fileName, remoteDir) {
if (this.remoteStorage
&& this.dirConfig[remoteDir]
&& this.dirConfig[remoteDir].moveToRemote) {
this.remoteFilesToSend.push({fileName, remoteDir});
}
}
async remoteSendFile(sendFileRec) {
const {fileName, remoteDir} = sendFileRec;
const sent = this.remoteSent;
if (!fileName || sent[fileName])
return;
log(`remoteSendFile ${remoteDir}/${path.basename(fileName)}`);
//отправляем в remoteStorage
await this.remoteStorage.putFile(fileName, remoteDir);
sent[fileName] = true;
await this.appDb.insert({table: 'remote_sent', ignore: true, rows: [{id: fileName, remoteDir}]});
}
async remoteSendAll() {
if (!this.remoteStorage)
return;
const newSendQueue = [];
while (this.remoteFilesToSend.length) {
const sendFileRec = this.remoteFilesToSend.shift();
if (sendFileRec.remoteDir
&& this.dirConfig[sendFileRec.remoteDir]
&& this.dirConfig[sendFileRec.remoteDir].moveToRemote) {
try {
await this.remoteSendFile(sendFileRec);
} catch (e) {
newSendQueue.push(sendFileRec)
log(LM_ERR, e.stack);
}
}
}
this.remoteFilesToSend = newSendQueue;
}
async cleanDir(config) {
const {dir, remoteDir, maxSize, moveToRemote} = config;
const sent = this.remoteSent;
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);
//удаленное хранилище
if (moveToRemote && this.remoteStorage) {
const foundFiles = new Set();
for (const file of files) {
foundFiles.add(file.name);
//отсылаем на всякий случай перед удалением, если вдруг remoteSendAll не справился
try {
await this.remoteSendFile({fileName: file.name, remoteDir});
} catch (e) {
log(LM_ERR, e.stack);
}
}
//почистим remoteSent и БД
//несколько неоптимально, таскает все записи из таблицы
const rows = await this.appDb.select({table: 'remote_sent'});
for (const row of rows) {
if ((row.remoteDir === remoteDir && !foundFiles.has(row.id))
|| !this.dirConfig[row.remoteDir]) {
delete sent[row.id];
await this.appDb.delete({table: 'remote_sent', where: `@@id(${this.appDb.esc(row.id)})`});
}
}
}
let i = 0;
let j = 0;
while (i < files.length && size > maxSize) {
const file = files[i];
const oldFile = file.name;
//реально удаляем только если сохранили в хранилище или размер dir увеличен в 1.5 раза
if (!(moveToRemote && this.remoteStorage)
|| (moveToRemote && this.remoteStorage && sent[oldFile])
|| size > maxSize*1.5) {
await fs.remove(oldFile);
j++;
}
size -= file.stat.size;
i++;
}
log(LM_WARN, `removed ${j} files`);
}
async periodicCleanDir() {
try {
if (!this.remoteSent)
this.remoteSent = {};
//инициализация this.remoteSent
if (this.remoteStorage) {
const rows = await this.appDb.select({table: 'remote_sent'});
for (const row of rows) {
this.remoteSent[row.id] = true;
}
}
let lastCleanDirTime = 0;
let lastRemoteSendTime = 0;
while (1) {// eslint-disable-line no-constant-condition
//отсылка в удаленное хранилище
if (Date.now() - lastRemoteSendTime >= remoteSendPeriod) {
try {
await this.remoteSendAll();
} catch(e) {
log(LM_ERR, e.stack);
}
lastRemoteSendTime = Date.now();
}
//чистка папок
if (Date.now() - lastCleanDirTime >= cleanDirPeriod) {
for (const config of Object.values(this.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);
}
}
}
module.exports = ReaderWorker;

View File

@@ -0,0 +1,98 @@
const fs = require('fs-extra');
const path = require('path');
const WebSocketConnection = require('./WebSocketConnection');
class RemoteStorage {
constructor(config) {
this.config = Object.assign({}, config);
this.config.maxContentLength = this.config.maxContentLength || 10*1024*1024;
this.accessToken = this.config.accessToken;
this.wsc = new WebSocketConnection(config.url, 10, 30, {rejectUnauthorized: false});
}
async wsRequest(query) {
const response = await this.wsc.message(
await this.wsc.send(Object.assign({accessToken: this.accessToken}, query), 600),
600
);
if (response.error)
throw new Error(response.error);
return response;
}
async wsStat(fileName) {
return await this.wsRequest({action: 'get-stat', fileName});
}
async wsGetFile(fileName) {
return this.wsRequest({action: 'get-file', fileName});
}
async wsPutFile(fileName, data) {//data base64 encoded string
return this.wsRequest({action: 'put-file', fileName, data});
}
async wsDelFile(fileName) {
return this.wsRequest({action: 'del-file', fileName});
}
makeRemoteFileName(fileName, dir = '') {
const base = path.basename(fileName);
if (base.length > 3) {
return `${dir}/${base.substr(0, 3)}/${base}`;
} else {
return `${dir}/${base}`;
}
}
async putFile(fileName, dir = '') {
if (!await fs.pathExists(fileName)) {
throw new Error(`File not found: ${fileName}`);
}
const remoteFilename = this.makeRemoteFileName(fileName, dir);
try {
const localStat = await fs.stat(fileName);
let remoteStat = await this.wsStat(remoteFilename);
remoteStat = remoteStat.stat;
if (remoteStat.isFile && localStat.size == remoteStat.size) {
return;
}
await this.wsDelFile(remoteFilename);
} catch (e) {
//
}
const data = await fs.readFile(fileName, 'base64');
await this.wsPutFile(remoteFilename, data);
}
async getFile(fileName, dir = '') {
if (await fs.pathExists(fileName)) {
return;
}
const remoteFilename = this.makeRemoteFileName(fileName, dir);
const response = await this.wsGetFile(remoteFilename);
await fs.writeFile(fileName, response.data, 'base64');
}
async getFileSuccess(filename, dir = '') {
try {
await this.getFile(filename, dir);
return true;
} catch (e) {
//
}
return false;
}
}
module.exports = RemoteStorage;

View File

@@ -46,16 +46,16 @@ class RemoteWebDavStorage {
return await this.wdc.createDirectory(dirname);
}
async putFile(filename) {
async putFile(filename, dir = '') {
if (!await fs.pathExists(filename)) {
throw new Error(`File not found: ${filename}`);
}
const base = path.basename(filename);
let remoteFilename = `/${base}`;
let remoteFilename = `${dir}/${base}`;
if (base.length > 3) {
const remoteDir = `/${base.substr(0, 3)}`;
const remoteDir = `${dir}/${base.substr(0, 3)}`;
try {
await this.mkdir(remoteDir);
} catch (e) {
@@ -79,24 +79,24 @@ class RemoteWebDavStorage {
await this.writeFile(remoteFilename, data);
}
async getFile(filename) {
async getFile(filename, dir = '') {
if (await fs.pathExists(filename)) {
return;
}
const base = path.basename(filename);
let remoteFilename = `/${base}`;
let remoteFilename = `${dir}/${base}`;
if (base.length > 3) {
remoteFilename = `/${base.substr(0, 3)}/${base}`;
remoteFilename = `${dir}/${base.substr(0, 3)}/${base}`;
}
const data = await this.readFile(remoteFilename);
await fs.writeFile(filename, data);
}
async getFileSuccess(filename) {
async getFileSuccess(filename, dir = '') {
try {
await this.getFile(filename);
await this.getFile(filename, dir);
return true;
} catch (e) {
//

View File

@@ -8,10 +8,13 @@ const cleanPeriod = 5*1000;//5 секунд
class WebSocketConnection {
//messageLifeTime в секундах (проверка каждый cleanPeriod интервал)
constructor(url, openTimeoutSecs = 10, messageLifeTimeSecs = 30) {
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;
@@ -91,10 +94,10 @@ class WebSocketConnection {
const url = this.url || `${protocol}//${window.location.host}/ws`;
this.ws = new this.WebSocket(url);
} else {
this.ws = new this.WebSocket(this.url);
this.ws = new this.WebSocket(this.url, this.webSocketOptions);
}
const onopen = (e) => {
const onopen = () => {
this.connecting = false;
resolve(this.ws);
};

View File

@@ -25,7 +25,7 @@ class WorkerState {
return {
set: state => this.setState(workerId, state),
finish: state => this.finishState(workerId, state),
get: workerId => this.getState(workerId),
get: () => this.getState(workerId),
};
}

View File

@@ -34,6 +34,12 @@ function getFileHash(filename, hashName, enc) {
});
}
function getBufHash(buf, hashName, enc) {
const hash = crypto.createHash(hashName);
hash.update(buf);
return hash.digest(enc);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
@@ -129,6 +135,7 @@ module.exports = {
fromBase36,
bufferRemoveZeroes,
getFileHash,
getBufHash,
sleep,
toUnixTime,
randomHexString,

View File

@@ -1,61 +0,0 @@
//TODO: удалить модуль в 2023г
const fs = require('fs-extra');
const SqliteConnectionPool = require('./SqliteConnectionPool');
const log = new (require('../core/AppLogger'))().log;//singleton
const migrations = {
'app': require('./migrations/app'),
'readerStorage': require('./migrations/readerStorage'),
};
let instance = null;
//singleton
class ConnManager {
constructor() {
if (!instance) {
this.inited = false;
instance = this;
}
return instance;
}
async init(config) {
this.config = config;
this._pool = {};
const force = null;//(config.branch == 'development' ? 'last' : null);
for (const poolConfig of this.config.db) {
const dbFileName = this.config.dataDir + '/' + poolConfig.fileName;
//бэкап
if (!poolConfig.noBak && await fs.pathExists(dbFileName))
await fs.copy(dbFileName, `${dbFileName}.bak`);
const connPool = new SqliteConnectionPool();
await connPool.open(poolConfig, dbFileName);
log(`Opened database "${poolConfig.poolName}"`);
//миграции
const migs = migrations[poolConfig.poolName];
if (migs && migs.data.length) {
const applied = await connPool.migrate(migs.data, migs.table, force);
if (applied.length)
log(`${applied.length} migrations applied to "${poolConfig.poolName}"`);
}
this._pool[poolConfig.poolName] = connPool;
}
this.inited = true;
}
get pool() {
return this._pool;
}
}
module.exports = ConnManager;

View File

@@ -1,42 +0,0 @@
//TODO: удалить модуль в 2023г
const fs = require('fs-extra');
const log = new (require('../core/AppLogger'))().log;//singleton
class Converter {
async run(config) {
log('Converter start');
try {
const connManager = new (require('./ConnManager'))();//singleton
const storagePool = connManager.pool.readerStorage;
const jembaConnManager = new (require('./JembaConnManager'))();//singleton
const db = jembaConnManager.db['reader-storage'];
const srcDbPath = `${config.dataDir}/reader-storage.sqlite`;
if (!await fs.pathExists(srcDbPath)) {
log(LM_WARN, ' Source DB does not exist, nothing to do');
return;
}
const rows = await db.select({table: 'storage', count: true});
if (rows.length && rows[0].count != 0) {
log(LM_WARN, ` Destination table already exists (found ${rows[0].count} items), nothing to do`);
return;
}
const dbSrc = await storagePool.get();
try {
const rows = await dbSrc.all(`SELECT * FROM storage`);
await db.insert({table: 'storage', rows});
log(` Inserted ${rows.length} items`);
} finally {
dbSrc.ret();
}
} finally {
log('Converter finish');
}
}
}
module.exports = Converter;

View File

@@ -31,7 +31,29 @@ class JembaConnManager {
ayncExit.add(this.close.bind(this));
const serverModes = new Set();
for (const serverCfg of this.config.servers) {
serverModes.add(serverCfg.mode);
}
for (const dbConfig of this.config.jembaDb) {
//проверка, надо ли открывать базу, зависит от serverMode
if (dbConfig.serverMode) {
let serverMode = dbConfig.serverMode;
if (!Array.isArray(dbConfig.serverMode))
serverMode = [dbConfig.serverMode];
let modePresent = false;
for (const mode of serverMode) {
modePresent = serverModes.has(mode);
if (modePresent)
break;
}
if (!modePresent)
continue;
}
const dbPath = `${this.config.dataDir}/db/${dbConfig.dbName}`;
//бэкап

View File

@@ -1,193 +0,0 @@
//TODO: удалить модуль в 2023г
const sqlite3 = require('sqlite3');
const sqlite = require('sqlite');
const SQL = require('sql-template-strings');
class SqliteConnectionPool {
constructor() {
this.closed = true;
}
async open(poolConfig, dbFileName) {
const connCount = poolConfig.connCount || 1;
const busyTimeout = poolConfig.busyTimeout || 60*1000;
const cacheSize = poolConfig.cacheSize || 2000;
this.dbFileName = dbFileName;
this.connections = [];
this.freed = new Set();
this.waitingQueue = [];
for (let i = 0; i < connCount; i++) {
let client = await sqlite.open({
filename: dbFileName,
driver: sqlite3.Database
});
client.configure('busyTimeout', busyTimeout); //ms
await client.exec(`PRAGMA cache_size = ${cacheSize}`);
client.ret = () => {
this.freed.add(i);
if (this.waitingQueue.length) {
this.waitingQueue.shift().onFreed(i);
}
};
this.freed.add(i);
this.connections[i] = client;
}
this.closed = false;
}
get() {
return new Promise((resolve) => {
if (this.closed)
throw new Error('Connection pool closed');
const freeConnIndex = this.freed.values().next().value;
if (freeConnIndex !== undefined) {
this.freed.delete(freeConnIndex);
resolve(this.connections[freeConnIndex]);
return;
}
this.waitingQueue.push({
onFreed: (connIndex) => {
this.freed.delete(connIndex);
resolve(this.connections[connIndex]);
},
});
});
}
async run(query) {
const dbh = await this.get();
try {
let result = await dbh.run(query);
dbh.ret();
return result;
} catch (e) {
dbh.ret();
throw e;
}
}
async all(query) {
const dbh = await this.get();
try {
let result = await dbh.all(query);
dbh.ret();
return result;
} catch (e) {
dbh.ret();
throw e;
}
}
async exec(query) {
const dbh = await this.get();
try {
let result = await dbh.exec(query);
dbh.ret();
return result;
} catch (e) {
dbh.ret();
throw e;
}
}
async close() {
for (let i = 0; i < this.connections.length; i++) {
await this.connections[i].close();
}
this.closed = true;
}
// Modified from node-sqlite/.../src/Database.js
async migrate(migs, table, force) {
const migrations = migs.sort((a, b) => Math.sign(a.id - b.id));
if (!migrations.length) {
throw new Error('No migration data');
}
migrations.map(migration => {
const data = migration.data;
const [up, down] = data.split(/^--\s+?down\b/mi);
if (!down) {
const message = `The ${migration.filename} file does not contain '-- Down' separator.`;
throw new Error(message);
} else {
/* eslint-disable no-param-reassign */
migration.up = up.replace(/^-- .*?$/gm, '').trim();// Remove comments
migration.down = down.trim(); // and trim whitespaces
}
});
// Create a database table for migrations meta data if it doesn't exist
await this.run(`CREATE TABLE IF NOT EXISTS "${table}" (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
up TEXT NOT NULL,
down TEXT NOT NULL
)`);
// Get the list of already applied migrations
let dbMigrations = await this.all(
`SELECT id, name, up, down FROM "${table}" ORDER BY id ASC`,
);
// Undo migrations that exist only in the database but not in migs,
// also undo the last migration if the `force` option was set to `last`.
const lastMigration = migrations[migrations.length - 1];
for (const migration of dbMigrations.slice().sort((a, b) => Math.sign(b.id - a.id))) {
if (!migrations.some(x => x.id === migration.id) ||
(force === 'last' && migration.id === lastMigration.id)) {
const dbh = await this.get();
await dbh.run('BEGIN');
try {
await dbh.exec(migration.down);
await dbh.run(SQL`DELETE FROM "`.append(table).append(SQL`" WHERE id = ${migration.id}`));
await dbh.run('COMMIT');
dbMigrations = dbMigrations.filter(x => x.id !== migration.id);
} catch (err) {
await dbh.run('ROLLBACK');
throw err;
} finally {
dbh.ret();
}
} else {
break;
}
}
// Apply pending migrations
let applied = [];
const lastMigrationId = dbMigrations.length ? dbMigrations[dbMigrations.length - 1].id : 0;
for (const migration of migrations) {
if (migration.id > lastMigrationId) {
const dbh = await this.get();
await dbh.run('BEGIN');
try {
await dbh.exec(migration.up);
await dbh.run(SQL`INSERT INTO "`.append(table).append(
SQL`" (id, name, up, down) VALUES (${migration.id}, ${migration.name}, ${migration.up}, ${migration.down})`)
);
await dbh.run('COMMIT');
applied.push(migration.id);
} catch (err) {
await dbh.run('ROLLBACK');
throw err;
} finally {
dbh.ret();
}
}
}
return applied;
}
}
module.exports = SqliteConnectionPool;

View File

@@ -0,0 +1,12 @@
module.exports = {
up: [
['create', {
table: 'remote_sent'
}],
],
down: [
['drop', {
table: 'remote_sent'
}],
]
};

View File

@@ -0,0 +1,22 @@
module.exports = {
up: [
['create', {
/*{
id, // book URL
queryTime: Number,
checkTime: Number, // 0 - never checked
modTime: String,
size: Number,
checkSum: String, //sha256
state: Number, // 0 - not processing, 1 - processing
error: String,
}*/
table: 'buc'
}],
],
down: [
['drop', {
table: 'buc'
}],
]
};

View File

@@ -0,0 +1,7 @@
module.exports = {
table: 'migration1',
data: [
{id: 1, name: 'create', data: require('./001-create')},
{id: 2, name: 'create', data: require('./002-create')},
]
}

View File

@@ -0,0 +1,29 @@
module.exports = {
up: [
['create', {
/*{
id, // book URL
queryTime: Number,
checkTime: Number, // 0 - never checked
modTime: String,
size: Number,
checkSum: String, //sha256
state: Number, // 0 - not processing, 1 - processing
error: String,
}*/
table: 'buc',
flag: [
{name: 'notProcessing', check: `(r) => r.state === 0`},
],
index: [
{field: 'queryTime', type: 'number'},
{field: 'checkTime', type: 'number'},
]
}],
],
down: [
['drop', {
table: 'buc'
}],
]
};

View File

@@ -1,4 +1,5 @@
module.exports = {
//'app': require('./jembaMigrations/app'),
'app': require('./app'),
'reader-storage': require('./reader-storage'),
'book-update-server': require('./book-update-server'),
};

View File

@@ -1,5 +0,0 @@
module.exports = {
table: 'migration1',
data: [
]
}

View File

@@ -1,7 +0,0 @@
module.exports = `
-- Up
CREATE TABLE storage (id TEXT PRIMARY KEY, rev INTEGER, time INTEGER, data TEXT);
-- Down
DROP TABLE storage;
`;

View File

@@ -1,16 +1,19 @@
require('tls').DEFAULT_MIN_VERSION = 'TLSv1';
const fs = require('fs-extra');
const path = require('path');
const argv = require('minimist')(process.argv.slice(2));
const express = require('express');
const compression = require('compression');
const http = require('http');
const https = require('https');
const WebSocket = require ('ws');
const ayncExit = new (require('./core/AsyncExit'))();
let log = null;
const maxPayloadSize = 50;//in MB
async function init() {
//config
const configManager = new (require('./config'))();//singleton
@@ -43,15 +46,8 @@ async function init() {
}
//connections
const connManager = new (require('./db/ConnManager'))();//singleton
await connManager.init(config);
const jembaConnManager = new (require('./db/JembaConnManager'))();//singleton
await jembaConnManager.init(config, argv['auto-repair']);
//converter SQLITE => JembaDb
const converter = new (require('./db/Converter'))();
await converter.run(config);
}
async function main() {
@@ -62,8 +58,16 @@ async function main() {
for (let serverCfg of config.servers) {
if (serverCfg.mode !== 'none') {
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server, maxPayload: 10*1024*1024 });
let server;
if (serverCfg.isHttps) {
const key = fs.readFileSync(`${config.dataDir}/${serverCfg.keysFile}.key`);
const cert = fs.readFileSync(`${config.dataDir}/${serverCfg.keysFile}.crt`);
server = https.createServer({key, cert}, app);
} else {
server = http.createServer(app);
}
const wss = new WebSocket.Server({ server, maxPayload: maxPayloadSize*1024*1024 });
const serverConfig = Object.assign({}, config, serverCfg);
@@ -75,20 +79,10 @@ async function main() {
}
app.use(compression({ level: 1 }));
app.use(express.json({limit: '10mb'}));
app.use(express.json({limit: `${maxPayloadSize}mb`}));
if (devModule)
devModule.logQueries(app);
app.use(express.static(serverConfig.publicDir, {
maxAge: '30d',
setHeaders: (res, filePath) => {
if (path.basename(path.dirname(filePath)) == 'tmp') {
res.set('Content-Type', 'application/xml');
res.set('Content-Encoding', 'gzip');
}
}
}));
require('./routes').initRoutes(app, wss, serverConfig);
if (devModule) {
@@ -101,7 +95,7 @@ async function main() {
}
server.listen(serverConfig.port, serverConfig.ip, function() {
log(`Server-${serverConfig.serverName} is ready on ${serverConfig.ip}:${serverConfig.port}, mode: ${serverConfig.mode}`);
log(`Server "${serverConfig.serverName}" is ready on ${(serverConfig.isHttps ? 'https://' : 'http://')}${serverConfig.ip}:${serverConfig.port}, mode: ${serverConfig.mode}`);
});
}
}

View File

@@ -1,8 +1,24 @@
const c = require('./controllers');
const utils = require('./core/utils');
const fs = require('fs-extra');
const path = require('path');
const express = require('express');
const multer = require('multer');
const ReaderWorker = require('./core/Reader/ReaderWorker');//singleton
const log = new (require('./core/AppLogger'))().log;//singleton
const c = require('./controllers');
const utils = require('./core/utils');
function initRoutes(app, wss, config) {
//эксклюзив для update_checker
if (config.mode === 'book_update_checker') {
new c.BookUpdateCheckerController(wss, config);
return;
}
initStatic(app, config);
const misc = new c.MiscController(config);
const reader = new c.ReaderController(config);
const worker = new c.WorkerController(config);
@@ -29,7 +45,6 @@ function initRoutes(app, wss, config) {
['POST', '/api/reader/load-book', reader.loadBook.bind(reader), [aAll], {}],
['POST', '/api/reader/storage', reader.storage.bind(reader), [aAll], {}],
['POST', '/api/reader/upload-file', [upload.single('file'), reader.uploadFile.bind(reader)], [aAll], {}],
['POST', '/api/reader/restore-cached-file', reader.restoreCachedFile.bind(reader), [aAll], {}],
['POST', '/api/worker/get-state', worker.getState.bind(worker), [aAll], {}],
];
@@ -77,6 +92,48 @@ function initRoutes(app, wss, config) {
}
}
function initStatic(app, config) {
const readerWorker = new ReaderWorker(config);
//восстановление файлов в /tmp и /upload из webdav-storage, при необходимости
app.use(async(req, res, next) => {
if ((req.method !== 'GET' && req.method !== 'HEAD') ||
!(req.path.indexOf('/tmp/') === 0 || req.path.indexOf('/upload/') === 0)
) {
return next();
}
const filePath = `${config.publicDir}${req.path}`;
//восстановим
try {
if (!await fs.pathExists(filePath)) {
if (req.path.indexOf('/tmp/') === 0) {
await readerWorker.restoreRemoteFile(req.path, '/tmp');
} else if (req.path.indexOf('/upload/') === 0) {
await readerWorker.restoreRemoteFile(req.path, '/upload');
}
}
} catch(e) {
log(LM_ERR, `static::restoreRemoteFile ${req.path} > ${e.message}`);
}
return next();
});
const tmpDir = `${config.publicDir}/tmp`;
app.use(express.static(config.publicDir, {
maxAge: '30d',
setHeaders: (res, filePath) => {
if (path.dirname(filePath) == tmpDir) {
res.set('Content-Type', 'application/xml');
res.set('Content-Encoding', 'gzip');
}
},
}));
}
module.exports = {
initRoutes
}