diff --git a/client/api/reader.js b/client/api/reader.js index 742a9792..f93efc39 100644 --- a/client/api/reader.js +++ b/client/api/reader.js @@ -1,5 +1,6 @@ import axios from 'axios'; import * as utils from '../share/utils'; +import * as cryptoUtils from '../share/cryptoUtils'; import wsc from './webSocketConnection'; const api = axios.create({ @@ -174,11 +175,10 @@ class Reader { return await axios.get(url, options); } - async uploadFile(file, maxUploadFileSize, callback) { - if (!maxUploadFileSize) - maxUploadFileSize = 10*1024*1024; + async uploadFile(file, maxUploadFileSize = 10*1024*1024, callback) { if (file.size > maxUploadFileSize) throw new Error(`Размер файла превышает ${maxUploadFileSize} байт`); + let formData = new FormData(); formData.append('file', file, file.name); @@ -225,6 +225,33 @@ class Reader { return response; } + + async uploadFileBuf(buf, urlCallback) { + const key = utils.toHex(cryptoUtils.sha256(buf)); + const url = `disk://${key}`; + + if (urlCallback) + urlCallback(url); + + let response; + try { + await axios.head(`/upload/${key}`, {headers: {'Cache-Control': 'no-cache'}}); + response = await wsc.message(await wsc.send({action: 'upload-file-touch', url})); + } catch (e) { + response = await wsc.message(await wsc.send({action: 'upload-file-buf', buf})); + } + + if (response.error) + throw new Error(response.error); + + return response; + } + + async getUploadedFileBuf(url) { + url = url.replace('disk://', '/upload/'); + return (await axios.get(url)).data; + } + } export default new Reader(); \ No newline at end of file diff --git a/client/components/Reader/Reader.vue b/client/components/Reader/Reader.vue index 5d211d27..c1c824b4 100644 --- a/client/components/Reader/Reader.vue +++ b/client/components/Reader/Reader.vue @@ -194,6 +194,7 @@ import ReaderDialogs from './ReaderDialogs/ReaderDialogs.vue'; import bookManager from './share/bookManager'; import wallpaperStorage from './share/wallpaperStorage'; +import coversStorage from './share/coversStorage'; import dynamicCss from '../../share/dynamicCss'; import rstore from '../../store/modules/reader'; @@ -366,6 +367,8 @@ class Reader { mounted() { (async() => { await wallpaperStorage.init(); + await coversStorage.init(); + await bookManager.init(this.settings); bookManager.addEventListener(this.bookManagerEvent); @@ -450,22 +453,47 @@ class Reader { //wallpaper css async loadWallpapers() { - const wallpaperDataLength = await wallpaperStorage.getLength(); - if (wallpaperDataLength !== this.wallpaperDataLength) {//оптимизация - this.wallpaperDataLength = wallpaperDataLength; + if (!_.isEqual(this.userWallpapers, this.prevUserWallpapers)) {//оптимизация + this.prevUserWallpapers = _.cloneDeep(this.userWallpapers); let newCss = ''; + let updated = false; + const wallpaperExists = new Set(); for (const wp of this.userWallpapers) { - const data = await wallpaperStorage.getData(wp.cssClass); + wallpaperExists.add(wp.cssClass); + let data = await wallpaperStorage.getData(wp.cssClass); if (!data) { //здесь будем восстанавливать данные с сервера + const url = `disk://${wp.cssClass.replace('user-paper', '')}`; + try { + data = await readerApi.getUploadedFileBuf(url); + await wallpaperStorage.setData(wp.cssClass, data); + updated = true; + } catch (e) { + console.error(e); + } } if (data) { newCss += `.${wp.cssClass} {background: url(${data}) center; background-size: 100% 100%;}`; } } + + //почистим wallpaperStorage + for (const key of await wallpaperStorage.getKeys()) { + if (!wallpaperExists.has(key)) { + await wallpaperStorage.removeData(key); + } + } + + //обновим settings, если загружали обои из /upload/ + if (updated) { + const newSettings = _.cloneDeep(this.settings); + newSettings.needUpdateSettingsView = (newSettings.needUpdateSettingsView < 10 ? newSettings.needUpdateSettingsView + 1 : 0); + this.commit('reader/setSettings', newSettings); + } + dynamicCss.replace('wallpapers', newCss); } } diff --git a/client/components/Reader/RecentBooksPage/RecentBooksPage.vue b/client/components/Reader/RecentBooksPage/RecentBooksPage.vue index 1209b876..f7ed8d8d 100644 --- a/client/components/Reader/RecentBooksPage/RecentBooksPage.vue +++ b/client/components/Reader/RecentBooksPage/RecentBooksPage.vue @@ -105,8 +105,9 @@
-
- +
+
+
@@ -213,6 +214,7 @@ import LockQueue from '../../../share/LockQueue'; import Window from '../../share/Window.vue'; import bookManager from '../share/bookManager'; import readerApi from '../../../api/reader'; +import coversStorage from '../share/coversStorage'; const componentOptions = { components: { @@ -240,6 +242,8 @@ class RecentBooksPage { showSameBook = false; archive = false; + covers = {}; + created() { this.commit = this.$store.commit; @@ -264,6 +268,7 @@ class RecentBooksPage { this.showBar(); await this.updateTableData(); await this.scrollToActiveBook(); + //await this.scrollRefresh(); })(); } @@ -336,6 +341,7 @@ class RecentBooksPage { active: (activeBook.key == book.key), activeParent: false, inGroup: false, + coverPageUrl: book.coverPageUrl, //для сортировки loadTimeRaw, @@ -435,8 +441,6 @@ class RecentBooksPage { //..... this.tableData = result; - - this.$refs.virtualScroll.refresh(); } finally { this.lock.ret(); } @@ -569,6 +573,8 @@ class RecentBooksPage { } async scrollToActiveBook() { + await this.$nextTick(); + this.lockScroll = true; try { let activeIndex = -1; @@ -614,6 +620,16 @@ class RecentBooksPage { } } + async scrollRefresh() { + this.lockScroll = true; + await utils.sleep(100); + try { + this.$refs.virtualScroll.refresh(); + } finally { + await utils.sleep(100); + this.lockScroll = false; + } + } get sortMethodOptions() { return [ @@ -643,6 +659,43 @@ class RecentBooksPage { } return true; } + + makeCoverHtml(data) { + return ``; + } + + isLoadedCover(coverPageUrl) { + if (!coverPageUrl) + return false; + + let loadedCover = this.covers[coverPageUrl]; + if (!loadedCover) { + (async() => { + //сначала заглянем в storage + let data = await coversStorage.getData(coverPageUrl); + if (data) { + this.covers[coverPageUrl] = this.makeCoverHtml(data); + } else {//иначе идем на сервер + try { + data = await readerApi.getUploadedFileBuf(coverPageUrl); + await coversStorage.setData(coverPageUrl, data); + this.covers[coverPageUrl] = this.makeCoverHtml(data); + } catch (e) { + console.error(e); + } + } + })(); + } + + return (loadedCover != undefined); + } + + getCoverHtml(coverPageUrl) { + if (coverPageUrl && this.covers[coverPageUrl]) + return this.covers[coverPageUrl]; + else + return ''; + } } export default vueComponent(RecentBooksPage); @@ -716,14 +769,14 @@ export default vueComponent(RecentBooksPage); line-height: 110%; border-left: 1px solid #cccccc; border-bottom: 1px solid #cccccc; - height: 12px; + height: 14px; } .row-info-top { line-height: 110%; border: 1px solid #cccccc; border-right: 0; - height: 12px; + height: 14px; } .time-info, .row-info-top { @@ -731,8 +784,8 @@ export default vueComponent(RecentBooksPage); } .read-bar { - height: 4px; - background-color: #bbbbbb; + height: 6px; + background-color: #b8b8b8; } .del-button { diff --git a/client/components/Reader/SettingsPage/SettingsPage.vue b/client/components/Reader/SettingsPage/SettingsPage.vue index 4982df37..ae158e5d 100644 --- a/client/components/Reader/SettingsPage/SettingsPage.vue +++ b/client/components/Reader/SettingsPage/SettingsPage.vue @@ -124,6 +124,7 @@ import NumInput from '../../share/NumInput.vue'; import UserHotKeys from './UserHotKeys/UserHotKeys.vue'; import wallpaperStorage from '../share/wallpaperStorage'; +import readerApi from '../../../api/reader'; import rstore from '../../../store/modules/reader'; import defPalette from './defPalette'; @@ -636,8 +637,17 @@ class SettingsPage { if (index < 0) newUserWallpapers.push({label, cssClass}); - if (!wallpaperStorage.keyExists(cssClass)) + if (!wallpaperStorage.keyExists(cssClass)) { await wallpaperStorage.setData(cssClass, data); + //отправим data на сервер в файл `/upload/${key}` + try { + //const res = + await readerApi.uploadFileBuf(data); + //console.log(res); + } catch (e) { + console.error(e); + } + } this.userWallpapers = newUserWallpapers; this.wallpaper = cssClass; diff --git a/client/components/Reader/share/BookParser.js b/client/components/Reader/share/BookParser.js index 2ff421fb..9f4a9211 100644 --- a/client/components/Reader/share/BookParser.js +++ b/client/components/Reader/share/BookParser.js @@ -85,6 +85,7 @@ export default class BookParser { let binaryId = ''; let binaryType = ''; let dimPromises = []; + this.coverPageId = ''; //оглавление this.contents = []; @@ -289,7 +290,7 @@ export default class BookParser { const href = attrs.href.value; const alt = (attrs.alt && attrs.alt.value ? attrs.alt.value : ''); const {id, local} = this.imageHrefToId(href); - if (href[0] == '#') {//local + if (local) {//local imageNum++; if (inPara && !this.sets.showInlineImagesInCenter && !center) @@ -301,6 +302,11 @@ export default class BookParser { if (inPara && this.sets.showInlineImagesInCenter) newParagraph(); + + //coverpage + if (path == '/fictionbook/description/title-info/coverpage/image') { + this.coverPageId = id; + } } else {//external imageNum++; diff --git a/client/components/Reader/share/bookManager.js b/client/components/Reader/share/bookManager.js index cc7d76f9..193f5c4d 100644 --- a/client/components/Reader/share/bookManager.js +++ b/client/components/Reader/share/bookManager.js @@ -2,8 +2,10 @@ import localForage from 'localforage'; import path from 'path-browserify'; import _ from 'lodash'; -import * as utils from '../../../share/utils'; import BookParser from './BookParser'; +import readerApi from '../../../api/reader'; +import coversStorage from './coversStorage'; +import * as utils from '../../../share/utils'; const maxDataSize = 500*1024*1024;//compressed bytes const maxRecentLength = 5000; @@ -345,9 +347,36 @@ class BookManager { const parsed = new BookParser(this.settings); const parsedMeta = await parsed.parse(data, callback); + + //cover page + let coverPageUrl = ''; + if (parsed.coverPageId && parsed.binary[parsed.coverPageId]) { + const bin = parsed.binary[parsed.coverPageId]; + let dataUrl = `data:${bin.type};base64,${bin.data}`; + try { + dataUrl = await utils.resizeImage(dataUrl, 160, 160, 0.94); + } catch (e) { + console.error(e); + } + + //отправим dataUrl на сервер в /upload + try { + await readerApi.uploadFileBuf(dataUrl, (url) => { + coverPageUrl = url; + }); + } catch (e) { + console.error(e); + } + + //сохраним в storage + if (coverPageUrl) + await coversStorage.setData(coverPageUrl, dataUrl); + } + const result = Object.assign({}, meta, parsedMeta, { length: data.length, textLength: parsed.textLength, + coverPageUrl, parsed }); diff --git a/client/components/Reader/share/coversStorage.js b/client/components/Reader/share/coversStorage.js new file mode 100644 index 00000000..6908d94b --- /dev/null +++ b/client/components/Reader/share/coversStorage.js @@ -0,0 +1,61 @@ +import localForage from 'localforage'; +//import _ from 'lodash'; +import * as utils from '../../../share/utils'; + +const maxDataSize = 100*1024*1024; + +const coversStore = localForage.createInstance({ + name: 'coversStorage' +}); + +class CoversStorage { + constructor() { + } + + async init() { + this.cleanCovers(); //no await + } + + async setData(key, data) { + await coversStore.setItem(key, {addTime: Date.now(), data}); + } + + async getData(key) { + const item = await coversStore.getItem(key); + return (item ? item.data : undefined); + } + + async removeData(key) { + await coversStore.removeItem(key); + } + + async cleanCovers() { + await utils.sleep(10000); + + while (1) {// eslint-disable-line no-constant-condition + let size = 0; + let min = Date.now(); + let toDel = null; + for (const key of (await coversStore.keys())) { + const item = await coversStore.getItem(key); + + size += item.data.length; + + if (item.addTime < min) { + toDel = key; + min = item.addTime; + } + } + + + if (size > maxDataSize && toDel) { + await this.removeData(toDel); + } else { + break; + } + } + } + +} + +export default new CoversStorage(); \ No newline at end of file diff --git a/client/components/Reader/share/wallpaperStorage.js b/client/components/Reader/share/wallpaperStorage.js index 192c4601..9d98603d 100644 --- a/client/components/Reader/share/wallpaperStorage.js +++ b/client/components/Reader/share/wallpaperStorage.js @@ -32,6 +32,10 @@ class WallpaperStorage { this.cachedKeys = await wpStore.keys(); } + async getKeys() { + return await wpStore.keys(); + } + keyExists(key) {//не асинхронная return this.cachedKeys.includes(key); } diff --git a/client/components/Reader/versionHistory.js b/client/components/Reader/versionHistory.js index 00b49a3a..334c6368 100644 --- a/client/components/Reader/versionHistory.js +++ b/client/components/Reader/versionHistory.js @@ -1,4 +1,18 @@ export const versionHistory = [ +{ + version: '0.11.8', + releaseDate: '2022-07-14', + showUntil: '2022-07-13', + content: +` +
    +
  • добавлено отображение и синхронизация обложек в окне загруженных книг
  • +
  • добавлена синхронизация обоев
  • +
+ +` +}, + { version: '0.11.7', releaseDate: '2022-07-12', diff --git a/client/share/utils.js b/client/share/utils.js index 916d94a0..39196212 100644 --- a/client/share/utils.js +++ b/client/share/utils.js @@ -363,4 +363,50 @@ export function getBookTitle(fb2) { ]).join(' - '); return result; +} + +export function resizeImage(dataUrl, toWidth, toHeight, quality = 0.9) { + return new Promise ((resolve, reject) => { (async() => { + const img = new Image(); + + let resolved = false; + img.onload = () => { + try { + let width = img.width; + let height = img.height; + + if (width > height) { + if (width > toWidth) { + height = height * (toWidth / width); + width = toWidth; + } + } else { + if (height > toHeight) { + width = width * (toHeight / height); + height = toHeight; + } + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, width, height); + const result = canvas.toDataURL('image/jpeg', quality); + resolved = true; + resolve(result); + } catch (e) { + reject(e); + return; + } + }; + + img.onerror = reject; + + img.src = dataUrl; + + await sleep(1000); + if (!resolved) + reject('Не удалось изменить размер'); + })().catch(reject); }); } \ No newline at end of file diff --git a/client/store/modules/reader.js b/client/store/modules/reader.js index ab4a8972..dadada1b 100644 --- a/client/store/modules/reader.js +++ b/client/store/modules/reader.js @@ -191,6 +191,8 @@ const settingDefaults = { recentShowSameBook: false, recentSortMethod: '', + + needUpdateSettingsView: 0, }; for (const font of fonts) diff --git a/package-lock.json b/package-lock.json index 3b35a2c2..3b0544d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "Liberama", - "version": "0.11.7", + "version": "0.11.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "Liberama", - "version": "0.11.7", + "version": "0.11.8", "hasInstallScript": true, "license": "CC0-1.0", "dependencies": { diff --git a/package.json b/package.json index 1303b588..f945b657 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Liberama", - "version": "0.11.7", + "version": "0.11.8", "author": "Book Pauk ", "license": "CC0-1.0", "repository": "bookpauk/liberama", diff --git a/server/controllers/WebSocketController.js b/server/controllers/WebSocketController.js index 229dbcf0..add0a3a8 100644 --- a/server/controllers/WebSocketController.js +++ b/server/controllers/WebSocketController.js @@ -25,6 +25,10 @@ class WebSocketController { ws.on('message', (message) => { this.onMessage(ws, message.toString()); }); + + ws.on('error', (err) => { + log(LM_ERR, err); + }); }); setTimeout(() => { this.periodicClean(); }, cleanPeriod); @@ -70,6 +74,10 @@ class WebSocketController { await this.readerRestoreCachedFile(req, ws); break; case 'reader-storage': await this.readerStorageDo(req, ws); break; + case 'upload-file-buf': + await this.uploadFileBuf(req, ws); break; + case 'upload-file-touch': + await this.uploadFileTouch(req, ws); break; default: throw new Error(`Action not found: ${req.action}`); @@ -168,6 +176,20 @@ class WebSocketController { this.send(await this.readerStorage.doAction(req.body), req, ws); } + + async uploadFileBuf(req, ws) { + if (!req.buf) + throw new Error(`key 'buf' is empty`); + + this.send({url: await this.readerWorker.saveFileBuf(req.buf)}, req, ws); + } + + async uploadFileTouch(req, ws) { + if (!req.url) + throw new Error(`key 'url' is empty`); + + this.send({url: await this.readerWorker.uploadFileTouch(req.url)}, req, ws); + } } module.exports = WebSocketController; diff --git a/server/core/Reader/ReaderWorker.js b/server/core/Reader/ReaderWorker.js index cebd5cfe..70a6530d 100644 --- a/server/core/Reader/ReaderWorker.js +++ b/server/core/Reader/ReaderWorker.js @@ -219,6 +219,27 @@ class ReaderWorker { return `disk://${hash}`; } + async saveFileBuf(buf) { + const hash = await utils.getBufHash(buf, 'sha256', 'hex'); + const outFilename = `${this.config.uploadDir}/${hash}`; + + if (!await fs.pathExists(outFilename)) { + await fs.writeFile(outFilename, buf); + } else { + await utils.touchFile(outFilename); + } + + return `disk://${hash}`; + } + + async uploadFileTouch(url) { + const outFilename = `${this.config.uploadDir}/${url.replace('disk://', '')}`; + + await utils.touchFile(outFilename); + + return url; + } + async restoreRemoteFile(filename) { const basename = path.basename(filename); const targetName = `${this.config.tempPublicDir}/${basename}`; diff --git a/server/core/WebSocketConnection.js b/server/core/WebSocketConnection.js index 3045659e..fcdbcd1e 100644 --- a/server/core/WebSocketConnection.js +++ b/server/core/WebSocketConnection.js @@ -94,7 +94,7 @@ class WebSocketConnection { this.ws = new this.WebSocket(this.url); } - const onopen = (e) => { + const onopen = () => { this.connecting = false; resolve(this.ws); }; diff --git a/server/core/utils.js b/server/core/utils.js index 7dc2b636..9d4a6c9b 100644 --- a/server/core/utils.js +++ b/server/core/utils.js @@ -34,6 +34,12 @@ function getFileHash(filename, hashName, enc) { }); } +function getBufHash(buf, hashName, enc) { + const hash = crypto.createHash(hashName); + hash.update(buf); + return hash.digest(enc); +} + function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -129,6 +135,7 @@ module.exports = { fromBase36, bufferRemoveZeroes, getFileHash, + getBufHash, sleep, toUnixTime, randomHexString, diff --git a/server/index.js b/server/index.js index 633f0f84..848d8c53 100644 --- a/server/index.js +++ b/server/index.js @@ -11,6 +11,8 @@ const ayncExit = new (require('./core/AsyncExit'))(); let log = null; +const maxPayloadSize = 50;//in MB + async function init() { //config const configManager = new (require('./config'))();//singleton @@ -63,7 +65,7 @@ async function main() { if (serverCfg.mode !== 'none') { const app = express(); const server = http.createServer(app); - const wss = new WebSocket.Server({ server, maxPayload: 10*1024*1024 }); + const wss = new WebSocket.Server({ server, maxPayload: maxPayloadSize*1024*1024 }); const serverConfig = Object.assign({}, config, serverCfg); @@ -75,7 +77,7 @@ async function main() { } app.use(compression({ level: 1 })); - app.use(express.json({limit: '10mb'})); + app.use(express.json({limit: `${maxPayloadSize}mb`})); if (devModule) devModule.logQueries(app);