From 8106f4744b07833526d16acf656eda11d29b99ad Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Sun, 1 Mar 2020 22:36:42 -0500 Subject: [PATCH] Add support for importing and storing media files --- ext/bg/background.html | 1 + .../data/dictionary-term-bank-v3-schema.json | 81 ++++++++++++++++- ext/bg/js/database.js | 11 ++- ext/bg/js/dictionary-importer.js | 90 +++++++++++++++++++ ext/bg/js/media-utility.js | 75 ++++++++++++++++ 5 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 ext/bg/js/media-utility.js diff --git a/ext/bg/background.html b/ext/bg/background.html index afe9c5d1..f1006f8d 100644 --- a/ext/bg/background.html +++ b/ext/bg/background.html @@ -36,6 +36,7 @@ + diff --git a/ext/bg/data/dictionary-term-bank-v3-schema.json b/ext/bg/data/dictionary-term-bank-v3-schema.json index bb982e36..4790e561 100644 --- a/ext/bg/data/dictionary-term-bank-v3-schema.json +++ b/ext/bg/data/dictionary-term-bank-v3-schema.json @@ -31,8 +31,85 @@ "type": "array", "description": "Array of definitions for the term/expression.", "items": { - "type": "string", - "description": "Single definition for the term/expression." + "oneOf": [ + { + "type": "string", + "description": "Single definition for the term/expression." + }, + { + "type": "object", + "description": "Single detailed definition for the term/expression.", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "The type of the data for this definition.", + "enum": ["text", "image"] + } + }, + "oneOf": [ + { + "required": [ + "type", + "text" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string", + "description": "Single definition for the term/expression." + } + } + }, + { + "required": [ + "type", + "path" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": ["image"] + }, + "path": { + "type": "string", + "description": "Path to the image file in the archive." + }, + "width": { + "type": "integer", + "description": "Preferred width of the image.", + "minimum": 1 + }, + "height": { + "type": "integer", + "description": "Preferred width of the image.", + "minimum": 1 + }, + "title": { + "type": "string", + "description": "Hover text for the image." + }, + "description": { + "type": "string", + "description": "Description of the image." + }, + "pixelated": { + "type": "boolean", + "description": "Whether or not the image should appear pixelated at sizes larger than the image's native resolution.", + "default": false + } + } + } + ] + } + ] } }, { diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js index 260c815a..0c7eee6a 100644 --- a/ext/bg/js/database.js +++ b/ext/bg/js/database.js @@ -33,7 +33,7 @@ class Database { } try { - this.db = await Database._open('dict', 5, (db, transaction, oldVersion) => { + this.db = await Database._open('dict', 6, (db, transaction, oldVersion) => { Database._upgrade(db, transaction, oldVersion, [ { version: 2, @@ -90,6 +90,15 @@ class Database { indices: ['dictionary', 'expression', 'reading', 'sequence', 'expressionReverse', 'readingReverse'] } } + }, + { + version: 6, + stores: { + media: { + primaryKey: {keyPath: 'id', autoIncrement: true}, + indices: ['dictionary', 'path'] + } + } } ]); }); diff --git a/ext/bg/js/dictionary-importer.js b/ext/bg/js/dictionary-importer.js index bf6809ec..8a4497a3 100644 --- a/ext/bg/js/dictionary-importer.js +++ b/ext/bg/js/dictionary-importer.js @@ -18,6 +18,7 @@ /* global * JSZip * JsonSchema + * mediaUtility * requestJson */ @@ -148,6 +149,22 @@ class DictionaryImporter { } } + // Extended data support + const extendedDataContext = { + archive, + media: new Map() + }; + for (const entry of termList) { + const glossaryList = entry.glossary; + for (let i = 0, ii = glossaryList.length; i < ii; ++i) { + const glossary = glossaryList[i]; + if (typeof glossary !== 'object' || glossary === null) { continue; } + glossaryList[i] = await this._formatDictionaryTermGlossaryObject(glossary, extendedDataContext, entry); + } + } + + const media = [...extendedDataContext.media.values()]; + // Add dictionary const summary = this._createSummary(dictionaryTitle, version, index, {prefixWildcardsSupported}); @@ -188,6 +205,7 @@ class DictionaryImporter { await bulkAdd('kanji', kanjiList); await bulkAdd('kanjiMeta', kanjiMetaList); await bulkAdd('tagMeta', tagList); + await bulkAdd('media', media); return {result: summary, errors}; } @@ -275,4 +293,76 @@ class DictionaryImporter { return [termBank, termMetaBank, kanjiBank, kanjiMetaBank, tagBank]; } + + async _formatDictionaryTermGlossaryObject(data, context, entry) { + switch (data.type) { + case 'text': + return data.text; + case 'image': + return await this._formatDictionaryTermGlossaryImage(data, context, entry); + default: + throw new Error(`Unhandled data type: ${data.type}`); + } + } + + async _formatDictionaryTermGlossaryImage(data, context, entry) { + const dictionary = entry.dictionary; + const {path, width: preferredWidth, height: preferredHeight, title, description, pixelated} = data; + if (context.media.has(path)) { + // Already exists + return data; + } + + let errorSource = entry.expression; + if (entry.reading.length > 0) { + errorSource += ` (${entry.reading});`; + } + + const file = context.archive.file(path); + if (file === null) { + throw new Error(`Could not find image at path ${JSON.stringify(path)} for ${errorSource}`); + } + + const source = await file.async('base64'); + const mediaType = mediaUtility.getImageMediaTypeFromFileName(path); + if (mediaType === null) { + throw new Error(`Could not determine media type for image at path ${JSON.stringify(path)} for ${errorSource}`); + } + + let image; + try { + image = await mediaUtility.loadImage(mediaType, source); + } catch (e) { + throw new Error(`Could not load image at path ${JSON.stringify(path)} for ${errorSource}`); + } + + const width = image.naturalWidth; + const height = image.naturalHeight; + + // Create image data + const mediaData = { + dictionary, + path, + mediaType, + width, + height, + source + }; + context.media.set(path, mediaData); + + // Create new data + const newData = { + type: 'image', + path, + width, + height + }; + if (typeof preferredWidth === 'number') { newData.preferredWidth = preferredWidth; } + if (typeof preferredHeight === 'number') { newData.preferredHeight = preferredHeight; } + if (typeof title === 'string') { newData.title = title; } + if (typeof description === 'string') { newData.description = description; } + if (typeof pixelated === 'boolean') { newData.pixelated = pixelated; } + + return newData; + } } diff --git a/ext/bg/js/media-utility.js b/ext/bg/js/media-utility.js new file mode 100644 index 00000000..24686838 --- /dev/null +++ b/ext/bg/js/media-utility.js @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +const mediaUtility = (() => { + function getFileNameExtension(fileName) { + const match = /\.[^.]*$/.exec(fileName); + return match !== null ? match[0] : ''; + } + + function getImageMediaTypeFromFileName(fileName) { + switch (getFileNameExtension(fileName).toLowerCase()) { + case '.apng': + return 'image/apng'; + case '.bmp': + return 'image/bmp'; + case '.gif': + return 'image/gif'; + case '.ico': + case '.cur': + return 'image/x-icon'; + case '.jpg': + case '.jpeg': + case '.jfif': + case '.pjpeg': + case '.pjp': + return 'image/jpeg'; + case '.png': + return 'image/png'; + case '.svg': + return 'image/svg+xml'; + case '.tif': + case '.tiff': + return 'image/tiff'; + case '.webp': + return 'image/webp'; + default: + return null; + } + } + + function loadImage(mediaType, base64Source) { + return new Promise((resolve, reject) => { + const image = new Image(); + const eventListeners = new EventListenerCollection(); + eventListeners.addEventListener(image, 'load', () => { + eventListeners.removeAllEventListeners(); + resolve(image); + }, false); + eventListeners.addEventListener(image, 'error', () => { + eventListeners.removeAllEventListeners(); + reject(new Error('Image failed to load')); + }, false); + image.src = `data:${mediaType};base64,${base64Source}`; + }); + } + + return { + getImageMediaTypeFromFileName, + loadImage + }; +})();