Compare commits

...

137 Commits
0.5.5 ... 0.6.8

Author SHA1 Message Date
Book Pauk
e671e4b6f5 Merge branch 'release/0.6.8' 2019-06-23 17:19:48 +07:00
Book Pauk
a66b2a4c70 Версия 0.6.8 2019-06-23 17:19:30 +07:00
Book Pauk
f1ae409535 На страницу загрузки добавлен блок "Поделиться" 2019-06-23 17:18:04 +07:00
Book Pauk
a4b56b477d Исправление автоформирования заголовка при вставке из буфера обмена 2019-06-23 16:29:36 +07:00
Book Pauk
d9c389812a Добавлен новый вариант анимации перелистывания - листание 2019-06-23 15:51:55 +07:00
Book Pauk
074ef3645f Добавлен вариант перелистывания - rotate 2019-06-23 14:13:59 +07:00
Book Pauk
cc3aa413e8 Исправил сообщение о загрузке шрифтов 2019-06-09 18:23:04 +07:00
Book Pauk
7f90c09227 Улучшение прогрессбара загрузки/сохранения книги 2019-06-09 16:44:11 +07:00
Book Pauk
f6f4d8ccc9 Исправлен баг - не распознавались некоторые книги формата fb2 в кодировке utf8 2019-06-09 15:03:04 +07:00
Book Pauk
31afce8304 Исправление бага - падение сервера при распаковке битых архивов 2019-06-04 17:35:32 +07:00
Book Pauk
2c4ff856cd Merge tag '0.6.7' into develop
Версия 0.6.7
2019-05-30 16:16:19 +07:00
Book Pauk
f59974e310 Merge branch 'release/0.6.7' 2019-05-30 16:16:08 +07:00
Book Pauk
70e2c12a6b Версия 0.6.7 2019-05-30 16:15:46 +07:00
Book Pauk
11f3c6ce6f Мелкие поправки 2019-05-30 16:14:41 +07:00
Book Pauk
e213c4640b Добавлен GET-параметр вида "reader?__pp=50.5&url=..." для указания позиции в книге в процентах 2019-05-30 16:00:47 +07:00
Book Pauk
959c5eaa59 Добавлен GET-параметр вида "reader?__refresh=1&url=..." для принудительного обновления загружаемого текста 2019-05-30 14:54:55 +07:00
Book Pauk
66fa510b26 Добавлена возможность указать название текста 2019-05-28 16:32:54 +07:00
Book Pauk
f26a3b31ac На страницу загрузки добавлена возможность загрузки книги из буфера обмена 2019-05-27 16:25:51 +07:00
Book Pauk
724fbf579e Мелкое форматирование 2019-05-27 15:10:40 +07:00
Book Pauk
f192f8e3cd Мелкий рефакторинг 2019-05-27 15:09:55 +07:00
Book Pauk
f13c3d19fb - добавлена возможность настройки отображаемых кнопок на панели управления
- некоторые кнопки на панели управления были скрыты по-умолчанию
2019-05-26 16:16:20 +07:00
Book Pauk
b51a09efcc В справку добавлена история версий проекта 2019-05-26 14:01:56 +07:00
Book Pauk
6004043782 Мелкие поправки 2019-05-23 13:47:36 +07:00
Book Pauk
f9fd0dc2c3 Поправлен баг 2019-05-23 13:47:12 +07:00
Book Pauk
eb5411cd20 К предыдущему 2019-04-27 19:13:56 +07:00
Book Pauk
da3c7a02f0 Небольшие поправки отображения загрузки шрифта 2019-04-27 18:34:23 +07:00
Book Pauk
e67d05007f Поправлен баг 2019-04-27 17:21:49 +07:00
Book Pauk
b0a9a6a08e Добавлена настройка showWhatsNewDialog 2019-04-27 17:04:34 +07:00
Book Pauk
d848ea35f4 Поправки верстки 2019-04-27 16:58:04 +07:00
Book Pauk
350f20effe Добавлена история версий 2019-04-27 16:40:48 +07:00
Book Pauk
b6dc8f98fe Добавлен диалог whatsNew 2019-04-27 15:40:11 +07:00
Book Pauk
1b762ee48d Merge tag '0.6.6' into develop
0.6.6
2019-03-28 14:48:17 +07:00
Book Pauk
cc3d7f1eac Merge branch 'release/0.6.6' 2019-03-28 14:47:52 +07:00
Book Pauk
4107282fbf Версия 0.6.6 2019-03-28 14:47:28 +07:00
Book Pauk
c29ffc3fcd Поправки багов 2019-03-28 14:45:42 +07:00
Book Pauk
f648bcda13 Доработки, оптимизация сохранения recentLast 2019-03-28 14:05:13 +07:00
Book Pauk
aa0044eed2 package-lock.json 2019-03-28 13:15:29 +07:00
Book Pauk
2312a721ae Поправлен текст помощи для автономной загрузки читалки 2019-03-28 13:14:57 +07:00
Book Pauk
b93fc39b00 Мелкая поправка 2019-03-28 12:44:27 +07:00
Book Pauk
2dc2cd700f Merge tag '0.6.5' into develop
0.6.5
2019-03-25 14:04:51 +07:00
Book Pauk
d69e534f8b Merge branch 'release/0.6.5' 2019-03-25 14:04:43 +07:00
Book Pauk
1de9ddd394 Версия 0.6.5 2019-03-25 14:04:16 +07:00
Book Pauk
77c68d4e11 Небольшие поправки 2019-03-25 14:03:50 +07:00
Book Pauk
2a0d1dcfce Поправка бага 2019-03-25 13:06:48 +07:00
Book Pauk
5a19cca407 Поправка текста 2019-03-25 12:53:50 +07:00
Book Pauk
4e8773ecde Мелкая поправка 2019-03-25 12:51:01 +07:00
Book Pauk
4c7dada809 Merge tag '0.6.4' into develop
0.6.4
2019-03-24 14:33:19 +07:00
Book Pauk
65690b15da Merge branch 'release/0.6.4' 2019-03-24 14:33:09 +07:00
Book Pauk
8ba07812ce Оптимизация 2019-03-24 14:32:08 +07:00
Book Pauk
2dd8f35001 Версия 0.6.4 2019-03-24 14:04:46 +07:00
Book Pauk
2d15aa88d4 Исправления багов 2019-03-24 14:04:21 +07:00
Book Pauk
e4257e50f0 Merge tag '0.6.3' into develop
0.6.3
2019-03-24 12:52:10 +07:00
Book Pauk
33ebc07915 Merge branch 'release/0.6.3' 2019-03-24 12:51:55 +07:00
Book Pauk
bc07299626 Версия 0.6.3 2019-03-24 12:51:27 +07:00
Book Pauk
25e8aeef53 Merge tag '0.6.2' into develop
0.6.2
2019-03-24 12:28:43 +07:00
Book Pauk
a2ed34abf3 Merge branch 'release/0.6.2' 2019-03-24 12:28:28 +07:00
Book Pauk
36a7b7b91a Версия 0.6.2, поправка мелкого бага 2019-03-24 12:27:43 +07:00
Book Pauk
b4e8b7375f Merge tag '0.6.1' into develop
0.6.1
2019-03-24 12:15:16 +07:00
Book Pauk
153b635bdb Merge branch 'release/0.6.1' 2019-03-24 12:15:05 +07:00
Book Pauk
80af72465e Версия 0.6.1, поправлен баг 2019-03-24 12:14:24 +07:00
Book Pauk
a91a8f9993 Merge tag '0.6.0' into develop
0.6.0
2019-03-24 12:07:41 +07:00
Book Pauk
a0ccc7fe07 Merge branch 'release/0.6.0' 2019-03-24 12:07:32 +07:00
Book Pauk
c162c9ae0e Версия 0.6.0 2019-03-24 12:07:10 +07:00
Book Pauk
25542cdff3 Исправления багов 2019-03-24 11:44:15 +07:00
Book Pauk
16d0ae60c1 Добавлен бэкап БД при запуске 2019-03-24 11:35:43 +07:00
Book Pauk
b1937eb8c0 Мелкий рефакторинг 2019-03-24 11:24:54 +07:00
Book Pauk
3f6b468021 Поправки багов 2019-03-22 20:14:33 +07:00
Book Pauk
92d929b704 Доделки сохранения recentLast 2019-03-22 17:03:06 +07:00
Book Pauk
737ae75c28 Синхронизация recent, пока не оптимизировано 2019-03-22 14:14:47 +07:00
Book Pauk
79ced4eca4 Работа над ServerStorage - saveRecent 2019-03-22 13:20:43 +07:00
Book Pauk
329ac44c11 Работа над ServerStorage, попутный рефакторинг 2019-03-22 13:00:59 +07:00
Book Pauk
f65a91dfed Работа над ServerStorage 2019-03-22 11:58:14 +07:00
Book Pauk
2a79207427 Поправил баг 2019-03-20 15:46:31 +07:00
Book Pauk
70be3d10d0 Дебаг 2019-03-20 15:38:06 +07:00
Book Pauk
3500a40599 Рефакторинг 2019-03-20 15:22:34 +07:00
Book Pauk
090ffa9921 Добавление событий, рефакторинг 2019-03-20 14:57:40 +07:00
Book Pauk
b12198fdcf Работа над ServerStorage, попутные оптимизации 2019-03-20 14:34:01 +07:00
Book Pauk
826ee18666 Работа над ServerStorage 2019-03-20 13:59:37 +07:00
Book Pauk
f9d8b37b1a Небольшие доработки 2019-03-20 12:30:13 +07:00
Book Pauk
e626cb6b40 Переделал механизм удаления recent 2019-03-18 19:38:47 +07:00
Book Pauk
20697ad9e4 Удалил более не нужный restoreOldSettings 2019-03-18 18:52:50 +07:00
Book Pauk
0800385b96 Мелкий рефакторинг 2019-03-18 18:48:16 +07:00
Book Pauk
d6e326e8be Небольшая оптимизация 2019-03-18 18:40:03 +07:00
Book Pauk
8b969a6d36 Мелкая поправка 2019-03-18 17:05:26 +07:00
Book Pauk
d520e13c88 Добавил настройку отображения уведомлений от синхронизатора 2019-03-18 17:03:28 +07:00
Book Pauk
ae4081001c Поправки сообщений 2019-03-18 16:52:30 +07:00
Book Pauk
5a48b597b9 Небольшие доработки 2019-03-18 16:47:35 +07:00
Book Pauk
c8a953db7c На LoaderPage всегда показываем toolBar 2019-03-17 23:46:25 +07:00
Book Pauk
d20ec144ff Небольшая доработка 2019-03-17 23:39:00 +07:00
Book Pauk
0147a82b0a Добавил ввод ключа доступа по ссылке 2019-03-17 23:33:34 +07:00
Book Pauk
8732a78d01 Небольшие поправки 2019-03-17 22:47:57 +07:00
Book Pauk
015254ae40 Мелкая поправка 2019-03-17 22:39:40 +07:00
Book Pauk
712bf405bb Работа с ServerStorage 2019-03-17 22:04:56 +07:00
Book Pauk
3a46a157f9 Работа над ServerStorage 2019-03-17 20:09:16 +07:00
Book Pauk
2a4ff926ae Работа над ServerStorage 2019-03-17 19:41:47 +07:00
Book Pauk
58941116c8 Работа над ключом доступа 2019-03-17 18:10:26 +07:00
Book Pauk
a13146d722 Работа над профилями 2019-03-17 16:14:03 +07:00
Book Pauk
02e6f392b4 Работа над профилями 2019-03-17 16:05:38 +07:00
Book Pauk
d4515bd643 Работа над профилями 2019-03-17 15:02:12 +07:00
Book Pauk
a73555b7ca Улучшение парсиннга 2019-03-16 16:45:38 +07:00
Book Pauk
983d9ee1b9 Улучшение парсинга html 2019-03-16 16:40:31 +07:00
Book Pauk
e800dfe796 Мелкий рефакторинг 2019-03-16 02:24:07 +07:00
Book Pauk
b0c59be340 Поправил комментарий 2019-03-16 01:56:16 +07:00
Book Pauk
dca12b6467 Сконфигурировал TerserPlugin 2019-03-16 01:52:51 +07:00
Book Pauk
5a0d98cbd0 Допиливание sjcl 2019-03-16 01:51:48 +07:00
Book Pauk
9cbaf22270 Добавлен модуль sjcl для шифрования AES, т.к. WebCrypto API не работает с http, а только с https 2019-03-16 01:11:20 +07:00
Book Pauk
a64687f64f Промежуточный коммит 2019-03-15 19:31:09 +07:00
Book Pauk
d229aab8c9 Работа над профилями и ключом доступа 2019-03-15 19:11:27 +07:00
Book Pauk
2ff94c1458 Работа над ServerStorage - профили 2019-03-15 17:56:19 +07:00
Book Pauk
3b9f3ea81d Мелкая поправка 2019-03-15 15:29:43 +07:00
Book Pauk
23f12ad3cf Добавил время записи в item 2019-03-15 13:52:37 +07:00
Book Pauk
01e7c1f183 Добавлено кодирование id в base58 при сохранении в server-storage 2019-03-15 13:15:08 +07:00
Book Pauk
37d60bc9b9 Улучшил парсинг имени автора из fb2 2019-03-15 11:27:34 +07:00
Book Pauk
cd5d3903fe Работа над ServerStorage 2019-03-13 20:22:04 +07:00
Book Pauk
6904cfd224 Добавил сжатие данных книги в кеше 2019-03-13 17:41:32 +07:00
Book Pauk
c430e2c8f4 Увличил каоличество шагов для отсылки прогресса 2019-03-13 17:41:08 +07:00
Book Pauk
0cf8a94b24 Небольшая оптимизация по памяти 2019-03-13 16:47:22 +07:00
Book Pauk
ff3674aca7 Мелкие поправки 2019-03-13 00:57:09 +07:00
Book Pauk
b50498fa46 Поправил баг 2019-03-13 00:51:16 +07:00
Book Pauk
571f71c7f0 Работа над ServerStorage 2019-03-13 00:14:43 +07:00
Book Pauk
091c50ec84 Модуль с утилитами шифрования 2019-03-12 23:35:25 +07:00
Book Pauk
e473dc8843 Работа над ServerStorage 2019-03-12 23:34:41 +07:00
Book Pauk
886af11d3a Добавлены пакеты base-x, pako, safe-buffer 2019-03-12 18:56:38 +07:00
Book Pauk
c72fd7ee9c Компонент ServerStorage, добавлена работа с api reader/storage 2019-03-11 19:22:59 +07:00
Book Pauk
7dc76b4222 Мелкая поправка 2019-03-11 16:02:05 +07:00
Book Pauk
5011e23050 Добавил периодическую очистку кэша 2019-03-11 15:59:05 +07:00
Book Pauk
89d9a90901 Небольшая оптимизация 2019-03-08 20:50:30 +07:00
Book Pauk
05128b12a8 Добавлен метод api /reader/storage и класс ReaderStorage 2019-03-08 20:38:07 +07:00
Book Pauk
c287ca9ea8 Пробные миграции 2019-03-08 18:26:03 +07:00
Book Pauk
5122cda6db Добавляем миграции в БД sqlite 2019-03-08 18:05:58 +07:00
Book Pauk
a39626f867 Добавлен connManager для управления пулами соединений к базам Sqlite, попутный рефакторинг 2019-03-08 16:50:44 +07:00
Book Pauk
c7abae10b7 Мелкий рефакторинг 2019-03-08 13:37:27 +07:00
Book Pauk
9a8f35fd8a Merge tag '0.5.6' into develop
0.5.6
2019-03-07 20:20:45 +07:00
Book Pauk
0341cc1630 Merge branch 'release/0.5.6' 2019-03-07 20:20:26 +07:00
Book Pauk
d307d233f0 Версия 0.5.6 2019-03-07 20:16:20 +07:00
Book Pauk
5931b9625b Изменил механизмы overflow при отрисовке страницы - теперь не "съедает" часть шрифта 2019-03-07 20:12:38 +07:00
Book Pauk
fb837f5b97 Merge tag '0.5.5' into develop
0.5.5
2019-03-05 18:38:45 +07:00
48 changed files with 3327 additions and 614 deletions

View File

@@ -32,7 +32,15 @@ module.exports = merge(baseWpConfig, {
},
optimization: {
minimizer: [
new TerserPlugin(),
new TerserPlugin({
cache: true,
parallel: true,
terserOptions: {
output: {
comments: false,
},
},
}),
new OptimizeCSSAssetsPlugin()
]
},

View File

@@ -1,12 +1,15 @@
import _ from 'lodash';
import axios from 'axios';
import {sleep} from '../share/utils';
import {Buffer} from 'safe-buffer';
import * as utils from '../share/utils';
const api = axios.create({
baseURL: '/api/reader'
baseURL: '/api/reader'
});
const workerApi = axios.create({
baseURL: '/api/worker'
baseURL: '/api/worker'
});
class Reader {
@@ -41,7 +44,7 @@ class Reader {
throw new Error(errMes);
}
if (i > 0)
await sleep(refreshPause);
await utils.sleep(refreshPause);
i++;
if (i > 120*1000/refreshPause) {//2 мин ждем телодвижений воркера
@@ -106,6 +109,16 @@ class Reader {
return url;
}
async storage(request) {
let response = await api.post('/storage', request);
const state = response.data.state;
if (!state)
throw new Error('Неверный ответ api');
return response.data;
}
}
export default new Reader();

View File

@@ -113,10 +113,13 @@ class App extends Vue {
this.dispatch('config/loadConfig');
this.$watch('apiError', function(newError) {
if (newError) {
let mes = newError.message;
if (newError.response && newError.response.config)
mes = newError.response.config.url + '<br>' + newError.response.statusText;
this.$notify.error({
title: 'Ошибка API',
dangerouslyUseHTMLString: true,
message: newError.response.config.url + '<br>' + newError.response.statusText
message: mes
});
}
});

View File

@@ -4,7 +4,8 @@
<ul>
<li>загрузка любой страницы интернета</li>
<li>изменение цвета фона, текста, размер и тип шрифта и прочее</li>
<li>установка и запоминание текущей позиции и настроек в браузере (в будущем планируется сохранение и на сервер)</li>
<li>установка и запоминание текущей позиции и настроек в браузере и на сервере</li>
<li>синхронизация данных (настроек и читаемых книг) между различными устройствами</li>
<li>кэширование файлов книг на клиенте и на сервере</li>
<li>открытие книг с локального диска</li>
<li>плавный скроллинг текста</li>
@@ -17,9 +18,15 @@
<li>поддерживаемые браузеры: Google Chrome, Mozilla Firefox последних версий</li>
</ul>
<p>В качестве URL можно задавать html-страничку с книгой, либо прямую ссылку
<p>В качестве URL книги можно задавать html-страничку с книгой, либо прямую ссылку
на файл из онлайн-библиотеки (например, скопировав адрес ссылки или кнопки "скачать fb2").</p>
<p>Поддерживаемые форматы: <b>fb2, fb2.zip, html, txt</b> и другие</p>
<p>Поддерживаемые форматы: <b>fb2, fb2.zip, html, txt</b> и другие.</p>
<p>Для автономной загрузки читалки (без интернета):<br>
В Google Chrome можно установить флаг <span class="clickable" @click="copyText('chrome://flags/#show-saved-copy')">chrome://flags/#show-saved-copy</span>
в значение "Primary". В этом случае на стандартной странице "нет соединения" появится кнопка для автономной загрузки сайта из кэша.<br>
В Mozilla Firefox в автономном режиме сайт загружается из кэша автоматически. Если этого не происходит, можно установить опцию
"Веб-разработка" -> "Работать автономно".</p>
<div v-html="automationHtml"></div>
<p>Связаться с разработчиком: <a href="mailto:bookpauk@gmail.com">bookpauk@gmail.com</a></p>
@@ -31,6 +38,8 @@
import Vue from 'vue';
import Component from 'vue-class-component';
import {copyTextToClipboard} from '../../../../share/utils';
export default @Component({
})
class CommonHelpPage extends Vue {
@@ -40,13 +49,22 @@ class CommonHelpPage extends Vue {
get automationHtml() {
if (this.config.mode == 'omnireader') {
return `<p>Вы можете добавить в свой браузер закладку, указав в ее свойствах вместо адреса следующий код:
return `<p>Вы также можете добавить в свой браузер закладку, указав в ее свойствах вместо адреса следующий код:
<br><strong>javascript:location.href='http://omnireader.ru/?url='+location.href;</strong>
<br>Тогда, нажав на получившуюся кнопку на любой странице интернета, вы автоматически откроете ее в Omni Reader.</p>`;
} else {
return '';
}
}
async copyText(text) {
const result = await copyTextToClipboard(text);
const msg = (result ? `Ссылка на флаг успешно скопирована в буфер обмена. Можно открыть ее в новой вкладке.` : 'Копирование не удалось');
if (result)
this.$notify.success({message: msg});
else
this.$notify.error({message: msg});
}
}
//-----------------------------------------------------------------------------
</script>
@@ -63,4 +81,10 @@ class CommonHelpPage extends Vue {
h4 {
margin: 0;
}
.clickable {
color: blue;
text-decoration: underline;
cursor: pointer;
}
</style>

View File

@@ -53,11 +53,10 @@ class DonateHelpPage extends Vue {
async copyAddress(address, prefix) {
const result = await copyTextToClipboard(address);
const msg = (result ? `${prefix}-адрес ${address} успешно скопирован в буфер обмена` : 'Копирование не удалось');
if (result)
this.$notify.success({message: msg});
this.$notify.success({message: `${prefix}-адрес ${address} успешно скопирован в буфер обмена`});
else
this.$notify.error({message: msg});
this.$notify.error({message: 'Копирование не удалось'});
}
}
//-----------------------------------------------------------------------------

View File

@@ -16,6 +16,9 @@
<el-tab-pane label="Мышь/тачпад">
<MouseHelpPage></MouseHelpPage>
</el-tab-pane>
<el-tab-pane label="История версий" name="releases">
<VersionHistoryPage></VersionHistoryPage>
</el-tab-pane>
<el-tab-pane label="Помочь проекту" name="donate">
<DonateHelpPage></DonateHelpPage>
</el-tab-pane>
@@ -36,6 +39,7 @@ import CommonHelpPage from './CommonHelpPage/CommonHelpPage.vue';
import HotkeysHelpPage from './HotkeysHelpPage/HotkeysHelpPage.vue';
import MouseHelpPage from './MouseHelpPage/MouseHelpPage.vue';
import DonateHelpPage from './DonateHelpPage/DonateHelpPage.vue';
import VersionHistoryPage from './VersionHistoryPage/VersionHistoryPage.vue';
export default @Component({
components: {
@@ -44,6 +48,7 @@ export default @Component({
HotkeysHelpPage,
MouseHelpPage,
DonateHelpPage,
VersionHistoryPage,
},
})
class HelpPage extends Vue {
@@ -57,6 +62,10 @@ class HelpPage extends Vue {
this.selectedTab = 'donate';
}
activateVersionHistoryHelpPage() {
this.selectedTab = 'releases';
}
keyHook(event) {
if (event.type == 'keydown' && (event.code == 'Escape')) {
this.close();

View File

@@ -0,0 +1,81 @@
<template>
<div id="versionHistoryPage" class="page">
<span class="clickable" v-for="(item, index) in versionHeader" :key="index" @click="showRelease(item)">
<p>
{{ item }}
</p>
</span>
<br>
<h4>История версий:</h4>
<br>
<div v-for="item in versionContent" :id="item.key" :key="item.key">
<span v-html="item.content"></span>
<br>
</div>
</div>
</template>
<script>
//-----------------------------------------------------------------------------
import Vue from 'vue';
import Component from 'vue-class-component';
import {versionHistory} from '../../versionHistory';
export default @Component({
})
class VersionHistoryPage extends Vue {
versionHeader = [];
versionContent = [];
created() {
}
mounted() {
let vh = [];
for (const version of versionHistory) {
vh.push(version.header);
}
this.versionHeader = vh;
let vc = [];
for (const version of versionHistory) {
vc.push({key: version.header, content: 'Версия ' + version.header + version.content});
}
this.versionContent = vc;
}
showRelease(id) {
let el = document.getElementById(id);
if (el) {
document.getElementById('versionHistoryPage').scrollTop = el.offsetTop;
}
}
}
//-----------------------------------------------------------------------------
</script>
<style scoped>
.page {
flex: 1;
padding: 15px;
overflow-y: auto;
font-size: 120%;
line-height: 130%;
}
h4 {
margin: 0;
}
p {
line-height: 15px;
}
.clickable {
color: blue;
text-decoration: underline;
cursor: pointer;
}
</style>

View File

@@ -127,7 +127,6 @@ class HistoryPage extends Vue {
init() {
this.updateTableData();
this.mostRecentBook = bookManager.mostRecentBook();
this.$nextTick(() => {
this.$refs.input.focus();
});
@@ -141,9 +140,11 @@ class HistoryPage extends Vue {
let result = [];
const sorted = bookManager.getSortedRecent();
const len = (sorted.length < 100 ? sorted.length : 100);
for (let i = 0; i < len; i++) {
for (let i = 0; i < sorted.length; i++) {
const book = sorted[i];
if (book.deleted)
continue;
let d = new Date();
d.setTime(book.touchTime);
const t = formatDate(d).split(' ');
@@ -164,11 +165,21 @@ class HistoryPage extends Vue {
else
title = '';
let author = _.compact([
fb2.lastName,
fb2.firstName,
fb2.middleName
]).join(' ');
let author = '';
if (fb2.author) {
const authorNames = fb2.author.map(a => _.compact([
a.lastName,
a.firstName,
a.middleName
]).join(' '));
author = authorNames.join(', ');
} else {
author = _.compact([
fb2.lastName,
fb2.firstName,
fb2.middleName
]).join(' ');
}
author = (author ? author : (fb2.bookTitle ? fb2.bookTitle : book.url));
result.push({
@@ -183,6 +194,8 @@ class HistoryPage extends Vue {
path: book.path,
key: book.key,
});
if (result.length >= 100)
break;
}
const search = this.search;
@@ -225,13 +238,7 @@ class HistoryPage extends Vue {
await bookManager.delRecentBook({key});
this.updateTableData();
const newRecent = bookManager.mostRecentBook();
if (!(this.mostRecentBook && newRecent && this.mostRecentBook.key == newRecent.key))
this.$emit('load-book', newRecent);
this.mostRecentBook = newRecent;
if (!this.mostRecentBook)
if (!bookManager.mostRecentBook())
this.close();
}
@@ -241,7 +248,10 @@ class HistoryPage extends Vue {
}
isUrl(url) {
return (url.indexOf('file://') != 0);
if (url)
return (url.indexOf('file://') != 0);
else
return false;
}
close() {

View File

@@ -18,7 +18,19 @@
Загрузить файл с диска
</el-button>
<div class="space"></div>
<span v-if="mode == 'omnireader'" class="bottom-span clickable" @click="openComments">Комментарии</span>
<el-button size="mini" @click="loadBufferClick">
Из буфера обмена
</el-button>
<div class="space"></div>
<div class="space"></div>
<div v-if="mode == 'omnireader'" ref="yaShare2" class="ya-share2"
data-services="collections,vkontakte,facebook,odnoklassniki,twitter,telegram"
data-description="Чтение fb2-книг онлайн. Загрузка любой страницы интернета одним кликом, синхронизация между устройствами, удобное управление, регистрация не требуется."
data-title="Omni Reader - браузерная онлайн-читалка"
data-url="http://omnireader.ru">
</div>
<div class="space"></div>
<span v-if="mode == 'omnireader'" class="bottom-span clickable" @click="openComments">Отзывы о читалке</span>
</div>
<div class="part bottom">
@@ -26,6 +38,8 @@
<span class="bottom-span clickable" @click="openDonate">Помочь проекту</span>
<span class="bottom-span">{{ version }}</span>
</div>
<PasteTextPage v-if="pasteTextActive" ref="pasteTextPage" @paste-text-toggle="pasteTextToggle" @load-buffer="loadBuffer"></PasteTextPage>
</div>
</template>
@@ -33,12 +47,17 @@
//-----------------------------------------------------------------------------
import Vue from 'vue';
import Component from 'vue-class-component';
import PasteTextPage from './PasteTextPage/PasteTextPage.vue';
export default @Component({
components: {
PasteTextPage,
},
})
class LoaderPage extends Vue {
bookUrl = null;
loadPercent = 0;
pasteTextActive = false;
created() {
this.commit = this.$store.commit;
@@ -46,6 +65,8 @@ class LoaderPage extends Vue {
mounted() {
this.progress = this.$refs.progress;
if (this.mode == 'omnireader')
Ya.share2(this.$refs.yaShare2);// eslint-disable-line no-undef
}
activated() {
@@ -53,7 +74,7 @@ class LoaderPage extends Vue {
}
get title() {
if (this.$store.state.config.mode == 'omnireader')
if (this.mode == 'omnireader')
return 'Omni Reader - браузерная онлайн-читалка.';
return 'Универсальная читалка книг и ресурсов интернета.';
@@ -83,12 +104,27 @@ class LoaderPage extends Vue {
}
loadFile() {
const file = this.$refs.file.files[0];
const file = this.$refs.file.files[0];
this.$refs.file.value = '';
if (file)
this.$emit('load-file', {file});
}
loadBufferClick() {
this.pasteTextToggle();
}
loadBuffer(opts) {
if (opts.buffer.length) {
const file = new File([opts.buffer], 'dummyName-PasteFromClipboard');
this.$emit('load-file', {file});
}
}
pasteTextToggle() {
this.pasteTextActive = !this.pasteTextActive;
}
openHelp() {
this.$emit('help-toggle');
}
@@ -102,6 +138,10 @@ class LoaderPage extends Vue {
}
keyHook(event) {
if (this.pasteTextActive) {
return this.$refs.pasteTextPage.keyHook(event);
}
//недостатки сторонних ui
const input = this.$refs.input.$refs.input;
if (document.activeElement === input && event.type == 'keydown' && event.code == 'Enter') {
@@ -130,7 +170,7 @@ class LoaderPage extends Vue {
flex: 1;
display: flex;
flex-direction: column;
min-height: 340px;
min-height: 400px;
}
.part {

View File

@@ -0,0 +1,136 @@
<template>
<div ref="main" class="main" @click="close">
<div class="mainWindow" @click.stop>
<Window @close="close">
<template slot="header">
Вставьте текст и нажмите
<el-button size="mini" style="font-size: 120%; color: blue" @click="loadBuffer">Загрузить</el-button>
или F2
</template>
<div>
<el-input placeholder="Введите название текста" class="input" v-model="bookTitle"></el-input>
</div>
<hr/>
<textarea ref="textArea" class="text" @paste="calcTitle"></textarea>
</Window>
</div>
</div>
</template>
<script>
//-----------------------------------------------------------------------------
import Vue from 'vue';
import Component from 'vue-class-component';
import Window from '../../../share/Window.vue';
import _ from 'lodash';
import * as utils from '../../../../share/utils';
export default @Component({
components: {
Window,
},
})
class PasteTextPage extends Vue {
bookTitle = '';
created() {
}
mounted() {
this.$refs.textArea.focus();
}
getNonEmptyLine3words(text, count) {
let result = '';
const lines = text.split("\n");
let i = 0;
while (i < lines.length) {
if (lines[i].trim() != '') {
count--;
if (count <= 0) {
result = lines[i];
break;
}
}
i++;
}
result = result.trim().split(' ');
return result.slice(0, 3).join(' ');
}
calcTitle(event) {
if (this.bookTitle == '') {
let text = event.clipboardData.getData('text');
this.bookTitle = `Из буфера обмена ${utils.formatDate(new Date(), 'noDate')}: ` + _.compact([
this.getNonEmptyLine3words(text, 1),
this.getNonEmptyLine3words(text, 2)
]).join(' - ');
}
}
loadBuffer() {
this.$emit('load-buffer', {buffer: `<cut-title>${this.bookTitle}</cut-title>${this.$refs.textArea.value}`});
this.close();
}
close() {
this.$emit('paste-text-toggle');
}
keyHook(event) {
if (event.type == 'keydown') {
switch (event.code) {
case 'F2':
this.loadBuffer();
break;
case 'Escape':
this.close();
break;
}
}
return true;
}
}
//-----------------------------------------------------------------------------
</script>
<style scoped>
.main {
position: absolute;
width: 100%;
height: 100%;
z-index: 40;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.mainWindow {
width: 100%;
height: 100%;
display: flex;
}
.text {
flex: 1;
overflow-wrap: anywhere;
overflow-y: auto;
padding: 0 10px 0 10px;
position: relative;
font-size: 120%;
}
.text:focus {
outline: none;
}
hr {
margin: 0;
padding: 0;
}
</style>

View File

@@ -7,35 +7,35 @@
</el-tooltip>
<div>
<el-tooltip content="Действие назад" :open-delay="1000" effect="light">
<el-tooltip v-show="showToolButton['undoAction']" content="Действие назад" :open-delay="1000" effect="light">
<el-button ref="undoAction" class="tool-button" :class="buttonActiveClass('undoAction')" @click="buttonClick('undoAction')" ><i class="el-icon-arrow-left"></i></el-button>
</el-tooltip>
<el-tooltip content="Действие вперед" :open-delay="1000" effect="light">
<el-tooltip v-show="showToolButton['redoAction']" content="Действие вперед" :open-delay="1000" effect="light">
<el-button ref="redoAction" class="tool-button" :class="buttonActiveClass('redoAction')" @click="buttonClick('redoAction')" ><i class="el-icon-arrow-right"></i></el-button>
</el-tooltip>
<div class="space"></div>
<el-tooltip content="На весь экран" :open-delay="1000" effect="light">
<el-tooltip v-show="showToolButton['fullScreen']" content="На весь экран" :open-delay="1000" effect="light">
<el-button ref="fullScreen" class="tool-button" :class="buttonActiveClass('fullScreen')" @click="buttonClick('fullScreen')"><i class="el-icon-rank"></i></el-button>
</el-tooltip>
<el-tooltip content="Плавный скроллинг" :open-delay="1000" effect="light">
<el-tooltip v-show="showToolButton['scrolling']" content="Плавный скроллинг" :open-delay="1000" effect="light">
<el-button ref="scrolling" class="tool-button" :class="buttonActiveClass('scrolling')" @click="buttonClick('scrolling')"><i class="el-icon-sort"></i></el-button>
</el-tooltip>
<el-tooltip content="Перелистнуть" :open-delay="1000" effect="light">
<el-tooltip v-show="showToolButton['setPosition']" content="На страницу" :open-delay="1000" effect="light">
<el-button ref="setPosition" class="tool-button" :class="buttonActiveClass('setPosition')" @click="buttonClick('setPosition')"><i class="el-icon-d-arrow-right"></i></el-button>
</el-tooltip>
<el-tooltip content="Найти в тексте" :open-delay="1000" effect="light">
<el-tooltip v-show="showToolButton['search']" content="Найти в тексте" :open-delay="1000" effect="light">
<el-button ref="search" class="tool-button" :class="buttonActiveClass('search')" @click="buttonClick('search')"><i class="el-icon-search"></i></el-button>
</el-tooltip>
<el-tooltip content="Скопировать текст со страницы" :open-delay="1000" effect="light">
<el-tooltip v-show="showToolButton['copyText']" content="Скопировать текст со страницы" :open-delay="1000" effect="light">
<el-button ref="copyText" class="tool-button" :class="buttonActiveClass('copyText')" @click="buttonClick('copyText')"><i class="el-icon-edit-outline"></i></el-button>
</el-tooltip>
<el-tooltip content="Принудительно обновить книгу в обход кэша" :open-delay="1000" effect="light">
<el-tooltip v-show="showToolButton['refresh']" content="Принудительно обновить книгу в обход кэша" :open-delay="1000" effect="light">
<el-button ref="refresh" class="tool-button" :class="buttonActiveClass('refresh')" @click="buttonClick('refresh')">
<i class="el-icon-refresh" :class="{clear: !showRefreshIcon}"></i>
</el-button>
</el-tooltip>
<div class="space"></div>
<el-tooltip content="Открыть недавние" :open-delay="1000" effect="light">
<el-tooltip v-show="showToolButton['history']" content="Открыть недавние" :open-delay="1000" effect="light">
<el-button ref="history" class="tool-button" :class="buttonActiveClass('history')" @click="buttonClick('history')"><i class="el-icon-document"></i></el-button>
</el-tooltip>
</div>
@@ -68,12 +68,27 @@
@start-text-search="startTextSearch"
@stop-text-search="stopTextSearch">
</SearchPage>
<CopyTextPage v-if="copyTextActive" ref="copyTextPage" @copy-text-toggle="copyTextToggle"></CopyTextPage>
<CopyTextPage v-if="copyTextActive" ref="copyTextPage" @copy-text-toggle="copyTextToggle"></CopyTextPage>
<HistoryPage v-show="historyActive" ref="historyPage" @load-book="loadBook" @history-toggle="historyToggle"></HistoryPage>
<SettingsPage v-if="settingsActive" ref="settingsPage" @settings-toggle="settingsToggle"></SettingsPage>
<HelpPage v-if="helpActive" ref="helpPage" @help-toggle="helpToggle"></HelpPage>
<ClickMapPage v-show="clickMapActive" ref="clickMapPage"></ClickMapPage>
<ServerStorage v-show="hidden" ref="serverStorage"></ServerStorage>
<el-dialog
title="Что нового:"
:visible.sync="whatsNewVisible"
width="80%">
<div style="line-height: 20px" v-html="whatsNewContent"></div>
<span class="clickable" @click="openVersionHistory">Посмотреть историю версий</span>
<span slot="footer" class="dialog-footer">
<el-button @click="whatsNewDisable">Больше не показывать</el-button>
</span>
</el-dialog>
</el-main>
</el-container>
</template>
@@ -81,6 +96,9 @@
//-----------------------------------------------------------------------------
import Vue from 'vue';
import Component from 'vue-class-component';
import _ from 'lodash';
import {Buffer} from 'safe-buffer';
import LoaderPage from './LoaderPage/LoaderPage.vue';
import TextPage from './TextPage/TextPage.vue';
import ProgressPage from './ProgressPage/ProgressPage.vue';
@@ -92,12 +110,12 @@ import HistoryPage from './HistoryPage/HistoryPage.vue';
import SettingsPage from './SettingsPage/SettingsPage.vue';
import HelpPage from './HelpPage/HelpPage.vue';
import ClickMapPage from './ClickMapPage/ClickMapPage.vue';
import ServerStorage from './ServerStorage/ServerStorage.vue';
import bookManager from './share/bookManager';
import readerApi from '../../api/reader';
import _ from 'lodash';
import {sleep} from '../../share/utils';
import restoreOldSettings from './share/restoreOldSettings';
import * as utils from '../../share/utils';
import {versionHistory} from './versionHistory';
export default @Component({
components: {
@@ -112,6 +130,7 @@ export default @Component({
SettingsPage,
HelpPage,
ClickMapPage,
ServerStorage,
},
watch: {
bookPos: function(newValue) {
@@ -163,9 +182,14 @@ class Reader extends Vue {
allowUrlParamBookPos = false;
showRefreshIcon = true;
mostRecentBookReactive = null;
showToolButton = {};
actionList = [];
actionCur = -1;
hidden = false;
whatsNewVisible = false;
whatsNewContent = '';
created() {
this.loading = true;
@@ -192,6 +216,18 @@ class Reader extends Vue {
}
}, 500);
this.debouncedSaveRecent = _.debounce(async() => {
const serverStorage = this.$refs.serverStorage;
while (!serverStorage.inited) await utils.sleep(1000);
await serverStorage.saveRecent();
}, 1000);
this.debouncedSaveRecentLast = _.debounce(async() => {
const serverStorage = this.$refs.serverStorage;
while (!serverStorage.inited) await utils.sleep(1000);
await serverStorage.saveRecentLast();
}, 1000);
document.addEventListener('fullscreenchange', () => {
this.fullScreenActive = (document.fullscreenElement !== null);
});
@@ -202,16 +238,21 @@ class Reader extends Vue {
mounted() {
(async() => {
await bookManager.init(this.settings);
await restoreOldSettings(this.settings, bookManager, this.commit);
bookManager.addEventListener(this.bookManagerEvent);
if (this.$root.rootRoute == '/reader') {
if (this.routeParamUrl) {
this.loadBook({url: this.routeParamUrl, bookPos: this.routeParamPos});
await this.loadBook({url: this.routeParamUrl, bookPos: this.routeParamPos, force: this.routeParamRefresh});
} else {
this.loaderActive = true;
}
}
this.checkSetStorageAccessKey();
this.checkActivateDonateHelpPage();
this.loading = false;
await this.showWhatsNew();
})();
}
@@ -222,6 +263,72 @@ class Reader extends Vue {
this.showClickMapPage = settings.showClickMapPage;
this.clickControl = settings.clickControl;
this.blinkCachedLoad = settings.blinkCachedLoad;
this.showWhatsNewDialog = settings.showWhatsNewDialog;
this.showToolButton = settings.showToolButton;
}
checkSetStorageAccessKey() {
const q = this.$route.query;
if (q['setStorageAccessKey']) {
this.$router.replace(`/reader`);
this.settingsToggle();
this.$nextTick(() => {
this.$refs.settingsPage.enterServerStorageKey(
Buffer.from(utils.fromBase58(q['setStorageAccessKey'])).toString()
);
});
}
}
checkActivateDonateHelpPage() {
const q = this.$route.query;
if (q['donate']) {
this.$router.replace(`/reader`);
this.helpToggle();
this.$nextTick(() => {
this.$refs.helpPage.activateDonateHelpPage();
});
}
}
checkBookPosPercent() {
const q = this.$route.query;
if (q['__pp']) {
let pp = q['__pp'];
if (pp) {
pp = parseFloat(pp) || 0;
const recent = this.mostRecentBook();
(async() => {
await utils.sleep(100);
this.bookPos = Math.floor(recent.textLength*pp/100);
})();
}
}
}
async showWhatsNew() {
await utils.sleep(2000);
const whatsNew = versionHistory[0];
if (this.showWhatsNewDialog &&
whatsNew.showUntil >= utils.formatDate(new Date(), 'coDate') &&
whatsNew.header != this.whatsNewContentHash) {
this.whatsNewContent = 'Версия ' + whatsNew.header + whatsNew.content;
this.whatsNewVisible = true;
}
}
openVersionHistory() {
this.whatsNewVisible = false;
this.versionHistoryToggle();
}
whatsNewDisable() {
this.whatsNewVisible = false;
const whatsNew = versionHistory[0];
this.commit('reader/setWhatsNewContentHash', whatsNew.header);
}
get routeParamPos() {
@@ -237,6 +344,8 @@ class Reader extends Vue {
}
updateRoute(isNewRoute) {
if (this.loading)
return;
const recent = this.mostRecentBook();
const pos = (recent && recent.bookPos && this.allowUrlParamBookPos ? `__p=${recent.bookPos}&` : '');
const url = (recent ? `url=${recent.url}` : '');
@@ -258,6 +367,11 @@ class Reader extends Vue {
return decodeURIComponent(result);
}
get routeParamRefresh() {
const q = this.$route.query;
return !!q['__refresh'];
}
bookPosChanged(event) {
if (event.bookPosSeen !== undefined)
this.bookPosSeen = event.bookPosSeen;
@@ -265,6 +379,45 @@ class Reader extends Vue {
this.debouncedUpdateRoute();
}
async bookManagerEvent(eventName) {
const serverStorage = this.$refs.serverStorage;
if (eventName == 'load-meta-finish') {
serverStorage.init();
const result = await bookManager.cleanRecentBooks();
if (result)
this.debouncedSaveRecent();
}
if (eventName == 'recent-changed' || eventName == 'save-recent') {
if (this.historyActive) {
this.$refs.historyPage.updateTableData();
}
const oldBook = this.mostRecentBookReactive;
const newBook = bookManager.mostRecentBook();
if (oldBook && newBook) {
if (oldBook.key != newBook.key) {
this.loadingBook = true;
try {
await this.loadBook(newBook);
} finally {
this.loadingBook = false;
}
} else if (oldBook.bookPos != newBook.bookPos) {
while (this.loadingBook) await utils.sleep(100);
this.bookPosChanged({bookPos: newBook.bookPos});
}
}
if (eventName == 'recent-changed') {
this.debouncedSaveRecentLast();
} else {
this.debouncedSaveRecent();
}
}
}
get toolBarActive() {
return this.reader.toolBarActive;
}
@@ -279,6 +432,10 @@ class Reader extends Vue {
return this.$store.state.reader.settings;
}
get whatsNewContentHash() {
return this.$store.state.reader.whatsNewContentHash;
}
addAction(pos) {
let a = this.actionList;
if (!a.length || a[a.length - 1] != pos) {
@@ -449,6 +606,15 @@ class Reader extends Vue {
}
}
versionHistoryToggle() {
this.helpToggle();
if (this.helpActive) {
this.$nextTick(() => {
this.$refs.helpPage.activateVersionHistoryHelpPage();
});
}
}
refreshBook() {
if (this.mostRecentBook()) {
this.loadBook({url: this.mostRecentBook().url, force: true});
@@ -584,6 +750,11 @@ class Reader extends Vue {
this.$root.$emit('set-app-title');
}
// на LoaderPage всегда показываем toolBar
if (result == 'LoaderPage' && !this.toolBarActive) {
this.toolBarToggle();
}
if (this.lastActivePage != result && result == 'TextPage') {
//акивируем страницу с текстом
this.$nextTick(async() => {
@@ -609,7 +780,7 @@ class Reader extends Vue {
return result;
}
loadBook(opts) {
async loadBook(opts) {
if (!opts || !opts.url) {
this.mostRecentBook();
return;
@@ -628,119 +799,122 @@ class Reader extends Vue {
}
this.progressActive = true;
this.$nextTick(async() => {
const progress = this.$refs.page;
this.actionList = [];
this.actionCur = -1;
await this.$nextTick();
try {
progress.show();
progress.setState({state: 'parse'});
const progress = this.$refs.page;
// есть ли среди недавних
const key = bookManager.keyFromUrl(url);
let wasOpened = await bookManager.getRecentBook({key});
wasOpened = (wasOpened ? wasOpened : {});
const bookPos = (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPos);
const bookPosSeen = (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPosSeen);
const bookPosPercent = wasOpened.bookPosPercent;
this.actionList = [];
this.actionCur = -1;
let book = null;
try {
progress.show();
progress.setState({state: 'parse'});
if (!opts.force) {
// пытаемся загрузить и распарсить книгу в менеджере из локального кэша
const bookParsed = await bookManager.getBook({url}, (prog) => {
progress.setState({progress: prog});
});
// есть ли среди недавних
const key = bookManager.keyFromUrl(url);
let wasOpened = await bookManager.getRecentBook({key});
wasOpened = (wasOpened ? wasOpened : {});
const bookPos = (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPos);
const bookPosSeen = (opts.bookPos !== undefined ? opts.bookPos : wasOpened.bookPosSeen);
// если есть в локальном кэше
if (bookParsed) {
await bookManager.setRecentBook(Object.assign({bookPos, bookPosSeen, bookPosPercent}, bookParsed));
this.mostRecentBook();
this.addAction(bookPos);
this.loaderActive = false;
progress.hide(); this.progressActive = false;
this.blinkCachedLoadMessage();
let book = null;
await this.activateClickMapPage();
return;
}
// иначе идем на сервер
// пытаемся загрузить готовый файл с сервера
if (wasOpened.path) {
try {
const resp = await readerApi.loadCachedBook(wasOpened.path, (state) => {
progress.setState(state);
});
book = Object.assign({}, wasOpened, {data: resp.data});
} catch (e) {
//молчим
}
}
}
progress.setState({totalSteps: 5});
// не удалось, скачиваем книгу полностью с конвертацией
let loadCached = true;
if (!book) {
book = await readerApi.loadBook(url, (state) => {
progress.setState(state);
});
loadCached = false;
}
// добавляем в bookManager
progress.setState({state: 'parse', step: 5});
const addedBook = await bookManager.addBook(book, (prog) => {
if (!opts.force) {
// пытаемся загрузить и распарсить книгу в менеджере из локального кэша
const bookParsed = await bookManager.getBook({url}, (prog) => {
progress.setState({progress: prog});
});
// добавляем в историю
await bookManager.setRecentBook(Object.assign({bookPos, bookPosSeen, bookPosPercent}, addedBook));
this.mostRecentBook();
this.addAction(bookPos);
this.updateRoute(true);
this.loaderActive = false;
progress.hide(); this.progressActive = false;
if (loadCached) {
// если есть в локальном кэше
if (bookParsed) {
await bookManager.setRecentBook(Object.assign({bookPos, bookPosSeen}, bookParsed));
this.mostRecentBook();
this.addAction(bookPos);
this.loaderActive = false;
progress.hide(); this.progressActive = false;
this.blinkCachedLoadMessage();
} else
this.stopBlink = true;
await this.activateClickMapPage();
} catch (e) {
progress.hide(); this.progressActive = false;
this.loaderActive = true;
this.$alert(e.message, 'Ошибка', {type: 'error'});
this.checkBookPosPercent();
await this.activateClickMapPage();
return;
}
// иначе идем на сервер
// пытаемся загрузить готовый файл с сервера
if (wasOpened.path) {
try {
const resp = await readerApi.loadCachedBook(wasOpened.path, (state) => {
progress.setState(state);
});
book = Object.assign({}, wasOpened, {data: resp.data});
} catch (e) {
//молчим
}
}
}
});
}
loadFile(opts) {
this.progressActive = true;
this.$nextTick(async() => {
const progress = this.$refs.page;
try {
progress.show();
progress.setState({state: 'upload'});
progress.setState({totalSteps: 5});
const url = await readerApi.uploadFile(opts.file, this.config.maxUploadFileSize, (state) => {
// не удалось, скачиваем книгу полностью с конвертацией
let loadCached = true;
if (!book) {
book = await readerApi.loadBook(url, (state) => {
progress.setState(state);
});
progress.hide(); this.progressActive = false;
this.loadBook({url});
} catch (e) {
progress.hide(); this.progressActive = false;
this.loaderActive = true;
this.$alert(e.message, 'Ошибка', {type: 'error'});
loadCached = false;
}
});
// добавляем в bookManager
progress.setState({state: 'parse', step: 5});
const addedBook = await bookManager.addBook(book, (prog) => {
progress.setState({progress: prog});
});
// добавляем в историю
await bookManager.setRecentBook(Object.assign({bookPos, bookPosSeen}, addedBook));
this.mostRecentBook();
this.addAction(bookPos);
this.updateRoute(true);
this.loaderActive = false;
progress.hide(); this.progressActive = false;
if (loadCached) {
this.blinkCachedLoadMessage();
} else
this.stopBlink = true;
this.checkBookPosPercent();
await this.activateClickMapPage();
} catch (e) {
progress.hide(); this.progressActive = false;
this.loaderActive = true;
this.$alert(e.message, 'Ошибка', {type: 'error'});
}
}
async loadFile(opts) {
this.progressActive = true;
await this.$nextTick();
const progress = this.$refs.page;
try {
progress.show();
progress.setState({state: 'upload'});
const url = await readerApi.uploadFile(opts.file, this.config.maxUploadFileSize, (state) => {
progress.setState(state);
});
progress.hide(); this.progressActive = false;
await this.loadBook({url});
} catch (e) {
progress.hide(); this.progressActive = false;
this.loaderActive = true;
this.$alert(e.message, 'Ошибка', {type: 'error'});
}
}
blinkCachedLoadMessage() {
@@ -757,7 +931,7 @@ class Reader extends Vue {
this.showRefreshIcon = !this.showRefreshIcon;
if (page.blinkCachedLoadMessage)
page.blinkCachedLoadMessage(this.showRefreshIcon);
await sleep(500);
await utils.sleep(500);
if (this.stopBlink)
break;
this.blinkCount--;
@@ -930,4 +1104,10 @@ i {
.clear {
color: rgba(0,0,0,0);
}
</style>
.clickable {
color: blue;
text-decoration: underline;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,691 @@
<template>
<div></div>
</template>
<script>
//-----------------------------------------------------------------------------
import Vue from 'vue';
import Component from 'vue-class-component';
import _ from 'lodash';
import bookManager from '../share/bookManager';
import readerApi from '../../../api/reader';
import * as utils from '../../../share/utils';
import * as cryptoUtils from '../../../share/cryptoUtils';
const maxSetTries = 5;
export default @Component({
watch: {
serverSyncEnabled: function() {
this.serverSyncEnabledChanged();
},
serverStorageKey: function() {
this.serverStorageKeyChanged(true);
},
settings: function() {
this.debouncedSaveSettings();
},
profiles: function() {
this.saveProfiles();
},
currentProfile: function() {
this.currentProfileChanged(true);
},
},
})
class ServerStorage extends Vue {
created() {
this.inited = false;
this.keyInited = false;
this.commit = this.$store.commit;
this.prevServerStorageKey = null;
this.$root.$on('generateNewServerStorageKey', () => {this.generateNewServerStorageKey()});
this.debouncedSaveSettings = _.debounce(() => {
this.saveSettings();
}, 500);
this.oldProfiles = {};
this.oldSettings = {};
this.oldRecent = {};
this.oldRecentLast = {};
this.oldRecentLastDiff = {};
}
async init() {
try {
if (!this.serverStorageKey) {
//генерируем новый ключ
await this.generateNewServerStorageKey();
} else {
await this.serverStorageKeyChanged();
}
this.oldRecent = _.cloneDeep(bookManager.recent);
this.oldRecentLast = _.cloneDeep(bookManager.recentLast) || {};
} finally {
this.inited = true;
}
}
async generateNewServerStorageKey() {
const key = utils.toBase58(utils.randomArray(32));
this.commit('reader/setServerStorageKey', key);
await this.serverStorageKeyChanged(true);
}
async serverSyncEnabledChanged() {
if (this.serverSyncEnabled) {
this.prevServerStorageKey = null;
if (!this.serverStorageKey) {
//генерируем новый ключ
await this.generateNewServerStorageKey();
} else {
await this.serverStorageKeyChanged(true);
}
}
}
async serverStorageKeyChanged(force) {
if (this.prevServerStorageKey != this.serverStorageKey) {
this.prevServerStorageKey = this.serverStorageKey;
this.hashedStorageKey = utils.toBase58(cryptoUtils.sha256(this.serverStorageKey));
this.keyInited = true;
await this.loadProfiles(force);
this.checkCurrentProfile();
await this.currentProfileChanged(force);
await this.loadRecent(force);
if (force)
await this.saveRecent();
}
}
async currentProfileChanged(force) {
if (!this.currentProfile)
return;
await this.loadSettings(force);
}
get serverSyncEnabled() {
return this.$store.state.reader.serverSyncEnabled;
}
get settings() {
return this.$store.state.reader.settings;
}
get settingsRev() {
return this.$store.state.reader.settingsRev;
}
get serverStorageKey() {
return this.$store.state.reader.serverStorageKey;
}
get profiles() {
return this.$store.state.reader.profiles;
}
get profilesRev() {
return this.$store.state.reader.profilesRev;
}
get currentProfile() {
return this.$store.state.reader.currentProfile;
}
get showServerStorageMessages() {
return this.settings.showServerStorageMessages;
}
checkCurrentProfile() {
if (!this.profiles[this.currentProfile]) {
this.commit('reader/setCurrentProfile', '');
}
}
notifySuccess() {
this.success('Данные синхронизированы с сервером');
}
success(message) {
if (this.showServerStorageMessages)
this.$notify.success({message});
}
warning(message) {
if (this.showServerStorageMessages)
this.$notify.warning({message});
}
error(message) {
if (this.showServerStorageMessages)
this.$notify.error({message});
}
async loadSettings(force) {
if (!this.keyInited || !this.serverSyncEnabled || !this.currentProfile)
return;
const setsId = `settings-${this.currentProfile}`;
const oldRev = this.settingsRev[setsId] || 0;
//проверим ревизию на сервере
if (!force) {
try {
const revs = await this.storageCheck({[setsId]: {}});
if (revs.state == 'success' && revs.items[setsId].rev == oldRev) {
return;
}
} catch(e) {
this.error(`Ошибка соединения с сервером: ${e.message}`);
return;
}
}
let sets = null;
try {
sets = await this.storageGet({[setsId]: {}});
} catch(e) {
this.error(`Ошибка соединения с сервером: ${e.message}`);
return;
}
if (sets.state == 'success') {
sets = sets.items[setsId];
if (sets.rev == 0)
sets.data = {};
this.oldSettings = _.cloneDeep(sets.data);
this.commit('reader/setSettings', sets.data);
this.commit('reader/setSettingsRev', {[setsId]: sets.rev});
this.notifySuccess();
} else {
this.warning(`Неверный ответ сервера: ${sets.state}`);
}
}
async saveSettings() {
if (!this.keyInited || !this.serverSyncEnabled || !this.currentProfile || this.savingSettings)
return;
const diff = utils.getObjDiff(this.oldSettings, this.settings);
if (utils.isEmptyObjDiff(diff))
return;
this.savingSettings = true;
try {
const setsId = `settings-${this.currentProfile}`;
let result = {state: ''};
let tries = 0;
while (result.state != 'success' && tries < maxSetTries) {
const oldRev = this.settingsRev[setsId] || 0;
try {
result = await this.storageSet({[setsId]: {rev: oldRev + 1, data: this.settings}});
} catch(e) {
this.savingSettings = false;
this.error(`Ошибка соединения с сервером (${e.message}). Данные не сохранены и могут быть перезаписаны.`);
return;
}
if (result.state == 'reject') {
await this.loadSettings(true);
const newSettings = utils.applyObjDiff(this.settings, diff);
this.commit('reader/setSettings', newSettings);
}
tries++;
}
if (tries >= maxSetTries) {
//отменять изменения не будем, просто предупредим
//this.commit('reader/setSettings', this.oldSettings);
console.error(result);
this.error('Не удалось отправить настройки на сервер. Данные не сохранены и могут быть перезаписаны.');
} else {
this.oldSettings = _.cloneDeep(this.settings);
this.commit('reader/setSettingsRev', {[setsId]: this.settingsRev[setsId] + 1});
}
} finally {
this.savingSettings = false;
}
}
async loadProfiles(force) {
if (!this.keyInited || !this.serverSyncEnabled)
return;
const oldRev = this.profilesRev;
//проверим ревизию на сервере
if (!force) {
try {
const revs = await this.storageCheck({profiles: {}});
if (revs.state == 'success' && revs.items.profiles.rev == oldRev) {
return;
}
} catch(e) {
this.error(`Ошибка соединения с сервером: ${e.message}`);
return;
}
}
let prof = null;
try {
prof = await this.storageGet({profiles: {}});
} catch(e) {
this.error(`Ошибка соединения с сервером: ${e.message}`);
return;
}
if (prof.state == 'success') {
prof = prof.items.profiles;
if (prof.rev == 0)
prof.data = {};
this.oldProfiles = _.cloneDeep(prof.data);
this.commit('reader/setProfiles', prof.data);
this.commit('reader/setProfilesRev', prof.rev);
this.notifySuccess();
} else {
this.warning(`Неверный ответ сервера: ${prof.state}`);
}
}
async saveProfiles() {
if (!this.keyInited || !this.serverSyncEnabled || this.savingProfiles)
return;
const diff = utils.getObjDiff(this.oldProfiles, this.profiles);
if (utils.isEmptyObjDiff(diff))
return;
//обнуляются профили во время разработки, подстраховка
if (!this.$store.state.reader.allowProfilesSave) {
console.error('Сохранение профилей не санкционировано');
return;
}
this.savingProfiles = true;
try {
let result = {state: ''};
let tries = 0;
while (result.state != 'success' && tries < maxSetTries) {
try {
result = await this.storageSet({profiles: {rev: this.profilesRev + 1, data: this.profiles}});
} catch(e) {
this.savingProfiles = false;
this.commit('reader/setProfiles', this.oldProfiles);
this.checkCurrentProfile();
this.error(`Ошибка соединения с сервером: (${e.message}). Изменения отменены.`);
return;
}
if (result.state == 'reject') {
await this.loadProfiles(true);
const newProfiles = utils.applyObjDiff(this.profiles, diff);
this.commit('reader/setProfiles', newProfiles);
}
tries++;
}
if (tries >= maxSetTries) {
this.commit('reader/setProfiles', this.oldProfiles);
this.checkCurrentProfile();
console.error(result);
this.error('Не удалось отправить данные на сервер. Изменения отменены.');
} else {
this.oldProfiles = _.cloneDeep(this.profiles);
this.commit('reader/setProfilesRev', this.profilesRev + 1);
}
} finally {
this.savingProfiles = false;
}
}
async loadRecent(force) {
if (!this.keyInited || !this.serverSyncEnabled)
return;
const oldRev = bookManager.recentRev;
const oldLastRev = bookManager.recentLastRev;
const oldLastDiffRev = bookManager.recentLastDiffRev;
//проверим ревизию на сервере
let revs = null;
if (!force) {
try {
revs = await this.storageCheck({recent: {}, recentLast: {}, recentLastDiff: {}});
if (revs.state == 'success' && revs.items.recent.rev == oldRev &&
revs.items.recentLast.rev == oldLastRev &&
revs.items.recentLastDiff.rev == oldLastDiffRev) {
return;
}
} catch(e) {
this.error(`Ошибка соединения с сервером: ${e.message}`);
return;
}
}
if (force || revs.items.recent.rev != oldRev) {
let recent = null;
try {
recent = await this.storageGet({recent: {}});
} catch(e) {
this.error(`Ошибка соединения с сервером: ${e.message}`);
return;
}
if (recent.state == 'success') {
recent = recent.items.recent;
if (recent.rev == 0)
recent.data = {};
this.oldRecent = _.cloneDeep(recent.data);
await bookManager.setRecent(recent.data);
await bookManager.setRecentRev(recent.rev);
} else {
this.warning(`Неверный ответ сервера: ${recent.state}`);
}
}
if (force || revs.items.recentLast.rev != oldLastRev || revs.items.recentLastDiff.rev != oldLastDiffRev) {
let recentLast = null;
try {
recentLast = await this.storageGet({recentLast: {}, recentLastDiff: {}});
} catch(e) {
this.error(`Ошибка соединения с сервером: ${e.message}`);
return;
}
if (recentLast.state == 'success') {
const recentLastDiff = recentLast.items.recentLastDiff;
recentLast = recentLast.items.recentLast;
if (recentLast.rev == 0)
recentLast.data = {};
if (recentLastDiff.rev == 0)
recentLastDiff.data = {};
this.oldRecentLastDiff = _.cloneDeep(recentLastDiff.data);
this.oldRecentLast = _.cloneDeep(recentLast.data);
recentLast.data = utils.applyObjDiff(recentLast.data, recentLastDiff.data);
await bookManager.setRecentLast(recentLast.data);
await bookManager.setRecentLastRev(recentLast.rev);
await bookManager.setRecentLastDiffRev(recentLastDiff.rev);
} else {
this.warning(`Неверный ответ сервера: ${recentLast.state}`);
}
}
this.notifySuccess();
}
async saveRecent() {
if (!this.keyInited || !this.serverSyncEnabled || this.savingRecent)
return;
const bm = bookManager;
const diff = utils.getObjDiff(this.oldRecent, bm.recent);
if (utils.isEmptyObjDiff(diff))
return;
this.savingRecent = true;
try {
let result = {state: ''};
let tries = 0;
while (result.state != 'success' && tries < maxSetTries) {
try {
result = await this.storageSet({recent: {rev: bm.recentRev + 1, data: bm.recent}});
} catch(e) {
this.savingRecent = false;
this.error(`Ошибка соединения с сервером: (${e.message}). Изменения не сохранены.`);
return;
}
if (result.state == 'reject') {
await this.loadRecent(true);
//похоже это лишнее
/*const newRecent = utils.applyObjDiff(bm.recent, diff);
await bm.setRecent(newRecent);*/
}
tries++;
}
if (tries >= maxSetTries) {
console.error(result);
this.error('Не удалось отправить данные на сервер. Данные не сохранены и могут быть перезаписаны.');
} else {
this.oldRecent = _.cloneDeep(bm.recent);
await bm.setRecentRev(bm.recentRev + 1);
await this.saveRecentLast(true);
}
} finally {
this.savingRecent = false;
}
}
async saveRecentLast(force = false) {
if (!this.keyInited || !this.serverSyncEnabled || this.savingRecentLast)
return;
const bm = bookManager;
let recentLast = bm.recentLast;
recentLast = (recentLast ? recentLast : {});
let lastRev = bm.recentLastRev;
const diff = utils.getObjDiff(this.oldRecentLast, recentLast);
if (utils.isEmptyObjDiff(diff))
return;
if (this.oldRecentLast.key == recentLast.key && JSON.stringify(recentLast) > JSON.stringify(diff)) {
await this.saveRecentLastDiff(diff, force);
return;
}
this.savingRecentLast = true;
try {
let result = {state: ''};
let tries = 0;
while (result.state != 'success' && tries < maxSetTries) {
if (force) {
try {
const revs = await this.storageCheck({recentLast: {}});
if (revs.items.recentLast.rev)
lastRev = revs.items.recentLast.rev;
} catch(e) {
this.error(`Ошибка соединения с сервером: ${e.message}`);
return;
}
}
try {
result = await this.storageSet({recentLast: {rev: lastRev + 1, data: recentLast}}, force);
} catch(e) {
this.savingRecentLast = false;
this.error(`Ошибка соединения с сервером: (${e.message}). Изменения не сохранены.`);
return;
}
if (result.state == 'reject') {
await this.loadRecent(false);
this.savingRecentLast = false;//!!!
return;//!!!
}
tries++;
}
if (tries >= maxSetTries) {
console.error(result);
this.error('Не удалось отправить данные на сервер. Данные не сохранены и могут быть перезаписаны.');
} else {
this.oldRecentLast = _.cloneDeep(recentLast);
await bm.setRecentLastRev(lastRev + 1);
await this.saveRecentLastDiff({}, true);
}
} finally {
this.savingRecentLast = false;
}
}
async saveRecentLastDiff(diff, force = false) {
if (!this.keyInited || !this.serverSyncEnabled || this.savingRecentLastDiff)
return;
const bm = bookManager;
let lastRev = bm.recentLastDiffRev;
const d = utils.getObjDiff(this.oldRecentLastDiff, diff);
if (utils.isEmptyObjDiff(d))
return;
this.savingRecentLastDiff = true;
try {
let result = {state: ''};
let tries = 0;
while (result.state != 'success' && tries < maxSetTries) {
if (force) {
try {
const revs = await this.storageCheck({recentLastDiff: {}});
if (revs.items.recentLastDiff.rev)
lastRev = revs.items.recentLastDiff.rev;
} catch(e) {
this.error(`Ошибка соединения с сервером: ${e.message}`);
return;
}
}
try {
result = await this.storageSet({recentLastDiff: {rev: lastRev + 1, data: diff}}, force);
} catch(e) {
this.savingRecentLastDiff = false;
this.error(`Ошибка соединения с сервером: (${e.message}). Изменения не сохранены.`);
return;
}
if (result.state == 'reject') {
await this.loadRecent(false);
this.savingRecentLastDiff = false;
return;
}
tries++;
}
if (tries >= maxSetTries) {
console.error(result);
this.error('Не удалось отправить данные на сервер. Данные не сохранены и могут быть перезаписаны.');
} else {
this.oldRecentLastDiff = _.cloneDeep(diff);
await bm.setRecentLastDiffRev(lastRev + 1);
}
} finally {
this.savingRecentLastDiff = false;
}
}
async storageCheck(items) {
return await this.storageApi('check', items);
}
async storageGet(items) {
return await this.storageApi('get', items);
}
async storageSet(items, force) {
return await this.storageApi('set', items, force);
}
async storageApi(action, items, force) {
const request = {action, items};
if (force)
request.force = true;
const encodedRequest = await this.encodeStorageItems(request);
return await this.decodeStorageItems(await readerApi.storage(encodedRequest));
}
async encodeStorageItems(request) {
if (!this.hashedStorageKey)
throw new Error('hashedStorageKey is empty');
if (!_.isObject(request.items))
throw new Error('items is not an object');
let result = Object.assign({}, request);
let items = {};
for (const id of Object.keys(request.items)) {
const item = request.items[id];
if (request.action == 'set' && !_.isObject(item.data))
throw new Error('encodeStorageItems: data is not an object');
let encoded = Object.assign({}, item);
if (item.data) {
const comp = utils.pako.deflate(JSON.stringify(item.data), {level: 1});
let encrypted = null;
try {
encrypted = cryptoUtils.aesEncrypt(comp, this.serverStorageKey);
} catch (e) {
throw new Error('encrypt failed');
}
encoded.data = '1' + utils.toBase64(encrypted);
}
items[`${this.hashedStorageKey}.${utils.toBase58(id)}`] = encoded;
}
result.items = items;
return result;
}
async decodeStorageItems(response) {
if (!this.hashedStorageKey)
throw new Error('hashedStorageKey is empty');
let result = Object.assign({}, response);
let items = {};
if (response.items) {
if (!_.isObject(response.items))
throw new Error('items is not an object');
for (const id of Object.keys(response.items)) {
const item = response.items[id];
let decoded = Object.assign({}, item);
if (item.data) {
if (!_.isString(item.data) || !item.data.length)
throw new Error('decodeStorageItems: data is not a string');
if (item.data[0] !== '1')
throw new Error('decodeStorageItems: unknown data format');
const a = utils.fromBase64(item.data.substr(1));
let decrypted = null;
try {
decrypted = cryptoUtils.aesDecrypt(a, this.serverStorageKey);
} catch (e) {
throw new Error('decrypt failed');
}
decoded.data = JSON.parse(utils.pako.inflate(decrypted, {to: 'string'}));
}
const ids = id.split('.');
if (!(ids.length == 2) || !(ids[0] == this.hashedStorageKey))
throw new Error(`decodeStorageItems: bad id - ${id}`);
items[utils.fromBase58(ids[1])] = decoded;
}
}
result.items = items;
return result;
}
}
//-----------------------------------------------------------------------------
</script>

View File

@@ -7,7 +7,107 @@
</template>
<el-tabs type="border-card" tab-position="left" v-model="selectedTab">
<!--------------------------------------------------------------------------->
<!-- Профили ------------------------------------------------------------------------->
<el-tab-pane label="Профили">
<el-form :model="form" size="small" label-width="80px" @submit.native.prevent>
<div class="partHeader">Управление синхронизацией данных</div>
<el-form-item label="">
<el-checkbox v-model="serverSyncEnabled">Включить синхронизацию с сервером</el-checkbox>
</el-form-item>
</el-form>
<div v-show="serverSyncEnabled">
<el-form :model="form" size="small" label-width="80px" @submit.native.prevent>
<div class="partHeader">Профили устройств</div>
<el-form-item label="">
<div class="text">
Выберите или добавьте профиль устройства, чтобы начать синхронизацию настроек с сервером.
<br>При выборе "Нет" синхронизация настроек (но не книг) отключается.
</div>
</el-form-item>
<el-form-item label="Устройство">
<el-select v-model="currentProfile" placeholder="">
<el-option label="Нет" value=""></el-option>
<el-option v-for="item in profilesArray"
:key="item"
:label="item"
:value="item">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="">
<el-button @click="addProfile">Добавить</el-button>
<el-button @click="delProfile">Удалить</el-button>
<el-button @click="delAllProfiles">Удалить все</el-button>
</el-form-item>
</el-form>
<el-form :model="form" size="small" label-width="80px" @submit.native.prevent>
<div class="partHeader">Ключ доступа</div>
<el-form-item label="">
<div class="text">
Ключ доступа позволяет восстановить профили с настройками и список читаемых книг.
Для этого необходимо передать ключ на новое устройство через почту, мессенджер или другим способом.
</div>
</el-form-item>
<el-form-item label="">
<el-button style="width: 250px" @click="showServerStorageKey">
<span v-show="serverStorageKeyVisible">Скрыть</span>
<span v-show="!serverStorageKeyVisible">Показать</span>
ключ доступа
</el-button>
</el-form-item>
<el-form-item label="">
<div v-if="!serverStorageKeyVisible">
<hr/>
<b>{{ partialStorageKey }}</b> (часть вашего ключа)
<hr/>
</div>
<div v-else style="line-height: 100%">
<hr/>
<div style="width: 300px; padding-top: 5px; overflow-wrap: break-word;"><b>{{ serverStorageKey }}</b></div>
<br><div class="center">
<el-button size="mini" class="copy-button" @click="copyToClip(serverStorageKey, 'Ключ')">Скопировать ключ</el-button>
</div>
<div v-if="mode == 'omnireader'">
<br>Переход по ссылке позволит автоматически ввести ключ доступа:
<br><div class="center" style="margin-top: 5px">
<a :href="setStorageKeyLink" target="_blank">Ссылка для ввода ключа</a>
</div>
<br><div class="center">
<el-button size="mini" class="copy-button" @click="copyToClip(setStorageKeyLink, 'Ссылка')">Скопировать ссылку</el-button>
</div>
</div>
<hr/>
</div>
</el-form-item>
<el-form-item label="">
<el-button style="width: 250px" @click="enterServerStorageKey">Ввести ключ доступа</el-button>
</el-form-item>
<el-form-item label="">
<el-button style="width: 250px" @click="generateServerStorageKey">Сгенерировать новый ключ</el-button>
</el-form-item>
<el-form-item label="">
<div class="text">
Рекомендуется сохранить ключ в надежном месте, чтобы всегда иметь возможность восстановить настройки,
например, после переустановки ОС или чистки/смены браузера.<br>
<b>ПРЕДУПРЕЖДЕНИЕ!</b> При утере ключа, НИКТО не сможет восстановить ваши данные, т.к. они сжимаются
и шифруются ключом доступа перед отправкой на сервер.
</div>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
<!-- Вид ------------------------------------------------------------------------->
<el-tab-pane label="Вид">
<el-form :model="form" size="small" label-width="120px" @submit.native.prevent>
@@ -246,7 +346,29 @@
</el-form>
</el-tab-pane>
<!--------------------------------------------------------------------------->
<!-- Кнопки ------------------------------------------------------------------------->
<el-tab-pane label="Кнопки">
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
<div class="partHeader">Показывать кнопки панели</div>
<el-form-item label="" v-for="item in toolButtons" :key="item.name">
<el-checkbox @change="changeShowToolButton(item.name)" :value="showToolButton[item.name]">{{item.text}}</el-checkbox>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- Управление ------------------------------------------------------------------------->
<el-tab-pane label="Управление">
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
<div class="partHeader">Управление</div>
<el-form-item label="">
<el-checkbox v-model="clickControl">Включить управление кликом</el-checkbox>
</el-form-item>
</el-form>
</el-tab-pane>
<!-- Листание ------------------------------------------------------------------------->
<el-tab-pane label="Листание">
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
<div class="partHeader">Анимация</div>
@@ -259,6 +381,8 @@
<el-option label="Вправо-влево" value="rightShift"></el-option>
<el-option label="Протаивание" value="thaw"></el-option>
<el-option label="Мерцание" value="blink"></el-option>
<el-option label="Вращение" value="rotate"></el-option>
<el-option v-show="wallpaper == ''" label="Листание" value="flip"></el-option>
</el-select>
</el-col>
</el-form-item>
@@ -281,14 +405,13 @@
</el-tooltip>
</el-form-item>
</el-form>
</el-tab-pane>
<!--------------------------------------------------------------------------->
<!-- Прочее ------------------------------------------------------------------------->
<el-tab-pane label="Прочее">
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
<el-form-item label="Управление">
<el-checkbox v-model="clickControl">Включить управление кликом</el-checkbox>
</el-form-item>
<div class="partHeader">Подсказки, уведомления</div>
<el-form-item label="Подсказка">
<el-tooltip :open-delay="500" effect="light">
<template slot="content">
@@ -306,7 +429,30 @@
<el-checkbox v-model="blinkCachedLoad">Предупреждать о загрузке из кэша</el-checkbox>
</el-tooltip>
</el-form-item>
<el-form-item label="URL">
<el-form-item label="Уведомление">
<el-tooltip :open-delay="500" effect="light">
<template slot="content">
Показывать уведомления и ошибки от<br>
синхронизатора данных с сервером
</template>
<el-checkbox v-model="showServerStorageMessages">Показывать сообщения синхронизации</el-checkbox>
</el-tooltip>
</el-form-item>
<el-form-item label="Уведомление">
<el-tooltip :open-delay="500" effect="light">
<template slot="content">
Показывать уведомления "Что нового"<br>
при каждом выходе новой версии читалки
</template>
<el-checkbox v-model="showWhatsNewDialog">Показывать уведомление "Что нового"</el-checkbox>
</el-tooltip>
</el-form-item>
</el-form>
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
<div class="partHeader">Прочее</div>
<el-form-item label="Парам. в URL">
<el-tooltip :open-delay="500" effect="light">
<template slot="content">
Добавление параметра "__p" в строке браузера<br>
@@ -340,9 +486,10 @@
</el-form-item>
</el-form>
</el-tab-pane>
<!--------------------------------------------------------------------------->
<!-- Сброс ------------------------------------------------------------------------->
<el-tab-pane label="Сброс">
<el-button @click="setDefaults">Установить по-умолчанию</el-button>
<el-button @click="setDefaults">Установить по умолчанию</el-button>
</el-tab-pane>
</el-tabs>
@@ -355,7 +502,9 @@
//-----------------------------------------------------------------------------
import Vue from 'vue';
import Component from 'vue-class-component';
import _ from 'lodash';
import * as utils from '../../../share/utils';
import Window from '../../share/Window.vue';
import rstore from '../../../store/modules/reader';
@@ -367,6 +516,9 @@ export default @Component({
return Object.assign({}, rstore.settingDefaults);
},
watch: {
settings: function() {
this.settingsChanged();
},
form: function(newValue) {
this.commit('reader/setSettings', newValue);
},
@@ -389,6 +541,10 @@ export default @Component({
const font = (newValue ? newValue : this.fontName);
this.vertShift = this.fontShifts[font] || 0;
},
wallpaper: function(newValue) {
if (newValue != '' && this.pageChangeAnimation == 'flip')
this.pageChangeAnimation = '';
},
},
})
class SettingsPage extends Vue {
@@ -401,10 +557,21 @@ class SettingsPage extends Vue {
webFonts = [];
fonts = [];
serverStorageKeyVisible = false;
toolButtons = [];
created() {
this.commit = this.$store.commit;
this.reader = this.$store.state.reader;
this.form = {};
this.toolButtons = rstore.toolButtons;
this.settingsChanged();
}
settingsChanged() {
if (_.isEqual(this.form, this.settings))
return;
this.form = Object.assign({}, this.settings);
for (let prop in rstore.settingDefaults) {
this[prop] = this.form[prop];
@@ -412,6 +579,7 @@ class SettingsPage extends Vue {
this.form = Object.assign({}, this.form, {[prop]: newValue});
});
}
this.fontBold = (this.fontWeight == 'bold');
this.fontItalic = (this.fontStyle == 'italic');
@@ -421,10 +589,52 @@ class SettingsPage extends Vue {
this.vertShift = this.fontShifts[font] || 0;
}
get mode() {
return this.$store.state.config.mode;
}
get settings() {
return this.$store.state.reader.settings;
}
get serverSyncEnabled() {
return this.$store.state.reader.serverSyncEnabled;
}
set serverSyncEnabled(newValue) {
this.commit('reader/setServerSyncEnabled', newValue);
}
get profiles() {
return this.$store.state.reader.profiles;
}
get profilesArray() {
const result = Object.keys(this.profiles)
result.sort();
return result;
}
get currentProfile() {
return this.$store.state.reader.currentProfile;
}
set currentProfile(newValue) {
this.commit('reader/setCurrentProfile', newValue);
}
get partialStorageKey() {
return this.serverStorageKey.substr(0, 7) + '***';
}
get serverStorageKey() {
return this.$store.state.reader.serverStorageKey;
}
get setStorageKeyLink() {
return `http://${window.location.host}/#/reader?setStorageAccessKey=${utils.toBase58(this.serverStorageKey)}`;
}
get predefineTextColors() {
return [
'#ffffff',
@@ -461,10 +671,10 @@ class SettingsPage extends Vue {
async setDefaults() {
try {
if (await this.$confirm('Подтвердите установку настроек по-умолчанию', '', {
confirmButtonText: 'OK',
cancelButtonText: 'Отмена',
type: 'warning'
if (await this.$confirm('Подтвердите установку настроек по-умолчанию:', '', {
confirmButtonText: 'OK',
cancelButtonText: 'Отмена',
type: 'warning'
})) {
this.form = Object.assign({}, rstore.settingDefaults);
for (let prop in rstore.settingDefaults) {
@@ -476,6 +686,152 @@ class SettingsPage extends Vue {
}
}
changeShowToolButton(buttonName) {
this.showToolButton = Object.assign({}, this.showToolButton, {[buttonName]: !this.showToolButton[buttonName]});
}
async addProfile() {
try {
if (Object.keys(this.profiles).length >= 100) {
this.$alert('Достигнут предел количества профилей', 'Ошибка');
return;
}
const result = await this.$prompt('Введите произвольное название для профиля устройства:', '', {
confirmButtonText: 'OK',
cancelButtonText: 'Отмена',
inputValidator: (str) => { if (!str) return 'Название не должно быть пустым'; else if (str.length > 50) return 'Слишком длинное название'; else return true; },
});
if (result.value) {
if (this.profiles[result.value]) {
this.$alert('Такой профиль уже существует', 'Ошибка');
} else {
const newProfiles = Object.assign({}, this.profiles, {[result.value]: 1});
this.commit('reader/setAllowProfilesSave', true);
await this.$nextTick();//ждем обработчики watch
this.commit('reader/setProfiles', newProfiles);
await this.$nextTick();//ждем обработчики watch
this.commit('reader/setAllowProfilesSave', false);
this.currentProfile = result.value;
}
}
} catch (e) {
//
}
}
async delProfile() {
if (!this.currentProfile)
return;
try {
const result = await this.$prompt(`<b>Предупреждение!</b> Удаление профиля '${this.currentProfile}' необратимо.` +
`<br>Все настройки профиля будут потеряны,<br>однако список читаемых книг сохранится.` +
`<br><br>Введите 'да' для подтверждения удаления:`, '', {
dangerouslyUseHTMLString: true,
confirmButtonText: 'OK',
cancelButtonText: 'Отмена',
inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Удаление не подтверждено'; },
type: 'warning'
});
if (result.value && result.value.toLowerCase() == 'да') {
if (this.profiles[this.currentProfile]) {
const newProfiles = Object.assign({}, this.profiles);
delete newProfiles[this.currentProfile];
this.commit('reader/setAllowProfilesSave', true);
await this.$nextTick();//ждем обработчики watch
this.commit('reader/setProfiles', newProfiles);
await this.$nextTick();//ждем обработчики watch
this.commit('reader/setAllowProfilesSave', false);
this.currentProfile = '';
}
}
} catch (e) {
//
}
}
async delAllProfiles() {
if (!Object.keys(this.profiles).length)
return;
try {
const result = await this.$prompt(`<b>Предупреждение!</b> Удаление ВСЕХ профилей с настройками необратимо.` +
`<br><br>Введите 'да' для подтверждения удаления:`, '', {
dangerouslyUseHTMLString: true,
confirmButtonText: 'OK',
cancelButtonText: 'Отмена',
inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Удаление не подтверждено'; },
type: 'warning'
});
if (result.value && result.value.toLowerCase() == 'да') {
this.commit('reader/setAllowProfilesSave', true);
await this.$nextTick();//ждем обработчики watch
this.commit('reader/setProfiles', {});
await this.$nextTick();//ждем обработчики watch
this.commit('reader/setAllowProfilesSave', false);
this.currentProfile = '';
}
} catch (e) {
//
}
}
async copyToClip(text, prefix) {
const result = await utils.copyTextToClipboard(text);
const suf = (prefix.substr(-1) == 'а' ? 'а' : '');
const msg = (result ? `${prefix} успешно скопирован${suf} в буфер обмена` : 'Копирование не удалось');
if (result)
this.$notify.success({message: msg});
else
this.$notify.error({message: msg});
}
async showServerStorageKey() {
this.serverStorageKeyVisible = !this.serverStorageKeyVisible;
}
async enterServerStorageKey(key) {
try {
const result = await this.$prompt(`<b>Предупреждение!</b> Изменение ключа доступа приведет к замене всех профилей и читаемых книг в читалке.` +
`<br><br>Введите новый ключ доступа:`, '', {
dangerouslyUseHTMLString: true,
confirmButtonText: 'OK',
cancelButtonText: 'Отмена',
inputValidator: (str) => { if (str && utils.fromBase58(str).length == 32) return true; else return 'Неверный формат ключа'; },
inputValue: (key && _.isString(key) ? key : null),
type: 'warning'
});
if (result.value && utils.fromBase58(result.value).length == 32) {
this.commit('reader/setServerStorageKey', result.value);
}
} catch (e) {
//
}
}
async generateServerStorageKey() {
try {
const result = await this.$prompt(`<b>Предупреждение!</b> Генерация нового ключа доступа приведет к удалению всех профилей и читаемых книг в читалке.` +
`<br><br>Введите 'да' для подтверждения генерации нового ключа:`, '', {
dangerouslyUseHTMLString: true,
confirmButtonText: 'OK',
cancelButtonText: 'Отмена',
inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Генерация не подтверждена'; },
type: 'warning'
});
if (result.value && result.value.toLowerCase() == 'да') {
this.$root.$emit('generateNewServerStorageKey');
}
} catch (e) {
//
}
}
keyHook(event) {
if (event.type == 'keydown' && event.code == 'Escape') {
this.close();
@@ -504,6 +860,11 @@ class SettingsPage extends Vue {
position: relative;
}
.text {
font-size: 90%;
line-height: 130%;
}
.el-form {
border-top: 2px solid #bbbbbb;
margin-bottom: 5px;
@@ -540,4 +901,7 @@ class SettingsPage extends Vue {
padding: 15px;
}
.center {
text-align: center;
}
</style>

View File

@@ -28,7 +28,7 @@ export default class DrawHelper {
let out = `<div style="width: ${this.w}px; height: ${this.h + (isScrolling ? this.lineHeight : 0)}px;` +
` position: absolute; top: ${this.fontSize*this.textShift}px; color: ${this.textColor}; font: ${font}; ${justify}` +
` line-height: ${this.lineHeight}px; white-space: nowrap; overflow: hidden;">`;
` line-height: ${this.lineHeight}px; white-space: nowrap;">`;
let imageDrawn = new Set();
let len = lines.length;
@@ -317,4 +317,58 @@ export default class DrawHelper {
await animation1Finish(duration);
}
}
async doPageAnimationRotate(page1, page2, duration, isDown, animation1Finish, animation2Finish) {
if (isDown) {
page1.style.transform = `rotateY(90deg)`;
await sleep(30);
page2.style.transition = `${duration/2}ms ease-in`;
page2.style.transform = `rotateY(-90deg)`;
await animation2Finish(duration/2);
page1.style.transition = `${duration/2}ms ease-out`;
page1.style.transform = `rotateY(0deg)`;
await animation1Finish(duration/2);
} else {
page1.style.transform = `rotateY(-90deg)`;
await sleep(30);
page2.style.transition = `${duration/2}ms ease-in`;
page2.style.transform = `rotateY(90deg)`;
await animation2Finish(duration/2);
page1.style.transition = `${duration/2}ms ease-out`;
page1.style.transform = `rotateY(0deg)`;
await animation1Finish(duration/2);
}
}
async doPageAnimationFlip(page1, page2, duration, isDown, animation1Finish, animation2Finish, backgroundColor) {
page2.style.background = backgroundColor;
if (isDown) {
page2.style.transformOrigin = '10%';
await sleep(30);
page2.style.transformOrigin = '0%';
page2.style.transition = `${duration}ms ease-in-out`;
page2.style.transform = `rotateY(-120deg)`;
await animation2Finish(duration);
} else {
page2.style.transformOrigin = '90%';
await sleep(30);
page2.style.transformOrigin = '100%';
page2.style.transition = `${duration}ms ease-in-out`;
page2.style.transform = `rotateY(120deg)`;
await animation2Finish(duration);
}
page2.style.transformOrigin = 'center';
page2.style.background = '';
}
}

View File

@@ -4,13 +4,13 @@
<div v-html="background"></div>
<!-- img -->
</div>
<div ref="scrollBox1" class="layout" style="overflow: hidden" @wheel.prevent.stop="onMouseWheel">
<div ref="scrollingPage1" class="layout" @transitionend="onPage1TransitionEnd" @animationend="onPage1AnimationEnd">
<div ref="scrollBox1" class="layout over-hidden" @wheel.prevent.stop="onMouseWheel">
<div ref="scrollingPage1" class="layout over-hidden" @transitionend="onPage1TransitionEnd" @animationend="onPage1AnimationEnd">
<div v-html="page1"></div>
</div>
</div>
<div ref="scrollBox2" class="layout" style="overflow: hidden" @wheel.prevent.stop="onMouseWheel">
<div ref="scrollingPage2" class="layout" @transitionend="onPage2TransitionEnd" @animationend="onPage2AnimationEnd">
<div ref="scrollBox2" class="layout over-hidden" @wheel.prevent.stop="onMouseWheel">
<div ref="scrollingPage2" class="layout over-hidden" @transitionend="onPage2TransitionEnd" @animationend="onPage2AnimationEnd">
<div v-html="page2"></div>
</div>
</div>
@@ -23,7 +23,6 @@
oncontextmenu="return false;">
<div v-show="showStatusBar" v-html="statusBarClickable" @mousedown.prevent.stop @touchstart.stop
@click.prevent.stop="onStatusBarClick"></div>
<div v-show="fontsLoading" ref="fontsLoading"></div>
</div>
<div v-show="!clickControl && showStatusBar" class="layout" v-html="statusBarClickable" @mousedown.prevent.stop @touchstart.stop
@click.prevent.stop="onStatusBarClick"></div>
@@ -77,7 +76,6 @@ class TextPage extends Vue {
page2 = null;
statusBar = null;
statusBarClickable = null;
fontsLoading = null;
lastBook = null;
bookPos = 0;
@@ -157,9 +155,10 @@ class TextPage extends Vue {
this.$refs.layoutEvents.style.height = this.realHeight + 'px';
this.w = this.realWidth - 2*this.indentLR;
this.h = this.realHeight - (this.showStatusBar ? this.statusBarHeight : 0) - 2*this.indentTB;
this.scrollHeight = this.realHeight - (this.showStatusBar ? this.statusBarHeight : 0);
this.h = this.scrollHeight - 2*this.indentTB;
this.lineHeight = this.fontSize + this.lineInterval;
this.pageLineCount = 1 + Math.floor((this.h - this.fontSize)/this.lineHeight);
this.pageLineCount = 1 + Math.floor((this.h - this.lineHeight + this.lineInterval/2)/this.lineHeight);
this.$refs.scrollingPage1.style.width = this.w + 'px';
this.$refs.scrollingPage2.style.width = this.w + 'px';
@@ -170,6 +169,13 @@ class TextPage extends Vue {
this.fontShift = this.fontVertShift/100;
this.textShift = this.textVertShift/100 + this.fontShift;
//statusBar
this.$refs.statusBar.style.left = '0px';
this.$refs.statusBar.style.top = (this.statusBarTop ? 1 : this.realHeight - this.statusBarHeight) + 'px';
this.statusBarColor = this.hex2rgba(this.textColor || '#000000', this.statusBarColorAlpha);
this.statusBarClickable = this.drawHelper.statusBarClickable(this.statusBarTop, this.statusBarHeight);
//drawHelper
this.drawHelper.realWidth = this.realWidth;
this.drawHelper.realHeight = this.realHeight;
@@ -195,15 +201,6 @@ class TextPage extends Vue {
this.drawHelper.lineHeight = this.lineHeight;
this.drawHelper.context = this.context;
//сообщение "Загрузка шрифтов..."
const flText = 'Загрузка шрифта...';
this.$refs.fontsLoading.innerHTML = flText;
const fontsLoadingStyle = this.$refs.fontsLoading.style;
fontsLoadingStyle.position = 'absolute';
fontsLoadingStyle.fontSize = this.fontSize + 'px';
fontsLoadingStyle.top = (this.realHeight/2 - 2*this.fontSize) + 'px';
fontsLoadingStyle.left = (this.realWidth - this.drawHelper.measureText(flText, {}))/2 + 'px';
//parsed
if (this.parsed) {
this.parsed.p = this.p;
@@ -224,28 +221,32 @@ class TextPage extends Vue {
this.parsed.imageFitWidth = this.imageFitWidth;
}
//statusBar
this.$refs.statusBar.style.left = '0px';
this.$refs.statusBar.style.top = (this.statusBarTop ? 1 : this.realHeight - this.statusBarHeight) + 'px';
this.statusBarColor = this.hex2rgba(this.textColor || '#000000', this.statusBarColorAlpha);
this.statusBarClickable = this.drawHelper.statusBarClickable(this.statusBarTop, this.statusBarHeight);
//scrolling page
const pageDelta = this.h - (this.pageLineCount*this.lineHeight - this.lineInterval);
let y = this.indentTB + pageDelta/2;
const pageSpace = this.scrollHeight - this.pageLineCount*this.lineHeight;
let y = pageSpace/2;
if (this.showStatusBar)
y += this.statusBarHeight*(this.statusBarTop ? 1 : 0);
const page1 = this.$refs.scrollBox1;
const page2 = this.$refs.scrollBox2;
page1.style.width = this.w + 'px';
page2.style.width = this.w + 'px';
page1.style.height = (this.h - pageDelta) + 'px';
page2.style.height = (this.h - pageDelta) + 'px';
let page1 = this.$refs.scrollBox1;
let page2 = this.$refs.scrollBox2;
page1.style.perspective = '3072px';
page2.style.perspective = '3072px';
page1.style.width = this.w + this.indentLR + 'px';
page2.style.width = this.w + this.indentLR + 'px';
page1.style.height = this.scrollHeight - (pageSpace > 0 ? pageSpace : 0) + 'px';
page2.style.height = this.scrollHeight - (pageSpace > 0 ? pageSpace : 0) + 'px';
page1.style.top = y + 'px';
page2.style.top = y + 'px';
page1.style.left = this.indentLR + 'px';
page2.style.left = this.indentLR + 'px';
page1 = this.$refs.scrollingPage1;
page2 = this.$refs.scrollingPage2;
page1.style.width = this.w + this.indentLR + 'px';
page2.style.width = this.w + this.indentLR + 'px';
page1.style.height = this.scrollHeight + this.lineHeight + 'px';
page2.style.height = this.scrollHeight + this.lineHeight + 'px';
}
async checkLoadedFonts() {
@@ -260,6 +261,18 @@ class TextPage extends Vue {
async loadFonts() {
this.fontsLoading = true;
let inst = null;
(async() => {
await sleep(500);
if (this.fontsLoading)
inst = this.$notify({
title: '',
dangerouslyUseHTMLString: true,
message: 'Загрузка шрифта &nbsp;<i class="el-icon-loading"></i>',
duration: 0
});
})();
if (!this.fontsLoaded)
this.fontsLoaded = {};
//загрузка дин.шрифта
@@ -290,6 +303,8 @@ class TextPage extends Vue {
}
this.fontsLoading = false;
if (inst)
inst.close();
}
getSettings() {
@@ -369,13 +384,17 @@ class TextPage extends Vue {
this.meta = bookManager.metaOnly(this.book);
this.fb2 = this.meta.fb2;
const authorName = _.compact([
this.fb2.lastName,
this.fb2.firstName,
this.fb2.middleName
]).join(' ');
let authorNames = [];
if (this.fb2.author) {
authorNames = this.fb2.author.map(a => _.compact([
a.lastName,
a.firstName,
a.middleName
]).join(' '));
}
this.title = _.compact([
authorName,
authorNames.join(', '),
this.fb2.bookTitle
]).join(' - ');
@@ -613,7 +632,7 @@ class TextPage extends Vue {
const animation1Finish = this.generateWaitingFunc('resolveAnimation1Finish', 'stopAnimation');
const animation2Finish = this.generateWaitingFunc('resolveAnimation2Finish', 'stopAnimation');
const transition1Finish = this.generateWaitingFunc('resolveTransition1Finish', 'stopAnimation');
//const transition2Finish = this.generateWaitingFunc('resolveTransition2Finish', 'stopAnimation');
const transition2Finish = this.generateWaitingFunc('resolveTransition2Finish', 'stopAnimation');
const duration = Math.round(3000*(1 - this.pageChangeAnimationSpeed/100));
let page1 = this.$refs.scrollingPage1;
@@ -633,8 +652,22 @@ class TextPage extends Vue {
duration, this.pageChangeDirectionDown, transition1Finish);
break;
case 'downShift':
page1.style.height = this.scrollHeight + 'px';
page2.style.height = this.scrollHeight + 'px';
await this.drawHelper.doPageAnimationDownShift(page1, page2,
duration, this.pageChangeDirectionDown, transition1Finish);
page1.style.height = this.scrollHeight + this.lineHeight + 'px';
page2.style.height = this.scrollHeight + this.lineHeight + 'px';
break;
case 'rotate':
await this.drawHelper.doPageAnimationRotate(page1, page2,
duration, this.pageChangeDirectionDown, transition1Finish, transition2Finish);
break;
case 'flip':
await this.drawHelper.doPageAnimationFlip(page1, page2,
duration, this.pageChangeDirectionDown, transition1Finish, transition2Finish, this.backgroundColor);
break;
}
@@ -1098,6 +1131,14 @@ class TextPage extends Vue {
z-index: 10;
}
.over-hidden {
overflow: hidden;
}
.on-top {
z-index: 100;
}
.back {
z-index: 5;
}
@@ -1163,4 +1204,5 @@ class TextPage extends Vue {
0% { opacity: 1; }
100% { opacity: 0; }
}
</style>

View File

@@ -201,6 +201,12 @@ export default class BookParser {
}
}
if (elemName == 'author' && path.indexOf('/fictionbook/description/title-info/author') == 0) {
if (!fb2.author)
fb2.author = [];
fb2.author.push({});
}
if (path.indexOf('/fictionbook/body') == 0) {
if (tag == 'body') {
if (!isFirstBody)
@@ -319,15 +325,19 @@ export default class BookParser {
text = text.replace(/[\t\n\r\xa0]/g, ' ');
const authorLength = (fb2.author && fb2.author.length ? fb2.author.length : 0);
switch (path) {
case '/fictionbook/description/title-info/author/first-name':
fb2.firstName = text;
if (authorLength)
fb2.author[authorLength - 1].firstName = text;
break;
case '/fictionbook/description/title-info/author/middle-name':
fb2.middleName = text;
if (authorLength)
fb2.author[authorLength - 1].middleName = text;
break;
case '/fictionbook/description/title-info/author/last-name':
fb2.lastName = text;
if (authorLength)
fb2.author[authorLength - 1].lastName = text;
break;
case '/fictionbook/description/title-info/genre':
fb2.genre = text;

View File

@@ -1,9 +1,10 @@
import localForage from 'localforage';
import _ from 'lodash';
import * as utils from '../../../share/utils';
import BookParser from './BookParser';
const maxDataSize = 500*1024*1024;//chars, not bytes
const maxDataSize = 300*1024*1024;//compressed bytes
const bmMetaStore = localForage.createInstance({
name: 'bmMetaStore'
@@ -25,6 +26,8 @@ class BookManager {
async init(settings) {
this.settings = settings;
this.eventListeners = [];
//bmCacheStore нужен только для ускорения загрузки читалки
this.booksCached = await bmCacheStore.getItem('books');
if (!this.booksCached)
@@ -33,10 +36,11 @@ class BookManager {
this.recentLast = await bmCacheStore.getItem('recent-last');
if (this.recentLast)
this.recent[this.recentLast.key] = this.recentLast;
this.recentRev = await bmRecentStore.getItem('recent-rev') || 0;
this.recentLastRev = await bmRecentStore.getItem('recent-last-rev') || 0;
this.recentLastDiffRev = await bmRecentStore.getItem('recent-last-diff-rev') || 0;
this.books = Object.assign({}, this.booksCached);
this.recentChanged1 = true;
this.recentChanged2 = true;
if (!this.books || !this.recent) {
@@ -63,25 +67,48 @@ class BookManager {
if (keySplit.length == 2 && keySplit[0] == 'bmMeta') {
let meta = await bmMetaStore.getItem(key);
const oldBook = this.books[meta.key];
this.books[meta.key] = meta;
if (_.isObject(meta)) {
const oldBook = this.books[meta.key];
this.books[meta.key] = meta;
if (oldBook && oldBook.data && oldBook.parsed) {
this.books[meta.key].data = oldBook.data;
this.books[meta.key].parsed = oldBook.parsed;
if (oldBook && oldBook.parsed) {
this.books[meta.key].parsed = oldBook.parsed;
}
} else {
await bmMetaStore.removeItem(key);
}
}
}
len = await bmRecentStore.length();
for (let i = 0; i < len; i++) {
const key = await bmRecentStore.key(i);
let r = await bmRecentStore.getItem(key);
this.recent[r.key] = r;
}
//"ленивая" загрузка
(async() => {
let key = null;
len = await bmRecentStore.length();
for (let i = 0; i < len; i++) {
key = await bmRecentStore.key(i);
if (key) {
let r = await bmRecentStore.getItem(key);
if (_.isObject(r) && r.key) {
this.recent[r.key] = r;
}
} else {
await bmRecentStore.removeItem(key);
}
}
})();
//размножение для дебага
/*if (key) {
for (let i = 0; i < 1000; i++) {
const k = this.keyFromUrl(i.toString());
this.recent[k] = Object.assign({}, _.cloneDeep(this.recent[key]), {key: k, touchTime: Date.now() - 1000000, url: utils.randomHexString(300)});
}
}*/
await this.cleanBooks();
await this.cleanRecentBooks();
//очистка позже
//await this.cleanRecentBooks();
this.booksCached = {};
for (const key in this.books) {
@@ -89,6 +116,7 @@ class BookManager {
}
await bmCacheStore.setItem('books', this.booksCached);
await bmCacheStore.setItem('recent', this.recent);
this.emit('load-meta-finish');
}
async cleanBooks() {
@@ -98,7 +126,8 @@ class BookManager {
let toDel = null;
for (let key in this.books) {
let book = this.books[key];
size += (book.length ? book.length : 0);
const bookLength = (book.length ? book.length : 0);
size += (book.dataCompressedLength ? book.dataCompressedLength : bookLength);
if (book.addTime < min) {
toDel = book;
@@ -107,13 +136,41 @@ class BookManager {
}
if (size > maxDataSize && toDel) {
await this.delBook(toDel);
await this._delBook(toDel);
} else {
break;
}
}
}
async deflateWithProgress(data, callback) {
const chunkSize = 16384;
const deflator = new utils.pako.Deflate({level: 9});
let chunkTotal = 1 + Math.floor(data.length/chunkSize);
let chunkNum = 0;
for (var i = 0; i < data.length; i += chunkSize) {
if ((i + chunkSize) >= data.length) {
deflator.push(data.substring(i, i + chunkSize), true);
} else {
deflator.push(data.substring(i, i + chunkSize), false);
}
chunkNum++;
callback(Math.round(chunkNum/chunkTotal*100));
await utils.sleep(1);
}
if (deflator.err) {
throw new Error(deflator.msg);
}
callback(100);
return deflator.result;
}
async addBook(newBook, callback) {
if (!this.books)
await this.init();
@@ -121,15 +178,35 @@ class BookManager {
meta.key = this.keyFromUrl(meta.url);
meta.addTime = Date.now();
const result = await this.parseBook(meta, newBook.data, callback);
const cb = (perc) => {
const p = Math.round(30*perc/100);
callback(p);
};
const cb2 = (perc) => {
const p = Math.round(30 + 65*perc/100);
callback(p);
};
const result = await this.parseBook(meta, newBook.data, cb);
result.dataCompressed = true;
let data = newBook.data;
if (result.dataCompressed) {
//data = utils.pako.deflate(data, {level: 9});
data = await this.deflateWithProgress(data, cb2);
result.dataCompressedLength = data.byteLength;
}
callback(95);
this.books[meta.key] = result;
this.booksCached[meta.key] = this.metaOnly(result);
await bmMetaStore.setItem(`bmMeta-${meta.key}`, this.metaOnly(result));
await bmDataStore.setItem(`bmData-${meta.key}`, result.data);
await bmDataStore.setItem(`bmData-${meta.key}`, data);
await bmCacheStore.setItem('books', this.booksCached);
callback(100);
return result;
}
@@ -150,30 +227,49 @@ class BookManager {
let result = undefined;
if (!meta.key)
meta.key = this.keyFromUrl(meta.url);
result = this.books[meta.key];
if (result && !result.data) {
result.data = await bmDataStore.getItem(`bmData-${meta.key}`);
this.books[meta.key] = result;
}
if (result && !result.parsed) {
result = await this.parseBook(result, result.data, callback);
let data = await bmDataStore.getItem(`bmData-${meta.key}`);
callback(10);
await utils.sleep(10);
if (result.dataCompressed) {
try {
data = utils.pako.inflate(data, {to: 'string'});
} catch (e) {
this.delBook(meta);
throw e;
}
}
callback(20);
const cb = (perc) => {
const p = 20 + Math.round(80*perc/100);
callback(p);
};
result = await this.parseBook(result, data, cb);
this.books[meta.key] = result;
}
return result;
}
async delBook(meta) {
if (!this.books)
await this.init();
async _delBook(meta) {
await bmMetaStore.removeItem(`bmMeta-${meta.key}`);
await bmDataStore.removeItem(`bmData-${meta.key}`);
delete this.books[meta.key];
delete this.booksCached[meta.key];
}
async delBook(meta) {
if (!this.books)
await this.init();
await this._delBook(meta);
await bmCacheStore.setItem('books', this.booksCached);
}
@@ -188,7 +284,6 @@ class BookManager {
const result = Object.assign({}, meta, parsedMeta, {
length: data.length,
textLength: parsed.textLength,
data,
parsed
});
@@ -197,7 +292,7 @@ class BookManager {
metaOnly(book) {
let result = Object.assign({}, book);
delete result.data;
delete result.data;//можно будет убрать эту строку со временем
delete result.parsed;
return result;
}
@@ -206,29 +301,40 @@ class BookManager {
return utils.stringToHex(url);
}
async setRecentBook(value, noTouch) {
async setRecentBook(value) {
if (!this.recent)
await this.init();
const result = this.metaOnly(value);
if (!noTouch)
Object.assign(result, {touchTime: Date.now()});
result.touchTime = Date.now();
result.deleted = 0;
if (result.textLength && !result.bookPos && result.bookPosPercent)
result.bookPos = Math.round(result.bookPosPercent*result.textLength);
if (this.recent[result.key] && this.recent[result.key].deleted) {
//восстановим из небытия пользовательские данные
if (!result.bookPos)
result.bookPos = this.recent[result.key].bookPos;
if (!result.bookPosSeen)
result.bookPosSeen = this.recent[result.key].bookPosSeen;
}
this.recent[result.key] = result;
await bmRecentStore.setItem(result.key, result);
//кэшируем, аккуратно
let saveRecent = false;
if (!(this.recentLast && this.recentLast.key == result.key)) {
await bmCacheStore.setItem('recent', this.recent);
saveRecent = true;
}
this.recentLast = result;
await bmCacheStore.setItem('recent-last', this.recentLast);
this.recentChanged1 = true;
this.mostRecentCached = result;
this.recentChanged2 = true;
if (saveRecent)
this.emit('save-recent');
this.emit('recent-changed');
return result;
}
@@ -242,38 +348,37 @@ class BookManager {
if (!this.recent)
await this.init();
await bmRecentStore.removeItem(value.key);
delete this.recent[value.key];
this.recent[value.key].deleted = 1;
await bmRecentStore.setItem(value.key, this.recent[value.key]);
await bmCacheStore.setItem('recent', this.recent);
this.recentChanged1 = true;
this.mostRecentCached = null;
this.recentChanged2 = true;
this.emit('save-recent');
}
async cleanRecentBooks() {
if (!this.recent)
await this.init();
if (Object.keys(this.recent).length > 1000) {
let min = Date.now();
let found = null;
for (let key in this.recent) {
const book = this.recent[key];
if (book.touchTime < min) {
min = book.touchTime;
found = book;
}
}
const sorted = this.getSortedRecent();
if (found) {
await this.delRecentBook(found);
await this.cleanRecentBooks();
}
let isDel = false;
for (let i = 1000; i < sorted.length; i++) {
await bmRecentStore.removeItem(sorted[i].key);
delete this.recent[sorted[i].key];
isDel = true;
}
this.sortedRecentCached = null;
await bmCacheStore.setItem('recent', this.recent);
return isDel;
}
mostRecentBook() {
if (!this.recentChanged1 && this.mostRecentCached) {
if (this.mostRecentCached) {
return this.mostRecentCached;
}
@@ -281,13 +386,12 @@ class BookManager {
let result = null;
for (let key in this.recent) {
const book = this.recent[key];
if (book.touchTime > max) {
if (!book.deleted && book.touchTime > max) {
max = book.touchTime;
result = book;
}
}
this.mostRecentCached = result;
this.recentChanged1 = false;
return result;
}
@@ -305,6 +409,89 @@ class BookManager {
return result;
}
async setRecent(value) {
const mergedRecent = _.cloneDeep(this.recent);
Object.assign(mergedRecent, value);
const newRecent = {};
//"ленивое" обновление хранилища
(async() => {
for (const rec of Object.values(mergedRecent)) {
if (rec.key) {
await bmRecentStore.setItem(rec.key, rec);
await utils.sleep(1);
}
}
})();
for (const rec of Object.values(mergedRecent)) {
if (rec.key) {
newRecent[rec.key] = rec;
}
}
this.recent = newRecent;
await bmCacheStore.setItem('recent', this.recent);
this.recentLast = null;
await bmCacheStore.setItem('recent-last', this.recentLast);
this.mostRecentCached = null;
this.emit('recent-changed');
}
async setRecentRev(value) {
await bmRecentStore.setItem('recent-rev', value);
this.recentRev = value;
}
async setRecentLast(value) {
if (!value.key)
value = null;
this.recentLast = value;
await bmCacheStore.setItem('recent-last', this.recentLast);
if (value && value.key) {
//гарантия переключения книги
const mostRecent = this.mostRecentBook();
if (mostRecent)
this.recent[mostRecent.key].touchTime = value.touchTime - 1;
this.recent[value.key] = value;
await bmRecentStore.setItem(value.key, value);
await bmCacheStore.setItem('recent', this.recent);
}
this.mostRecentCached = null;
this.emit('recent-changed');
}
async setRecentLastRev(value) {
await bmRecentStore.setItem('recent-last-rev', value);
this.recentLastRev = value;
}
async setRecentLastDiffRev(value) {
await bmRecentStore.setItem('recent-last-diff-rev', value);
this.recentLastDiffRev = value;
}
addEventListener(listener) {
if (this.eventListeners.indexOf(listener) < 0)
this.eventListeners.push(listener);
}
removeEventListener(listener) {
const i = this.eventListeners.indexOf(listener);
if (i >= 0)
this.eventListeners.splice(i, 1);
}
emit(eventName, value) {
for (const listener of this.eventListeners)
listener(eventName, value);
}
}

View File

@@ -1,70 +0,0 @@
export default async function restoreOldSettings(settings, bookManager, commit) {
const oldSets = localStorage['colorSetting'];
let isOld = false;
for (let i = 0; i < localStorage.length; i++) {
let key = unescape(localStorage.key(i));
if (key.indexOf('bpr-book-') == 0)
isOld = true;
}
if (isOld || oldSets) {
let newSettings = null;
if (oldSets) {
const [textColor, backgroundColor, lineStep, , , statusBarHeight, scInt] = unescape(oldSets).split('|');
const fontSize = Math.round(lineStep*0.8);
const scrollingDelay = fontSize*scInt;
newSettings = Object.assign({}, settings, {
textColor,
backgroundColor,
fontSize,
statusBarHeight: statusBarHeight*1,
scrollingDelay,
});
}
for (let i = 0; i < localStorage.length; i++) {
let key = localStorage.key(i);
if (key.indexOf('bpr-') == 0) {
let v = unescape(localStorage[key]);
key = unescape(key);
if (key.lastIndexOf('=timestamp') == key.length - 10) {
continue;
}
if (key.indexOf('bpr-book-') == 0) {
const url = key.substr(9);
const [scrollTop, scrollHeight, ] = v.split('|');
const bookPosPercent = scrollTop*1/(scrollHeight*1 + 1);
const title = unescape(localStorage[`bpr-title-${escape(url)}`]);
const author = unescape(localStorage[`bpr-author-${escape(url)}`]);
const time = unescape(localStorage[`bpr-book-${escape(url)}=timestamp`]).split(';')[0];
const touchTime = Date.parse(time);
const bookKey = bookManager.keyFromUrl(url);
const recent = await bookManager.getRecentBook({key: bookKey});
if (!recent) {
await bookManager.setRecentBook({
key: bookKey,
touchTime,
bookPosPercent,
url,
fb2: {
bookTitle: title,
lastName: author,
}
}, true);
}
}
}
}
localStorage.clear();
if (oldSets)
commit('reader/setSettings', newSettings);
}
}

View File

@@ -0,0 +1,113 @@
export const versionHistory = [
{
showUntil: '2019-06-22',
header: '0.6.8 (2019-06-23)',
content:
`
<ul>
<li>исправлен баг - падение сервера при распаковке битых архивов книг</li>
<li>исправлен баг - не распознавались некоторые книги формата fb2 в кодировке utf8</li>
<li>добавлены новые варианты анимации перелистывания</li>
<li>на страницу загрузки добавлен блок "Поделиться"</li>
<li>улучшены прогрессбары</li>
<li>исправления недочетов</li>
</ul>
`
},
{
showUntil: '2019-06-05',
header: '0.6.7 (2019-05-30)',
content:
`
<ul>
<li>добавлен диалог "Что нового"</li>
<li>в справку добавлена история версий проекта</li>
<li>добавлена возможность настройки отображаемых кнопок на панели управления</li>
<li>некоторые кнопки на панели управления были скрыты по умолчанию</li>
<li>на страницу загрузки добавлена возможность загрузки книги из буфера обмена</li>
<li>добавлен GET-параметр вида "/reader?__refresh=1&url=..." для принудительного обновления загружаемого текста</li>
<li>добавлен GET-параметр вида "/reader?__pp=50.5&url=..." для указания позиции в книге в процентах</li>
<li>исправления багов и недочетов</li>
</ul>
`
},
{
showUntil: '2019-03-29',
header: '0.6.6 (2019-03-29)',
content:
`
<ul>
<li>в справку добавлено описание настройки браузеров для автономной работы читалки (без доступа к интернету)</li>
<li>оптимизации процесса синхронизации, внутренние переделки</li>
</ul>
`
},
{
showUntil: '2019-03-24',
header: '0.6.4 (2019-03-24)',
content:
`
<ul>
<li>исправления багов, оптимизации</li>
<li>добавлена возможность синхронизации данных между устройствами</li>
</ul>
`
},
{
showUntil: '2019-03-04',
header: '0.5.4 (2019-03-04)',
content:
`
<ul>
<li>добавлена поддержка форматов pdf, epub, mobi</li>
<li>(0.5.2) добавлена поддержка форматов rtf, doc, docx</li>
<li>(0.4.2) фильтр для СИ больше не вырезает изображения</li>
<li>(0.4.0) добавлено отображение картинок в fb2</li>
</ul>
`
},
{
showUntil: '2019-02-17',
header: '0.3.0 (2019-02-17)',
content:
`
<ul>
<li>поправки багов</li>
<li>улучшено распознавание текста</li>
<li>изменена верстка страницы - убрано позиционирование каждого слова</li>
</ul>
`
},
{
showUntil: '2019-02-14',
header: '0.1.7 (2019-02-14)',
content:
`
<ul>
<li>увеличены верхние границы отступов и др.размеров</li>
<li>добавлена настройка для удаления/вставки пустых параграфов</li>
<li>добавлена настройка включения/отключения управления кликом</li>
<li>добавлена возможность сброса настроек</li>
<li>убран автоматический редирект на последнюю загруженную книгу, если не задан url в маршруте</li>
</ul>
`
},
{
showUntil: '2019-02-12',
header: '0.1.0 (2019-02-12)',
content:
`
<ul>
<li>первый деплой проекта, длительность разработки - 2 месяца</li>
</ul>
`
},
]

View File

@@ -86,6 +86,9 @@ import './theme/form-item.css';
import ElColorPicker from 'element-ui/lib/color-picker';
import './theme/color-picker.css';
import ElDialog from 'element-ui/lib/dialog';
import './theme/dialog.css';
import Notification from 'element-ui/lib/notification';
import './theme/notification.css';
@@ -95,12 +98,15 @@ import './theme/loading.css';
import MessageBox from 'element-ui/lib/message-box';
import './theme/message-box.css';
//import Message from 'element-ui/lib/message';
//import './theme/message.css';
const components = {
ElMenu, ElMenuItem, ElButton, ElButtonGroup, ElCheckbox, ElTabs, ElTabPane, ElTooltip,
ElCol, ElContainer, ElAside, ElMain, ElHeader,
ElInput, ElInputNumber, ElSelect, ElOption, ElTable, ElTableColumn,
ElProgress, ElSlider, ElForm, ElFormItem,
ElColorPicker,
ElColorPicker, ElDialog,
};
for (let name in components) {

View File

@@ -9,5 +9,6 @@
</head>
<body>
<div id="app"></div>
<script src="https://yastatic.net/share2/share.js" async="async"></script>
</body>
</html>

View File

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

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

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

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

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

View File

@@ -1,21 +1,34 @@
import _ from 'lodash';
import baseX from 'base-x';
import PAKO from 'pako';
import {Buffer} from 'safe-buffer';
import sjclWrapper from './sjclWrapper';
export const pako = PAKO;
const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const bs58 = baseX(BASE58);
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function stringToHex(str) {
let result = '';
for (let i = 0; i < str.length; i++) {
result += str.charCodeAt(i).toString(16);
}
return result;
return Buffer.from(str).toString('hex');
}
export function hexToString(str) {
let result = '';
for (let i = 0; i < str.length; i += 2) {
result += String.fromCharCode(parseInt(str.substr(i, 2), 16));
}
return result;
return Buffer.from(str, 'hex').toString();
}
export function randomArray(len) {
const a = new Uint8Array(len);
window.crypto.getRandomValues(a);
return a;
}
export function randomHexString(len) {
return Buffer.from(randomArray(len)).toString('hex');
}
export function formatDate(d, format) {
@@ -26,6 +39,10 @@ export function formatDate(d, format) {
case 'normal':
return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.${d.getFullYear()} ` +
`${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
case 'coDate':
return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
case 'noDate':
return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.${d.getFullYear()}`;
}
}
@@ -62,4 +79,90 @@ export async function copyTextToClipboard(text) {
}
return result;
}
}
export function toBase58(data) {
return bs58.encode(Buffer.from(data));
}
export function fromBase58(data) {
return bs58.decode(data);
}
//base-x слишком тормозит, используем sjcl
export function toBase64(data) {
return sjclWrapper.codec.base64.fromBits(
sjclWrapper.codec.bytes.toBits(Buffer.from(data))
);
}
//base-x слишком тормозит, используем sjcl
export function fromBase64(data) {
return Buffer.from(sjclWrapper.codec.bytes.fromBits(
sjclWrapper.codec.base64.toBits(data)
));
}
export function getObjDiff(oldObj, newObj) {
const result = {__isDiff: true, change: {}, add: {}, del: []};
for (const key of Object.keys(oldObj)) {
if (newObj.hasOwnProperty(key)) {
if (!_.isEqual(oldObj[key], newObj[key])) {
if (_.isObject(oldObj[key]) && _.isObject(newObj[key])) {
result.change[key] = getObjDiff(oldObj[key], newObj[key]);
} else {
result.change[key] = _.cloneDeep(newObj[key]);
}
}
} else {
result.del.push(key);
}
}
for (const key of Object.keys(newObj)) {
if (!oldObj.hasOwnProperty(key)) {
result.add[key] = _.cloneDeep(newObj[key]);
}
}
return result;
}
export function isEmptyObjDiff(diff) {
return (!_.isObject(diff) || !diff.__isDiff ||
(!Object.keys(diff.change).length &&
!Object.keys(diff.add).length &&
!diff.del.length
)
);
}
export function applyObjDiff(obj, diff, isAddChanged) {
const result = _.cloneDeep(obj);
if (!diff.__isDiff)
return result;
const change = diff.change;
for (const key of Object.keys(change)) {
if (result.hasOwnProperty(key)) {
if (_.isObject(change[key])) {
result[key] = applyObjDiff(result[key], change[key], isAddChanged);
} else {
result[key] = _.cloneDeep(change[key]);
}
} else if (isAddChanged) {
result[key] = _.cloneDeep(change[key]);
}
}
for (const key of Object.keys(diff.add)) {
result[key] = _.cloneDeep(diff.add[key]);
}
for (const key of diff.del) {
delete result[key];
}
return result;
}

View File

@@ -1,3 +1,16 @@
//занчение toolButtons.name не должно совпадать с settingDefaults-propertyName
const toolButtons = [
{name: 'undoAction', show: true, text: 'Действие назад'},
{name: 'redoAction', show: true, text: 'Действие вперед'},
{name: 'fullScreen', show: true, text: 'На весь экран'},
{name: 'scrolling', show: false, text: 'Плавный скроллинг'},
{name: 'setPosition', show: true, text: 'На страницу'},
{name: 'search', show: true, text: 'Найти в тексте'},
{name: 'copyText', show: false, text: 'Скопировать текст со страницы'},
{name: 'refresh', show: true, text: 'Принудительно обновить книгу'},
{name: 'history', show: true, text: 'Открыть недавние'},
];
const fonts = [
{name: 'ReaderDefault', label: 'По-умолчанию', fontVertShift: 0},
{name: 'GEO_1', label: 'BPG Arial', fontVertShift: 10},
@@ -123,61 +136,74 @@ const webFonts = [
];
const settingDefaults = {
textColor: '#000000',
backgroundColor: '#EBE2C9',
wallpaper: '',
fontStyle: '',// 'italic'
fontWeight: '',// 'bold'
fontSize: 20,// px
fontName: 'ReaderDefault',
webFontName: '',
fontVertShift: 0,
textVertShift: -20,
textColor: '#000000',
backgroundColor: '#EBE2C9',
wallpaper: '',
fontStyle: '',// 'italic'
fontWeight: '',// 'bold'
fontSize: 20,// px
fontName: 'ReaderDefault',
webFontName: '',
fontVertShift: 0,
textVertShift: -20,
lineInterval: 3,// px, межстрочный интервал
textAlignJustify: true,// выравнивание по ширине
p: 25,// px, отступ параграфа
indentLR: 15,// px, отступ всего текста слева и справа
indentTB: 0,// px, отступ всего текста сверху и снизу
wordWrap: true,//перенос по слогам
keepLastToFirst: true,// перенос последней строки в первую при листании
lineInterval: 3,// px, межстрочный интервал
textAlignJustify: true,// выравнивание по ширине
p: 25,// px, отступ параграфа
indentLR: 15,// px, отступ всего текста слева и справа
indentTB: 0,// px, отступ всего текста сверху и снизу
wordWrap: true,//перенос по слогам
keepLastToFirst: false,// перенос последней строки в первую при листании
showStatusBar: true,
statusBarTop: false,// top, bottom
statusBarHeight: 19,// px
statusBarColorAlpha: 0.4,
showStatusBar: true,
statusBarTop: false,// top, bottom
statusBarHeight: 19,// px
statusBarColorAlpha: 0.4,
scrollingDelay: 3000,// замедление, ms
scrollingType: 'ease-in-out', //linear, ease, ease-in, ease-out, ease-in-out
scrollingDelay: 3000,// замедление, ms
scrollingType: 'ease-in-out', //linear, ease, ease-in, ease-out, ease-in-out
pageChangeAnimation: 'blink',// '' - нет, downShift, rightShift, thaw - протаивание, blink - мерцание
pageChangeAnimationSpeed: 80, //0-100%
pageChangeAnimation: 'flip',// '' - нет, downShift, rightShift, thaw - протаивание, blink - мерцание, rotate - вращение, flip - листание
pageChangeAnimationSpeed: 80, //0-100%
allowUrlParamBookPos: false,
lazyParseEnabled: false,
copyFullText: false,
showClickMapPage: true,
clickControl: true,
cutEmptyParagraphs: false,
addEmptyParagraphs: 0,
blinkCachedLoad: true,
showImages: true,
showInlineImagesInCenter: true,
imageHeightLines: 100,
imageFitWidth: true,
allowUrlParamBookPos: false,
lazyParseEnabled: false,
copyFullText: false,
showClickMapPage: true,
clickControl: true,
cutEmptyParagraphs: false,
addEmptyParagraphs: 0,
blinkCachedLoad: true,
showImages: true,
showInlineImagesInCenter: true,
imageHeightLines: 100,
imageFitWidth: true,
showServerStorageMessages: true,
showWhatsNewDialog: true,
fontShifts: {},
fontShifts: {},
showToolButton: {},
};
for (const font of fonts)
settingDefaults.fontShifts[font.name] = font.fontVertShift;
for (const font of webFonts)
settingDefaults.fontShifts[font.name] = font.fontVertShift;
for (const button of toolButtons)
settingDefaults.showToolButton[button.name] = button.show;
// initial state
const state = {
toolBarActive: true,
serverSyncEnabled: false,
serverStorageKey: '',
profiles: {},
profilesRev: 0,
allowProfilesSave: false,//подстраховка для разработки
whatsNewContentHash: '',
currentProfile: '',
settings: Object.assign({}, settingDefaults),
settingsRev: {},
};
// getters
@@ -191,12 +217,37 @@ const mutations = {
setToolBarActive(state, value) {
state.toolBarActive = value;
},
setServerSyncEnabled(state, value) {
state.serverSyncEnabled = value;
},
setServerStorageKey(state, value) {
state.serverStorageKey = value;
},
setProfiles(state, value) {
state.profiles = value;
},
setProfilesRev(state, value) {
state.profilesRev = value;
},
setAllowProfilesSave(state, value) {
state.allowProfilesSave = value;
},
setWhatsNewContentHash(state, value) {
state.whatsNewContentHash = value;
},
setCurrentProfile(state, value) {
state.currentProfile = value;
},
setSettings(state, value) {
state.settings = Object.assign({}, state.settings, value);
}
},
setSettingsRev(state, value) {
state.settingsRev = Object.assign({}, state.settingsRev, value);
},
};
export default {
toolButtons,
fonts,
webFonts,
settingDefaults,

203
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "Liberama",
"version": "0.5.2",
"version": "0.6.7",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -1596,6 +1596,14 @@
}
}
},
"base-x": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.5.tgz",
"integrity": "sha512-C3picSgzPSLE+jW3tcBzJoGwitOtazb5B+5YmAxZm2ybmTi9LNgAtDO/jjVEBZwHoXmDBZ9m/IELj3elJVRBcA==",
"requires": {
"safe-buffer": "^5.0.1"
}
},
"base64-js": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz",
@@ -1615,6 +1623,15 @@
"integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
"dev": true
},
"binary": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
"integrity": "sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk=",
"requires": {
"buffers": "~0.1.1",
"chainsaw": "~0.1.0"
}
},
"binary-extensions": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.12.0.tgz",
@@ -1839,6 +1856,11 @@
"integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=",
"dev": true
},
"buffers": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz",
"integrity": "sha1-skV5w77U1tOWru5tmorn9Ugqt7s="
},
"builtin-status-codes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz",
@@ -2049,6 +2071,14 @@
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw="
},
"chainsaw": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz",
"integrity": "sha1-XqtQsor+WAdNDVgpE4iCi15fvJg=",
"requires": {
"traverse": ">=0.3.0 <0.4"
}
},
"chalk": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
@@ -3066,6 +3096,51 @@
"mimic-response": "^1.0.0"
}
},
"decompress-zip": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decompress-zip/-/decompress-zip-0.2.2.tgz",
"integrity": "sha512-v+Na3Ck86Px7s2ix+f77pMQC3GlkxHHN+YyvnkEW7+xX5F39pcDpIV/VFvGYk8MznTFcMoPjL3XNWEJLXWoSPw==",
"requires": {
"binary": "^0.3.0",
"graceful-fs": "^4.1.3",
"mkpath": "^0.1.0",
"nopt": "^3.0.1",
"q": "^1.1.2",
"readable-stream": "^1.1.8",
"touch": "0.0.3"
},
"dependencies": {
"isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
"integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
},
"nopt": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz",
"integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=",
"requires": {
"abbrev": "1"
}
},
"readable-stream": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
"integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.1",
"isarray": "0.0.1",
"string_decoder": "~0.10.x"
}
},
"string_decoder": {
"version": "0.10.31",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
}
}
},
"deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
@@ -4054,43 +4129,6 @@
}
}
},
"extract-zip": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.6.7.tgz",
"integrity": "sha1-qEC0uK9kAyZMjbV/Txp0Mz74H+k=",
"requires": {
"concat-stream": "1.6.2",
"debug": "2.6.9",
"mkdirp": "0.5.1",
"yauzl": "2.4.1"
},
"dependencies": {
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"requires": {
"ms": "2.0.0"
}
},
"fd-slicer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz",
"integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=",
"requires": {
"pend": "~1.2.0"
}
},
"yauzl": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.4.1.tgz",
"integrity": "sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU=",
"requires": {
"fd-slicer": "~1.0.1"
}
}
}
},
"extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
@@ -4402,8 +4440,7 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"aproba": {
"version": "1.2.0",
@@ -4424,14 +4461,12 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -4446,20 +4481,17 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"core-util-is": {
"version": "1.0.2",
@@ -4576,8 +4608,7 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"ini": {
"version": "1.3.5",
@@ -4589,7 +4620,6 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@@ -4604,7 +4634,6 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@@ -4612,14 +4641,12 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"minipass": {
"version": "2.2.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.1",
"yallist": "^3.0.0"
@@ -4638,7 +4665,6 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@@ -4719,8 +4745,7 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"object-assign": {
"version": "4.1.1",
@@ -4732,7 +4757,6 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@@ -4818,8 +4842,7 @@
"safe-buffer": {
"version": "5.1.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"safer-buffer": {
"version": "2.1.2",
@@ -4855,7 +4878,6 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@@ -4875,7 +4897,6 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@@ -4919,14 +4940,12 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"yallist": {
"version": "3.0.2",
"bundled": true,
"dev": true,
"optional": true
"dev": true
}
}
},
@@ -6504,6 +6523,11 @@
}
}
},
"mkpath": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/mkpath/-/mkpath-0.1.0.tgz",
"integrity": "sha1-dVSm+Nhxg0zJe1RisSLEwSTW3pE="
},
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@@ -7144,10 +7168,9 @@
"dev": true
},
"pako": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.7.tgz",
"integrity": "sha512-3HNK5tW4x8o5mO8RuHZp3Ydw9icZXx0RANAOMzlMzx7LVXhMJ4mo3MOBpzyd7r/+RUu8BmndP47LXT+vzjtWcQ==",
"dev": true
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.10.tgz",
"integrity": "sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw=="
},
"parallel-transform": {
"version": "1.1.0",
@@ -7293,11 +7316,6 @@
"sha.js": "^2.4.8"
}
},
"pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA="
},
"performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@@ -9429,8 +9447,7 @@
"q": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
"integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=",
"dev": true
"integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc="
},
"qs": {
"version": "6.5.2",
@@ -10077,6 +10094,11 @@
}
}
},
"sjcl": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/sjcl/-/sjcl-1.0.8.tgz",
"integrity": "sha512-LzIjEQ0S0DpIgnxMEayM1rq9aGwGRG4OnZhCdjx7glTaJtf4zRfpg87ImfjSJjoW9vKpagd82McDOwbRT5kQKQ=="
},
"slash": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz",
@@ -10998,6 +11020,24 @@
"integrity": "sha1-LmhELZ9k7HILjMieZEOsbKqVACk=",
"dev": true
},
"touch": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/touch/-/touch-0.0.3.tgz",
"integrity": "sha1-Ua7z1ElXHU8oel2Hyci0kYGg2x0=",
"requires": {
"nopt": "~1.0.10"
},
"dependencies": {
"nopt": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz",
"integrity": "sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=",
"requires": {
"abbrev": "1"
}
}
}
},
"tough-cookie": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz",
@@ -11014,6 +11054,11 @@
}
}
},
"traverse": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
"integrity": "sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk="
},
"trim-right": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "Liberama",
"version": "0.5.5",
"version": "0.6.8",
"engines": {
"node": ">=10.0.0"
},
@@ -56,11 +56,12 @@
},
"dependencies": {
"axios": "^0.18.0",
"base-x": "^3.0.5",
"chardet": "^0.7.0",
"compression": "^1.7.3",
"decompress-zip": "^0.2.2",
"element-ui": "^2.4.11",
"express": "^4.16.4",
"extract-zip": "^1.6.7",
"fg-loadcss": "^2.1.0",
"fs-extra": "^7.0.1",
"got": "^9.5.1",
@@ -70,7 +71,10 @@
"lodash": "^4.17.11",
"minimist": "^1.2.0",
"multer": "^1.4.1",
"pako": "^1.0.10",
"path-browserify": "^1.0.0",
"safe-buffer": "^5.1.2",
"sjcl": "^1.0.8",
"sql-template-strings": "^2.2.2",
"sqlite": "^3.0.0",
"tar-fs": "^2.0.0",

View File

@@ -14,7 +14,6 @@ module.exports = {
logDir: `${dataDir}/log`,
publicDir: `${execDir}/public`,
uploadDir: `${execDir}/public/upload`,
dbFileName: 'db.sqlite',
loggingEnabled: true,
maxUploadFileSize: 50*1024*1024,//50Мб
@@ -23,6 +22,19 @@ module.exports = {
useExternalBookConverter: false,
db: [
{
poolName: 'app',
connCount: 20,
fileName: 'app.sqlite',
},
{
poolName: 'readerStorage',
connCount: 20,
fileName: 'reader-storage.sqlite',
}
],
servers: [
{
serverName: '1',

View File

@@ -1,6 +1,5 @@
class BaseController {
constructor(connPool, config) {
this.connPool = connPool;
constructor(config) {
this.config = config;
}
}

View File

@@ -1,12 +1,11 @@
const BaseController = require('./BaseController');
const ReaderWorker = require('../core/ReaderWorker');
const workerState = require('../core/workerState');
//const log = require('../core/getLogger').getLog();
//const _ = require('lodash');
const ReaderWorker = require('../core/ReaderWorker');
const readerStorage = require('../core/readerStorage');
const workerState = require('../core/workerState');
class ReaderController extends BaseController {
constructor(connPool, config) {
super(connPool, config);
constructor(config) {
super(config);
this.readerWorker = new ReaderWorker(config);
}
@@ -27,6 +26,24 @@ class ReaderController extends BaseController {
return false;
}
async storage(req, res) {
const request = req.body;
let error = '';
try {
if (!request.action)
throw new Error(`key 'action' is empty`);
if (!request.items || Array.isArray(request.data))
throw new Error(`key 'items' is empty`);
return await readerStorage.doAction(request);
} catch (e) {
error = e.message;
}
//error
res.status(500).send({error});
return false;
}
async uploadFile(req, res) {
const file = req.file;
let error = '';

View File

@@ -1,6 +1,7 @@
const fs = require('fs-extra');
const iconv = require('iconv-lite');
const chardet = require('chardet');
const he = require('he');
const textUtils = require('./textUtils');
const utils = require('../utils');
@@ -80,6 +81,10 @@ class ConvertBase {
return text.replace(/&nbsp;|[\t\n\r]/g, ' ');
}
escapeEntities(text) {
return he.escape(he.decode(text));
}
formatFb2(fb2) {
let out = '<?xml version="1.0" encoding="utf-8"?>';
out += '<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:l="http://www.w3.org/1999/xlink">';

View File

@@ -28,7 +28,7 @@ class ConvertHtml extends ConvertBase {
} else {
isText = opts.isText;
}
const {cutTitle} = opts;
let {cutTitle} = opts;
let titleInfo = {};
let desc = {_n: 'description', 'title-info': titleInfo};
@@ -79,6 +79,8 @@ class ConvertHtml extends ConvertBase {
const newPara = new Set(['tr', '/table', 'hr', 'br', 'br/', 'li', 'dt', 'dd', 'p', 'title', '/title', 'h1', 'h2', 'h3', '/h1', '/h2', '/h3']);
const onTextNode = (text, cutCounter, cutTag) => {// eslint-disable-line no-unused-vars
text = this.escapeEntities(text);
if (!cutCounter && !(cutTitle && inTitle)) {
let tOpen = (bold ? '<strong>' : '');
tOpen += (italic ? '<emphasis>' : '');
@@ -121,8 +123,11 @@ class ConvertHtml extends ConvertBase {
}
}
if (tag == 'title')
if (tag == 'title' || tag == 'cut-title') {
inTitle = true;
if (tag == 'cut-title')
cutTitle = true;
}
if (tag == 'fb2-image') {
inImage = true;
@@ -151,7 +156,7 @@ class ConvertHtml extends ConvertBase {
}
}
if (tag == 'title')
if (tag == 'title' || tag == 'cut-title')
inTitle = false;
if (tag == 'fb2-image')
@@ -242,11 +247,50 @@ class ConvertHtml extends ConvertBase {
body.section._a[0] = pars;
}
//убираем лишнее
//убираем лишнее, делаем валидный fb2, т.к. в рез-те разбиения на параграфы бьются теги
bold = false;
italic = false;
pars = body.section._a[0];
for (let i = 0; i < pars.length; i++)
for (let i = 0; i < pars.length; i++) {
if (pars[i]._n != 'p')
continue;
pars[i]._t = this.repSpaces(pars[i]._t).trim();
if (pars[i]._t.indexOf('<') >= 0) {
const t = pars[i]._t;
let a = [];
const onTextNode = (text) => {
let tOpen = (bold ? '<strong>' : '');
tOpen += (italic ? '<emphasis>' : '');
let tClose = (italic ? '</emphasis>' : '');
tClose += (bold ? '</strong>' : '');
a.push(`${tOpen}${text}${tClose}`);
}
const onStartNode = (tag) => {
if (tag == 'strong')
bold = true;
if (tag == 'emphasis')
italic = true;
}
const onEndNode = (tag) => {
if (tag == 'strong')
bold = false;
if (tag == 'emphasis')
italic = false;
}
sax.parseSync(t, { onStartNode, onEndNode, onTextNode });
pars[i]._t = '';
pars[i]._a = a;
}
}
return this.formatFb2(fb2);
}

View File

@@ -218,6 +218,8 @@ class ConvertSamlib extends ConvertBase {
if (!text)
return;
text = this.escapeEntities(text);
switch (path) {
case '/html/body/center/h2':
titleInfo['book-title'] = text;

View File

@@ -11,7 +11,7 @@ function parseSync(xstr, options) {
let i = 0;
const len = xstr.length;
const progStep = len/10;
const progStep = len/20;
let nextProg = 0;
let cutCounter = 0;
@@ -151,7 +151,7 @@ async function parse(xstr, options) {
let i = 0;
const len = xstr.length;
const progStep = len/10;
const progStep = len/20;
let nextProg = 0;
let cutCounter = 0;

View File

@@ -2,9 +2,9 @@ const fs = require('fs-extra');
const zlib = require('zlib');
const crypto = require('crypto');
const path = require('path');
const extractZip = require('extract-zip');
const unbzip2Stream = require('unbzip2-stream');
const tar = require('tar-fs')
const tar = require('tar-fs');
const DecompressZip = require('decompress-zip');
const utils = require('./utils');
const FileDetector = require('./FileDetector');
@@ -114,16 +114,24 @@ class FileDecompressor {
async unZip(filename, outputDir) {
return new Promise((resolve, reject) => {
const files = [];
extractZip(filename, {
dir: outputDir,
onEntry: (entry) => {
files.push({path: entry.fileName, size: entry.uncompressedSize});
}
}, (err) => {
if (err)
reject(err);
const unzipper = new DecompressZip(filename);
unzipper.on('error', function(err) {
reject(err);
});
unzipper.on('extract', function() {
resolve(files);
});
unzipper.extract({
path: outputDir,
filter: function(file) {
if (file.type == 'File')
files.push({path: file.path, size: file.uncompressedSize});
return true;
}
});
});
}
@@ -184,6 +192,10 @@ class FileDecompressor {
resolve([file]);
});
stream.on('error', (err) => {
reject(err);
});
inputStream.on('error', (err) => {
reject(err);
});

View File

@@ -707,7 +707,8 @@
"rules": [
{ "type": "or", "rules":
[
{ "type": "equal", "end": 19, "bytes": "3c3f786d6c2076657273696f6e3d22312e3022" }
{ "type": "equal", "end": 19, "bytes": "3c3f786d6c2076657273696f6e3d22312e3022" },
{ "type": "equal", "end": 22, "bytes": "efbbbf3c3f786d6c2076657273696f6e3d22312e3022" }
]
}
]

View File

@@ -131,32 +131,34 @@ class ReaderWorker {
return `file://${hash}`;
}
async periodicCleanDir(dir, maxSize, timeout) {
const list = await fs.readdir(dir);
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});
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});
}
}
files.sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs);
let i = 0;
while (i < files.length && size > maxSize) {
const file = files[i];
await fs.remove(`${dir}/${file.name}`);
size -= file.stat.size;
i++;
}
} finally {
setTimeout(() => {
this.periodicCleanDir(dir, maxSize, timeout);
}, timeout);
}
files.sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs);
let i = 0;
while (i < files.length && size > maxSize) {
const file = files[i];
await fs.remove(`${dir}/${file.name}`);
size -= file.stat.size;
i++;
}
setTimeout(() => {
this.periodicCleanDir(dir, maxSize, timeout);
}, timeout);
}
}

View File

@@ -1,91 +0,0 @@
const utils = require('./utils');
const sqlite = require('sqlite');
const waitingDelay = 100; //ms
class SqliteConnectionPool {
constructor(connCount, config) {
this.connCount = connCount;
this.config = config;
}
async init() {
const dbFileName = this.config.dataDir + '/' + this.config.dbFileName;
this.connections = [];
this.taken = new Set();
this.freed = new Set();
for (let i = 0; i < this.connCount; i++) {
let client = await sqlite.open(dbFileName);
client.configure('busyTimeout', 10000); //ms
client.ret = () => {
this.taken.delete(i);
this.freed.add(i);
};
this.freed.add(i);
this.connections[i] = client;
}
}
_setImmediate() {
return new Promise((resolve) => {
setImmediate(() => {
return resolve();
});
});
}
async get() {
if (this.closed)
return;
let freeConnIndex = this.freed.values().next().value;
if (freeConnIndex == null) {
if (waitingDelay)
await utils.sleep(waitingDelay);
return await this._setImmediate().then(() => this.get());
}
this.freed.delete(freeConnIndex);
this.taken.add(freeConnIndex);
return this.connections[freeConnIndex];
}
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 close() {
for (let i = 0; i < this.connections.length; i++) {
await this.connections[i].close();
}
this.closed = true;
}
}
module.exports = SqliteConnectionPool;

View File

@@ -0,0 +1,118 @@
const SQL = require('sql-template-strings');
const _ = require('lodash');
const connManager = require('../db/connManager');
class ReaderStorage {
constructor() {
this.storagePool = connManager.pool.readerStorage;
this.periodicCleanCache(3*3600*1000);//1 раз в 3 часа
}
async doAction(act) {
if (!_.isObject(act.items))
throw new Error('items is not an object');
let result = {};
switch (act.action) {
case 'check':
result = await this.checkItems(act.items);
break;
case 'get':
result = await this.getItems(act.items);
break;
case 'set':
result = await this.setItems(act.items, act.force);
break;
default:
throw new Error('Unknown action');
}
return result;
}
async checkItems(items) {
let result = {state: 'success', items: {}};
const dbh = await this.storagePool.get();
try {
for (const id of Object.keys(items)) {
if (this.cache[id]) {
result.items[id] = this.cache[id];
} else {
const rows = await dbh.all(SQL`SELECT rev FROM storage WHERE id = ${id}`);
const rev = (rows.length && rows[0].rev ? rows[0].rev : 0);
result.items[id] = {rev};
this.cache[id] = result.items[id];
}
}
} finally {
dbh.ret();
}
return result;
}
async getItems(items) {
let result = {state: 'success', items: {}};
const dbh = await this.storagePool.get();
try {
for (const id of Object.keys(items)) {
const rows = await dbh.all(SQL`SELECT rev, data FROM storage WHERE id = ${id}`);
const rev = (rows.length && rows[0].rev ? rows[0].rev : 0);
const data = (rows.length && rows[0].data ? rows[0].data : '');
result.items[id] = {rev, data};
}
} finally {
dbh.ret();
}
return result;
}
async setItems(items, force) {
let check = await this.checkItems(items);
//сначала проверим совпадение ревизий
for (const id of Object.keys(items)) {
if (!_.isString(items[id].data))
throw new Error('items.data is not a string');
if (!force && check.items[id].rev + 1 !== items[id].rev)
return {state: 'reject', items: check.items};
}
const dbh = await this.storagePool.get();
await dbh.run('BEGIN');
try {
const newRev = {};
for (const id of Object.keys(items)) {
await dbh.run(SQL`INSERT OR REPLACE INTO storage (id, rev, time, data) VALUES (${id}, ${items[id].rev}, strftime('%s','now'), ${items[id].data})`);
newRev[id] = {rev: items[id].rev};
}
await dbh.run('COMMIT');
Object.assign(this.cache, newRev);
} catch (e) {
await dbh.run('ROLLBACK');
throw e;
} finally {
dbh.ret();
}
return {state: 'success'};
}
periodicCleanCache(timeout) {
this.cache = {};
setTimeout(() => {
this.periodicCleanCache(timeout);
}, timeout);
}
}
const readerStorage = new ReaderStorage();
module.exports = readerStorage;

View File

@@ -0,0 +1,188 @@
const sqlite = require('sqlite');
const SQL = require('sql-template-strings');
const utils = require('../core/utils');
const waitingDelay = 100; //ms
class SqliteConnectionPool {
constructor() {
this.closed = true;
}
async open(connCount, dbFileName) {
if (!Number.isInteger(connCount) || connCount <= 0)
return;
this.connections = [];
this.taken = new Set();
this.freed = new Set();
for (let i = 0; i < connCount; i++) {
let client = await sqlite.open(dbFileName);
client.configure('busyTimeout', 10000); //ms
client.ret = () => {
this.taken.delete(i);
this.freed.add(i);
};
this.freed.add(i);
this.connections[i] = client;
}
this.closed = false;
}
_setImmediate() {
return new Promise((resolve) => {
setImmediate(() => {
return resolve();
});
});
}
async get() {
if (this.closed)
return;
let freeConnIndex = this.freed.values().next().value;
if (freeConnIndex == null) {
if (waitingDelay)
await utils.sleep(waitingDelay);
return await this._setImmediate().then(() => this.get());
}
this.freed.delete(freeConnIndex);
this.taken.add(freeConnIndex);
return this.connections[freeConnIndex];
}
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;

51
server/db/connManager.js Normal file
View File

@@ -0,0 +1,51 @@
const fs = require('fs-extra');
const SqliteConnectionPool = require('./SqliteConnectionPool');
const log = require('../core/getLogger').getLog();
const migrations = {
'app': require('./migrations/app'),
'readerStorage': require('./migrations/readerStorage'),
};
class ConnManager {
constructor() {
this._pool = {};
}
async init(config) {
this.config = config;
const force = null;//(config.branch == 'development' ? 'last' : null);
for (const poolConfig of this.config.db) {
const dbFileName = this.config.dataDir + '/' + poolConfig.fileName;
//бэкап
if (await fs.pathExists(dbFileName))
await fs.copy(dbFileName, `${dbFileName}.bak`);
const connPool = new SqliteConnectionPool();
await connPool.open(poolConfig.connCount, 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;
}
}
get pool() {
return this._pool;
}
}
const connManager = new ConnManager();
module.exports = connManager;

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ function webpackDevMiddleware(app) {
function logQueries(app) {
app.use(function(req, res, next) {
const start = Date.now();
log(`${req.method} ${req.originalUrl} ${JSON.stringify(req.body)}`);
log(`${req.method} ${req.originalUrl} ${JSON.stringify(req.body).substr(0, 2000)}`);
//log(`${JSON.stringify(req.headers, null, 2)}`)
res.once('finish', () => {
log(`${Date.now() - start}ms`);

View File

@@ -11,7 +11,7 @@ const path = require('path');
const express = require('express');
const compression = require('compression');
const SqliteConnectionPool = require('./core/SqliteConnectionPool');
const connManager = require('./db/connManager');
async function init() {
await fs.ensureDir(config.dataDir);
@@ -35,9 +35,7 @@ async function main() {
log('Initializing');
await init();
log('Opening database');
const connPool = new SqliteConnectionPool(20, config);
await connPool.init();
await connManager.init(config);
//servers
for (let server of config.servers) {
@@ -53,7 +51,7 @@ async function main() {
}
app.use(compression({ level: 1 }));
app.use(express.json());
app.use(express.json({limit: '10mb'}));
if (devModule)
devModule.logQueries(app);
@@ -67,7 +65,7 @@ async function main() {
}
}));
require('./routes').initRoutes(app, connPool, serverConfig);
require('./routes').initRoutes(app, serverConfig);
if (devModule) {
devModule.logErrors(app);

View File

@@ -2,10 +2,10 @@ const c = require('./controllers');
const utils = require('./core/utils');
const multer = require('multer');
function initRoutes(app, connPool, config) {
const misc = new c.MiscController(connPool, config);
const reader = new c.ReaderController(connPool, config);
const worker = new c.WorkerController(connPool, config);
function initRoutes(app, config) {
const misc = new c.MiscController(config);
const reader = new c.ReaderController(config);
const worker = new c.WorkerController(config);
//access
const [aAll, aNormal, aSite, aReader, aOmnireader] = // eslint-disable-line no-unused-vars
@@ -26,6 +26,7 @@ function initRoutes(app, connPool, config) {
const routes = [
['POST', '/api/config', misc.getConfig.bind(misc), [aAll], {}],
['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/worker/get-state', worker.getState.bind(worker), [aAll], {}],
];