Compare commits
256 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
580b030ee4 | ||
|
|
6a7cbc70d6 | ||
|
|
d76f60639c | ||
|
|
e2bea407ee | ||
|
|
558fed31aa | ||
|
|
f6513d40c8 | ||
|
|
259f9baa59 | ||
|
|
db5650e276 | ||
|
|
51ebbbc569 | ||
|
|
5184661652 | ||
|
|
7853a14ce6 | ||
|
|
a01e78ace9 | ||
|
|
f7eb576d0d | ||
|
|
34f1ad8fae | ||
|
|
c60f0991df | ||
|
|
d505fd0795 | ||
|
|
93cf506535 | ||
|
|
bfb37e55d4 | ||
|
|
92afc5cb33 | ||
|
|
75cb611701 | ||
|
|
2ec1dd58a5 | ||
|
|
7d59af54de | ||
|
|
2b5f47b3de | ||
|
|
16eebfb9a4 | ||
|
|
9025218671 | ||
|
|
6bccb546bb | ||
|
|
29d49046a0 | ||
|
|
717af9ffaf | ||
|
|
00060c9f43 | ||
|
|
759ff46c92 | ||
|
|
41957cdceb | ||
|
|
d418e3a1c9 | ||
|
|
f650124428 | ||
|
|
795d109c76 | ||
|
|
6868b3effc | ||
|
|
26747b7013 | ||
|
|
5198f8aa60 | ||
|
|
552da48a32 | ||
|
|
db8a688620 | ||
|
|
3088028d05 | ||
|
|
fd62ef865d | ||
|
|
ed74ed00ed | ||
|
|
741317aaaf | ||
|
|
9b6ecd4e6b | ||
|
|
7863b3358e | ||
|
|
e1be68ec3d | ||
|
|
a054186d4b | ||
|
|
2d5c549c83 | ||
|
|
9f6072dfe1 | ||
|
|
69c44fe1ab | ||
|
|
4fa7b2443e | ||
|
|
25a69592bb | ||
|
|
44e0b26990 | ||
|
|
c4496f8dc8 | ||
|
|
9e296231d9 | ||
|
|
49b3f05d65 | ||
|
|
f124b9c050 | ||
|
|
63a86f7c06 | ||
|
|
fd0f523c64 | ||
|
|
487e605520 | ||
|
|
9e169e1f4b | ||
|
|
9612e7ebcd | ||
|
|
f66162efe7 | ||
|
|
656642697b | ||
|
|
feb70f85f8 | ||
|
|
ab1981559b | ||
|
|
c8852d9a8e | ||
|
|
9ac8dc7fd1 | ||
|
|
c9419d99e6 | ||
|
|
a1f4a83e72 | ||
|
|
a8abd5d427 | ||
|
|
629d1b0630 | ||
|
|
97c368f63a | ||
|
|
3266a444d0 | ||
|
|
1c246f71f8 | ||
|
|
96945dfc4a | ||
|
|
30eb3001ef | ||
|
|
bdd8636390 | ||
|
|
f762d2a271 | ||
|
|
cf2efc2b92 | ||
|
|
7670da4cba | ||
|
|
d87f9f2a21 | ||
|
|
6e690f3fea | ||
|
|
6321002617 | ||
|
|
15ec362428 | ||
|
|
454004e705 | ||
|
|
e14b414fc1 | ||
|
|
c4b47a5915 | ||
|
|
957c252cd7 | ||
|
|
d6a6c21762 | ||
|
|
834580cfdf | ||
|
|
de13cfb555 | ||
|
|
4f87508834 | ||
|
|
682a044f32 | ||
|
|
bdb5d90b1d | ||
|
|
01880f4456 | ||
|
|
39f78ce7e8 | ||
|
|
755c6b92da | ||
|
|
2eab9c2837 | ||
|
|
63861789de | ||
|
|
086c353eff | ||
|
|
4fe5b44655 | ||
|
|
036547e260 | ||
|
|
696f434c90 | ||
|
|
0c654d9346 | ||
|
|
a2c393b06b | ||
|
|
eae2c2b102 | ||
|
|
d9e49e3484 | ||
|
|
a28d4c2f1c | ||
|
|
9af055ec54 | ||
|
|
0d41171e9d | ||
|
|
08af826ae9 | ||
|
|
4fd577d7c5 | ||
|
|
2c8efebe98 | ||
|
|
93c9fb53ac | ||
|
|
5a4d249cf9 | ||
|
|
4cc7bdee37 | ||
|
|
a6af568411 | ||
|
|
576a6a094a | ||
|
|
e671e4b6f5 | ||
|
|
a66b2a4c70 | ||
|
|
f1ae409535 | ||
|
|
a4b56b477d | ||
|
|
d9c389812a | ||
|
|
074ef3645f | ||
|
|
cc3aa413e8 | ||
|
|
7f90c09227 | ||
|
|
f6f4d8ccc9 | ||
|
|
31afce8304 | ||
|
|
2c4ff856cd | ||
|
|
f59974e310 | ||
|
|
70e2c12a6b | ||
|
|
11f3c6ce6f | ||
|
|
e213c4640b | ||
|
|
959c5eaa59 | ||
|
|
66fa510b26 | ||
|
|
f26a3b31ac | ||
|
|
724fbf579e | ||
|
|
f192f8e3cd | ||
|
|
f13c3d19fb | ||
|
|
b51a09efcc | ||
|
|
6004043782 | ||
|
|
f9fd0dc2c3 | ||
|
|
eb5411cd20 | ||
|
|
da3c7a02f0 | ||
|
|
e67d05007f | ||
|
|
b0a9a6a08e | ||
|
|
d848ea35f4 | ||
|
|
350f20effe | ||
|
|
b6dc8f98fe | ||
|
|
1b762ee48d | ||
|
|
cc3d7f1eac | ||
|
|
4107282fbf | ||
|
|
c29ffc3fcd | ||
|
|
f648bcda13 | ||
|
|
aa0044eed2 | ||
|
|
2312a721ae | ||
|
|
b93fc39b00 | ||
|
|
2dc2cd700f | ||
|
|
d69e534f8b | ||
|
|
1de9ddd394 | ||
|
|
77c68d4e11 | ||
|
|
2a0d1dcfce | ||
|
|
5a19cca407 | ||
|
|
4e8773ecde | ||
|
|
4c7dada809 | ||
|
|
65690b15da | ||
|
|
8ba07812ce | ||
|
|
2dd8f35001 | ||
|
|
2d15aa88d4 | ||
|
|
e4257e50f0 | ||
|
|
33ebc07915 | ||
|
|
bc07299626 | ||
|
|
25e8aeef53 | ||
|
|
a2ed34abf3 | ||
|
|
36a7b7b91a | ||
|
|
b4e8b7375f | ||
|
|
153b635bdb | ||
|
|
80af72465e | ||
|
|
a91a8f9993 | ||
|
|
a0ccc7fe07 | ||
|
|
c162c9ae0e | ||
|
|
25542cdff3 | ||
|
|
16d0ae60c1 | ||
|
|
b1937eb8c0 | ||
|
|
3f6b468021 | ||
|
|
92d929b704 | ||
|
|
737ae75c28 | ||
|
|
79ced4eca4 | ||
|
|
329ac44c11 | ||
|
|
f65a91dfed | ||
|
|
2a79207427 | ||
|
|
70be3d10d0 | ||
|
|
3500a40599 | ||
|
|
090ffa9921 | ||
|
|
b12198fdcf | ||
|
|
826ee18666 | ||
|
|
f9d8b37b1a | ||
|
|
e626cb6b40 | ||
|
|
20697ad9e4 | ||
|
|
0800385b96 | ||
|
|
d6e326e8be | ||
|
|
8b969a6d36 | ||
|
|
d520e13c88 | ||
|
|
ae4081001c | ||
|
|
5a48b597b9 | ||
|
|
c8a953db7c | ||
|
|
d20ec144ff | ||
|
|
0147a82b0a | ||
|
|
8732a78d01 | ||
|
|
015254ae40 | ||
|
|
712bf405bb | ||
|
|
3a46a157f9 | ||
|
|
2a4ff926ae | ||
|
|
58941116c8 | ||
|
|
a13146d722 | ||
|
|
02e6f392b4 | ||
|
|
d4515bd643 | ||
|
|
a73555b7ca | ||
|
|
983d9ee1b9 | ||
|
|
e800dfe796 | ||
|
|
b0c59be340 | ||
|
|
dca12b6467 | ||
|
|
5a0d98cbd0 | ||
|
|
9cbaf22270 | ||
|
|
a64687f64f | ||
|
|
d229aab8c9 | ||
|
|
2ff94c1458 | ||
|
|
3b9f3ea81d | ||
|
|
23f12ad3cf | ||
|
|
01e7c1f183 | ||
|
|
37d60bc9b9 | ||
|
|
cd5d3903fe | ||
|
|
6904cfd224 | ||
|
|
c430e2c8f4 | ||
|
|
0cf8a94b24 | ||
|
|
ff3674aca7 | ||
|
|
b50498fa46 | ||
|
|
571f71c7f0 | ||
|
|
091c50ec84 | ||
|
|
e473dc8843 | ||
|
|
886af11d3a | ||
|
|
c72fd7ee9c | ||
|
|
7dc76b4222 | ||
|
|
5011e23050 | ||
|
|
89d9a90901 | ||
|
|
05128b12a8 | ||
|
|
c287ca9ea8 | ||
|
|
5122cda6db | ||
|
|
a39626f867 | ||
|
|
c7abae10b7 | ||
|
|
9a8f35fd8a | ||
|
|
0341cc1630 | ||
|
|
d307d233f0 | ||
|
|
5931b9625b | ||
|
|
fb837f5b97 |
@@ -9,6 +9,7 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const CleanWebpackPlugin = require('clean-webpack-plugin');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
const AppCachePlugin = require('appcache-webpack-plugin');
|
||||
|
||||
const publicDir = path.resolve(__dirname, '../dist/tmp/public');
|
||||
const clientDir = path.resolve(__dirname, '../client');
|
||||
@@ -32,7 +33,15 @@ module.exports = merge(baseWpConfig, {
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [
|
||||
new TerserPlugin(),
|
||||
new TerserPlugin({
|
||||
cache: true,
|
||||
parallel: true,
|
||||
terserOptions: {
|
||||
output: {
|
||||
comments: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
new OptimizeCSSAssetsPlugin()
|
||||
]
|
||||
},
|
||||
@@ -45,6 +54,7 @@ module.exports = merge(baseWpConfig, {
|
||||
template: `${clientDir}/index.html.template`,
|
||||
filename: `${publicDir}/index.html`
|
||||
}),
|
||||
new CopyWebpackPlugin([{from: `${clientDir}/assets/*`, to: `${publicDir}/`, flatten: true}])
|
||||
new CopyWebpackPlugin([{from: `${clientDir}/assets/*`, to: `${publicDir}/`, flatten: true}]),
|
||||
new AppCachePlugin({exclude: ['../index.html']})
|
||||
]
|
||||
});
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import axios from 'axios';
|
||||
import {sleep} from '../share/utils';
|
||||
|
||||
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 {
|
||||
async loadBook(url, callback) {
|
||||
async loadBook(opts, callback) {
|
||||
const refreshPause = 300;
|
||||
if (!callback) callback = () => {};
|
||||
|
||||
let response = await api.post('/load-book', {type: 'url', url});
|
||||
let response = await api.post('/load-book', opts);
|
||||
|
||||
const workerId = response.data.workerId;
|
||||
if (!workerId)
|
||||
@@ -41,7 +42,7 @@ class Reader {
|
||||
throw new Error(errMes);
|
||||
}
|
||||
if (i > 0)
|
||||
await sleep(refreshPause);
|
||||
await utils.sleep(refreshPause);
|
||||
|
||||
i++;
|
||||
if (i > 120*1000/refreshPause) {//2 мин ждем телодвижений воркера
|
||||
@@ -63,12 +64,13 @@ class Reader {
|
||||
estSize = response.headers['content-length'];
|
||||
}
|
||||
|
||||
callback({state: 'loading', progress: 0});
|
||||
const options = {
|
||||
onDownloadProgress: progress => {
|
||||
while (progress.loaded > estSize) estSize *= 1.5;
|
||||
|
||||
if (callback)
|
||||
callback({state: 'loading', progress: Math.round((progress.loaded*100)/estSize)});
|
||||
callback({progress: Math.round((progress.loaded*100)/estSize)});
|
||||
}
|
||||
}
|
||||
//загрузка
|
||||
@@ -106,6 +108,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();
|
||||
@@ -47,14 +47,12 @@
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import * as utils from '../share/utils';
|
||||
|
||||
export default @Component({
|
||||
watch: {
|
||||
rootRoute: function() {
|
||||
this.setAppTitle();
|
||||
this.redirectIfNeeded();
|
||||
},
|
||||
mode: function() {
|
||||
this.setAppTitle();
|
||||
this.redirectIfNeeded();
|
||||
}
|
||||
},
|
||||
@@ -113,13 +111,19 @@ 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
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.setAppTitle();
|
||||
this.redirectIfNeeded();
|
||||
}
|
||||
|
||||
toggleCollapse() {
|
||||
@@ -198,15 +202,18 @@ class App extends Vue {
|
||||
}
|
||||
|
||||
redirectIfNeeded() {
|
||||
if ((this.mode == 'reader' || this.mode == 'omnireader') && (this.rootRoute != '/reader')) {
|
||||
if ((this.mode == 'reader' || this.mode == 'omnireader') && (!this.isReaderActive)) {
|
||||
//старый url
|
||||
const search = window.location.search.substr(1);
|
||||
const url = search.split('url=')[1] || '';
|
||||
const s = search.split('url=');
|
||||
const url = s[1] || '';
|
||||
const q = utils.parseQuery(s[0] || '');
|
||||
if (url) {
|
||||
window.location = `/#/reader?url=${url}`;
|
||||
} else {
|
||||
this.$router.replace('/reader');
|
||||
q.url = decodeURIComponent(url);
|
||||
}
|
||||
|
||||
window.history.replaceState({}, '', '/');
|
||||
this.$router.replace({ path: '/reader', query: q });
|
||||
}
|
||||
|
||||
//yandex-метрика для omnireader
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Скопировать текст
|
||||
</template>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Скопировать текст
|
||||
</template>
|
||||
|
||||
<div ref="text" class="text" tabindex="-1">
|
||||
<div v-html="text"></div>
|
||||
</div>
|
||||
</Window>
|
||||
<div ref="text" class="text" tabindex="-1">
|
||||
<div v-html="text"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -109,23 +105,6 @@ class CopyTextPage extends Vue {
|
||||
</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;
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
<h4>Возможности читалки:</h4>
|
||||
<ul>
|
||||
<li>загрузка любой страницы интернета</li>
|
||||
<li>работа в автономном режиме (без связи)</li>
|
||||
<li>изменение цвета фона, текста, размер и тип шрифта и прочее</li>
|
||||
<li>установка и запоминание текущей позиции и настроек в браузере (в будущем планируется сохранение и на сервер)</li>
|
||||
<li>установка и запоминание текущей позиции и настроек в браузере и на сервере</li>
|
||||
<li>синхронизация данных (настроек и читаемых книг) между различными устройствами</li>
|
||||
<li>кэширование файлов книг на клиенте и на сервере</li>
|
||||
<li>открытие книг с локального диска</li>
|
||||
<li>плавный скроллинг текста</li>
|
||||
@@ -12,16 +14,27 @@
|
||||
<li>поиск по тексту и копирование фрагмента</li>
|
||||
<li>запоминание недавних книг, скачивание книги из читалки в формате fb2</li>
|
||||
<li>управление кликом и с клавиатуры</li>
|
||||
<li>подключение к интернету не обязательно для чтения книги после ее загрузки</li>
|
||||
<li>регистрация не требуется</li>
|
||||
<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>
|
||||
|
||||
<div v-html="automationHtml"></div>
|
||||
<div v-show="mode == 'omnireader'">
|
||||
<p>Вы можете добавить в свой браузер закладку, указав в ее свойствах вместо адреса следующий код:
|
||||
<br><strong>javascript:location.href='https://omnireader.ru/?url='+location.href;</strong>
|
||||
|
||||
<span class="clickable" @click="copyText('javascript:location.href=\'https://omnireader.ru/?url=\'+location.href;', 'Код для адреса закладки успешно скопирован в буфер обмена')">
|
||||
(скопировать)
|
||||
</span>
|
||||
<br>или перетащив на панель закладок следующую ссылку:
|
||||
<br><a style="margin-left: 50px" href="javascript:location.href='https://omnireader.ru/?url='+location.href;">Omni Reader</a>
|
||||
<br>Тогда, активировав получившуюся закладку на любой странице интернета, вы автоматически загрузите эту страницу в Omni Reader.
|
||||
<br>В Chrome для Android можно вызывать такую закладку по имени прямо в адресной строке браузера (имя стоит сделать попроще).
|
||||
</p>
|
||||
</div>
|
||||
<p>Связаться с разработчиком: <a href="mailto:bookpauk@gmail.com">bookpauk@gmail.com</a></p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -31,21 +44,25 @@
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
import {copyTextToClipboard} from '../../../../share/utils';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class CommonHelpPage extends Vue {
|
||||
created() {
|
||||
this.config = this.$store.state.config;
|
||||
}
|
||||
|
||||
get automationHtml() {
|
||||
if (this.config.mode == 'omnireader') {
|
||||
return `<p>Вы можете добавить в свой браузер закладку, указав в ее свойствах вместо адреса следующий код:
|
||||
<br><strong>javascript:location.href='http://omnireader.ru/?url='+location.href;</strong>
|
||||
<br>Тогда, нажав на получившуюся кнопку на любой странице интернета, вы автоматически откроете ее в Omni Reader.</p>`;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
get mode() {
|
||||
return this.$store.state.config.mode;
|
||||
}
|
||||
|
||||
async copyText(text, mes) {
|
||||
const result = await copyTextToClipboard(text);
|
||||
const msg = (result ? mes : 'Копирование не удалось');
|
||||
if (result)
|
||||
this.$notify.success({message: msg});
|
||||
else
|
||||
this.$notify.error({message: msg});
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
@@ -63,4 +80,10 @@ class CommonHelpPage extends Vue {
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
color: blue;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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: 'Копирование не удалось'});
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Справка
|
||||
</template>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Справка
|
||||
</template>
|
||||
|
||||
<el-tabs type="border-card" v-model="selectedTab">
|
||||
<el-tab-pane class="tab" label="Общее">
|
||||
<CommonHelpPage></CommonHelpPage>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Клавиатура">
|
||||
<HotkeysHelpPage></HotkeysHelpPage>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Мышь/тачпад">
|
||||
<MouseHelpPage></MouseHelpPage>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Помочь проекту" name="donate">
|
||||
<DonateHelpPage></DonateHelpPage>
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
</Window>
|
||||
</div>
|
||||
</div>
|
||||
<el-tabs type="border-card" v-model="selectedTab">
|
||||
<el-tab-pane class="tab" label="Общее">
|
||||
<CommonHelpPage></CommonHelpPage>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Клавиатура">
|
||||
<HotkeysHelpPage></HotkeysHelpPage>
|
||||
</el-tab-pane>
|
||||
<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>
|
||||
</el-tabs>
|
||||
</Window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -36,6 +34,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 +43,7 @@ export default @Component({
|
||||
HotkeysHelpPage,
|
||||
MouseHelpPage,
|
||||
DonateHelpPage,
|
||||
VersionHistoryPage,
|
||||
},
|
||||
})
|
||||
class HelpPage extends Vue {
|
||||
@@ -57,6 +57,10 @@ class HelpPage extends Vue {
|
||||
this.selectedTab = 'donate';
|
||||
}
|
||||
|
||||
activateVersionHistoryHelpPage() {
|
||||
this.selectedTab = 'releases';
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (event.type == 'keydown' && (event.code == 'Escape')) {
|
||||
this.close();
|
||||
@@ -68,23 +72,6 @@ class HelpPage extends Vue {
|
||||
</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;
|
||||
}
|
||||
|
||||
.el-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<li><b>Ctrl+C</b> - скопировать текст со страницы</li>
|
||||
<li><b>R</b> - принудительно обновить книгу в обход кэша</li>
|
||||
<li><b>X</b> - открыть недавние</li>
|
||||
<li><b>O</b> - автономный режим</li>
|
||||
<li><b>S</b> - открыть окно настроек</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,280 +0,0 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Последние 100 открытых книг
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="tableData"
|
||||
style="width: 100%"
|
||||
size="mini"
|
||||
height="1px"
|
||||
stripe
|
||||
border
|
||||
:default-sort = "{prop: 'touchDateTime', order: 'descending'}"
|
||||
:header-cell-style = "headerCellStyle"
|
||||
:row-key = "rowKey"
|
||||
>
|
||||
|
||||
<el-table-column
|
||||
type="index"
|
||||
width="35px"
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="touchDateTime"
|
||||
min-width="90px"
|
||||
sortable
|
||||
>
|
||||
<template slot="header" slot-scope="scope"><!-- eslint-disable-line vue/no-unused-vars -->
|
||||
<span style="font-size: 90%">Время<br>просм.</span>
|
||||
</template>
|
||||
<template slot-scope="scope"><!-- eslint-disable-line vue/no-unused-vars -->
|
||||
<div class="desc" @click="loadBook(scope.row.url)">
|
||||
{{ scope.row.touchDate }}<br>
|
||||
{{ scope.row.touchTime }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
>
|
||||
<template slot="header" slot-scope="scope"><!-- eslint-disable-line vue/no-unused-vars -->
|
||||
<!--el-input ref="input"
|
||||
:value="search" @input="search = $event"
|
||||
size="mini"
|
||||
style="margin: 0; padding: 0; vertical-align: bottom; margin-top: 10px"
|
||||
placeholder="Найти"/-->
|
||||
<div class="el-input el-input--mini">
|
||||
<input class="el-input__inner"
|
||||
ref="input"
|
||||
placeholder="Найти"
|
||||
style="margin: 0; padding: 0; vertical-align: bottom; margin-top: 20px; padding: 0 10px 0 10px"
|
||||
:value="search" @input="search = $event.target.value"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table-column
|
||||
min-width="300px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<div class="desc" @click="loadBook(scope.row.url)">
|
||||
<span style="color: green">{{ scope.row.desc.author }}</span><br>
|
||||
<span>{{ scope.row.desc.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
min-width="100px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<a v-show="isUrl(scope.row.url)" :href="scope.row.url" target="_blank">Оригинал</a><br>
|
||||
<a :href="scope.row.path" :download="getFileNameFromPath(scope.row.path)">Скачать FB2</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
width="60px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
size="mini"
|
||||
style="width: 30px; padding: 7px 0 7px 0; margin-left: 4px"
|
||||
@click="handleDel(scope.row.key)"><i class="el-icon-close"></i>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
</el-table-column>
|
||||
|
||||
</el-table>
|
||||
</Window>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import path from 'path';
|
||||
import _ from 'lodash';
|
||||
|
||||
import {formatDate} from '../../../share/utils';
|
||||
import Window from '../../share/Window.vue';
|
||||
import bookManager from '../share/bookManager';
|
||||
|
||||
export default @Component({
|
||||
components: {
|
||||
Window,
|
||||
},
|
||||
watch: {
|
||||
search: function() {
|
||||
this.updateTableData();
|
||||
}
|
||||
},
|
||||
})
|
||||
class HistoryPage extends Vue {
|
||||
search = null;
|
||||
tableData = null;
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
init() {
|
||||
this.updateTableData();
|
||||
this.mostRecentBook = bookManager.mostRecentBook();
|
||||
this.$nextTick(() => {
|
||||
this.$refs.input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
rowKey(row) {
|
||||
return row.key;
|
||||
}
|
||||
|
||||
updateTableData() {
|
||||
let result = [];
|
||||
|
||||
const sorted = bookManager.getSortedRecent();
|
||||
const len = (sorted.length < 100 ? sorted.length : 100);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const book = sorted[i];
|
||||
let d = new Date();
|
||||
d.setTime(book.touchTime);
|
||||
const t = formatDate(d).split(' ');
|
||||
|
||||
let perc = '';
|
||||
let textLen = '';
|
||||
const p = (book.bookPosSeen ? book.bookPosSeen : (book.bookPos ? book.bookPos : 0));
|
||||
if (book.textLength) {
|
||||
perc = ` [${((p/book.textLength)*100).toFixed(2)}%]`;
|
||||
textLen = ` ${Math.round(book.textLength/1000)}k`;
|
||||
}
|
||||
|
||||
const fb2 = (book.fb2 ? book.fb2 : {});
|
||||
|
||||
let title = fb2.bookTitle;
|
||||
if (title)
|
||||
title = `"${title}"`;
|
||||
else
|
||||
title = '';
|
||||
|
||||
let author = _.compact([
|
||||
fb2.lastName,
|
||||
fb2.firstName,
|
||||
fb2.middleName
|
||||
]).join(' ');
|
||||
author = (author ? author : (fb2.bookTitle ? fb2.bookTitle : book.url));
|
||||
|
||||
result.push({
|
||||
touchDateTime: book.touchTime,
|
||||
touchDate: t[0],
|
||||
touchTime: t[1],
|
||||
desc: {
|
||||
title: `${title}${perc}${textLen}`,
|
||||
author,
|
||||
},
|
||||
url: book.url,
|
||||
path: book.path,
|
||||
key: book.key,
|
||||
});
|
||||
}
|
||||
|
||||
const search = this.search;
|
||||
result = result.filter(item => {
|
||||
return !search ||
|
||||
item.touchTime.includes(search) ||
|
||||
item.touchDate.includes(search) ||
|
||||
item.desc.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.desc.author.toLowerCase().includes(search.toLowerCase())
|
||||
});
|
||||
|
||||
this.tableData = result;
|
||||
}
|
||||
|
||||
headerCellStyle(cell) {
|
||||
let result = {margin: 0, padding: 0};
|
||||
if (cell.columnIndex > 0) {
|
||||
result['border-bottom'] = 0;
|
||||
}
|
||||
if (cell.rowIndex > 0) {
|
||||
result.height = '0px';
|
||||
result['border-right'] = 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getFileNameFromPath(fb2Path) {
|
||||
return path.basename(fb2Path).substr(0, 10) + '.fb2';
|
||||
}
|
||||
|
||||
openOriginal(url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
openFb2(path) {
|
||||
window.open(path, '_blank');
|
||||
}
|
||||
|
||||
async handleDel(key) {
|
||||
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)
|
||||
this.close();
|
||||
}
|
||||
|
||||
loadBook(url) {
|
||||
this.$emit('load-book', {url});
|
||||
this.close();
|
||||
}
|
||||
|
||||
isUrl(url) {
|
||||
return (url.indexOf('file://') != 0);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$emit('history-toggle');
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (event.type == 'keydown' && event.code == 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mainWindow {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.desc {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -18,7 +18,20 @@
|
||||
Загрузить файл с диска
|
||||
</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="https://omnireader.ru">
|
||||
</div>
|
||||
<div class="space"></div>
|
||||
<span v-if="mode == 'omnireader'" class="bottom-span clickable" @click="openComments">Отзывы о читалке</span>
|
||||
<span v-if="mode == 'omnireader'" class="bottom-span clickable" @click="openOldVersion">Старая версия</span>
|
||||
</div>
|
||||
|
||||
<div class="part bottom">
|
||||
@@ -26,6 +39,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 +48,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 +66,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 +75,7 @@ class LoaderPage extends Vue {
|
||||
}
|
||||
|
||||
get title() {
|
||||
if (this.$store.state.config.mode == 'omnireader')
|
||||
if (this.mode == 'omnireader')
|
||||
return 'Omni Reader - браузерная онлайн-читалка.';
|
||||
return 'Универсальная читалка книг и ресурсов интернета.';
|
||||
|
||||
@@ -83,12 +105,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');
|
||||
}
|
||||
@@ -101,7 +138,15 @@ class LoaderPage extends Vue {
|
||||
window.open('http://samlib.ru/comment/b/bookpauk/bookpauk_reader', '_blank');
|
||||
}
|
||||
|
||||
openOldVersion() {
|
||||
window.open('http://old.omnireader.ru', '_blank');
|
||||
}
|
||||
|
||||
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 +175,7 @@ class LoaderPage extends Vue {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 340px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.part {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
<span style="position: relative; top: -3px">
|
||||
Вставьте текст и нажмите
|
||||
<span class="clickable" style="font-size: 150%; position: relative; top: 1px" @click="loadBuffer">загрузить</span>
|
||||
или F2
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<el-input placeholder="Введите название текста" class="input" v-model="bookTitle"></el-input>
|
||||
</div>
|
||||
<hr/>
|
||||
<textarea ref="textArea" class="text" @paste="calcTitle"></textarea>
|
||||
</Window>
|
||||
</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>
|
||||
.text {
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
overflow-y: auto;
|
||||
padding: 0 10px 0 10px;
|
||||
position: relative;
|
||||
font-size: 120%;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.text:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
color: blue;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -94,6 +94,6 @@ class ProgressPage extends Vue {
|
||||
</style>
|
||||
<style>
|
||||
.el-progress__text {
|
||||
color: lightgreen;
|
||||
color: lightgreen !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,42 +1,45 @@
|
||||
<template>
|
||||
<el-container>
|
||||
<el-header v-show="toolBarActive" height='50px'>
|
||||
<div class="header">
|
||||
<div ref="header" class="header">
|
||||
<el-tooltip content="Загрузить книгу" :open-delay="1000" effect="light">
|
||||
<el-button ref="loader" class="tool-button" :class="buttonActiveClass('loader')" @click="buttonClick('loader')"><i class="el-icon-back"></i></el-button>
|
||||
</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-button ref="history" class="tool-button" :class="buttonActiveClass('history')" @click="buttonClick('history')"><i class="el-icon-document"></i></el-button>
|
||||
<el-tooltip v-show="showToolButton['offlineMode']" content="Автономный режим (без интернета)" :open-delay="1000" effect="light">
|
||||
<el-button ref="offlineMode" class="tool-button" :class="buttonActiveClass('offlineMode')" @click="buttonClick('offlineMode')"><i class="el-icon-connection"></i></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-show="showToolButton['recentBooks']" content="Открыть недавние" :open-delay="1000" effect="light">
|
||||
<el-button ref="recentBooks" class="tool-button" :class="buttonActiveClass('recentBooks')" @click="buttonClick('recentBooks')"><i class="el-icon-document"></i></el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
@@ -68,12 +71,108 @@
|
||||
@start-text-search="startTextSearch"
|
||||
@stop-text-search="stopTextSearch">
|
||||
</SearchPage>
|
||||
<CopyTextPage v-if="copyTextActive" ref="copyTextPage" @copy-text-toggle="copyTextToggle"></CopyTextPage>
|
||||
<HistoryPage v-show="historyActive" ref="historyPage" @load-book="loadBook" @history-toggle="historyToggle"></HistoryPage>
|
||||
<CopyTextPage v-if="copyTextActive" ref="copyTextPage" @copy-text-toggle="copyTextToggle"></CopyTextPage>
|
||||
<RecentBooksPage v-show="recentBooksActive" ref="recentBooksPage" @load-book="loadBook" @recent-books-toggle="recentBooksToggle"></RecentBooksPage>
|
||||
<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-dialog
|
||||
title="Внимание!"
|
||||
:visible.sync="migrationVisible1"
|
||||
width="90%">
|
||||
<div>
|
||||
Появилась httpS-версия сайта по адресу <a href="https://omnireader.ru" target="_blank">https://omnireader.ru</a><br>
|
||||
Работа по httpS-протоколу, помимо безопасности соединения, позволяет воспользоваться всеми возможностями
|
||||
современных браузеров, а именно, применительно к нашему ресурсу:
|
||||
|
||||
<ul>
|
||||
<li>возможность автономной работы с читалкой (без доступа к интернету), кеширование сайта через appcache</li>
|
||||
<li>безопасная передача на сервер данных о настройках и читаемых книгах при включенной синхронизации; все данные шифруются на стороне
|
||||
браузера ключом доступа и никто (в т.ч. администратор) не имеет возможности их прочитать
|
||||
<li>использование встроенных в JS функций шифрования и других</li>
|
||||
</ul>
|
||||
|
||||
Для того, чтобы перейти на новую версию с сохранением настроек и читаемых книг необходимо синхронизировать обе читалки:
|
||||
<ul>
|
||||
<li>зайти в "Настройки"->"Профили" и поставить галочку "Включить синхронизацию с сервером"</li>
|
||||
<li>там же добавить профиль устройства с любым именем для синхронизации настроек<br>
|
||||
<span style="margin-left: 20px"><i style="font-size: 90%" class="el-icon-info"></i>
|
||||
после этого все данные будут автоматически сохранены на сервер
|
||||
</span>
|
||||
</li>
|
||||
<li>далее нажать на кнопку "Показать ключ доступа" и кликнуть по ссылке "Ссылка для ввода ключа"<br>
|
||||
<span style="margin-left: 20px"><i style="font-size: 90%" class="el-icon-info"></i>
|
||||
произойдет переход на https-версию читалки и откроется окно для ввода ключа
|
||||
</span><br>
|
||||
<span style="margin-left: 20px"><i style="font-size: 90%" class="el-icon-info"></i>
|
||||
подтвердив ввод ключа нажатием "OK", включив синхронизацию с сервером и выбрав профиль устройства, вы восстановите все ваши настройки в новой версии
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
||||
Старая http-версия сайта будет доступна до конца 2019 года.<br>
|
||||
Приносим извинения за доставленные неудобства.
|
||||
</div>
|
||||
|
||||
<span slot="footer" class="dialog-footer">
|
||||
<el-button @click="migrationDialogDisable">Больше не показывать</el-button>
|
||||
<el-button @click="migrationDialogRemind">Напомнить позже</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
title="Внимание!"
|
||||
:visible.sync="migrationVisible2"
|
||||
width="90%">
|
||||
<div>
|
||||
Информация для пользователей старой версии читалки по адресу <a href="http://omnireader.ru" target="_blank">http://omnireader.ru</a><br>
|
||||
Для того, чтобы перейти на новую httpS-версию с сохранением настроек и читаемых книг необходимо синхронизировать обе читалки:
|
||||
<ul>
|
||||
<li>перейти на старую версию ресурса <a href="http://omnireader.ru" target="_blank">http://omnireader.ru</a></li>
|
||||
<li>зайти в "Настройки"->"Профили" и поставить галочку "Включить синхронизацию с сервером"</li>
|
||||
<li>там же добавить профиль устройства с любым именем для синхронизации настроек<br>
|
||||
<span style="margin-left: 20px"><i style="font-size: 90%" class="el-icon-info"></i>
|
||||
после этого все данные будут автоматически сохранены на сервер
|
||||
</span>
|
||||
</li>
|
||||
<li>далее нажать на кнопку "Показать ключ доступа" и кликнуть по ссылке "Ссылка для ввода ключа"<br>
|
||||
<span style="margin-left: 20px"><i style="font-size: 90%" class="el-icon-info"></i>
|
||||
произойдет переход на https-версию читалки и откроется окно для ввода ключа
|
||||
</span><br>
|
||||
<span style="margin-left: 20px"><i style="font-size: 90%" class="el-icon-info"></i>
|
||||
подтвердив ввод ключа нажатием "OK", включив синхронизацию с сервером и выбрав профиль устройства, вы восстановите все ваши настройки в новой версии
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
||||
Старая http-версия сайта будет доступна до конца 2019 года.<br>
|
||||
Приносим извинения за доставленные неудобства.
|
||||
</div>
|
||||
|
||||
<span slot="footer" class="dialog-footer">
|
||||
<el-button @click="migrationDialogDisable">Больше не показывать</el-button>
|
||||
<el-button @click="migrationDialogRemind">Напомнить позже</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
|
||||
</el-main>
|
||||
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
@@ -81,6 +180,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';
|
||||
@@ -88,16 +190,16 @@ import ProgressPage from './ProgressPage/ProgressPage.vue';
|
||||
import SetPositionPage from './SetPositionPage/SetPositionPage.vue';
|
||||
import SearchPage from './SearchPage/SearchPage.vue';
|
||||
import CopyTextPage from './CopyTextPage/CopyTextPage.vue';
|
||||
import HistoryPage from './HistoryPage/HistoryPage.vue';
|
||||
import RecentBooksPage from './RecentBooksPage/RecentBooksPage.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: {
|
||||
@@ -108,10 +210,11 @@ export default @Component({
|
||||
SetPositionPage,
|
||||
SearchPage,
|
||||
CopyTextPage,
|
||||
HistoryPage,
|
||||
RecentBooksPage,
|
||||
SettingsPage,
|
||||
HelpPage,
|
||||
ClickMapPage,
|
||||
ServerStorage,
|
||||
},
|
||||
watch: {
|
||||
bookPos: function(newValue) {
|
||||
@@ -138,10 +241,12 @@ export default @Component({
|
||||
this.updateRoute();
|
||||
},
|
||||
loaderActive: function(newValue) {
|
||||
const recent = this.mostRecentBook();
|
||||
if (!newValue && !this.loading && recent && !bookManager.hasBookParsed(recent)) {
|
||||
this.loadBook(recent);
|
||||
}
|
||||
(async() => {
|
||||
const recent = this.mostRecentBook();
|
||||
if (!newValue && !this.loading && recent && !await bookManager.hasBookParsed(recent)) {
|
||||
this.loadBook(recent);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -154,7 +259,8 @@ class Reader extends Vue {
|
||||
setPositionActive = false;
|
||||
searchActive = false;
|
||||
copyTextActive = false;
|
||||
historyActive = false;
|
||||
recentBooksActive = false;
|
||||
offlineModeActive = false;
|
||||
settingsActive = false;
|
||||
helpActive = false;
|
||||
clickMapActive = false;
|
||||
@@ -163,9 +269,16 @@ class Reader extends Vue {
|
||||
allowUrlParamBookPos = false;
|
||||
showRefreshIcon = true;
|
||||
mostRecentBookReactive = null;
|
||||
showToolButton = {};
|
||||
|
||||
actionList = [];
|
||||
actionCur = -1;
|
||||
hidden = false;
|
||||
|
||||
whatsNewVisible = false;
|
||||
whatsNewContent = '';
|
||||
migrationVisible1 = false;
|
||||
migrationVisible2 = false;
|
||||
|
||||
created() {
|
||||
this.loading = true;
|
||||
@@ -197,21 +310,39 @@ class Reader extends Vue {
|
||||
});
|
||||
|
||||
this.loadSettings();
|
||||
|
||||
//TODO: убрать в будущем
|
||||
if (this.showToolButton['history']) {
|
||||
const newShowToolButton = Object.assign({}, this.showToolButton);
|
||||
newShowToolButton['recentBooks'] = true;
|
||||
delete newShowToolButton['history'];
|
||||
const newSettings = Object.assign({}, this.settings, { showToolButton: newShowToolButton });
|
||||
this.commit('reader/setSettings', newSettings);
|
||||
}
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.updateHeaderMinWidth();
|
||||
|
||||
(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.$refs.serverStorage.init();
|
||||
await this.showWhatsNew();
|
||||
await this.showMigration();
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -222,6 +353,109 @@ class Reader extends Vue {
|
||||
this.showClickMapPage = settings.showClickMapPage;
|
||||
this.clickControl = settings.clickControl;
|
||||
this.blinkCachedLoad = settings.blinkCachedLoad;
|
||||
this.showWhatsNewDialog = settings.showWhatsNewDialog;
|
||||
this.showMigrationDialog = settings.showMigrationDialog;
|
||||
this.showToolButton = settings.showToolButton;
|
||||
this.enableSitesFilter = settings.enableSitesFilter;
|
||||
|
||||
this.updateHeaderMinWidth();
|
||||
}
|
||||
|
||||
updateHeaderMinWidth() {
|
||||
const showButtonCount = Object.values(this.showToolButton).reduce((a, b) => a + (b ? 1 : 0), 0);
|
||||
if (this.$refs.header)
|
||||
this.$refs.header.style.minWidth = 65*showButtonCount + 'px';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async showMigration() {
|
||||
await utils.sleep(3000);
|
||||
if (!this.settingsActive &&
|
||||
this.mode == 'omnireader' && this.showMigrationDialog && this.migrationRemindDate != utils.formatDate(new Date(), 'coDate')) {
|
||||
if (window.location.protocol == 'http:') {
|
||||
this.migrationVisible1 = true;
|
||||
} else if (window.location.protocol == 'https:') {
|
||||
this.migrationVisible2 = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
migrationDialogDisable() {
|
||||
this.migrationVisible1 = false;
|
||||
this.migrationVisible2 = false;
|
||||
if (this.showMigrationDialog) {
|
||||
const newSettings = Object.assign({}, this.settings, { showMigrationDialog: false });
|
||||
this.commit('reader/setSettings', newSettings);
|
||||
}
|
||||
}
|
||||
|
||||
migrationDialogRemind() {
|
||||
this.migrationVisible1 = false;
|
||||
this.migrationVisible2 = false;
|
||||
this.commit('reader/setMigrationRemindDate', utils.formatDate(new Date(), 'coDate'));
|
||||
}
|
||||
|
||||
openVersionHistory() {
|
||||
this.whatsNewVisible = false;
|
||||
this.versionHistoryToggle();
|
||||
}
|
||||
|
||||
whatsNewDisable() {
|
||||
this.whatsNewVisible = false;
|
||||
const whatsNew = versionHistory[0];
|
||||
this.commit('reader/setWhatsNewContentHash', whatsNew.header);
|
||||
}
|
||||
|
||||
get routeParamPos() {
|
||||
@@ -237,16 +471,22 @@ 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}` : '');
|
||||
if (isNewRoute)
|
||||
this.$router.push(`/reader?${pos}${url}`);
|
||||
this.$router.push(`/reader?${pos}${url}`).catch(() => {});
|
||||
else
|
||||
this.$router.replace(`/reader?${pos}${url}`);
|
||||
this.$router.replace(`/reader?${pos}${url}`).catch(() => {});
|
||||
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.$store.state.config.mode;
|
||||
}
|
||||
|
||||
get routeParamUrl() {
|
||||
let result = '';
|
||||
const path = this.$route.fullPath;
|
||||
@@ -258,6 +498,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 +510,38 @@ class Reader extends Vue {
|
||||
this.debouncedUpdateRoute();
|
||||
}
|
||||
|
||||
async bookManagerEvent(eventName) {
|
||||
if (eventName == 'set-recent' || eventName == 'recent-deleted') {
|
||||
const oldBook = (this.textPage ? this.textPage.lastBook : null);
|
||||
const oldPos = (this.textPage ? this.textPage.bookPos : null);
|
||||
const newBook = bookManager.mostRecentBook();
|
||||
|
||||
if (!(oldBook && newBook && oldBook.key == newBook.key)) {
|
||||
this.mostRecentBook();
|
||||
}
|
||||
|
||||
if (oldBook && newBook) {
|
||||
if (oldBook.key != newBook.key || oldBook.path != newBook.path) {
|
||||
this.loadingBook = true;
|
||||
try {
|
||||
await this.loadBook(newBook);
|
||||
} finally {
|
||||
this.loadingBook = false;
|
||||
}
|
||||
} else if (oldPos != newBook.bookPos) {
|
||||
while (this.loadingBook) await utils.sleep(100);
|
||||
this.bookPosChanged({bookPos: newBook.bookPos});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (eventName == 'recent-changed') {
|
||||
if (this.recentBooksActive) {
|
||||
await this.$refs.recentBooksPage.updateTableData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get toolBarActive() {
|
||||
return this.reader.toolBarActive;
|
||||
}
|
||||
@@ -279,6 +556,14 @@ class Reader extends Vue {
|
||||
return this.$store.state.reader.settings;
|
||||
}
|
||||
|
||||
get whatsNewContentHash() {
|
||||
return this.$store.state.reader.whatsNewContentHash;
|
||||
}
|
||||
|
||||
get migrationRemindDate() {
|
||||
return this.$store.state.reader.migrationRemindDate;
|
||||
}
|
||||
|
||||
addAction(pos) {
|
||||
let a = this.actionList;
|
||||
if (!a.length || a[a.length - 1] != pos) {
|
||||
@@ -319,7 +604,7 @@ class Reader extends Vue {
|
||||
closeAllTextPages() {
|
||||
this.setPositionActive = false;
|
||||
this.copyTextActive = false;
|
||||
this.historyActive = false;
|
||||
this.recentBooksActive = false;
|
||||
this.settingsActive = false;
|
||||
this.stopScrolling();
|
||||
this.stopSearch();
|
||||
@@ -411,22 +696,31 @@ class Reader extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
historyToggle() {
|
||||
this.historyActive = !this.historyActive;
|
||||
if (this.historyActive) {
|
||||
recentBooksToggle() {
|
||||
this.recentBooksActive = !this.recentBooksActive;
|
||||
if (this.recentBooksActive) {
|
||||
this.closeAllTextPages();
|
||||
this.$refs.historyPage.init();
|
||||
this.historyActive = true;
|
||||
this.$refs.recentBooksPage.init();
|
||||
this.recentBooksActive = true;
|
||||
} else {
|
||||
this.historyActive = false;
|
||||
this.recentBooksActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
offlineModeToggle() {
|
||||
this.offlineModeActive = !this.offlineModeActive;
|
||||
this.$refs.serverStorage.offlineModeActive = this.offlineModeActive;
|
||||
}
|
||||
|
||||
settingsToggle() {
|
||||
this.settingsActive = !this.settingsActive;
|
||||
if (this.settingsActive) {
|
||||
this.closeAllTextPages();
|
||||
this.settingsActive = true;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.settingsPage.init();
|
||||
});
|
||||
} else {
|
||||
this.settingsActive = false;
|
||||
}
|
||||
@@ -449,6 +743,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});
|
||||
@@ -494,12 +797,15 @@ class Reader extends Vue {
|
||||
case 'copyText':
|
||||
this.copyTextToggle();
|
||||
break;
|
||||
case 'history':
|
||||
this.historyToggle();
|
||||
break;
|
||||
case 'refresh':
|
||||
this.refreshBook();
|
||||
break;
|
||||
case 'recentBooks':
|
||||
this.recentBooksToggle();
|
||||
break;
|
||||
case 'offlineMode':
|
||||
this.offlineModeToggle();
|
||||
break;
|
||||
case 'settings':
|
||||
this.settingsToggle();
|
||||
break;
|
||||
@@ -518,7 +824,8 @@ class Reader extends Vue {
|
||||
case 'scrolling':
|
||||
case 'search':
|
||||
case 'copyText':
|
||||
case 'history':
|
||||
case 'recentBooks':
|
||||
case 'offlineMode':
|
||||
case 'settings':
|
||||
if (this[`${button}Active`])
|
||||
classResult = classActive;
|
||||
@@ -536,7 +843,7 @@ class Reader extends Vue {
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.activePage == 'LoaderPage' || !this.mostRecentBook()) {
|
||||
if (this.activePage == 'LoaderPage' || !this.mostRecentBookReactive) {
|
||||
switch (button) {
|
||||
case 'undoAction':
|
||||
case 'redoAction':
|
||||
@@ -546,9 +853,9 @@ class Reader extends Vue {
|
||||
case 'copyText':
|
||||
classResult = classDisabled;
|
||||
break;
|
||||
case 'history':
|
||||
case 'recentBooks':
|
||||
case 'refresh':
|
||||
if (!this.mostRecentBook())
|
||||
if (!this.mostRecentBookReactive)
|
||||
classResult = classDisabled;
|
||||
break;
|
||||
}
|
||||
@@ -584,11 +891,17 @@ 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() => {
|
||||
const last = this.mostRecentBookReactive;
|
||||
const isParsed = bookManager.hasBookParsed(last);
|
||||
const isParsed = await bookManager.hasBookParsed(last);
|
||||
|
||||
if (!isParsed) {
|
||||
this.$root.$emit('set-app-title');
|
||||
return;
|
||||
@@ -597,6 +910,7 @@ class Reader extends Vue {
|
||||
this.updateRoute();
|
||||
const textPage = this.$refs.page;
|
||||
if (textPage.showBook) {
|
||||
this.textPage = textPage;
|
||||
textPage.lastBook = last;
|
||||
textPage.bookPos = (last.bookPos !== undefined ? last.bookPos : 0);
|
||||
|
||||
@@ -609,7 +923,7 @@ class Reader extends Vue {
|
||||
return result;
|
||||
}
|
||||
|
||||
loadBook(opts) {
|
||||
async loadBook(opts) {
|
||||
if (!opts || !opts.url) {
|
||||
this.mostRecentBook();
|
||||
return;
|
||||
@@ -621,126 +935,131 @@ class Reader extends Vue {
|
||||
url = 'http://' + url;
|
||||
|
||||
// уже просматривается сейчас
|
||||
const lastBook = (this.$refs.page ? this.$refs.page.lastBook : null);
|
||||
if (!opts.force && lastBook && lastBook.url == url && bookManager.hasBookParsed(lastBook)) {
|
||||
const lastBook = (this.textPage ? this.textPage.lastBook : null);
|
||||
if (!opts.force && lastBook && lastBook.url == url &&
|
||||
(!opts.path || opts.path == lastBook.path) &&
|
||||
await bookManager.hasBookParsed(lastBook)) {
|
||||
this.loaderActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
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, path: opts.path}, (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) {
|
||||
progress.setState({totalSteps: 5});
|
||||
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'});
|
||||
|
||||
const url = await readerApi.uploadFile(opts.file, this.config.maxUploadFileSize, (state) => {
|
||||
progress.setState({totalSteps: 5});
|
||||
// не удалось, скачиваем книгу полностью с конвертацией
|
||||
let loadCached = true;
|
||||
if (!book) {
|
||||
book = await readerApi.loadBook({url, enableSitesFilter: this.enableSitesFilter}, (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() {
|
||||
@@ -755,9 +1074,9 @@ class Reader extends Vue {
|
||||
let page = this.$refs.page;
|
||||
while (this.blinkCount) {
|
||||
this.showRefreshIcon = !this.showRefreshIcon;
|
||||
if (page.blinkCachedLoadMessage)
|
||||
if (page && page.blinkCachedLoadMessage)
|
||||
page.blinkCachedLoadMessage(this.showRefreshIcon);
|
||||
await sleep(500);
|
||||
await utils.sleep(500);
|
||||
if (this.stopBlink)
|
||||
break;
|
||||
this.blinkCount--;
|
||||
@@ -765,7 +1084,7 @@ class Reader extends Vue {
|
||||
}
|
||||
this.showRefreshIcon = true;
|
||||
this.inBlink = false;
|
||||
if (page.blinkCachedLoadMessage)
|
||||
if (page && page.blinkCachedLoadMessage)
|
||||
page.blinkCachedLoadMessage('finish');
|
||||
});
|
||||
}
|
||||
@@ -780,8 +1099,8 @@ class Reader extends Vue {
|
||||
if (!handled && this.settingsActive)
|
||||
handled = this.$refs.settingsPage.keyHook(event);
|
||||
|
||||
if (!handled && this.historyActive)
|
||||
handled = this.$refs.historyPage.keyHook(event);
|
||||
if (!handled && this.recentBooksActive)
|
||||
handled = this.$refs.recentBooksPage.keyHook(event);
|
||||
|
||||
if (!handled && this.setPositionActive)
|
||||
handled = this.$refs.setPositionPage.keyHook(event);
|
||||
@@ -831,10 +1150,13 @@ class Reader extends Vue {
|
||||
this.refreshBook();
|
||||
break;
|
||||
case 'KeyX':
|
||||
this.historyToggle();
|
||||
this.recentBooksToggle();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
break;
|
||||
case 'KeyO':
|
||||
this.offlineModeToggle();
|
||||
break;
|
||||
case 'KeyS':
|
||||
this.settingsToggle();
|
||||
break;
|
||||
@@ -862,11 +1184,10 @@ class Reader extends Vue {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-width: 550px;
|
||||
}
|
||||
|
||||
.el-main {
|
||||
@@ -890,6 +1211,10 @@ class Reader extends Vue {
|
||||
box-shadow: 3px 3px 5px black;
|
||||
}
|
||||
|
||||
.tool-button + .tool-button {
|
||||
margin: 0 2px 0 2px;
|
||||
}
|
||||
|
||||
.tool-button:hover {
|
||||
background-color: white;
|
||||
}
|
||||
@@ -930,4 +1255,10 @@ i {
|
||||
.clear {
|
||||
color: rgba(0,0,0,0);
|
||||
}
|
||||
</style>
|
||||
|
||||
.clickable {
|
||||
color: blue;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
321
client/components/Reader/RecentBooksPage/RecentBooksPage.vue
Normal file
321
client/components/Reader/RecentBooksPage/RecentBooksPage.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<Window width="600px" ref="window" @close="close">
|
||||
<template slot="header">
|
||||
<span v-show="!loading">Последние {{tableData ? tableData.length : 0}} открытых книг</span>
|
||||
<span v-show="loading"><i class="el-icon-loading" style="font-size: 25px"></i> <span style="position: relative; top: -4px">Список загружается</span></span>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="tableData"
|
||||
style="width: 570px"
|
||||
size="mini"
|
||||
height="1px"
|
||||
stripe
|
||||
border
|
||||
:default-sort = "{prop: 'touchDateTime', order: 'descending'}"
|
||||
:header-cell-style = "headerCellStyle"
|
||||
:row-key = "rowKey"
|
||||
>
|
||||
|
||||
<el-table-column
|
||||
type="index"
|
||||
width="35px"
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="touchDateTime"
|
||||
min-width="85px"
|
||||
sortable
|
||||
>
|
||||
<template slot="header" slot-scope="scope"><!-- eslint-disable-line vue/no-unused-vars -->
|
||||
<span style="font-size: 90%">Время<br>просм.</span>
|
||||
</template>
|
||||
<template slot-scope="scope"><!-- eslint-disable-line vue/no-unused-vars -->
|
||||
<div class="desc" @click="loadBook(scope.row.url)">
|
||||
{{ scope.row.touchDate }}<br>
|
||||
{{ scope.row.touchTime }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
>
|
||||
<template slot="header" slot-scope="scope"><!-- eslint-disable-line vue/no-unused-vars -->
|
||||
<!--el-input ref="input"
|
||||
:value="search" @input="search = $event"
|
||||
size="mini"
|
||||
style="margin: 0; padding: 0; vertical-align: bottom; margin-top: 10px"
|
||||
placeholder="Найти"/-->
|
||||
<div class="el-input el-input--mini">
|
||||
<input class="el-input__inner"
|
||||
ref="input"
|
||||
placeholder="Найти"
|
||||
style="margin: 0; vertical-align: bottom; margin-top: 20px; padding: 0 10px 0 10px"
|
||||
:value="search" @input="search = $event.target.value"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table-column
|
||||
min-width="280px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<div class="desc" @click="loadBook(scope.row.url)">
|
||||
<span style="color: green">{{ scope.row.desc.author }}</span><br>
|
||||
<span>{{ scope.row.desc.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
min-width="90px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<a v-show="isUrl(scope.row.url)" :href="scope.row.url" target="_blank">Оригинал</a><br>
|
||||
<a :href="scope.row.path" :download="getFileNameFromPath(scope.row.path)">Скачать FB2</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
width="60px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
size="mini"
|
||||
style="width: 30px; padding: 7px 0 7px 0; margin-left: 4px"
|
||||
@click="handleDel(scope.row.key)"><i class="el-icon-close"></i>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
</el-table-column>
|
||||
|
||||
</el-table>
|
||||
</Window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import path from 'path';
|
||||
import _ from 'lodash';
|
||||
|
||||
import * as utils from '../../../share/utils';
|
||||
import Window from '../../share/Window.vue';
|
||||
import bookManager from '../share/bookManager';
|
||||
|
||||
export default @Component({
|
||||
components: {
|
||||
Window,
|
||||
},
|
||||
watch: {
|
||||
search: function() {
|
||||
this.updateTableData();
|
||||
}
|
||||
},
|
||||
})
|
||||
class RecentBooksPage extends Vue {
|
||||
loading = false;
|
||||
search = null;
|
||||
tableData = [];
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
init() {
|
||||
this.$refs.window.init();
|
||||
|
||||
this.$nextTick(() => {
|
||||
//this.$refs.input.focus();
|
||||
});
|
||||
(async() => {//отбражение подгрузки списка, иначе тормозит
|
||||
if (this.initing)
|
||||
return;
|
||||
this.initing = true;
|
||||
|
||||
await this.updateTableData(3);
|
||||
await utils.sleep(200);
|
||||
|
||||
if (bookManager.loaded) {
|
||||
const t = Date.now();
|
||||
await this.updateTableData(10);
|
||||
if (bookManager.getSortedRecent().length > 10)
|
||||
await utils.sleep(10*(Date.now() - t));
|
||||
} else {
|
||||
let i = 0;
|
||||
let j = 5;
|
||||
while (i < 500 && !bookManager.loaded) {
|
||||
if (i % j == 0) {
|
||||
bookManager.sortedRecentCached = null;
|
||||
await this.updateTableData(100);
|
||||
j *= 2;
|
||||
}
|
||||
|
||||
await utils.sleep(100);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
await this.updateTableData();
|
||||
this.initing = false;
|
||||
})();
|
||||
}
|
||||
|
||||
rowKey(row) {
|
||||
return row.key;
|
||||
}
|
||||
|
||||
async updateTableData(limit) {
|
||||
while (this.updating) await utils.sleep(100);
|
||||
this.updating = true;
|
||||
let result = [];
|
||||
|
||||
this.loading = !!limit;
|
||||
const sorted = bookManager.getSortedRecent();
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const book = sorted[i];
|
||||
if (book.deleted)
|
||||
continue;
|
||||
|
||||
if (limit && result.length >= limit)
|
||||
break;
|
||||
|
||||
let d = new Date();
|
||||
d.setTime(book.touchTime);
|
||||
const t = utils.formatDate(d).split(' ');
|
||||
|
||||
let perc = '';
|
||||
let textLen = '';
|
||||
const p = (book.bookPosSeen ? book.bookPosSeen : (book.bookPos ? book.bookPos : 0));
|
||||
if (book.textLength) {
|
||||
perc = ` [${((p/book.textLength)*100).toFixed(2)}%]`;
|
||||
textLen = ` ${Math.round(book.textLength/1000)}k`;
|
||||
}
|
||||
|
||||
const fb2 = (book.fb2 ? book.fb2 : {});
|
||||
|
||||
let title = fb2.bookTitle;
|
||||
if (title)
|
||||
title = `"${title}"`;
|
||||
else
|
||||
title = '';
|
||||
|
||||
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({
|
||||
touchDateTime: book.touchTime,
|
||||
touchDate: t[0],
|
||||
touchTime: t[1],
|
||||
desc: {
|
||||
title: `${title}${perc}${textLen}`,
|
||||
author,
|
||||
},
|
||||
url: book.url,
|
||||
path: book.path,
|
||||
key: book.key,
|
||||
});
|
||||
if (result.length >= 100)
|
||||
break;
|
||||
}
|
||||
|
||||
const search = this.search;
|
||||
result = result.filter(item => {
|
||||
return !search ||
|
||||
item.touchTime.includes(search) ||
|
||||
item.touchDate.includes(search) ||
|
||||
item.desc.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.desc.author.toLowerCase().includes(search.toLowerCase())
|
||||
});
|
||||
|
||||
/*for (let i = 0; i < result.length; i++) {
|
||||
if (!_.isEqual(this.tableData[i], result[i])) {
|
||||
this.$set(this.tableData, i, result[i]);
|
||||
await utils.sleep(10);
|
||||
}
|
||||
}
|
||||
if (this.tableData.length > result.length)
|
||||
this.tableData.splice(result.length);*/
|
||||
|
||||
this.tableData = result;
|
||||
this.updating = false;
|
||||
}
|
||||
|
||||
headerCellStyle(cell) {
|
||||
let result = {margin: 0, padding: 0};
|
||||
if (cell.columnIndex > 0) {
|
||||
result['border-bottom'] = 0;
|
||||
}
|
||||
if (cell.rowIndex > 0) {
|
||||
result.height = '0px';
|
||||
result['border-right'] = 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getFileNameFromPath(fb2Path) {
|
||||
return path.basename(fb2Path).substr(0, 10) + '.fb2';
|
||||
}
|
||||
|
||||
openOriginal(url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
openFb2(path) {
|
||||
window.open(path, '_blank');
|
||||
}
|
||||
|
||||
async handleDel(key) {
|
||||
await bookManager.delRecentBook({key});
|
||||
this.updateTableData();
|
||||
|
||||
if (!bookManager.mostRecentBook())
|
||||
this.close();
|
||||
}
|
||||
|
||||
loadBook(url) {
|
||||
this.$emit('load-book', {url});
|
||||
this.close();
|
||||
}
|
||||
|
||||
isUrl(url) {
|
||||
if (url)
|
||||
return (url.indexOf('file://') != 0);
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$emit('recent-books-toggle');
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (event.type == 'keydown' && event.code == 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.desc {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,28 +1,24 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
{{ header }}
|
||||
</template>
|
||||
<Window ref="window" height="125px" max-width="600px" :top-shift="-50" @close="close">
|
||||
<template slot="header">
|
||||
{{ header }}
|
||||
</template>
|
||||
|
||||
<div class="content">
|
||||
<span v-show="initStep">{{ initPercentage }}%</span>
|
||||
<div class="content">
|
||||
<span v-show="initStep">{{ initPercentage }}%</span>
|
||||
|
||||
<div v-show="!initStep" class="input">
|
||||
<input ref="input" class="el-input__inner"
|
||||
placeholder="что ищем"
|
||||
:value="needle" @input="needle = $event.target.value"/>
|
||||
<div style="position: absolute; right: 10px; margin-top: 10px; font-size: 16px;">{{ foundText }}</div>
|
||||
</div>
|
||||
<el-button-group v-show="!initStep" class="button-group">
|
||||
<el-button @click="showNext"><i class="el-icon-arrow-down"></i></el-button>
|
||||
<el-button @click="showPrev"><i class="el-icon-arrow-up"></i></el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</Window>
|
||||
<div v-show="!initStep" class="input">
|
||||
<input ref="input" class="el-input__inner"
|
||||
placeholder="что ищем"
|
||||
:value="needle" @input="needle = $event.target.value"/>
|
||||
<div style="position: absolute; right: 10px; margin-top: 10px; font-size: 16px;">{{ foundText }}</div>
|
||||
</div>
|
||||
<el-button-group v-show="!initStep" class="button-group">
|
||||
<el-button @click="showNext"><i class="el-icon-arrow-down"></i></el-button>
|
||||
<el-button @click="showPrev"><i class="el-icon-arrow-up"></i></el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -61,6 +57,8 @@ class SearchPage extends Vue {
|
||||
}
|
||||
|
||||
async init(parsed) {
|
||||
this.$refs.window.init();
|
||||
|
||||
if (this.parsed != parsed) {
|
||||
this.initStep = true;
|
||||
this.stopInit = false;
|
||||
@@ -178,32 +176,13 @@ class SearchPage extends Vue {
|
||||
</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%;
|
||||
max-width: 500px;
|
||||
height: 125px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
top: -50px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
min-width: 430px;
|
||||
}
|
||||
|
||||
.input {
|
||||
|
||||
642
client/components/Reader/ServerStorage/ServerStorage.vue
Normal file
642
client/components/Reader/ServerStorage/ServerStorage.vue
Normal file
@@ -0,0 +1,642 @@
|
||||
<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';
|
||||
|
||||
import localForage from 'localforage';
|
||||
const ssCacheStore = localForage.createInstance({
|
||||
name: 'ssCacheStore'
|
||||
});
|
||||
|
||||
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.debouncedSaveRecent = _.debounce((itemKey) => {
|
||||
this.saveRecent(itemKey);
|
||||
}, 1000);
|
||||
|
||||
this.debouncedNotifySuccess = _.debounce(() => {
|
||||
this.success('Данные синхронизированы с сервером');
|
||||
}, 1000);
|
||||
|
||||
this.oldProfiles = {};
|
||||
this.oldSettings = {};
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.cachedRecent = await ssCacheStore.getItem('recent');
|
||||
if (!this.cachedRecent)
|
||||
await this.setCachedRecent({rev: 0, data: {}});
|
||||
|
||||
this.cachedRecentPatch = await ssCacheStore.getItem('recent-patch');
|
||||
if (!this.cachedRecentPatch)
|
||||
await this.setCachedRecentPatch({rev: 0, data: {}});
|
||||
|
||||
this.cachedRecentMod = await ssCacheStore.getItem('recent-mod');
|
||||
if (!this.cachedRecentMod)
|
||||
await this.setCachedRecentMod({rev: 0, data: {}});
|
||||
|
||||
if (!this.serverStorageKey) {
|
||||
//генерируем новый ключ
|
||||
await this.generateNewServerStorageKey();
|
||||
} else {
|
||||
await this.serverStorageKeyChanged();
|
||||
}
|
||||
|
||||
bookManager.addEventListener(this.bookManagerEvent);
|
||||
} finally {
|
||||
this.inited = true;
|
||||
}
|
||||
}
|
||||
|
||||
async setCachedRecent(value) {
|
||||
await ssCacheStore.setItem('recent', value);
|
||||
this.cachedRecent = value;
|
||||
}
|
||||
|
||||
async setCachedRecentPatch(value) {
|
||||
await ssCacheStore.setItem('recent-patch', value);
|
||||
this.cachedRecentPatch = value;
|
||||
}
|
||||
|
||||
async setCachedRecentMod(value) {
|
||||
await ssCacheStore.setItem('recent-mod', value);
|
||||
this.cachedRecentMod = value;
|
||||
}
|
||||
|
||||
async bookManagerEvent(eventName, itemKey) {
|
||||
if (!this.serverSyncEnabled)
|
||||
return;
|
||||
|
||||
if (eventName == 'recent-changed') {
|
||||
if (itemKey) {
|
||||
this.debouncedSaveRecent(itemKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
const loadSuccess = await this.loadRecent();
|
||||
if (loadSuccess && 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', '');
|
||||
}
|
||||
}
|
||||
|
||||
success(message) {
|
||||
if (this.showServerStorageMessages)
|
||||
this.$notify.success({message});
|
||||
}
|
||||
|
||||
warning(message) {
|
||||
if (this.showServerStorageMessages && !this.offlineModeActive)
|
||||
this.$notify.warning({message});
|
||||
}
|
||||
|
||||
error(message) {
|
||||
if (this.showServerStorageMessages && !this.offlineModeActive)
|
||||
this.$notify.error({message});
|
||||
}
|
||||
|
||||
async loadSettings(force = false, doNotifySuccess = true) {
|
||||
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});
|
||||
|
||||
if (doNotifySuccess)
|
||||
this.debouncedNotifySuccess();
|
||||
} 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: ''};
|
||||
|
||||
const oldRev = this.settingsRev[setsId] || 0;
|
||||
try {
|
||||
result = await this.storageSet({[setsId]: {rev: oldRev + 1, data: this.settings}});
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером (${e.message}). Данные не сохранены и могут быть перезаписаны.`);
|
||||
}
|
||||
|
||||
if (result.state == 'reject') {
|
||||
await this.loadSettings(true, false);
|
||||
this.warning(`Последние изменения отменены. Данные синхронизированы с сервером.`);
|
||||
} else if (result.state == 'success') {
|
||||
this.oldSettings = _.cloneDeep(this.settings);
|
||||
this.commit('reader/setSettingsRev', {[setsId]: this.settingsRev[setsId] + 1});
|
||||
}
|
||||
} finally {
|
||||
this.savingSettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadProfiles(force = false, doNotifySuccess = true) {
|
||||
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.checkCurrentProfile();
|
||||
|
||||
if (doNotifySuccess)
|
||||
this.debouncedNotifySuccess();
|
||||
} 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;
|
||||
|
||||
//обнуляются профили во время разработки при hotReload, подстраховка
|
||||
if (!this.$store.state.reader.allowProfilesSave) {
|
||||
console.error('Сохранение профилей не санкционировано');
|
||||
return;
|
||||
}
|
||||
|
||||
this.savingProfiles = true;
|
||||
try {
|
||||
let result = {state: ''};
|
||||
try {
|
||||
result = await this.storageSet({profiles: {rev: this.profilesRev + 1, data: this.profiles}});
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером (${e.message}). Данные не сохранены и могут быть перезаписаны.`);
|
||||
}
|
||||
|
||||
if (result.state == 'reject') {
|
||||
await this.loadProfiles(true, false);
|
||||
this.warning(`Последние изменения отменены. Данные синхронизированы с сервером.`);
|
||||
} else if (result.state == 'success') {
|
||||
this.oldProfiles = _.cloneDeep(this.profiles);
|
||||
this.commit('reader/setProfilesRev', this.profilesRev + 1);
|
||||
}
|
||||
} finally {
|
||||
this.savingProfiles = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadRecent(skipRevCheck = false, doNotifySuccess = true) {
|
||||
if (!this.keyInited || !this.serverSyncEnabled || this.loadingRecent)
|
||||
return;
|
||||
this.loadingRecent = true;
|
||||
try {
|
||||
//проверим ревизию на сервере
|
||||
let query = {recent: {}, recentPatch: {}, recentMod: {}};
|
||||
let revs = null;
|
||||
if (!skipRevCheck) {
|
||||
try {
|
||||
revs = await this.storageCheck(query);
|
||||
if (revs.state == 'success') {
|
||||
if (revs.items.recent.rev != this.cachedRecent.rev) {
|
||||
//no changes
|
||||
} else if (revs.items.recentPatch.rev != this.cachedRecentPatch.rev) {
|
||||
query = {recentPatch: {}, recentMod: {}};
|
||||
} else if (revs.items.recentMod.rev != this.cachedRecentMod.rev) {
|
||||
query = {recentMod: {}};
|
||||
} else
|
||||
return true;
|
||||
}
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let recent = null;
|
||||
try {
|
||||
recent = await this.storageGet(query);
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (recent.state == 'success') {
|
||||
let newRecent = recent.items.recent;
|
||||
let newRecentPatch = recent.items.recentPatch;
|
||||
let newRecentMod = recent.items.recentMod;
|
||||
|
||||
if (!newRecent) {
|
||||
newRecent = _.cloneDeep(this.cachedRecent);
|
||||
}
|
||||
if (!newRecentPatch) {
|
||||
newRecentPatch = _.cloneDeep(this.cachedRecentPatch);
|
||||
}
|
||||
if (!newRecentMod) {
|
||||
newRecentMod = _.cloneDeep(this.cachedRecentMod);
|
||||
}
|
||||
|
||||
if (newRecent.rev == 0) newRecent.data = {};
|
||||
if (newRecentPatch.rev == 0) newRecentPatch.data = {};
|
||||
if (newRecentMod.rev == 0) newRecentMod.data = {};
|
||||
|
||||
let result = Object.assign({}, newRecent.data, newRecentPatch.data);
|
||||
|
||||
const md = newRecentMod.data;
|
||||
if (md.key && result[md.key])
|
||||
result[md.key] = utils.applyObjDiff(result[md.key], md.mod);
|
||||
|
||||
if (newRecent.rev != this.cachedRecent.rev)
|
||||
await this.setCachedRecent(newRecent);
|
||||
if (newRecentPatch.rev != this.cachedRecentPatch.rev)
|
||||
await this.setCachedRecentPatch(newRecentPatch);
|
||||
if (newRecentMod.rev != this.cachedRecentMod.rev)
|
||||
await this.setCachedRecentMod(newRecentMod);
|
||||
|
||||
if (!bookManager.loaded) {
|
||||
this.warning('Ожидание загрузки списка книг перед синхронизацией');
|
||||
while (!bookManager.loaded) await utils.sleep(100);
|
||||
}
|
||||
|
||||
await bookManager.setRecent(result);
|
||||
} else {
|
||||
this.warning(`Неверный ответ сервера: ${recent.state}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (doNotifySuccess)
|
||||
this.debouncedNotifySuccess();
|
||||
} finally {
|
||||
this.loadingRecent = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async saveRecent(itemKey, recurse) {
|
||||
if (!this.keyInited || !this.serverSyncEnabled || this.savingRecent)
|
||||
return;
|
||||
|
||||
const bm = bookManager;
|
||||
|
||||
let needSaveRecent = false;
|
||||
let needSaveRecentPatch = false;
|
||||
let needSaveRecentMod = false;
|
||||
|
||||
let applyMod = null;
|
||||
|
||||
//newRecentMod
|
||||
let newRecentMod = {};
|
||||
|
||||
if (itemKey && this.cachedRecentPatch.data[itemKey]) {
|
||||
if (this.prevItemKey == itemKey) {//сохраняем только дифф
|
||||
newRecentMod = _.cloneDeep(this.cachedRecentMod);
|
||||
newRecentMod.rev++;
|
||||
|
||||
newRecentMod.data.key = itemKey;
|
||||
newRecentMod.data.mod = utils.getObjDiff(this.cachedRecentPatch.data[itemKey], bm.recent[itemKey]);
|
||||
needSaveRecentMod = true;
|
||||
} else {//ключ не совпадает, надо сохранять патч
|
||||
applyMod = newRecentMod.data;
|
||||
}
|
||||
}
|
||||
this.prevItemKey = itemKey;
|
||||
|
||||
//newRecentPatch
|
||||
let newRecentPatch = {};
|
||||
if (itemKey && !needSaveRecentMod) {
|
||||
newRecentPatch = _.cloneDeep(this.cachedRecentPatch);
|
||||
newRecentPatch.rev++;
|
||||
newRecentPatch.data[itemKey] = bm.recent[itemKey];
|
||||
if (applyMod && applyMod.key && newRecentPatch.data[applyMod.key])
|
||||
newRecentPatch.data[applyMod.key] = utils.applyObjDiff(newRecentPatch.data[applyMod.key], applyMod.mod);
|
||||
newRecentMod = {rev: this.cachedRecentMod.rev + 1, data: {}};
|
||||
needSaveRecentPatch = true;
|
||||
needSaveRecentMod = true;
|
||||
}
|
||||
|
||||
//newRecent
|
||||
let newRecent = {};
|
||||
if (!itemKey || (needSaveRecentPatch && Object.keys(newRecentPatch.data).length > 10)) {
|
||||
newRecent = {rev: this.cachedRecent.rev + 1, data: bm.recent};
|
||||
newRecentPatch = {rev: this.cachedRecentPatch.rev + 1, data: {}};
|
||||
newRecentMod = {rev: this.cachedRecentMod.rev + 1, data: {}};
|
||||
needSaveRecent = true;
|
||||
needSaveRecentPatch = true;
|
||||
needSaveRecentMod = true;
|
||||
}
|
||||
|
||||
//query
|
||||
let query = {};
|
||||
if (needSaveRecent) {
|
||||
query = {recent: newRecent, recentPatch: newRecentPatch, recentMod: newRecentMod};
|
||||
} else if (needSaveRecentPatch) {
|
||||
query = {recentPatch: newRecentPatch, recentMod: newRecentMod};
|
||||
} else {
|
||||
query = {recentMod: newRecentMod};
|
||||
}
|
||||
|
||||
//сохранение
|
||||
this.savingRecent = true;
|
||||
try {
|
||||
let result = {state: ''};
|
||||
|
||||
try {
|
||||
result = await this.storageSet(query);
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером (${e.message}). Данные не сохранены и могут быть перезаписаны.`);
|
||||
}
|
||||
|
||||
if (result.state == 'reject') {
|
||||
|
||||
await this.loadRecent(false, false);
|
||||
|
||||
this.warning(`Последние изменения отменены. Данные синхронизированы с сервером.`);
|
||||
if (!recurse && itemKey) {
|
||||
this.savingRecent = false;
|
||||
this.saveRecent(itemKey, true);
|
||||
return;
|
||||
}
|
||||
} else if (result.state == 'success') {
|
||||
if (needSaveRecent && newRecent.rev)
|
||||
await this.setCachedRecent(newRecent);
|
||||
if (needSaveRecentPatch && newRecentPatch.rev)
|
||||
await this.setCachedRecentPatch(newRecentPatch);
|
||||
if (needSaveRecentMod && newRecentMod.rev)
|
||||
await this.setCachedRecentMod(newRecentMod);
|
||||
}
|
||||
} finally {
|
||||
this.savingRecent = 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>
|
||||
@@ -1,17 +1,13 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Установить позицию
|
||||
</template>
|
||||
<Window ref="window" height="140px" max-width="600px" :top-shift="-50" @close="close">
|
||||
<template slot="header">
|
||||
Установить позицию
|
||||
</template>
|
||||
|
||||
<div class="slider">
|
||||
<el-slider v-model="sliderValue" :max="sliderMax" :format-tooltip="formatTooltip"></el-slider>
|
||||
</div>
|
||||
</Window>
|
||||
<div class="slider">
|
||||
<el-slider v-model="sliderValue" :max="sliderMax" :format-tooltip="formatTooltip"></el-slider>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -43,6 +39,8 @@ class SetPositionPage extends Vue {
|
||||
}
|
||||
|
||||
init(sliderValue, sliderMax) {
|
||||
this.$refs.window.init();
|
||||
|
||||
this.sliderMax = sliderMax;
|
||||
this.sliderValue = sliderValue;
|
||||
this.initialized = true;
|
||||
@@ -70,26 +68,6 @@ class SetPositionPage extends Vue {
|
||||
</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%;
|
||||
max-width: 600px;
|
||||
height: 140px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
top: -50px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
margin: 20px;
|
||||
background-color: #efefef;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,56 @@ 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 = '5%';
|
||||
await sleep(30);
|
||||
|
||||
page2.style.transition = `${duration}ms ease-in-out`;
|
||||
page2.style.transform = `rotateY(-120deg) translateX(${this.w/4}px)`;
|
||||
await animation2Finish(duration);
|
||||
} else {
|
||||
page2.style.transformOrigin = '95%';
|
||||
await sleep(30);
|
||||
|
||||
page2.style.transition = `${duration}ms ease-in-out`;
|
||||
page2.style.transform = `rotateY(120deg) translateX(-${this.w/4}px)`;
|
||||
await animation2Finish(duration);
|
||||
}
|
||||
|
||||
page2.style.transformOrigin = 'center';
|
||||
page2.style.background = '';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -133,7 +131,6 @@ class TextPage extends Vue {
|
||||
}, 10);
|
||||
|
||||
this.$root.$on('resize', () => {this.$nextTick(this.onResize)});
|
||||
this.mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
mounted() {
|
||||
@@ -157,9 +154,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 +168,12 @@ 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);
|
||||
|
||||
//drawHelper
|
||||
this.drawHelper.realWidth = this.realWidth;
|
||||
this.drawHelper.realHeight = this.realHeight;
|
||||
@@ -195,14 +199,8 @@ 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';
|
||||
//statusBar
|
||||
this.statusBarClickable = this.drawHelper.statusBarClickable(this.statusBarTop, this.statusBarHeight);
|
||||
|
||||
//parsed
|
||||
if (this.parsed) {
|
||||
@@ -222,30 +220,35 @@ class TextPage extends Vue {
|
||||
this.parsed.showInlineImagesInCenter = this.showInlineImagesInCenter;
|
||||
this.parsed.imageHeightLines = this.imageHeightLines;
|
||||
this.parsed.imageFitWidth = this.imageFitWidth;
|
||||
this.parsed.compactTextPerc = this.compactTextPerc;
|
||||
}
|
||||
|
||||
//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 +263,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: 'Загрузка шрифта <i class="el-icon-loading"></i>',
|
||||
duration: 0
|
||||
});
|
||||
})();
|
||||
|
||||
if (!this.fontsLoaded)
|
||||
this.fontsLoaded = {};
|
||||
//загрузка дин.шрифта
|
||||
@@ -290,6 +305,8 @@ class TextPage extends Vue {
|
||||
}
|
||||
|
||||
this.fontsLoading = false;
|
||||
if (inst)
|
||||
inst.close();
|
||||
}
|
||||
|
||||
getSettings() {
|
||||
@@ -369,13 +386,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 +634,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 +654,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;
|
||||
}
|
||||
|
||||
@@ -971,7 +1006,7 @@ class TextPage extends Vue {
|
||||
}
|
||||
|
||||
onTouchStart(event) {
|
||||
if (!this.mobile)
|
||||
if (!this.$isMobileDevice)
|
||||
return;
|
||||
this.endClickRepeat();
|
||||
if (event.touches.length == 1) {
|
||||
@@ -987,19 +1022,19 @@ class TextPage extends Vue {
|
||||
}
|
||||
|
||||
onTouchEnd() {
|
||||
if (!this.mobile)
|
||||
if (!this.$isMobileDevice)
|
||||
return;
|
||||
this.endClickRepeat();
|
||||
}
|
||||
|
||||
onTouchCancel() {
|
||||
if (!this.mobile)
|
||||
if (!this.$isMobileDevice)
|
||||
return;
|
||||
this.endClickRepeat();
|
||||
}
|
||||
|
||||
onMouseDown(event) {
|
||||
if (this.mobile)
|
||||
if (this.$isMobileDevice)
|
||||
return;
|
||||
this.endClickRepeat();
|
||||
if (event.button == 0) {
|
||||
@@ -1015,13 +1050,13 @@ class TextPage extends Vue {
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
if (this.mobile)
|
||||
if (this.$isMobileDevice)
|
||||
return;
|
||||
this.endClickRepeat();
|
||||
}
|
||||
|
||||
onMouseWheel(event) {
|
||||
if (this.mobile)
|
||||
if (this.$isMobileDevice)
|
||||
return;
|
||||
if (event.deltaY > 0) {
|
||||
this.doDown();
|
||||
@@ -1098,6 +1133,14 @@ class TextPage extends Vue {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.over-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.on-top {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.back {
|
||||
z-index: 5;
|
||||
}
|
||||
@@ -1163,4 +1206,5 @@ class TextPage extends Vue {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -179,7 +179,7 @@ export default class BookParser {
|
||||
if (tag == 'binary') {
|
||||
let attrs = sax.getAttrsSync(tail);
|
||||
binaryType = (attrs['content-type'] && attrs['content-type'].value ? attrs['content-type'].value : '');
|
||||
if (binaryType == 'image/jpeg' || binaryType == 'image/png')
|
||||
if (binaryType == 'image/jpeg' || binaryType == 'image/png' || binaryType == 'application/octet-stream')
|
||||
binaryId = (attrs.id.value ? attrs.id.value : '');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -610,7 +620,8 @@ export default class BookParser {
|
||||
para.parsed.addEmptyParagraphs === this.addEmptyParagraphs &&
|
||||
para.parsed.showImages === this.showImages &&
|
||||
para.parsed.imageHeightLines === this.imageHeightLines &&
|
||||
para.parsed.imageFitWidth === this.imageFitWidth
|
||||
para.parsed.imageFitWidth === this.imageFitWidth &&
|
||||
para.parsed.compactTextPerc === this.compactTextPerc
|
||||
)
|
||||
return para.parsed;
|
||||
|
||||
@@ -625,6 +636,7 @@ export default class BookParser {
|
||||
showImages: this.showImages,
|
||||
imageHeightLines: this.imageHeightLines,
|
||||
imageFitWidth: this.imageFitWidth,
|
||||
compactTextPerc: this.compactTextPerc,
|
||||
visible: !(
|
||||
(this.cutEmptyParagraphs && para.cut) ||
|
||||
(para.addIndex > this.addEmptyParagraphs)
|
||||
@@ -655,6 +667,7 @@ export default class BookParser {
|
||||
let style = {};
|
||||
let ofs = 0;//смещение от начала параграфа para.offset
|
||||
let imgW = 0;
|
||||
const compactWidth = this.measureText('W', {})*this.compactTextPerc/100;
|
||||
// тут начинается самый замес, перенос по слогам и стилизация, а также изображения
|
||||
for (const part of parts) {
|
||||
style = part.style;
|
||||
@@ -739,7 +752,7 @@ export default class BookParser {
|
||||
p = (style.space ? p + parsed.p*style.space : p);
|
||||
let w = this.measureText(str, style) + p;
|
||||
let wordTail = word;
|
||||
if (w > parsed.w && prevStr != '') {
|
||||
if (w > parsed.w + compactWidth && prevStr != '') {
|
||||
if (parsed.wordWrap) {//по слогам
|
||||
let slogi = this.splitToSlogi(word);
|
||||
|
||||
@@ -752,7 +765,7 @@ export default class BookParser {
|
||||
for (let k = 0; k < slogiLen - 1; k++) {
|
||||
let slog = slogi[0];
|
||||
let ww = this.measureText(s + slog + (slog[slog.length - 1] == '-' ? '' : '-'), style) + p;
|
||||
if (ww <= parsed.w) {
|
||||
if (ww <= parsed.w + compactWidth) {
|
||||
s += slog;
|
||||
ss += slog;
|
||||
} else
|
||||
|
||||
@@ -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'
|
||||
@@ -17,78 +18,86 @@ const bmRecentStore = localForage.createInstance({
|
||||
name: 'bmRecentStore'
|
||||
});
|
||||
|
||||
const bmCacheStore = localForage.createInstance({
|
||||
name: 'bmCacheStore'
|
||||
});
|
||||
|
||||
class BookManager {
|
||||
async init(settings) {
|
||||
this.loaded = false;
|
||||
this.settings = settings;
|
||||
|
||||
//bmCacheStore нужен только для ускорения загрузки читалки
|
||||
this.booksCached = await bmCacheStore.getItem('books');
|
||||
if (!this.booksCached)
|
||||
this.booksCached = {};
|
||||
this.recent = await bmCacheStore.getItem('recent');
|
||||
this.recentLast = await bmCacheStore.getItem('recent-last');
|
||||
if (this.recentLast)
|
||||
this.eventListeners = [];
|
||||
this.books = {};
|
||||
this.recent = {};
|
||||
|
||||
this.recentLast = await bmRecentStore.getItem('recent-last');
|
||||
if (this.recentLast) {
|
||||
this.recent[this.recentLast.key] = this.recentLast;
|
||||
|
||||
this.books = Object.assign({}, this.booksCached);
|
||||
|
||||
this.recentChanged1 = true;
|
||||
this.recentChanged2 = true;
|
||||
|
||||
if (!this.books || !this.recent) {
|
||||
this.books = {};
|
||||
this.recent = {};
|
||||
await this.loadMeta(true);
|
||||
} else {
|
||||
this.loadMeta(false);
|
||||
const meta = await bmMetaStore.getItem(`bmMeta-${this.recentLast.key}`);
|
||||
if (_.isObject(meta)) {
|
||||
this.books[meta.key] = meta;
|
||||
}
|
||||
}
|
||||
|
||||
this.recentChanged = true;
|
||||
|
||||
this.loadStored();//no await
|
||||
}
|
||||
|
||||
//долгая загрузка из хранилища,
|
||||
//хранение в отдельных записях дает относительно
|
||||
//нормальное поведение при нескольких вкладках с читалкой в браузере
|
||||
async loadMeta(immediate) {
|
||||
if (!immediate)
|
||||
await utils.sleep(2000);
|
||||
//Долгая асинхронная загрузка из хранилища.
|
||||
//Хранение в отдельных записях дает относительно
|
||||
//нормальное поведение при нескольких вкладках с читалкой в браузере.
|
||||
async loadStored() {
|
||||
//даем время для загрузки последней читаемой книги, чтобы не блокировать приложение
|
||||
await utils.sleep(2000);
|
||||
|
||||
let len = await bmMetaStore.length();
|
||||
for (let i = 0; i < len; i++) {
|
||||
for (let i = len - 1; i >= 0; i--) {
|
||||
const key = await bmMetaStore.key(i);
|
||||
const keySplit = key.split('-');
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let key = null;
|
||||
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;
|
||||
for (let i = len - 1; i >= 0; 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();
|
||||
|
||||
this.booksCached = {};
|
||||
for (const key in this.books) {
|
||||
this.booksCached[key] = this.metaOnly(this.books[key]);
|
||||
}
|
||||
await bmCacheStore.setItem('books', this.booksCached);
|
||||
await bmCacheStore.setItem('recent', this.recent);
|
||||
this.recentChanged = true;
|
||||
this.loaded = true;
|
||||
this.emit('load-stored-finish');
|
||||
}
|
||||
|
||||
async cleanBooks() {
|
||||
@@ -98,7 +107,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;
|
||||
@@ -114,51 +124,173 @@ class BookManager {
|
||||
}
|
||||
}
|
||||
|
||||
async addBook(newBook, callback) {
|
||||
if (!this.books)
|
||||
await this.init();
|
||||
async deflateWithProgress(data, callback) {
|
||||
const chunkSize = 128*1024;
|
||||
const deflator = new utils.pako.Deflate({level: 5});
|
||||
|
||||
let chunkTotal = 1 + Math.floor(data.length/chunkSize);
|
||||
let chunkNum = 0;
|
||||
let perc = 0;
|
||||
let prevPerc = 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++;
|
||||
|
||||
perc = Math.round(chunkNum/chunkTotal*100);
|
||||
if (perc != prevPerc) {
|
||||
callback(perc);
|
||||
await utils.sleep(1);
|
||||
prevPerc = perc;
|
||||
}
|
||||
}
|
||||
|
||||
if (deflator.err) {
|
||||
throw new Error(deflator.msg);
|
||||
}
|
||||
|
||||
callback(100);
|
||||
|
||||
return deflator.result;
|
||||
}
|
||||
|
||||
async inflateWithProgress(data, callback) {
|
||||
const chunkSize = 64*1024;
|
||||
const inflator = new utils.pako.Inflate({to: 'string'});
|
||||
|
||||
let chunkTotal = 1 + Math.floor(data.length/chunkSize);
|
||||
let chunkNum = 0;
|
||||
let perc = 0;
|
||||
let prevPerc = 0;
|
||||
|
||||
for (var i = 0; i < data.length; i += chunkSize) {
|
||||
if ((i + chunkSize) >= data.length) {
|
||||
inflator.push(data.subarray(i, i + chunkSize), true);
|
||||
} else {
|
||||
inflator.push(data.subarray(i, i + chunkSize), false);
|
||||
}
|
||||
chunkNum++;
|
||||
|
||||
perc = Math.round(chunkNum/chunkTotal*100);
|
||||
if (perc != prevPerc) {
|
||||
callback(perc);
|
||||
await utils.sleep(1);
|
||||
prevPerc = perc;
|
||||
}
|
||||
}
|
||||
|
||||
if (inflator.err) {
|
||||
throw new Error(inflator.msg);
|
||||
}
|
||||
|
||||
callback(100);
|
||||
|
||||
return inflator.result;
|
||||
}
|
||||
|
||||
async addBook(newBook, callback) {
|
||||
let meta = {url: newBook.url, path: newBook.path};
|
||||
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: 5});
|
||||
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 bmCacheStore.setItem('books', this.booksCached);
|
||||
await bmDataStore.setItem(`bmData-${meta.key}`, data);
|
||||
|
||||
callback(100);
|
||||
return result;
|
||||
}
|
||||
|
||||
hasBookParsed(meta) {
|
||||
async hasBookParsed(meta) {
|
||||
if (!this.books)
|
||||
return false;
|
||||
if (!meta.url)
|
||||
return false;
|
||||
if (!meta.key)
|
||||
meta.key = this.keyFromUrl(meta.url);
|
||||
|
||||
let book = this.books[meta.key];
|
||||
|
||||
if (!book && !this.loaded) {
|
||||
book = await bmDataStore.getItem(`bmMeta-${meta.key}`);
|
||||
if (book)
|
||||
this.books[meta.key] = book;
|
||||
}
|
||||
|
||||
return !!(book && book.parsed);
|
||||
}
|
||||
|
||||
async getBook(meta, callback) {
|
||||
if (!this.books)
|
||||
await this.init();
|
||||
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 = await bmDataStore.getItem(`bmMeta-${meta.key}`);
|
||||
if (result)
|
||||
this.books[meta.key] = result;
|
||||
}
|
||||
|
||||
//Если файл на сервере изменился, считаем, что в кеше его нету
|
||||
if (meta.path && result && meta.path != result.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result && !result.parsed) {
|
||||
result = await this.parseBook(result, result.data, callback);
|
||||
let data = await bmDataStore.getItem(`bmData-${meta.key}`);
|
||||
callback(5);
|
||||
await utils.sleep(10);
|
||||
|
||||
let cb = (perc) => {
|
||||
const p = 5 + Math.round(15*perc/100);
|
||||
callback(p);
|
||||
};
|
||||
|
||||
if (result.dataCompressed) {
|
||||
try {
|
||||
//data = utils.pako.inflate(data, {to: 'string'});
|
||||
data = await this.inflateWithProgress(data, cb);
|
||||
} catch (e) {
|
||||
this.delBook(meta);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
callback(20);
|
||||
|
||||
cb = (perc) => {
|
||||
const p = 20 + Math.round(80*perc/100);
|
||||
callback(p);
|
||||
};
|
||||
|
||||
result = await this.parseBook(result, data, cb);
|
||||
this.books[meta.key] = result;
|
||||
}
|
||||
|
||||
@@ -166,29 +298,19 @@ class BookManager {
|
||||
}
|
||||
|
||||
async delBook(meta) {
|
||||
if (!this.books)
|
||||
await this.init();
|
||||
|
||||
await bmMetaStore.removeItem(`bmMeta-${meta.key}`);
|
||||
await bmDataStore.removeItem(`bmData-${meta.key}`);
|
||||
|
||||
delete this.books[meta.key];
|
||||
delete this.booksCached[meta.key];
|
||||
|
||||
await bmCacheStore.setItem('books', this.booksCached);
|
||||
}
|
||||
|
||||
async parseBook(meta, data, callback) {
|
||||
if (!this.books)
|
||||
await this.init();
|
||||
|
||||
const parsed = new BookParser(this.settings);
|
||||
|
||||
const parsedMeta = await parsed.parse(data, callback);
|
||||
const result = Object.assign({}, meta, parsedMeta, {
|
||||
length: data.length,
|
||||
textLength: parsed.textLength,
|
||||
data,
|
||||
parsed
|
||||
});
|
||||
|
||||
@@ -197,7 +319,7 @@ class BookManager {
|
||||
|
||||
metaOnly(book) {
|
||||
let result = Object.assign({}, book);
|
||||
delete result.data;
|
||||
delete result.data;//можно будет убрать эту строку со временем
|
||||
delete result.parsed;
|
||||
return result;
|
||||
}
|
||||
@@ -206,93 +328,98 @@ class BookManager {
|
||||
return utils.stringToHex(url);
|
||||
}
|
||||
|
||||
async setRecentBook(value, noTouch) {
|
||||
if (!this.recent)
|
||||
await this.init();
|
||||
//-- recent --------------------------------------------------------------
|
||||
async setRecentBook(value) {
|
||||
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);
|
||||
|
||||
//кэшируем, аккуратно
|
||||
if (!(this.recentLast && this.recentLast.key == result.key)) {
|
||||
await bmCacheStore.setItem('recent', this.recent);
|
||||
}
|
||||
this.recentLast = result;
|
||||
await bmCacheStore.setItem('recent-last', this.recentLast);
|
||||
await bmRecentStore.setItem('recent-last', this.recentLast);
|
||||
|
||||
this.recentChanged1 = true;
|
||||
this.recentChanged2 = true;
|
||||
this.recentChanged = true;
|
||||
this.emit('recent-changed', result.key);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getRecentBook(value) {
|
||||
if (!this.recent)
|
||||
await this.init();
|
||||
return this.recent[value.key];
|
||||
let result = this.recent[value.key];
|
||||
if (!result) {
|
||||
result = await bmRecentStore.getItem(value.key);
|
||||
if (result)
|
||||
this.recent[value.key] = result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async delRecentBook(value) {
|
||||
if (!this.recent)
|
||||
await this.init();
|
||||
this.recent[value.key].deleted = 1;
|
||||
await bmRecentStore.setItem(value.key, this.recent[value.key]);
|
||||
|
||||
await bmRecentStore.removeItem(value.key);
|
||||
delete this.recent[value.key];
|
||||
await bmCacheStore.setItem('recent', this.recent);
|
||||
|
||||
this.recentChanged1 = true;
|
||||
this.recentChanged2 = true;
|
||||
if (this.recentLast.key == value.key) {
|
||||
this.recentLast = null;
|
||||
await bmRecentStore.setItem('recent-last', this.recentLast);
|
||||
}
|
||||
this.emit('recent-deleted', value.key);
|
||||
this.emit('recent-changed', value.key);
|
||||
}
|
||||
|
||||
async cleanRecentBooks() {
|
||||
if (!this.recent)
|
||||
await this.init();
|
||||
const sorted = this.getSortedRecent();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
await bmRecentStore.removeItem(sorted[i].key);
|
||||
isDel = true;
|
||||
}
|
||||
|
||||
this.sortedRecentCached = null;
|
||||
|
||||
if (isDel)
|
||||
this.emit('recent-changed');
|
||||
return isDel;
|
||||
}
|
||||
|
||||
mostRecentBook() {
|
||||
if (!this.recentChanged1 && this.mostRecentCached) {
|
||||
return this.mostRecentCached;
|
||||
if (this.recentLast) {
|
||||
return this.recentLast;
|
||||
}
|
||||
const oldRecentLast = this.recentLast;
|
||||
|
||||
let max = 0;
|
||||
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;
|
||||
this.recentLast = result;
|
||||
bmRecentStore.setItem('recent-last', this.recentLast);//no await
|
||||
|
||||
if (this.recentLast !== oldRecentLast)
|
||||
this.emit('recent-changed');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getSortedRecent() {
|
||||
if (!this.recentChanged2 && this.sortedRecentCached) {
|
||||
if (!this.recentChanged && this.sortedRecentCached) {
|
||||
return this.sortedRecentCached;
|
||||
}
|
||||
|
||||
@@ -301,10 +428,60 @@ class BookManager {
|
||||
result.sort((a, b) => b.touchTime - a.touchTime);
|
||||
|
||||
this.sortedRecentCached = result;
|
||||
this.recentChanged2 = false;
|
||||
this.recentChanged = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
async setRecent(value) {
|
||||
const mergedRecent = _.cloneDeep(this.recent);
|
||||
|
||||
Object.assign(mergedRecent, value);
|
||||
|
||||
//подстраховка от hotReload
|
||||
for (let i of Object.keys(mergedRecent)) {
|
||||
if (!mergedRecent[i].key || mergedRecent[i].key !== i)
|
||||
delete mergedRecent[i];
|
||||
}
|
||||
|
||||
//"ленивое" обновление хранилища
|
||||
(async() => {
|
||||
for (const rec of Object.values(mergedRecent)) {
|
||||
if (rec.key) {
|
||||
await bmRecentStore.setItem(rec.key, rec);
|
||||
await utils.sleep(1);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
this.recent = mergedRecent;
|
||||
|
||||
this.recentLast = null;
|
||||
await bmRecentStore.setItem('recent-last', this.recentLast);
|
||||
|
||||
this.recentChanged = true;
|
||||
this.emit('set-recent');
|
||||
this.emit('recent-changed');
|
||||
}
|
||||
|
||||
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) {
|
||||
if (this.eventListeners) {
|
||||
for (const listener of this.eventListeners) {
|
||||
//console.log(eventName);
|
||||
listener(eventName, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
167
client/components/Reader/versionHistory.js
Normal file
167
client/components/Reader/versionHistory.js
Normal file
@@ -0,0 +1,167 @@
|
||||
export const versionHistory = [
|
||||
{
|
||||
showUntil: '2019-10-17',
|
||||
header: '0.7.3 (2019-10-18)',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>внутренние переделки механизма синхронизации с сервером</li>
|
||||
<li>добавлен html-фильтр для сайтов www.fanfiction.net, archiveofourown.org</li>
|
||||
<li>добавлен параметр "Включить html-фильтр для сайтов" в раздел "Вид"->"Текст" в настройках</li>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
showUntil: '2019-09-19',
|
||||
header: '0.7.1 (2019-09-20)',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправления багов</li>
|
||||
<li>на панель управления добавлена кнопка "Автономный режим"</li>
|
||||
<li>актуализирована справка</li>
|
||||
</ul>
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
showUntil: '2019-10-01',
|
||||
header: '0.7.0 (2019-09-07)',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>налажена работа https-версии сайта, рекомендуется плавный переход</li>
|
||||
<li>добавлена возможность загрузки и работы https-версии читалки в оффлайн-режиме (при отсутствии интернета)</li>
|
||||
<li>упрощение механизма серверной синхронизации с целью повышения надежности и избавления от багов</li>
|
||||
<li>окна теперь можно перемещать за заголовок</li>
|
||||
<li>немного улучшен внешний вид и управление на смартфонах</li>
|
||||
<li>добавлен параметр "Компактность" в раздел "Вид"->"Текст" в настройках</li>
|
||||
</ul>
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
showUntil: '2019-07-20',
|
||||
header: '0.6.10 (2019-07-21)',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
showUntil: '2019-06-22',
|
||||
header: '0.6.9 (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>
|
||||
`
|
||||
},
|
||||
|
||||
]
|
||||
@@ -1,10 +1,15 @@
|
||||
<template>
|
||||
<div class="window">
|
||||
<div class="header">
|
||||
<span class="header-text"><slot name="header"></slot></span>
|
||||
<span class="close-button" @click="close"><i class="el-icon-close"></i></span>
|
||||
<div ref="main" class="main" @click="close" @mouseup="onMouseUp" @mousemove="onMouseMove">
|
||||
<div ref="windowBox" class="windowBox" @click.stop>
|
||||
<div class="window">
|
||||
<div ref="header" class="header" @mousedown.prevent.stop="onMouseDown"
|
||||
@touchstart.stop="onTouchStart" @touchend.stop="onTouchEnd" @touchmove.stop="onTouchMove">
|
||||
<span class="header-text"><slot name="header"></slot></span>
|
||||
<span class="close-button" @mousedown.stop @click="close"><i class="el-icon-close"></i></span>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -14,17 +19,116 @@ import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
props: {
|
||||
height: { type: String, default: '100%' },
|
||||
width: { type: String, default: '100%' },
|
||||
maxWidth: { type: String, default: '' },
|
||||
topShift: { type: Number, default: 0 },
|
||||
}
|
||||
})
|
||||
class Window extends Vue {
|
||||
close() {
|
||||
this.$emit('close');
|
||||
init() {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.windowBox.style.height = this.height;
|
||||
this.$refs.windowBox.style.width = this.width;
|
||||
if (this.maxWidth)
|
||||
this.$refs.windowBox.style.maxWidth = this.maxWidth;
|
||||
|
||||
const left = (this.$refs.main.offsetWidth - this.$refs.windowBox.offsetWidth)/2;
|
||||
const top = (this.$refs.main.offsetHeight - this.$refs.windowBox.offsetHeight)/2 + this.topShift;
|
||||
this.$refs.windowBox.style.left = (left > 0 ? left : 0) + 'px';
|
||||
this.$refs.windowBox.style.top = (top > 0 ? top : 0) + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
onMouseDown(event) {
|
||||
if (this.$isMobileDevice)
|
||||
return;
|
||||
if (event.button == 0) {
|
||||
this.$refs.header.style.cursor = 'move';
|
||||
this.startX = event.screenX;
|
||||
this.startY = event.screenY;
|
||||
this.moving = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp(event) {
|
||||
if (event.button == 0) {
|
||||
this.$refs.header.style.cursor = 'default';
|
||||
this.moving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event) {
|
||||
if (this.moving) {
|
||||
const deltaX = event.screenX - this.startX;
|
||||
const deltaY = event.screenY - this.startY;
|
||||
this.startX = event.screenX;
|
||||
this.startY = event.screenY;
|
||||
|
||||
this.$refs.windowBox.style.left = (this.$refs.windowBox.offsetLeft + deltaX) + 'px';
|
||||
this.$refs.windowBox.style.top = (this.$refs.windowBox.offsetTop + deltaY) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
onTouchStart(event) {
|
||||
if (!this.$isMobileDevice)
|
||||
return;
|
||||
if (event.touches.length == 1) {
|
||||
const touch = event.touches[0];
|
||||
this.$refs.header.style.cursor = 'move';
|
||||
this.startX = touch.screenX;
|
||||
this.startY = touch.screenY;
|
||||
this.moving = true;
|
||||
}
|
||||
}
|
||||
|
||||
onTouchMove(event) {
|
||||
if (!this.$isMobileDevice)
|
||||
return;
|
||||
if (event.touches.length == 1 && this.moving) {
|
||||
const touch = event.touches[0];
|
||||
const deltaX = touch.screenX - this.startX;
|
||||
const deltaY = touch.screenY - this.startY;
|
||||
this.startX = touch.screenX;
|
||||
this.startY = touch.screenY;
|
||||
|
||||
this.$refs.windowBox.style.left = (this.$refs.windowBox.offsetLeft + deltaX) + 'px';
|
||||
this.$refs.windowBox.style.top = (this.$refs.windowBox.offsetTop + deltaY) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
onTouchEnd() {
|
||||
if (!this.$isMobileDevice)
|
||||
return;
|
||||
this.$refs.header.style.cursor = 'default';
|
||||
this.moving = false;
|
||||
}
|
||||
|
||||
|
||||
close() {
|
||||
if (!this.moving)
|
||||
this.$emit('close');
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.windowBox {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.window {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -39,9 +143,9 @@ class Window extends Vue {
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background-color: #e5e7ea;
|
||||
background-color: #59B04F;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
@@ -54,8 +158,12 @@ class Window extends Vue {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background-color: #69C05F;
|
||||
}
|
||||
</style>
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html manifest="/app/manifest.appcache">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
@@ -9,5 +9,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="https://yastatic.net/share2/share.js" async="async"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Vue from 'vue';
|
||||
import App from './components/App.vue';
|
||||
|
||||
import router from './router';
|
||||
import store from './store';
|
||||
import './element';
|
||||
|
||||
import App from './components/App.vue';
|
||||
//Vue.config.productionTip = false;
|
||||
Vue.prototype.$isMobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent);
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
|
||||
@@ -2,21 +2,25 @@ import Vue from 'vue';
|
||||
import VueRouter from 'vue-router';
|
||||
import _ from 'lodash';
|
||||
|
||||
import App from './components/App.vue';
|
||||
//немедленная загрузка
|
||||
import CardIndex from './components/CardIndex/CardIndex.vue';
|
||||
//const CardIndex = () => import('./components/CardIndex/CardIndex.vue');
|
||||
|
||||
const CardIndex = () => import('./components/CardIndex/CardIndex.vue');
|
||||
const Search = () => import('./components/CardIndex/Search/Search.vue');
|
||||
const Card = () => import('./components/CardIndex/Card/Card.vue');
|
||||
const Book = () => import('./components/CardIndex/Book/Book.vue');
|
||||
const History = () => import('./components/CardIndex/History/History.vue');
|
||||
|
||||
const Reader = () => import('./components/Reader/Reader.vue');
|
||||
//немедленная загрузка
|
||||
//const Reader = () => import('./components/Reader/Reader.vue');
|
||||
import Reader from './components/Reader/Reader.vue';
|
||||
|
||||
//const Forum = () => import('./components/Forum/Forum.vue');
|
||||
const Income = () => import('./components/Income/Income.vue');
|
||||
const Sources = () => import('./components/Sources/Sources.vue');
|
||||
const Settings = () => import('./components/Settings/Settings.vue');
|
||||
const Help = () => import('./components/Help/Help.vue');
|
||||
const NotFound404 = () => import('./components/NotFound404/NotFound404.vue');
|
||||
//const NotFound404 = () => import('./components/NotFound404/NotFound404.vue');
|
||||
|
||||
const myRoutes = [
|
||||
['/', null, null, '/cardindex'],
|
||||
|
||||
26
client/share/cryptoUtils.js
Normal file
26
client/share/cryptoUtils.js
Normal 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
60
client/share/sjcl.js
Normal 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
146
client/share/sjclWrapper.js
Normal 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;
|
||||
@@ -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,118 @@ 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 isObjDiff(diff) {
|
||||
return (_.isObject(diff) && diff.__isDiff);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function parseQuery(str) {
|
||||
if (typeof str != 'string' || str.length == 0)
|
||||
return {};
|
||||
let s = str.split('&');
|
||||
let s_length = s.length;
|
||||
let bit, query = {}, first, second;
|
||||
|
||||
for (let i = 0; i < s_length; i++) {
|
||||
bit = s[i].split('=');
|
||||
first = decodeURIComponent(bit[0]);
|
||||
if (first.length == 0)
|
||||
continue;
|
||||
second = decodeURIComponent(bit[1]);
|
||||
if (typeof query[first] == 'undefined')
|
||||
query[first] = second;
|
||||
else
|
||||
if (query[first] instanceof Array)
|
||||
query[first].push(second);
|
||||
else
|
||||
query[first] = [query[first], second];
|
||||
}
|
||||
return query;
|
||||
}
|
||||
@@ -1,3 +1,17 @@
|
||||
//занчение 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: 'offlineMode', show: false, text: 'Автономный режим (без интернета)'},
|
||||
{name: 'recentBooks', show: true, text: 'Открыть недавние'},
|
||||
];
|
||||
|
||||
const fonts = [
|
||||
{name: 'ReaderDefault', label: 'По-умолчанию', fontVertShift: 0},
|
||||
{name: 'GEO_1', label: 'BPG Arial', fontVertShift: 10},
|
||||
@@ -123,61 +137,78 @@ 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: 0,
|
||||
|
||||
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,
|
||||
compactTextPerc: 0,
|
||||
imageHeightLines: 100,
|
||||
imageFitWidth: true,
|
||||
showServerStorageMessages: true,
|
||||
showWhatsNewDialog: true,
|
||||
showMigrationDialog: true,
|
||||
enableSitesFilter: 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: '',
|
||||
migrationRemindDate: '',
|
||||
currentProfile: '',
|
||||
settings: Object.assign({}, settingDefaults),
|
||||
settingsRev: {},
|
||||
};
|
||||
|
||||
// getters
|
||||
@@ -191,12 +222,40 @@ 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;
|
||||
},
|
||||
setMigrationRemindDate(state, value) {
|
||||
state.migrationRemindDate = 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,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1,3 +1,34 @@
|
||||
server {
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/omnireader.ru/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/omnireader.ru/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
server_name omnireader.ru;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
gzip on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types *;
|
||||
|
||||
location /api {
|
||||
proxy_pass http://localhost:44081;
|
||||
}
|
||||
|
||||
location /tmp {
|
||||
root /home/liberama/public;
|
||||
add_header Content-Type text/xml;
|
||||
add_header Content-Encoding gzip;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /home/liberama/public;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name omnireader.ru;
|
||||
|
||||
5001
package-lock.json
generated
5001
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
65
package.json
65
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Liberama",
|
||||
"version": "0.5.5",
|
||||
"version": "0.7.3",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
@@ -19,65 +19,70 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-core": "^6.22.1",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"babel-loader": "^7.1.1",
|
||||
"babel-plugin-component": "^1.1.1",
|
||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"babel-plugin-transform-decorators-legacy": "^1.3.5",
|
||||
"babel-preset-env": "^1.3.2",
|
||||
"clean-webpack-plugin": "^1.0.0",
|
||||
"clean-webpack-plugin": "^1.0.1",
|
||||
"copy-webpack-plugin": "^4.6.0",
|
||||
"css-loader": "^1.0.0",
|
||||
"disable-output-webpack-plugin": "^1.0.1",
|
||||
"element-theme-chalk": "^2.4.11",
|
||||
"eslint": "^5.11.1",
|
||||
"eslint-plugin-html": "^5.0.0",
|
||||
"element-theme-chalk": "^2.12.0",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-plugin-html": "^5.0.5",
|
||||
"eslint-plugin-node": "^8.0.0",
|
||||
"eslint-plugin-vue": "^5.0.0",
|
||||
"event-hooks-webpack-plugin": "^2.1.1",
|
||||
"eslint-plugin-vue": "^5.2.3",
|
||||
"event-hooks-webpack-plugin": "^2.1.4",
|
||||
"file-loader": "^3.0.1",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"mini-css-extract-plugin": "^0.5.0",
|
||||
"null-loader": "^0.1.1",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.1",
|
||||
"pkg": "^4.3.7",
|
||||
"terser-webpack-plugin": "^1.2.1",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.3",
|
||||
"pkg": "4.3.7",
|
||||
"terser-webpack-plugin": "^1.4.1",
|
||||
"url-loader": "^1.1.2",
|
||||
"vue-class-component": "^6.3.2",
|
||||
"vue-loader": "^15.4.2",
|
||||
"vue-loader": "^15.7.1",
|
||||
"vue-style-loader": "^4.1.2",
|
||||
"vue-template-compiler": "^2.5.21",
|
||||
"webpack": "^4.28.2",
|
||||
"webpack-cli": "^3.1.2",
|
||||
"webpack-dev-middleware": "^3.4.0",
|
||||
"webpack-hot-middleware": "^2.24.3",
|
||||
"webpack-merge": "^4.1.5"
|
||||
"vue-template-compiler": "^2.6.10",
|
||||
"webpack": "^4.39.3",
|
||||
"webpack-cli": "^3.3.7",
|
||||
"webpack-dev-middleware": "^3.7.1",
|
||||
"webpack-hot-middleware": "^2.25.0",
|
||||
"webpack-merge": "^4.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.18.0",
|
||||
"adm-zip": "^0.4.13",
|
||||
"appcache-webpack-plugin": "^1.4.0",
|
||||
"axios": "^0.18.1",
|
||||
"base-x": "^3.0.6",
|
||||
"chardet": "^0.7.0",
|
||||
"compression": "^1.7.3",
|
||||
"element-ui": "^2.4.11",
|
||||
"express": "^4.16.4",
|
||||
"extract-zip": "^1.6.7",
|
||||
"compression": "^1.7.4",
|
||||
"element-ui": "^2.12.0",
|
||||
"express": "^4.17.1",
|
||||
"fg-loadcss": "^2.1.0",
|
||||
"fs-extra": "^7.0.1",
|
||||
"got": "^9.5.1",
|
||||
"got": "^9.6.0",
|
||||
"he": "^1.2.0",
|
||||
"iconv-lite": "^0.4.24",
|
||||
"localforage": "^1.7.3",
|
||||
"lodash": "^4.17.11",
|
||||
"lodash": "^4.17.15",
|
||||
"minimist": "^1.2.0",
|
||||
"multer": "^1.4.1",
|
||||
"multer": "^1.4.2",
|
||||
"pako": "^1.0.10",
|
||||
"path-browserify": "^1.0.0",
|
||||
"safe-buffer": "^5.2.0",
|
||||
"sjcl": "^1.0.8",
|
||||
"sql-template-strings": "^2.2.2",
|
||||
"sqlite": "^3.0.0",
|
||||
"sqlite": "3.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"unbzip2-stream": "^1.3.3",
|
||||
"vue": "^2.5.21",
|
||||
"vue-router": "^3.0.2",
|
||||
"vuex": "^3.0.1",
|
||||
"vue": "^2.6.10",
|
||||
"vue-router": "^3.1.3",
|
||||
"vuex": "^3.1.1",
|
||||
"vuex-persistedstate": "^2.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
class BaseController {
|
||||
constructor(connPool, config) {
|
||||
this.connPool = connPool;
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -16,7 +15,10 @@ class ReaderController extends BaseController {
|
||||
try {
|
||||
if (!request.url)
|
||||
throw new Error(`key 'url' is empty`);
|
||||
const workerId = this.readerWorker.loadBookUrl(request.url);
|
||||
const workerId = this.readerWorker.loadBookUrl({
|
||||
url: request.url,
|
||||
enableSitesFilter: (request.hasOwnProperty('enableSitesFilter') ? request.enableSitesFilter : true)
|
||||
});
|
||||
const state = workerState.getState(workerId);
|
||||
return (state ? state : {});
|
||||
} catch (e) {
|
||||
@@ -27,6 +29,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 = '';
|
||||
|
||||
@@ -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(/ |[\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">';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ class ConvertSamlib extends ConvertBase {
|
||||
}
|
||||
|
||||
async run(data, opts) {
|
||||
if (!opts.enableSitesFilter)
|
||||
return false;
|
||||
|
||||
const checkResult = this.check(data, opts);
|
||||
if (!checkResult)
|
||||
return false;
|
||||
@@ -218,6 +221,8 @@ class ConvertSamlib extends ConvertBase {
|
||||
if (!text)
|
||||
return;
|
||||
|
||||
text = this.escapeEntities(text);
|
||||
|
||||
switch (path) {
|
||||
case '/html/body/center/h2':
|
||||
titleInfo['book-title'] = text;
|
||||
|
||||
71
server/core/BookConverter/ConvertSites.js
Normal file
71
server/core/BookConverter/ConvertSites.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const URL = require('url').URL;
|
||||
|
||||
const ConvertHtml = require('./ConvertHtml');
|
||||
|
||||
const sitesFilter = {
|
||||
'www.fanfiction.net': {
|
||||
converter: 'cutter',
|
||||
begin: `<div class='storytext xcontrast_txt nocopy' id='storytext'>`,
|
||||
end: `<div style='height:5px'></div><div style='clear:both;text-align:right;'>`,
|
||||
},
|
||||
'archiveofourown.org': {
|
||||
converter: 'cutter',
|
||||
begin: `<!-- BEGIN section where work skin applies -->`,
|
||||
end: `<!-- END work skin -->`,
|
||||
}
|
||||
};
|
||||
|
||||
class ConvertSites extends ConvertHtml {
|
||||
check(data, opts) {
|
||||
const {url, dataType} = opts;
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
if (dataType && dataType.ext == 'html') {
|
||||
if (sitesFilter[parsedUrl.hostname])
|
||||
return {hostname: parsedUrl.hostname};
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async run(data, opts) {
|
||||
if (!opts.enableSitesFilter)
|
||||
return false;
|
||||
|
||||
const checkResult = this.check(data, opts);
|
||||
if (!checkResult)
|
||||
return false;
|
||||
|
||||
const {hostname} = checkResult;
|
||||
|
||||
let text = this.decode(data).toString();
|
||||
|
||||
text = this[sitesFilter[hostname].converter](text, sitesFilter[hostname]);
|
||||
|
||||
if (text === false)
|
||||
return false;
|
||||
|
||||
return await super.run(Buffer.from(text), {skipCheck: true, cutTitle: true});
|
||||
}
|
||||
|
||||
getTitle(text) {
|
||||
let title = '';
|
||||
const m = text.match(/<title>([\s\S]*?)<\/title>/);
|
||||
if (m)
|
||||
title = m[1];
|
||||
|
||||
return `<title>${title.trim()}</title>`;
|
||||
}
|
||||
|
||||
cutter(text, opts) {
|
||||
const title = this.getTitle(text);
|
||||
const l = text.indexOf(opts.begin);
|
||||
const r = text.indexOf(opts.end);
|
||||
if (l < 0 || r < 0 || r <= l)
|
||||
return false;
|
||||
|
||||
return text.substring(l, r) + title;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ConvertSites;
|
||||
@@ -11,6 +11,7 @@ const convertClassFactory = [
|
||||
require('./ConvertMobi'),
|
||||
require('./ConvertFb2'),
|
||||
require('./ConvertSamlib'),
|
||||
require('./ConvertSites'),
|
||||
require('./ConvertHtml'),
|
||||
];
|
||||
|
||||
@@ -24,13 +25,14 @@ class BookConverter {
|
||||
}
|
||||
}
|
||||
|
||||
async convertToFb2(inputFiles, outputFile, url, callback) {
|
||||
async convertToFb2(inputFiles, outputFile, opts, callback) {
|
||||
const selectedFileType = await this.detector.detectFile(inputFiles.selectedFile);
|
||||
const data = await fs.readFile(inputFiles.selectedFile);
|
||||
|
||||
const convertOpts = Object.assign({}, opts, {inputFiles, callback, dataType: selectedFileType});
|
||||
let result = false;
|
||||
for (const convert of this.convertFactory) {
|
||||
result = await convert.run(data, {inputFiles, url, callback, dataType: selectedFileType});
|
||||
result = await convert.run(data, convertOpts);
|
||||
if (result) {
|
||||
await fs.writeFile(outputFile, result);
|
||||
break;
|
||||
@@ -38,14 +40,14 @@ class BookConverter {
|
||||
}
|
||||
|
||||
if (!result && inputFiles.nesting) {
|
||||
result = await this.convertToFb2(inputFiles.nesting, outputFile, url, callback);
|
||||
result = await this.convertToFb2(inputFiles.nesting, outputFile, opts, callback);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
if (selectedFileType)
|
||||
throw new Error(`Этот формат файла не поддерживается: ${selectedFileType.mime}`);
|
||||
else {
|
||||
throw new Error(`Не удалось определить формат файла: ${url}`);
|
||||
throw new Error(`Не удалось определить формат файла: ${opts.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -74,7 +74,7 @@ function getEncoding(buf, returnAll) {
|
||||
|
||||
if (returnAll)
|
||||
return sorted;
|
||||
else if (sorted[0].c > 0)
|
||||
else if (sorted[0].c > 0 && sorted[0].c > sorted[0].totalChecked/2)
|
||||
return sorted[0].codePage;
|
||||
else
|
||||
return 'ISO-8859-5';
|
||||
|
||||
@@ -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 AdmZip = require('adm-zip');
|
||||
|
||||
const utils = require('./utils');
|
||||
const FileDetector = require('./FileDetector');
|
||||
@@ -112,18 +112,17 @@ class FileDecompressor {
|
||||
}
|
||||
|
||||
async unZip(filename, outputDir) {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise((resolve) => {
|
||||
const files = [];
|
||||
extractZip(filename, {
|
||||
dir: outputDir,
|
||||
onEntry: (entry) => {
|
||||
files.push({path: entry.fileName, size: entry.uncompressedSize});
|
||||
}
|
||||
}, (err) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
resolve(files);
|
||||
const zip = new AdmZip(filename);
|
||||
|
||||
zip.getEntries().forEach(function(zipEntry) {
|
||||
files.push({path: zipEntry.entryName, size: zipEntry.header.size});
|
||||
});
|
||||
|
||||
zip.extractAllTo(outputDir, true);
|
||||
|
||||
resolve(files);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,6 +183,10 @@ class FileDecompressor {
|
||||
resolve([file]);
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
inputStream.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ const FileDownloader = require('./FileDownloader');
|
||||
const FileDecompressor = require('./FileDecompressor');
|
||||
const BookConverter = require('./BookConverter');
|
||||
const utils = require('./utils');
|
||||
const log = require('./getLogger').getLog();
|
||||
|
||||
let singleCleanExecute = false;
|
||||
|
||||
@@ -31,7 +32,8 @@ class ReaderWorker {
|
||||
}
|
||||
}
|
||||
|
||||
async loadBook(url, wState) {
|
||||
async loadBook(opts, wState) {
|
||||
const url = opts.url;
|
||||
let errMes = '';
|
||||
let decompDir = '';
|
||||
let downloadedFilename = '';
|
||||
@@ -76,7 +78,7 @@ class ReaderWorker {
|
||||
//конвертирование в fb2
|
||||
wState.set({state: 'convert', step: 3, progress: 0});
|
||||
convertFilename = `${this.config.tempDownloadDir}/${tempFilename2}`;
|
||||
await this.bookConverter.convertToFb2(decompFiles, convertFilename, url, progress => {
|
||||
await this.bookConverter.convertToFb2(decompFiles, convertFilename, opts, progress => {
|
||||
wState.set({progress});
|
||||
});
|
||||
|
||||
@@ -104,12 +106,12 @@ class ReaderWorker {
|
||||
}
|
||||
}
|
||||
|
||||
loadBookUrl(url) {
|
||||
loadBookUrl(opts) {
|
||||
const workerId = workerState.generateWorkerId();
|
||||
const wState = workerState.getControl(workerId);
|
||||
wState.set({state: 'start'});
|
||||
|
||||
this.loadBook(url, wState);
|
||||
this.loadBook(opts, wState);
|
||||
|
||||
return workerId;
|
||||
}
|
||||
@@ -131,32 +133,40 @@ class ReaderWorker {
|
||||
return `file://${hash}`;
|
||||
}
|
||||
|
||||
async periodicCleanDir(dir, maxSize, timeout) {
|
||||
const list = await fs.readdir(dir);
|
||||
async periodicCleanDir(dir, maxSize, timeout) {
|
||||
try {
|
||||
log(`Start clean dir: ${dir}, maxSize=${maxSize}`);
|
||||
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});
|
||||
}
|
||||
}
|
||||
log(`found ${files.length} files in dir ${dir}`);
|
||||
|
||||
files.sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs);
|
||||
|
||||
let i = 0;
|
||||
while (i < files.length && size > maxSize) {
|
||||
const file = files[i];
|
||||
log(`rm ${dir}/${file.name}`);
|
||||
await fs.remove(`${dir}/${file.name}`);
|
||||
size -= file.stat.size;
|
||||
i++;
|
||||
}
|
||||
log(`removed ${i} files`);
|
||||
} catch(e) {
|
||||
log(LM_ERR, e.message);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
118
server/core/readerStorage.js
Normal file
118
server/core/readerStorage.js
Normal 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;
|
||||
188
server/db/SqliteConnectionPool.js
Normal file
188
server/db/SqliteConnectionPool.js
Normal 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
51
server/db/connManager.js
Normal 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;
|
||||
5
server/db/migrations/app/index.js
Normal file
5
server/db/migrations/app/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
table: 'migration1',
|
||||
data: [
|
||||
]
|
||||
}
|
||||
7
server/db/migrations/readerStorage/001-create.js
Normal file
7
server/db/migrations/readerStorage/001-create.js
Normal 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;
|
||||
`;
|
||||
6
server/db/migrations/readerStorage/index.js
Normal file
6
server/db/migrations/readerStorage/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
table: 'migration1',
|
||||
data: [
|
||||
{id: 1, name: 'create', data: require('./001-create')}
|
||||
]
|
||||
}
|
||||
@@ -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, 4000)}`);
|
||||
//log(`${JSON.stringify(req.headers, null, 2)}`)
|
||||
res.once('finish', () => {
|
||||
log(`${Date.now() - start}ms`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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], {}],
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user