import localForage from 'localforage'; import path from 'path-browserify'; import _ from 'lodash'; 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; //локальный кэш метаданных книг, ограничение maxDataSize const bmMetaStore = localForage.createInstance({ name: 'bmMetaStore' }); //локальный кэш самих книг, ограничение maxDataSize const bmDataStore = localForage.createInstance({ name: 'bmDataStore' }); //список недавно открытых книг const bmRecentStoreNew = localForage.createInstance({ name: 'bmRecentStoreNew' }); class BookManager { async init(settings) { this.loaded = false; this.settings = settings; this.eventListeners = []; this.books = {}; this.recent = {}; this.saveRecent = _.debounce(() => { bmRecentStoreNew.setItem('recent', this.recent); }, 300, {maxWait: 800}); this.saveRecentItem = _.debounce(() => { bmRecentStoreNew.setItem('recent-item', this.recentItem); this.recentRev = (this.recentRev < maxRecentLength ? this.recentRev + 1 : 1); bmRecentStoreNew.setItem('rev', this.recentRev); }, 200, {maxWait: 300}); //загрузка bmRecentStore this.recentRev = await bmRecentStoreNew.getItem('rev') || 0; if (this.recentRev) { this.recent = await bmRecentStoreNew.getItem('recent'); if (!this.recent) this.recent = {}; this.recentItem = await bmRecentStoreNew.getItem('recent-item'); if (this.recentItem) this.recent[this.recentItem.key] = this.recentItem; //конвертируем в новые ключи await this.convertRecent(); this.recentLastKey = await bmRecentStoreNew.getItem('recent-last-key'); if (this.recentLastKey) { const meta = await bmMetaStore.getItem(`bmMeta-${this.recentLastKey}`); if (_.isObject(meta)) { this.books[meta.key] = meta; } } await this.cleanRecentBooks(); } this.recentChanged = true; this.loadStored();//no await } //TODO: убрать в 2025г async convertRecent() { const converted = await bmRecentStoreNew.getItem('recent-converted'); if (converted) return; const newRecent = {}; for (const book of Object.values(this.recent)) { if (!book.path) { continue; } const newKey = this.keyFromPath(book.path); newRecent[newKey] = _.cloneDeep(book); newRecent[newKey].key = newKey; if (!newRecent[newKey].loadTime) newRecent[newKey].loadTime = newRecent[newKey].addTime; } this.recent = newRecent; //console.log(converted); (async() => { await utils.sleep(3000); this.saveRecent(); this.emit('recent-changed'); this.emit('set-recent'); await bmRecentStoreNew.setItem('recent-converted', true); })(); } //Ленивая асинхронная загрузка bmMetaStore async loadStored() { //даем время для загрузки последней читаемой книги, чтобы не блокировать приложение await utils.sleep(2000); let len = await bmMetaStore.length(); 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); if (_.isObject(meta)) { //уже может быть распарсена книга const oldBook = this.books[meta.key]; this.books[meta.key] = meta; if (oldBook && oldBook.parsed) { this.books[meta.key].parsed = oldBook.parsed; } } else { await bmMetaStore.removeItem(key); } } } await this.cleanBooks(); this.loaded = true; this.emit('load-stored-finish'); } async cleanBooks() { while (1) {// eslint-disable-line no-constant-condition let size = 0; let min = Date.now(); let toDel = null; for (let key in this.books) { let book = this.books[key]; const bookLength = (book.length ? book.length : 0); size += (book.dataCompressedLength ? book.dataCompressedLength : bookLength); if (book.addTime < min) { toDel = book; min = book.addTime; } } if (size > maxDataSize && toDel) { await this.delBook(toDel); } else { break; } } } async deflateWithProgress(data, callback) { const chunkSize = 512*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 = 512*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}; if (newBook.downloadSize !== undefined && newBook.downloadSize >= 0) meta.downloadSize = newBook.downloadSize; meta.key = this.keyFromPath(meta.path); meta.addTime = Date.now();//время добавления в кеш 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; await bmMetaStore.setItem(`bmMeta-${meta.key}`, this.metaOnly(result)); await bmDataStore.setItem(`bmData-${meta.key}`, data); callback(100); return result; } async hasBookParsed(meta) { if (!this.books) return false; if (!meta.path) return false; if (!meta.key) meta.key = this.keyFromPath(meta.path); let book = this.books[meta.key]; if (!book && !this.loaded) { book = await bmMetaStore.getItem(`bmMeta-${meta.key}`); if (book) this.books[meta.key] = book; } return !!(book && book.parsed); } async getBook(meta, callback) { let result = undefined; if (!meta.path) return; if (!meta.key) meta.key = this.keyFromPath(meta.path); result = this.books[meta.key]; if (!result) { result = await bmMetaStore.getItem(`bmMeta-${meta.key}`); if (result) this.books[meta.key] = result; } if (result && !result.parsed) { 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; } return result; } async delBook(meta) { await bmMetaStore.removeItem(`bmMeta-${meta.key}`); await bmDataStore.removeItem(`bmData-${meta.key}`); delete this.books[meta.key]; } async parseBook(meta, data, callback) { const parsed = new BookParser(this.settings); const parsedMeta = await parsed.parse(data, callback); //cover page let coverPageUrl = ''; if (parsed.coverPageId && parsed.binary[parsed.coverPageId]) { const bin = parsed.binary[parsed.coverPageId]; let dataUrl = `data:${bin.type};base64,${bin.data}`; try { dataUrl = await utils.resizeImage(dataUrl, 160, 160, 0.94); } catch (e) { console.error(e); } coverPageUrl = readerApi.makeUrlFromBuf(dataUrl); //далее асинхронно (async() => { //отправим dataUrl на сервер в /upload try { await readerApi.uploadFileBuf(dataUrl, coverPageUrl); } catch (e) { console.error(e); } //сохраним в storage await coversStorage.setData(coverPageUrl, dataUrl); })(); } const result = Object.assign({}, meta, parsedMeta, { length: data.length, textLength: parsed.textLength, coverPageUrl, parsed }); return result; } metaOnly(book) { let result = Object.assign({}, book); delete result.parsed; return result; } /*keyFromUrl(url) { return utils.stringToHex(url); }*/ keyFromPath(bookPath) { return path.basename(bookPath); } keysEqual(bookPath1, bookPath2) { if (bookPath1 === undefined || bookPath2 === undefined) return false; return (this.keyFromPath(bookPath1) === this.keyFromPath(bookPath2)); } //-- recent -------------------------------------------------------------- async recentSetItem(item = null, skipCheck = false) { const rev = await bmRecentStoreNew.getItem('rev'); if (rev != this.recentRev && !skipCheck) {//если изменение произошло в другой вкладке барузера const newRecent = await bmRecentStoreNew.getItem('recent'); Object.assign(this.recent, newRecent); this.recentItem = await bmRecentStoreNew.getItem('recent-item'); this.recentRev = rev; } const prevKey = (this.recentItem ? this.recentItem.key : ''); if (item) { this.recent[item.key] = item; this.recentItem = item; } else { this.recentItem = null; } this.saveRecentItem(); if (!item || prevKey != item.key) { this.saveRecent(); } this.recentChanged = true; if (item) { this.emit('recent-changed', item.key); } else { this.emit('recent-changed'); } } async recentSetLastKey(key) { this.recentLastKey = key; await bmRecentStoreNew.setItem('recent-last-key', this.recentLastKey); } async setRecentBook(value) { let result = this.metaOnly(value); result.touchTime = Date.now();//время последнего чтения if (!result.loadTime) result.loadTime = Date.now();//время загрузки файла result.deleted = 0; if (this.recent[result.key]) { result = Object.assign({}, this.recent[result.key], result); } await this.recentSetLastKey(result.key); await this.recentSetItem(result); return result; } async getRecentBook(value) { return this.recent[value.key]; } /* async delRecentBook(value, delFlag = 1) { const item = this.recent[value.key]; item.deleted = delFlag; if (this.recentLastKey == value.key) { await this.recentSetLastKey(null); } await this.recentSetItem(item); this.emit('recent-deleted', value.key); } */ async delRecentBooks(values, delFlag = 1) { for (const value of values) { const item = this.recent[value.key]; item.deleted = delFlag; if (this.recentLastKey == value.key) { await this.recentSetLastKey(null); } await this.recentSetItem(item); } this.emit('recent-deleted'); } /* async restoreRecentBook(value) { const item = this.recent[value.key]; item.deleted = 0; await this.recentSetItem(item); } */ async restoreRecentBooks(values) { for (const value of values) { const item = this.recent[value.key]; item.deleted = 0; await this.recentSetItem(item); } } async setCheckBuc(value, checkBuc) { const item = this.recent[value.key]; const updateItems = []; if (item) { if (item.sameBookKey !== undefined) { const sorted = this.getSortedRecent(); for (const book of sorted) { if (!book.deleted && book.sameBookKey === item.sameBookKey) updateItems.push(book); } } else { updateItems.push(item); } } const now = Date.now(); for (const book of updateItems) { book.checkBuc = checkBuc; if (checkBuc) book.checkBucTime = now; await this.recentSetItem(book); } } async cleanRecentBooks() { const sorted = this.getSortedRecent(); let isDel = false; for (let i = maxRecentLength; i < sorted.length; i++) { delete this.recent[sorted[i].key]; isDel = true; } this.sortedRecentCached = null; if (isDel) await this.recentSetItem(); return isDel; } mostRecentBook() { if (this.recentLastKey) { return this.recent[this.recentLastKey]; } const oldKey = this.recentLastKey; let max = 0; let result = null; for (const key in this.recent) { const book = this.recent[key]; if (!book.deleted && book.touchTime > max) { max = book.touchTime; result = book; } } const newRecentLastKey = (result ? result.key : null); this.recentSetLastKey(newRecentLastKey);//no await if (newRecentLastKey !== oldKey) this.emit('recent-changed'); return result; } getSortedRecent() { if (!this.recentChanged && this.sortedRecentCached) { return this.sortedRecentCached; } let result = Object.values(this.recent); result.sort((a, b) => b.touchTime - a.touchTime); this.sortedRecentCached = result; this.recentChanged = false; return result; } findRecentByUrlAndPath(url, bookPath) { if (bookPath) { const key = this.keyFromPath(bookPath); const book = this.recent[key]; if (book && !book.deleted) return book; } let max = 0; let result = null; for (const key in this.recent) { const book = this.recent[key]; if (!book.deleted && book.url == url && book.loadTime > max) { max = book.loadTime; result = book; } } return result; } findRecentBySameBookKey(sameKey) { let max = 0; let result = null; for (const key in this.recent) { const book = this.recent[key]; if (!book.deleted && book.sameBookKey == sameKey && book.loadTime > max) { max = book.loadTime; result = book; } } 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]; } this.recent = mergedRecent; await this.recentSetLastKey(null); await this.recentSetItem(null, true); this.emit('set-recent'); } 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); } } } } export default new BookManager();