diff --git a/README.md b/README.md index e50e1f3..8737970 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,9 @@ inpx-web ## Использование Поместите приложение `inpx-web` в папку с .inpx-файлом и файлами библиотеки и запустите. +Там же, при первом запуске, будет создана рабочая директория `.inpx-web`, в которой хранится +конфигурационный файл `config.json`, файлы базы данных, журналы и прочее. + По умолчанию сервер будет доступен по адресу http://127.0.0.1:12380 @@ -78,6 +81,10 @@ Options: // включить(true)/выключить(false) журналирование "loggingEnabled": true, + // максимальный размер кеша каждой таблицы в БД, в блоках (требуется примерно 1-10Мб памяти на один блок) + // если надо кешировать всю БД, можно поставить значение от 1000 и больше + "dbCacheSize": 5, + // максимальный размер в байтах директории закешированных файлов в <раб.дир>/public/files // чистка каждый час "maxFilesDirSize": 1073741824, @@ -99,6 +106,10 @@ Options: // во столько же раз увеличивается время создания "lowMemoryMode": false, + // включить(true)/выключить(false) полную оптимизацию поисковой БД + // ускоряет работу поиска, но увеличивает размер БД в 2-3 раза при импорте INPX + "fullOptimization": false, + // включить(true)/выключить(false) режим "Удаленная библиотека" (сервер) "allowRemoteLib": false, diff --git a/client/components/Api/Api.vue b/client/components/Api/Api.vue index b0b7df0..b37c478 100644 --- a/client/components/Api/Api.vue +++ b/client/components/Api/Api.vue @@ -35,24 +35,22 @@ import vueComponent from '../vueComponent.js'; import wsc from './webSocketConnection'; import * as utils from '../../share/utils'; import * as cryptoUtils from '../../share/cryptoUtils'; -import LockQueue from '../../share/LockQueue'; +import LockQueue from '../../../server/core/LockQueue'; import packageJson from '../../../package.json'; const rotor = '|/-\\'; const stepBound = [ 0, 0,// jobStep = 1 - 18,// jobStep = 2 - 20,// jobStep = 3 - 60,// jobStep = 4 - 72,// jobStep = 5 - 72,// jobStep = 6 - 74,// jobStep = 7 - 75,// jobStep = 8 - 79,// jobStep = 9 - 79,// jobStep = 10 - 80,// jobStep = 11 - 100,// jobStep = 12 + 40,// jobStep = 2 + 50,// jobStep = 3 + 54,// jobStep = 4 + 58,// jobStep = 5 + 69,// jobStep = 6 + 69,// jobStep = 7 + 70,// jobStep = 8 + 95,// jobStep = 9 + 100,// jobStep = 10 ]; const componentOptions = { @@ -185,80 +183,60 @@ class Api { } async request(params, timeoutSecs = 10) { + let errCount = 0; while (1) {// eslint-disable-line - if (this.accessToken) - params.accessToken = this.accessToken; + try { + if (this.accessToken) + params.accessToken = this.accessToken; - const response = await wsc.message(await wsc.send(params), timeoutSecs); + const response = await wsc.message(await wsc.send(params), timeoutSecs); - if (response && response.error == 'need_access_token') { - await this.showPasswordDialog(); - } else if (response && response.error == 'server_busy') { - await this.showBusyDialog(); - } else { - return response; + if (response && response.error == 'need_access_token') { + await this.showPasswordDialog(); + } else if (response && response.error == 'server_busy') { + await this.showBusyDialog(); + } else { + if (response.error) { + throw new Error(response.error); + } + + return response; + } + + errCount = 0; + } catch(e) { + errCount++; + if (e.message !== 'WebSocket не отвечает' || errCount > 10) { + errCount = 0; + throw e; + } + await utils.sleep(100); } } } - async search(query) { - const response = await this.request({action: 'search', query}); - - if (response.error) { - throw new Error(response.error); - } - - return response; + async search(from, query) { + return await this.request({action: 'search', from, query}, 30); } - async getBookList(authorId) { - const response = await this.request({action: 'get-book-list', authorId}); - - if (response.error) { - throw new Error(response.error); - } - - return response; + async getAuthorBookList(authorId) { + return await this.request({action: 'get-author-book-list', authorId}); } async getSeriesBookList(series) { - const response = await this.request({action: 'get-series-book-list', series}); - - if (response.error) { - throw new Error(response.error); - } - - return response; + return await this.request({action: 'get-series-book-list', series}); } async getGenreTree() { - const response = await this.request({action: 'get-genre-tree'}); - - if (response.error) { - throw new Error(response.error); - } - - return response; + return await this.request({action: 'get-genre-tree'}); } async getBookLink(params) { - const response = await this.request(Object.assign({action: 'get-book-link'}, params), 120); - - if (response.error) { - throw new Error(response.error); - } - - return response; + return await this.request(Object.assign({action: 'get-book-link'}, params), 120); } async getConfig() { - const response = await this.request({action: 'get-config'}); - - if (response.error) { - throw new Error(response.error); - } - - return response; + return await this.request({action: 'get-config'}); } } diff --git a/client/components/App.vue b/client/components/App.vue index 7c93bc8..b36ba19 100644 --- a/client/components/App.vue +++ b/client/components/App.vue @@ -121,7 +121,7 @@ body, html, #app { padding: 0; width: 100%; height: 100%; - font: normal 12px GameDefault; + font: normal 13px Web Default; } .dborder { @@ -142,9 +142,13 @@ body, html, #app { } @font-face { - font-family: 'GameDefault'; - src: url('fonts/web-default.woff') format('woff'), - url('fonts/web-default.ttf') format('truetype'); + font-family: 'Web Default'; + src: url('fonts/web-default.ttf') format('truetype'); } +@font-face { + font-family: 'Verdana'; + font-weight: bold; + src: url('fonts/web-default-bold.ttf') format('truetype'); +} diff --git a/client/components/Search/AuthorList/AuthorList.vue b/client/components/Search/AuthorList/AuthorList.vue new file mode 100644 index 0000000..07ada45 --- /dev/null +++ b/client/components/Search/AuthorList/AuthorList.vue @@ -0,0 +1,447 @@ + + + + + diff --git a/client/components/Search/BaseList.js b/client/components/Search/BaseList.js new file mode 100644 index 0000000..0a5d23e --- /dev/null +++ b/client/components/Search/BaseList.js @@ -0,0 +1,522 @@ +import moment from 'moment'; +import _ from 'lodash'; + +import authorBooksStorage from './authorBooksStorage'; + +import BookView from './BookView/BookView.vue'; +import LoadingMessage from './LoadingMessage/LoadingMessage.vue'; +import * as utils from '../../share/utils'; + +const showMoreCount = 100;//значение для "Показать еще" +const maxItemCount = 500;//выше этого значения показываем "Загрузка" + +const componentOptions = { + components: { + BookView, + LoadingMessage, + }, + watch: { + settings() { + this.loadSettings(); + }, + search: { + handler(newValue) { + this.limit = newValue.limit; + + if (this.pageCount > 1) + this.prevPage = this.search.page; + + this.refresh(); + }, + deep: true, + }, + showDeleted() { + this.refresh(); + }, + }, +}; +export default class BaseList { + _options = componentOptions; + _props = { + list: Object, + search: Object, + genreMap: Object, + }; + + loadingMessage = ''; + loadingMessage2 = ''; + + //settings + expandedAuthor = []; + expandedSeries = []; + + showCounts = true; + showRates = true; + showGenres = true; + showDeleted = false; + abCacheEnabled = true; + + //stuff + refreshing = false; + + showMoreCount = showMoreCount; + maxItemCount = maxItemCount; + + searchResult = {}; + tableData = []; + + created() { + this.commit = this.$store.commit; + this.api = this.$root.api; + + this.loadSettings(); + } + + mounted() { + this.refresh();//no await + } + + loadSettings() { + const settings = this.settings; + + this.expandedAuthor = _.cloneDeep(settings.expandedAuthor); + this.expandedSeries = _.cloneDeep(settings.expandedSeries); + this.showCounts = settings.showCounts; + this.showRates = settings.showRates; + this.showGenres = settings.showGenres; + this.showDeleted = settings.showDeleted; + this.abCacheEnabled = settings.abCacheEnabled; + } + + get config() { + return this.$store.state.config; + } + + get settings() { + return this.$store.state.settings; + } + + get showReadLink() { + return this.config.bookReadLink != '' || this.list.liberamaReady; + } + + scrollToTop() { + this.$emit('listEvent', {action: 'scrollToTop'}); + } + + selectAuthor(author) { + this.search.author = `=${author}`; + this.scrollToTop(); + } + + selectSeries(series) { + this.search.series = `=${series}`; + } + + selectTitle(title) { + this.search.title = `=${title}`; + } + + async download(book, action) { + if (this.downloadFlag) + return; + + this.downloadFlag = true; + (async() => { + await utils.sleep(200); + if (this.downloadFlag) + this.loadingMessage2 = 'Подготовка файла...'; + })(); + + try { + const makeValidFilenameOrEmpty = (s) => { + try { + return utils.makeValidFilename(s); + } catch(e) { + return ''; + } + }; + + //имя файла + let downFileName = 'default-name'; + const author = book.author.split(','); + const at = [author[0], book.title]; + downFileName = makeValidFilenameOrEmpty(at.filter(r => r).join(' - ')) + || makeValidFilenameOrEmpty(at[0]) + || makeValidFilenameOrEmpty(at[1]) + || downFileName; + downFileName = downFileName.substring(0, 100); + + const ext = `.${book.ext}`; + if (downFileName.substring(downFileName.length - ext.length) != ext) + downFileName += ext; + + const bookPath = `${book.folder}/${book.file}${ext}`; + //подготовка + const response = await this.api.getBookLink({bookPath, downFileName}); + + const link = response.link; + const href = `${window.location.origin}${link}`; + + if (action == 'download') { + //скачивание + const d = this.$refs.download; + d.href = href; + d.download = downFileName; + + d.click(); + } else if (action == 'copyLink') { + //копирование ссылки + if (await utils.copyTextToClipboard(href)) + this.$root.notify.success('Ссылка успешно скопирована'); + else + this.$root.stdDialog.alert( +`Копирование ссылки не удалось. Пожалуйста, попробуйте еще раз. +

+Пояснение: вероятно, браузер запретил копирование, т.к. прошло
+слишком много времени с момента нажатия на кнопку (инициация
+пользовательского события). Сейчас ссылка уже закеширована,
+поэтому повторная попытка должна быть успешной.`, 'Ошибка'); + } else if (action == 'readBook') { + //читать + if (this.list.liberamaReady) { + this.$emit('listEvent', {action: 'submitUrl', data: href}); + } else { + const url = this.config.bookReadLink.replace('${DOWNLOAD_LINK}', href); + window.open(url, '_blank'); + } + } + } catch(e) { + this.$root.stdDialog.alert(e.message, 'Ошибка'); + } finally { + this.downloadFlag = false; + this.loadingMessage2 = ''; + } + } + + bookEvent(event) { + switch (event.action) { + case 'authorClick': + this.selectAuthor(event.book.author); + break; + case 'seriesClick': + this.selectSeries(event.book.series); + break; + case 'titleClick': + this.selectTitle(event.book.title); + break; + case 'download': + case 'copyLink': + case 'readBook': + this.download(event.book, event.action);//no await + break; + } + } + + isExpandedAuthor(item) { + return this.expandedAuthor.indexOf(item.author) >= 0; + } + + isExpandedSeries(seriesItem) { + return this.expandedSeries.indexOf(seriesItem.key) >= 0; + } + + setSetting(name, newValue) { + this.commit('setSettings', {[name]: _.cloneDeep(newValue)}); + } + + highlightPageScroller(query) { + this.$emit('listEvent', {action: 'highlightPageScroller', query}); + } + + async expandSeries(seriesItem) { + this.$emit('listEvent', {action: 'ignoreScroll'}); + + const expandedSeries = _.cloneDeep(this.expandedSeries); + const key = seriesItem.key; + + if (!this.isExpandedSeries(seriesItem)) { + expandedSeries.push(key); + + if (expandedSeries.length > 100) { + expandedSeries.shift(); + } + + this.getSeriesBooks(seriesItem); //no await + + this.setSetting('expandedSeries', expandedSeries); + } else { + const i = expandedSeries.indexOf(key); + if (i >= 0) { + expandedSeries.splice(i, 1); + this.setSetting('expandedSeries', expandedSeries); + } + } + } + + async loadAuthorBooks(authorId) { + try { + let result; + + if (this.abCacheEnabled) { + const key = `author-${authorId}-${this.list.inpxHash}`; + const data = await authorBooksStorage.getData(key); + if (data) { + result = JSON.parse(data); + } else { + result = await this.api.getAuthorBookList(authorId); + await authorBooksStorage.setData(key, JSON.stringify(result)); + } + } else { + result = await this.api.getAuthorBookList(authorId); + } + + return (result.books ? JSON.parse(result.books) : []); + } catch (e) { + this.$root.stdDialog.alert(e.message, 'Ошибка'); + } + } + + async loadSeriesBooks(series) { + try { + let result; + + if (this.abCacheEnabled) { + const key = `series-${series}-${this.list.inpxHash}`; + const data = await authorBooksStorage.getData(key); + if (data) { + result = JSON.parse(data); + } else { + result = await this.api.getSeriesBookList(series); + await authorBooksStorage.setData(key, JSON.stringify(result)); + } + } else { + result = await this.api.getSeriesBookList(series); + } + + return (result.books ? JSON.parse(result.books) : []); + } catch (e) { + this.$root.stdDialog.alert(e.message, 'Ошибка'); + } + } + + async getSeriesBooks(seriesItem) { + //блокируем повторный вызов + if (seriesItem.seriesBookLoading) + return; + seriesItem.seriesBookLoading = true; + + try { + seriesItem.allBooksLoaded = await this.loadSeriesBooks(seriesItem.series); + + if (seriesItem.allBooksLoaded) { + seriesItem.allBooksLoaded = seriesItem.allBooksLoaded.filter(book => (this.showDeleted || !book.del)); + this.sortSeriesBooks(seriesItem.allBooksLoaded); + this.showMoreAll(seriesItem); + } + } finally { + seriesItem.seriesBookLoading = false; + } + } + + filterBooks(books) { + const s = this.search; + + const emptyFieldValue = '?'; + const maxUtf8Char = String.fromCodePoint(0xFFFFF); + const ruAlphabet = 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя'; + const enAlphabet = 'abcdefghijklmnopqrstuvwxyz'; + const enru = new Set((ruAlphabet + enAlphabet).split('')); + + const splitAuthor = (author) => { + if (!author) { + author = emptyFieldValue; + } + + const result = author.split(','); + if (result.length > 1) + result.push(author); + + return result; + }; + + const filterBySearch = (bookValue, searchValue) => { + if (!searchValue) + return true; + + if (!bookValue) + bookValue = emptyFieldValue; + + bookValue = bookValue.toLowerCase(); + searchValue = searchValue.toLowerCase(); + + //особая обработка префиксов + if (searchValue[0] == '=') { + + searchValue = searchValue.substring(1); + return bookValue.localeCompare(searchValue) == 0; + } else if (searchValue[0] == '*') { + + searchValue = searchValue.substring(1); + return bookValue !== emptyFieldValue && bookValue.indexOf(searchValue) >= 0; + } else if (searchValue[0] == '#') { + + searchValue = searchValue.substring(1); + return !bookValue || (bookValue !== emptyFieldValue && !enru.has(bookValue[0]) && bookValue.indexOf(searchValue) >= 0); + } else { + //where = `@dirtyIndexLR('value', ${db.esc(a)}, ${db.esc(a + maxUtf8Char)})`; + return bookValue.localeCompare(searchValue) >= 0 && bookValue.localeCompare(searchValue + maxUtf8Char) <= 0; + } + }; + + return books.filter((book) => { + //author + let authorFound = false; + const authors = splitAuthor(book.author); + for (const a of authors) { + if (filterBySearch(a, s.author)) { + authorFound = true; + break; + } + } + + //genre + let genreFound = !s.genre; + if (!genreFound) { + const searchGenres = new Set(s.genre.split(',')); + const bookGenres = book.genre.split(','); + + for (let g of bookGenres) { + if (!g) + g = emptyFieldValue; + + if (searchGenres.has(g)) { + genreFound = true; + break; + } + } + } + + //lang + let langFound = !s.lang; + if (!langFound) { + const searchLang = new Set(s.lang.split(',')); + langFound = searchLang.has(book.lang || emptyFieldValue); + } + + //date + let dateFound = !s.date; + if (!dateFound) { + const date = this.queryDate(s.date).split(','); + let [from = '0000-00-00', to = '9999-99-99'] = date; + + dateFound = (book.date >= from && book.date <= to); + } + + //librate + let librateFound = !s.librate; + if (!librateFound) { + const searchLibrate = new Set(s.librate.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n))); + librateFound = searchLibrate.has(book.librate); + } + + return (this.showDeleted || !book.del) + && authorFound + && filterBySearch(book.series, s.series) + && filterBySearch(book.title, s.title) + && genreFound + && langFound + && dateFound + && librateFound + ; + }); + } + + showMore(item, all = false) { + if (item.booksLoaded) { + const currentLen = (item.books ? item.books.length : 0); + let books; + if (all || currentLen + this.showMoreCount*1.5 > item.booksLoaded.length) { + books = item.booksLoaded; + } else { + books = item.booksLoaded.slice(0, currentLen + this.showMoreCount); + } + + item.showMore = (books.length < item.booksLoaded.length); + item.books = books; + } + } + + showMoreAll(seriesItem, all = false) { + if (seriesItem.allBooksLoaded) { + const currentLen = (seriesItem.allBooks ? seriesItem.allBooks.length : 0); + let books; + if (all || currentLen + this.showMoreCount*1.5 > seriesItem.allBooksLoaded.length) { + books = seriesItem.allBooksLoaded; + } else { + books = seriesItem.allBooksLoaded.slice(0, currentLen + this.showMoreCount); + } + + seriesItem.showMoreAll = (books.length < seriesItem.allBooksLoaded.length); + seriesItem.allBooks = books; + } + } + + sortSeriesBooks(seriesBooks) { + seriesBooks.sort((a, b) => { + const dserno = (a.serno || Number.MAX_VALUE) - (b.serno || Number.MAX_VALUE); + const dtitle = a.title.localeCompare(b.title); + const dext = a.ext.localeCompare(b.ext); + return (dserno ? dserno : (dtitle ? dtitle : dext)); + }); + } + + queryDate(date) { + if (!utils.isManualDate(date)) {//!manual + /* + {label: 'сегодня', value: 'today'}, + {label: 'за 3 дня', value: '3days'}, + {label: 'за неделю', value: 'week'}, + {label: 'за 2 недели', value: '2weeks'}, + {label: 'за месяц', value: 'month'}, + {label: 'за 2 месяца', value: '2months'}, + {label: 'за 3 месяца', value: '3months'}, + {label: 'указать даты', value: 'manual'}, + */ + const sqlFormat = 'YYYY-MM-DD'; + switch (date) { + case 'today': date = utils.dateFormat(moment(), sqlFormat); break; + case '3days': date = utils.dateFormat(moment().subtract(3, 'days'), sqlFormat); break; + case 'week': date = utils.dateFormat(moment().subtract(1, 'weeks'), sqlFormat); break; + case '2weeks': date = utils.dateFormat(moment().subtract(2, 'weeks'), sqlFormat); break; + case 'month': date = utils.dateFormat(moment().subtract(1, 'months'), sqlFormat); break; + case '2months': date = utils.dateFormat(moment().subtract(2, 'months'), sqlFormat); break; + case '3months': date = utils.dateFormat(moment().subtract(3, 'months'), sqlFormat); break; + default: + date = ''; + } + } + + return date; + } + + getQuery() { + let newQuery = _.cloneDeep(this.search); + newQuery = newQuery.setDefaults(newQuery); + delete newQuery.setDefaults; + + //дата + if (newQuery.date) { + newQuery.date = this.queryDate(newQuery.date); + } + + //offset + newQuery.offset = (newQuery.page - 1)*newQuery.limit; + + //del + if (!this.showDeleted) + newQuery.del = 0; + + return newQuery; + } +} \ No newline at end of file diff --git a/client/components/Search/BookView/BookView.vue b/client/components/Search/BookView/BookView.vue index 89e4a32..e76ac05 100644 --- a/client/components/Search/BookView/BookView.vue +++ b/client/components/Search/BookView/BookView.vue @@ -1,8 +1,8 @@