diff --git a/ext/css/settings.css b/ext/css/settings.css index e59122f3db..8464bf7427 100644 --- a/ext/css/settings.css +++ b/ext/css/settings.css @@ -2324,6 +2324,7 @@ input[type=number].dictionary-priority { margin-right: 0.5em; } .dictionary-outdated-button, +.dictionary-update-available, .dictionary-integrity-button { --button-content-color: transparent; --button-border-color: transparent; diff --git a/ext/data/schemas/dictionary-index-schema.json b/ext/data/schemas/dictionary-index-schema.json index 3ab25fa28d..ecf2825ecc 100644 --- a/ext/data/schemas/dictionary-index-schema.json +++ b/ext/data/schemas/dictionary-index-schema.json @@ -21,7 +21,7 @@ }, "revision": { "type": "string", - "description": "Revision of the dictionary. This value is only used for displaying information." + "description": "Revision of the dictionary. This value is displayed, and used to check for dictionary updates." }, "sequenced": { "type": "boolean", @@ -42,9 +42,22 @@ "type": "string", "description": "Creator of the dictionary." }, + "isUpdatable": { + "type": "boolean", + "const": true, + "description": "Whether this dictionary contains links to its latest version." + }, + "indexUrl": { + "type": "string", + "description": "URL for the index file of the latest revision of the dictionary, used to check for updates." + }, + "downloadUrl": { + "type": "string", + "description": "URL for the download of the latest revision of the dictionary." + }, "url": { "type": "string", - "description": "URL for the source of the dictionary." + "description": "URL for the source of the dictionary, displayed in the dictionary details." }, "description": { "type": "string", @@ -101,5 +114,8 @@ { "required": ["version"] } - ] + ], + "dependencies": { + "isUpdatable": ["indexUrl", "downloadUrl"] + } } diff --git a/ext/js/dictionary/dictionary-data-util.js b/ext/js/dictionary/dictionary-data-util.js index 1c5dd5e549..39b604c8c4 100644 --- a/ext/js/dictionary/dictionary-data-util.js +++ b/ext/js/dictionary/dictionary-data-util.js @@ -296,6 +296,33 @@ export function isNonNounVerbOrAdjective(wordClasses) { return isVerbOrAdjective && !(isSuruVerb && isNoun); } +/** + * @param {string} current + * @param {string} latest + * @returns {boolean} + */ +export function compareRevisions(current, latest) { + const simpleVersionTest = /^(\d+\.)*\d+$/; // dot-separated integers, so 4.7 or 24.1.1.1 are ok, 1.0.0-alpha is not + if (!simpleVersionTest.test(current) || !simpleVersionTest.test(latest)) { + return current < latest; + } + + const currentParts = current.split('.').map((part) => Number.parseInt(part, 10)); + const latestParts = latest.split('.').map((part) => Number.parseInt(part, 10)); + + if (currentParts.length !== latestParts.length) { + return current < latest; + } + + for (let i = 0; i < currentParts.length; i++) { + if (currentParts[i] !== latestParts[i]) { + return currentParts[i] < latestParts[i]; + } + } + + return false; +} + // Private /** diff --git a/ext/js/dictionary/dictionary-importer.js b/ext/js/dictionary/dictionary-importer.js index 3cfeb3264b..a558853e6e 100644 --- a/ext/js/dictionary/dictionary-importer.js +++ b/ext/js/dictionary/dictionary-importer.js @@ -311,6 +311,7 @@ export class DictionaryImporter { * @param {import('dictionary-data').Index} index * @param {import('dictionary-importer').SummaryDetails} details * @returns {import('dictionary-importer').Summary} + * @throws {Error} */ _createSummary(dictionaryTitle, version, index, details) { const indexSequenced = index.sequenced; @@ -327,7 +328,7 @@ export class DictionaryImporter { styles, }; - const {author, url, description, attribution, frequencyMode, sourceLanguage, targetLanguage} = index; + const {author, url, description, attribution, frequencyMode, isUpdatable, sourceLanguage, targetLanguage} = index; if (typeof author === 'string') { summary.author = author; } if (typeof url === 'string') { summary.url = url; } if (typeof description === 'string') { summary.description = description; } @@ -335,10 +336,37 @@ export class DictionaryImporter { if (typeof frequencyMode === 'string') { summary.frequencyMode = frequencyMode; } if (typeof sourceLanguage === 'string') { summary.sourceLanguage = sourceLanguage; } if (typeof targetLanguage === 'string') { summary.targetLanguage = targetLanguage; } - + if (typeof isUpdatable === 'boolean') { + const {indexUrl, downloadUrl} = index; + if (!isUpdatable || !this._validateUrl(indexUrl) || !this._validateUrl(downloadUrl)) { + throw new Error('Invalid index data for updatable dictionary'); + } + summary.isUpdatable = isUpdatable; + summary.indexUrl = indexUrl; + summary.downloadUrl = downloadUrl; + } return summary; } + /** + * @param {string|undefined} string + * @returns {boolean} + */ + _validateUrl(string) { + if (typeof string !== 'string') { + return false; + } + + let url; + try { + url = new URL(string); + } catch (_) { + return false; + } + + return url.protocol === 'http:' || url.protocol === 'https:'; + } + /** * @param {import('ajv').ValidateFunction} schema * @param {string} fileName diff --git a/ext/js/pages/settings/collapsible-dictionary-controller.js b/ext/js/pages/settings/collapsible-dictionary-controller.js index f2a03b5c40..fe4e2c3dd1 100644 --- a/ext/js/pages/settings/collapsible-dictionary-controller.js +++ b/ext/js/pages/settings/collapsible-dictionary-controller.js @@ -153,7 +153,7 @@ export class CollapsibleDictionaryController { nameNode.textContent = dictionary; /** @type {HTMLElement} */ - const versionNode = querySelectorNotNull(node, '.dictionary-version'); + const versionNode = querySelectorNotNull(node, '.dictionary-revision'); versionNode.textContent = version; return querySelectorNotNull(node, '.definitions-collapsible'); diff --git a/ext/js/pages/settings/dictionary-controller.js b/ext/js/pages/settings/dictionary-controller.js index 4dbcf994d9..5099d1f42c 100644 --- a/ext/js/pages/settings/dictionary-controller.js +++ b/ext/js/pages/settings/dictionary-controller.js @@ -16,11 +16,16 @@ * along with this program. If not, see . */ +import * as ajvSchemas0 from '../../../lib/validate-schemas.js'; import {EventListenerCollection} from '../../core/event-listener-collection.js'; +import {readResponseJson} from '../../core/json.js'; import {log} from '../../core/log.js'; +import {compareRevisions} from '../../dictionary/dictionary-data-util.js'; import {DictionaryWorker} from '../../dictionary/dictionary-worker.js'; import {querySelectorNotNull} from '../../dom/query-selector.js'; +const ajvSchemas = /** @type {import('dictionary-importer').CompiledSchemaValidators} */ (/** @type {unknown} */ (ajvSchemas0)); + class DictionaryEntry { /** * @param {DictionaryController} dictionaryController @@ -55,10 +60,12 @@ class DictionaryEntry { this._outdatedButton = querySelectorNotNull(fragment, '.dictionary-outdated-button'); /** @type {HTMLButtonElement} */ this._integrityButton = querySelectorNotNull(fragment, '.dictionary-integrity-button'); + /** @type {HTMLButtonElement} */ + this._updatesAvailable = querySelectorNotNull(fragment, '.dictionary-update-available'); /** @type {HTMLElement} */ this._titleNode = querySelectorNotNull(fragment, '.dictionary-title'); /** @type {HTMLElement} */ - this._versionNode = querySelectorNotNull(fragment, '.dictionary-version'); + this._versionNode = querySelectorNotNull(fragment, '.dictionary-revision'); /** @type {HTMLElement} */ this._titleContainer = querySelectorNotNull(fragment, '.dictionary-item-title-container'); } @@ -70,6 +77,7 @@ class DictionaryEntry { /** */ prepare() { + // const index = this._index; const {title, revision, version} = this._dictionaryInfo; @@ -85,6 +93,7 @@ class DictionaryEntry { this._eventListeners.addEventListener(this._downButton, 'click', (() => { this._move(1); }).bind(this), false); this._eventListeners.addEventListener(this._outdatedButton, 'click', this._onOutdatedButtonClick.bind(this), false); this._eventListeners.addEventListener(this._integrityButton, 'click', this._onIntegrityButtonClick.bind(this), false); + this._eventListeners.addEventListener(this._updatesAvailable, 'click', this._onUpdateButtonClick.bind(this), false); } /** */ @@ -113,6 +122,36 @@ class DictionaryEntry { this._enabledCheckbox.checked = value; } + /** + * @returns {Promise} + */ + async checkForUpdate() { + this._updatesAvailable.hidden = true; + const {isUpdatable, indexUrl, revision: currentRevision, downloadUrl: currentDownloadUrl} = this._dictionaryInfo; + if (!isUpdatable || !indexUrl || !currentDownloadUrl) { return false; } + const response = await fetch(indexUrl); + + /** @type {unknown} */ + const index = await readResponseJson(response); + + if (!ajvSchemas.dictionaryIndex(index)) { + throw new Error('Invalid dictionary index'); + } + + const validIndex = /** @type {import('dictionary-data').Index} */ (index); + const {revision: latestRevision, downloadUrl: latestDownloadUrl} = validIndex; + + if (!compareRevisions(currentRevision, latestRevision)) { + return false; + } + + const downloadUrl = latestDownloadUrl ?? currentDownloadUrl; + + this._updatesAvailable.dataset.downloadUrl = downloadUrl; + this._updatesAvailable.hidden = false; + return true; + } + // Private /** @@ -155,6 +194,12 @@ class DictionaryEntry { this._showDetails(); } + /** */ + _onUpdateButtonClick() { + const downloadUrl = this._updatesAvailable.dataset.downloadUrl; + this._dictionaryController.updateDictionary(this.dictionaryTitle, downloadUrl); + } + /** */ _onIntegrityButtonClick() { this._showDetails(); @@ -170,7 +215,7 @@ class DictionaryEntry { /** @type {HTMLElement} */ const titleElement = querySelectorNotNull(modal.node, '.dictionary-title'); /** @type {HTMLElement} */ - const versionElement = querySelectorNotNull(modal.node, '.dictionary-version'); + const versionElement = querySelectorNotNull(modal.node, '.dictionary-revision'); /** @type {HTMLElement} */ const outdateElement = querySelectorNotNull(modal.node, '.dictionary-outdated-notification'); /** @type {HTMLElement} */ @@ -393,8 +438,12 @@ export class DictionaryController { /** @type {?import('core').TokenObject} */ this._databaseStateToken = null; /** @type {boolean} */ + this._checkingUpdates = false; + /** @type {boolean} */ this._checkingIntegrity = false; /** @type {?HTMLButtonElement} */ + this._checkUpdatesButton = document.querySelector('#dictionary-check-updates'); + /** @type {?HTMLButtonElement} */ this._checkIntegrityButton = document.querySelector('#dictionary-check-integrity'); /** @type {HTMLElement} */ this._dictionaryEntryContainer = querySelectorNotNull(document, '#dictionary-list'); @@ -408,6 +457,8 @@ export class DictionaryController { this._noDictionariesEnabledWarnings = null; /** @type {?import('./modal.js').Modal} */ this._deleteDictionaryModal = null; + /** @type {?import('./modal.js').Modal} */ + this._updateDictionaryModal = null; /** @type {HTMLInputElement} */ this._allCheckbox = querySelectorNotNull(document, '#all-dictionaries-enabled'); /** @type {?DictionaryExtraInfo} */ @@ -431,8 +482,12 @@ export class DictionaryController { this._noDictionariesInstalledWarnings = /** @type {NodeListOf} */ (document.querySelectorAll('.no-dictionaries-installed-warning')); this._noDictionariesEnabledWarnings = /** @type {NodeListOf} */ (document.querySelectorAll('.no-dictionaries-enabled-warning')); this._deleteDictionaryModal = this._modalController.getModal('dictionary-confirm-delete'); + this._updateDictionaryModal = this._modalController.getModal('dictionary-confirm-update'); /** @type {HTMLButtonElement} */ const dictionaryDeleteButton = querySelectorNotNull(document, '#dictionary-confirm-delete-button'); + /** @type {HTMLButtonElement} */ + const dictionaryUpdateButton = querySelectorNotNull(document, '#dictionary-confirm-update-button'); + /** @type {HTMLButtonElement} */ const dictionaryMoveButton = querySelectorNotNull(document, '#dictionary-move-button'); @@ -440,7 +495,12 @@ export class DictionaryController { this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); this._allCheckbox.addEventListener('change', this._onAllCheckboxChange.bind(this), false); dictionaryDeleteButton.addEventListener('click', this._onDictionaryConfirmDelete.bind(this), false); + dictionaryUpdateButton.addEventListener('click', this._onDictionaryConfirmUpdate.bind(this), false); + dictionaryMoveButton.addEventListener('click', this._onDictionaryMoveButtonClick.bind(this), false); + if (this._checkUpdatesButton !== null) { + this._checkUpdatesButton.addEventListener('click', this._onCheckUpdatesButtonClick.bind(this), false); + } if (this._checkIntegrityButton !== null) { this._checkIntegrityButton.addEventListener('click', this._onCheckIntegrityButtonClick.bind(this), false); } @@ -463,6 +523,21 @@ export class DictionaryController { modal.setVisible(true); } + /** + * @param {string} dictionaryTitle + * @param {string|undefined} downloadUrl + */ + updateDictionary(dictionaryTitle, downloadUrl) { + const modal = this._updateDictionaryModal; + if (modal === null) { return; } + modal.node.dataset.downloadUrl = downloadUrl; + modal.node.dataset.dictionaryTitle = dictionaryTitle; + /** @type {Element} */ + const nameElement = querySelectorNotNull(modal.node, '#dictionary-confirm-update-name'); + nameElement.textContent = dictionaryTitle; + modal.setVisible(true); + } + /** * @param {number} currentIndex * @param {number} targetIndex @@ -727,6 +802,23 @@ export class DictionaryController { void this._deleteDictionary(title); } + /** + * @param {MouseEvent} e + */ + _onDictionaryConfirmUpdate(e) { + e.preventDefault(); + + const modal = /** @type {import('./modal.js').Modal} */ (this._updateDictionaryModal); + modal.setVisible(false); + + const title = modal.node.dataset.dictionaryTitle; + const downloadUrl = modal.node.dataset.downloadUrl; + if (typeof title !== 'string') { return; } + delete modal.node.dataset.dictionaryTitle; + + void this._updateDictionary(title, downloadUrl); + } + /** * @param {MouseEvent} e */ @@ -735,6 +827,14 @@ export class DictionaryController { void this._checkIntegrity(); } + /** + * @param {MouseEvent} e + */ + _onCheckUpdatesButtonClick(e) { + e.preventDefault(); + void this._checkForUpdates(); + } + /** */ _onDictionaryMoveButtonClick() { const modal = /** @type {import('./modal.js').Modal} */ (this._modalController.getModal('dictionary-move-location')); @@ -778,9 +878,27 @@ export class DictionaryController { } } + /** */ + async _checkForUpdates() { + if (this._dictionaries === null || this._checkingIntegrity || this._checkingUpdates || this._isDeleting) { return; } + try { + this._checkingUpdates = true; + this._setButtonsEnabled(false); + + const updateChecks = this._dictionaryEntries.map((entry) => entry.checkForUpdate()); + const updateCount = (await Promise.all(updateChecks)).reduce((sum, value) => (sum + (value ? 1 : 0)), 0); + if (this._checkUpdatesButton !== null) { + this._checkUpdatesButton.textContent = updateCount ? `${updateCount} update${updateCount > 1 ? 's' : ''}` : 'No updates'; + } + } finally { + this._setButtonsEnabled(true); + this._checkingUpdates = false; + } + } + /** */ async _checkIntegrity() { - if (this._dictionaries === null || this._checkingIntegrity || this._isDeleting) { return; } + if (this._dictionaries === null || this._checkingIntegrity || this._checkingUpdates || this._isDeleting) { return; } try { this._checkingIntegrity = true; @@ -906,6 +1024,23 @@ export class DictionaryController { } } + /** + * @param {string} dictionaryTitle + * @param {string|undefined} downloadUrl + */ + async _updateDictionary(dictionaryTitle, downloadUrl) { + if (this._checkingIntegrity || this._checkingUpdates || this._isDeleting || this._dictionaries === null) { return; } + + const dictionaryInfo = this._dictionaries.find((entry) => entry.title === dictionaryTitle); + if (typeof dictionaryInfo === 'undefined') { throw new Error('Dictionary not found'); } + downloadUrl = downloadUrl ?? dictionaryInfo.downloadUrl; + if (typeof downloadUrl !== 'string') { throw new Error('Attempted to update dictionary without download URL'); } + + await this._deleteDictionary(dictionaryTitle); + + this._settingsController.trigger('importDictionaryFromUrl', {url: downloadUrl}); + } + /** * @param {boolean} value */ diff --git a/ext/js/pages/settings/dictionary-import-controller.js b/ext/js/pages/settings/dictionary-import-controller.js index ed6d83d64c..c30f4bf6dc 100644 --- a/ext/js/pages/settings/dictionary-import-controller.js +++ b/ext/js/pages/settings/dictionary-import-controller.js @@ -87,10 +87,19 @@ export class DictionaryImportController { this._importFileDrop.addEventListener('dragover', this._onFileDropOver.bind(this), false); this._importFileDrop.addEventListener('dragleave', this._onFileDropLeave.bind(this), false); this._importFileDrop.addEventListener('drop', this._onFileDrop.bind(this), false); + + this._settingsController.on('importDictionaryFromUrl', this._onEventImportDictionaryFromUrl.bind(this)); } // Private + /** + * @param {import('settings-controller').EventArgument<'importDictionaryFromUrl'>} details + */ + _onEventImportDictionaryFromUrl({url}) { + void this.importFilesFromURLs(url); + } + /** */ _onImportFileButtonClick() { /** @type {HTMLInputElement} */ (this._importFileInput).click(); @@ -260,6 +269,13 @@ export class DictionaryImportController { async _onImportFromURL() { const text = this._importURLText.value.trim(); if (!text) { return; } + await this.importFilesFromURLs(text); + } + + /** + * @param {string} text + */ + async importFilesFromURLs(text) { const urls = text.split('\n'); const importProgressTracker = new ImportProgressTracker(this._getUrlImportSteps(), urls.length); diff --git a/ext/js/pages/settings/secondary-search-dictionary-controller.js b/ext/js/pages/settings/secondary-search-dictionary-controller.js index c708bf650b..63f178f812 100644 --- a/ext/js/pages/settings/secondary-search-dictionary-controller.js +++ b/ext/js/pages/settings/secondary-search-dictionary-controller.js @@ -86,7 +86,7 @@ export class SecondarySearchDictionaryController { nameNode.textContent = name; /** @type {HTMLElement} */ - const versionNode = querySelectorNotNull(node, '.dictionary-version'); + const versionNode = querySelectorNotNull(node, '.dictionary-revision'); versionNode.textContent = `rev.${dictionaryInfo.revision}`; /** @type {HTMLElement} */ diff --git a/ext/settings.html b/ext/settings.html index dd6321e9ae..f2071f5259 100644 --- a/ext/settings.html +++ b/ext/settings.html @@ -2497,6 +2497,7 @@

Yomitan Settings

@@ -2551,6 +2552,27 @@
or click here to upload
+ +