feat: add AniList character dictionary sync

This commit is contained in:
2026-03-05 22:43:19 -08:00
parent 2f07c3407a
commit 33ded3c1bf
117 changed files with 3579 additions and 6443 deletions

View File

@@ -36,6 +36,8 @@ export class DisplayContentManager {
this._eventListeners = new EventListenerCollection();
/** @type {import('display-content-manager').LoadMediaRequest[]} */
this._loadMediaRequests = [];
/** @type {string[]} */
this._mediaUrls = [];
}
/** @type {import('display-content-manager').LoadMediaRequest[]} */
@@ -47,10 +49,10 @@ export class DisplayContentManager {
* Queues loading media file from a given dictionary.
* @param {string} path
* @param {string} dictionary
* @param {OffscreenCanvas} canvas
* @param {HTMLImageElement|HTMLCanvasElement} element
*/
loadMedia(path, dictionary, canvas) {
this._loadMediaRequests.push({path, dictionary, canvas});
loadMedia(path, dictionary, element) {
this._loadMediaRequests.push({path, dictionary, element});
}
/**
@@ -62,6 +64,10 @@ export class DisplayContentManager {
this._eventListeners.removeAllEventListeners();
this._loadMediaRequests = [];
for (const mediaUrl of this._mediaUrls) {
URL.revokeObjectURL(mediaUrl);
}
this._mediaUrls = [];
}
/**
@@ -83,7 +89,70 @@ export class DisplayContentManager {
* Execute media requests
*/
async executeMediaRequests() {
this._display.application.api.drawMedia(this._loadMediaRequests, this._loadMediaRequests.map(({canvas}) => canvas));
const token = this._token;
for (const {path, dictionary, element} of this._loadMediaRequests) {
try {
const data = await this._display.application.api.getMedia([{path, dictionary}]);
if (this._token !== token) { return; }
const item = data[0];
if (
typeof item !== 'object' ||
item === null ||
typeof item.content !== 'string' ||
typeof item.mediaType !== 'string'
) {
this._setMediaElementState(element, 'load-error');
continue;
}
const buffer = base64ToArrayBuffer(item.content);
const blob = new Blob([buffer], {type: item.mediaType});
const blobUrl = URL.createObjectURL(blob);
if (element instanceof HTMLImageElement) {
this._mediaUrls.push(blobUrl);
element.onload = () => {
if (this._token !== token) { return; }
this._setMediaElementState(element, 'loaded');
};
element.onerror = () => {
if (this._token !== token) { return; }
this._setMediaElementState(element, 'load-error');
};
element.src = blobUrl;
continue;
}
const image = new Image();
image.onload = () => {
try {
if (this._token !== token) { return; }
const context = element.getContext('2d');
if (context === null) {
this._setMediaElementState(element, 'load-error');
return;
}
element.width = image.naturalWidth || element.width;
element.height = image.naturalHeight || element.height;
context.drawImage(image, 0, 0, element.width, element.height);
this._setMediaElementState(element, 'loaded');
} finally {
URL.revokeObjectURL(blobUrl);
}
};
image.onerror = () => {
if (this._token === token) {
this._setMediaElementState(element, 'load-error');
}
URL.revokeObjectURL(blobUrl);
};
image.src = blobUrl;
} catch (_e) {
if (this._token !== token) { return; }
this._setMediaElementState(element, 'load-error');
}
}
this._loadMediaRequests = [];
}
@@ -127,4 +196,17 @@ export class DisplayContentManager {
content: null,
});
}
/**
* @param {HTMLImageElement|HTMLCanvasElement} element
* @param {'loaded'|'load-error'} state
*/
_setMediaElementState(element, state) {
const link = element.closest('.gloss-image-link');
if (link === null) { return; }
link.dataset.imageLoadState = state;
if (state === 'loaded') {
link.dataset.hasImage = 'true';
}
}
}

View File

@@ -148,9 +148,7 @@ export class StructuredContentGenerator {
}
if (this._contentManager !== null) {
const image = this._contentManager instanceof DisplayContentManager ?
/** @type {HTMLCanvasElement} */ (this._createElement('canvas', 'gloss-image')) :
/** @type {HTMLImageElement} */ (this._createElement('img', 'gloss-image'));
const image = /** @type {HTMLImageElement} */ (this._createElement('img', 'gloss-image'));
if (sizeUnits === 'em' && (hasPreferredWidth || hasPreferredHeight)) {
const emSize = 14; // We could Number.parseFloat(getComputedStyle(document.documentElement).fontSize); here for more accuracy but it would cause a layout and be extremely slow; possible improvement would be to calculate and cache the value
const scaleFactor = 2 * this._window.devicePixelRatio;
@@ -172,7 +170,7 @@ export class StructuredContentGenerator {
this._contentManager.loadMedia(
path,
dictionary,
(/** @type {HTMLCanvasElement} */(image)).transferControlToOffscreen(),
image,
);
} else if (this._contentManager instanceof AnkiTemplateRendererContentManager) {
this._contentManager.loadMedia(

View File

@@ -720,6 +720,24 @@ export class DictionaryController {
modal.setVisible(true);
}
/**
* @param {string} dictionaryTitle
* @returns {Promise<void>}
*/
async deleteDictionaryNow(dictionaryTitle) {
const dictionaries = await this._settingsController.getDictionaryInfo();
if (!dictionaries.some((dictionary) => dictionary.title === dictionaryTitle)) {
return;
}
await this._deleteDictionary(dictionaryTitle);
const remaining = await this._settingsController.getDictionaryInfo();
if (remaining.some((dictionary) => dictionary.title === dictionaryTitle)) {
throw new Error(`Dictionary still present after delete: ${dictionaryTitle}`);
}
}
/**
* @param {string} dictionaryTitle
* @returns {Promise<string[]>}

View File

@@ -452,6 +452,25 @@ export class DictionaryImportController {
);
}
/**
* @param {ArrayBuffer} archiveContent
* @param {string} fileName
* @returns {Promise<void>}
*/
async importDictionaryArchiveContent(archiveContent, fileName='dictionary.zip') {
const file = new File([archiveContent], fileName, {type: 'application/zip'});
const importProgressTracker = new ImportProgressTracker(this._getFileImportSteps(), 1);
const errors = await this._importDictionaries(
this._arrayToAsyncGenerator([file]),
null,
null,
importProgressTracker,
);
if (errors.length > 0) {
throw errors[0];
}
}
/**
* @param {string[]} urls
* @param {import('dictionary-worker').ImportProgressCallback} onProgress
@@ -532,9 +551,10 @@ export class DictionaryImportController {
* @param {import('settings-controller').ProfilesDictionarySettings} profilesDictionarySettings
* @param {import('settings-controller').ImportDictionaryDoneCallback} onImportDone
* @param {ImportProgressTracker} importProgressTracker
* @returns {Promise<Error[]>}
*/
async _importDictionaries(dictionaries, profilesDictionarySettings, onImportDone, importProgressTracker) {
if (this._modifying) { return; }
if (this._modifying) { return [new Error('Dictionary import already in progress.')]; }
const statusFooter = this._statusFooter;
const progressSelector = '.dictionary-import-progress';
@@ -588,6 +608,7 @@ export class DictionaryImportController {
this._triggerStorageChanged();
if (onImportDone) { onImportDone(); }
}
return errors;
}
/**

View File

@@ -113,6 +113,21 @@ await Application.main(true, async (application) => {
const dictionaryImportController = new DictionaryImportController(settingsController, modalController, statusFooter);
dictionaryImportController.prepare();
globalThis.__subminerYomitanSettingsAutomation = {
ready: false,
importDictionaryArchiveBase64: async (archiveBase64, fileName='dictionary.zip') => {
const binary = atob(archiveBase64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; ++i) {
bytes[i] = binary.charCodeAt(i);
}
await dictionaryImportController.importDictionaryArchiveContent(bytes.buffer, fileName);
},
deleteDictionary: async (dictionaryTitle) => {
await dictionaryController.deleteDictionaryNow(dictionaryTitle);
},
};
const genericSettingController = new GenericSettingController(settingsController);
preparePromises.push(setupGenericSettingController(genericSettingController));
@@ -184,5 +199,6 @@ await Application.main(true, async (application) => {
await Promise.all(preparePromises);
globalThis.__subminerYomitanSettingsAutomation.ready = true;
document.documentElement.dataset.loaded = 'true';
});