feat(assets): bundle runtime assets and vendor dependencies

This commit is contained in:
2026-02-22 21:43:43 -08:00
parent d3fd47f0ec
commit ae95601698
429 changed files with 165389 additions and 0 deletions

View File

@@ -0,0 +1,605 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 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 <https://www.gnu.org/licenses/>.
*/
import {ExtensionError} from '../core/extension-error.js';
import {deferPromise, sanitizeCSS} from '../core/utilities.js';
import {convertHiraganaToKatakana, convertKatakanaToHiragana} from '../language/ja/japanese.js';
import {cloneFieldMarkerPattern, getRootDeckName} from './anki-util.js';
export class AnkiNoteBuilder {
/**
* Initiate an instance of AnkiNoteBuilder.
* @param {import('anki-note-builder').MinimalApi} api
* @param {import('../templates/template-renderer-proxy.js').TemplateRendererProxy|import('../templates/template-renderer.js').TemplateRenderer} templateRenderer
*/
constructor(api, templateRenderer) {
/** @type {import('anki-note-builder').MinimalApi} */
this._api = api;
/** @type {RegExp} */
this._markerPattern = cloneFieldMarkerPattern(true);
/** @type {import('../templates/template-renderer-proxy.js').TemplateRendererProxy|import('../templates/template-renderer.js').TemplateRenderer} */
this._templateRenderer = templateRenderer;
/** @type {import('anki-note-builder').BatchedRequestGroup[]} */
this._batchedRequests = [];
/** @type {boolean} */
this._batchedRequestsQueued = false;
}
/**
* @param {import('anki-note-builder').CreateNoteDetails} details
* @returns {Promise<import('anki-note-builder').CreateNoteResult>}
*/
async createNote({
dictionaryEntry,
cardFormat,
context,
template,
tags = [],
requirements = [],
duplicateScope = 'collection',
duplicateScopeCheckAllModels = false,
resultOutputMode = 'split',
glossaryLayoutMode = 'default',
compactTags = false,
mediaOptions = null,
dictionaryStylesMap = new Map(),
}) {
const {deck: deckName, model: modelName, fields: fieldsSettings} = cardFormat;
const fields = Object.entries(fieldsSettings);
let duplicateScopeDeckName = null;
let duplicateScopeCheckChildren = false;
if (duplicateScope === 'deck-root') {
duplicateScope = 'deck';
duplicateScopeDeckName = getRootDeckName(deckName);
duplicateScopeCheckChildren = true;
}
/** @type {Error[]} */
const allErrors = [];
let media;
if (requirements.length > 0 && mediaOptions !== null) {
let errors;
({media, errors} = await this._injectMedia(dictionaryEntry, requirements, mediaOptions));
for (const error of errors) {
allErrors.push(ExtensionError.deserialize(error));
}
}
// Make URL field blank if URL source is Yomitan
try {
const url = new URL(context.url);
if (url.protocol === new URL(import.meta.url).protocol) {
context.url = '';
}
} catch (e) {
// Ignore
}
const commonData = this._createData(dictionaryEntry, cardFormat, context, resultOutputMode, glossaryLayoutMode, compactTags, media, dictionaryStylesMap);
const formattedFieldValuePromises = [];
for (const [, {value: fieldValue}] of fields) {
const formattedFieldValuePromise = this._formatField(fieldValue, commonData, template);
formattedFieldValuePromises.push(formattedFieldValuePromise);
}
const formattedFieldValues = await Promise.all(formattedFieldValuePromises);
/** @type {Map<string, import('anki-note-builder').Requirement>} */
const uniqueRequirements = new Map();
/** @type {import('anki').NoteFields} */
const noteFields = {};
for (let i = 0, ii = fields.length; i < ii; ++i) {
const fieldName = fields[i][0];
const {value, errors: fieldErrors, requirements: fieldRequirements} = formattedFieldValues[i];
noteFields[fieldName] = value;
allErrors.push(...fieldErrors);
for (const requirement of fieldRequirements) {
const key = JSON.stringify(requirement);
if (uniqueRequirements.has(key)) { continue; }
uniqueRequirements.set(key, requirement);
}
}
/** @type {import('anki').Note} */
const note = {
fields: noteFields,
tags,
deckName,
modelName,
options: {
allowDuplicate: true,
duplicateScope,
duplicateScopeOptions: {
deckName: duplicateScopeDeckName,
checkChildren: duplicateScopeCheckChildren,
checkAllModels: duplicateScopeCheckAllModels,
},
},
};
return {note, errors: allErrors, requirements: [...uniqueRequirements.values()]};
}
/**
* @param {import('anki-note-builder').GetRenderingDataDetails} details
* @returns {Promise<import('anki-templates').NoteData>}
*/
async getRenderingData({
dictionaryEntry,
cardFormat,
context,
resultOutputMode = 'split',
glossaryLayoutMode = 'default',
compactTags = false,
marker,
dictionaryStylesMap,
}) {
const commonData = this._createData(dictionaryEntry, cardFormat, context, resultOutputMode, glossaryLayoutMode, compactTags, void 0, dictionaryStylesMap);
return await this._templateRenderer.getModifiedData({marker, commonData}, 'ankiNote');
}
/**
* @param {import('dictionary').DictionaryEntry} dictionaryEntry
* @returns {import('api').InjectAnkiNoteMediaDefinitionDetails}
*/
getDictionaryEntryDetailsForNote(dictionaryEntry) {
const {type} = dictionaryEntry;
if (type === 'kanji') {
const {character} = dictionaryEntry;
return {type, character};
}
const {headwords} = dictionaryEntry;
let bestIndex = -1;
for (let i = 0, ii = headwords.length; i < ii; ++i) {
const {term, reading, sources} = headwords[i];
for (const {deinflectedText} of sources) {
if (term === deinflectedText) {
bestIndex = i;
i = ii;
break;
} else if (reading === deinflectedText && bestIndex < 0) {
bestIndex = i;
break;
}
}
}
const {term, reading} = headwords[Math.max(0, bestIndex)];
return {type, term, reading};
}
/**
* @param {import('settings').DictionariesOptions} dictionaries
* @returns {Map<string, string>}
*/
getDictionaryStylesMap(dictionaries) {
const styleMap = new Map();
for (const dictionary of dictionaries) {
const {name, styles} = dictionary;
if (typeof styles === 'string') {
styleMap.set(name, sanitizeCSS(styles));
}
}
return styleMap;
}
// Private
/**
* @param {import('dictionary').DictionaryEntry} dictionaryEntry
* @param {import('settings').AnkiCardFormat} cardFormat
* @param {import('anki-templates-internal').Context} context
* @param {import('settings').ResultOutputMode} resultOutputMode
* @param {import('settings').GlossaryLayoutMode} glossaryLayoutMode
* @param {boolean} compactTags
* @param {import('anki-templates').Media|undefined} media
* @param {Map<string, string>} dictionaryStylesMap
* @returns {import('anki-note-builder').CommonData}
*/
_createData(dictionaryEntry, cardFormat, context, resultOutputMode, glossaryLayoutMode, compactTags, media, dictionaryStylesMap) {
return {
dictionaryEntry,
cardFormat,
context,
resultOutputMode,
glossaryLayoutMode,
compactTags,
media,
dictionaryStylesMap,
};
}
/**
* @param {string} field
* @param {import('anki-note-builder').CommonData} commonData
* @param {string} template
* @returns {Promise<{value: string, errors: ExtensionError[], requirements: import('anki-note-builder').Requirement[]}>}
*/
async _formatField(field, commonData, template) {
/** @type {ExtensionError[]} */
const errors = [];
/** @type {import('anki-note-builder').Requirement[]} */
const requirements = [];
const value = await this._stringReplaceAsync(field, this._markerPattern, async (match) => {
const marker = match[1];
try {
const {result, requirements: fieldRequirements} = await this._renderTemplateBatched(template, commonData, marker);
requirements.push(...fieldRequirements);
return result;
} catch (e) {
const error = new ExtensionError(`Template render error for {${marker}}`);
error.data = {error: e};
errors.push(error);
return `{${marker}-render-error}`;
}
});
return {value, errors, requirements};
}
/**
* @param {string} str
* @param {RegExp} regex
* @param {(match: RegExpExecArray, index: number, str: string) => (string|Promise<string>)} replacer
* @returns {Promise<string>}
*/
async _stringReplaceAsync(str, regex, replacer) {
let match;
let index = 0;
/** @type {(Promise<string>|string)[]} */
const parts = [];
while ((match = regex.exec(str)) !== null) {
parts.push(str.substring(index, match.index), replacer(match, match.index, str));
index = regex.lastIndex;
}
if (parts.length === 0) {
return str;
}
parts.push(str.substring(index));
return (await Promise.all(parts)).join('');
}
/**
* @param {string} template
* @returns {import('anki-note-builder').BatchedRequestGroup}
*/
_getBatchedTemplateGroup(template) {
for (const item of this._batchedRequests) {
if (item.template === template) {
return item;
}
}
const result = {template, commonDataRequestsMap: new Map()};
this._batchedRequests.push(result);
return result;
}
/**
* @param {string} template
* @param {import('anki-note-builder').CommonData} commonData
* @param {string} marker
* @returns {Promise<import('template-renderer').RenderResult>}
*/
_renderTemplateBatched(template, commonData, marker) {
/** @type {import('core').DeferredPromiseDetails<import('template-renderer').RenderResult>} */
const {promise, resolve, reject} = deferPromise();
const {commonDataRequestsMap} = this._getBatchedTemplateGroup(template);
let requests = commonDataRequestsMap.get(commonData);
if (typeof requests === 'undefined') {
requests = [];
commonDataRequestsMap.set(commonData, requests);
}
requests.push({resolve, reject, marker});
this._runBatchedRequestsDelayed();
return promise;
}
/**
* @returns {void}
*/
_runBatchedRequestsDelayed() {
if (this._batchedRequestsQueued) { return; }
this._batchedRequestsQueued = true;
void Promise.resolve().then(() => {
this._batchedRequestsQueued = false;
this._runBatchedRequests();
});
}
/**
* @returns {void}
*/
_runBatchedRequests() {
if (this._batchedRequests.length === 0) { return; }
const allRequests = [];
/** @type {import('template-renderer').RenderMultiItem[]} */
const items = [];
for (const {template, commonDataRequestsMap} of this._batchedRequests) {
/** @type {import('template-renderer').RenderMultiTemplateItem[]} */
const templateItems = [];
for (const [commonData, requests] of commonDataRequestsMap.entries()) {
/** @type {import('template-renderer').PartialOrCompositeRenderData[]} */
const datas = [];
for (const {marker} of requests) {
datas.push({marker});
}
allRequests.push(...requests);
templateItems.push({
type: /** @type {import('anki-templates').RenderMode} */ ('ankiNote'),
commonData,
datas,
});
}
items.push({template, templateItems});
}
this._batchedRequests.length = 0;
void this._resolveBatchedRequests(items, allRequests);
}
/**
* @param {import('template-renderer').RenderMultiItem[]} items
* @param {import('anki-note-builder').BatchedRequestData[]} requests
*/
async _resolveBatchedRequests(items, requests) {
let responses;
try {
responses = await this._templateRenderer.renderMulti(items);
} catch (e) {
for (const {reject} of requests) {
reject(e);
}
return;
}
for (let i = 0, ii = requests.length; i < ii; ++i) {
const request = requests[i];
try {
const response = responses[i];
const {error} = response;
if (typeof error !== 'undefined') {
throw ExtensionError.deserialize(error);
} else {
request.resolve(response.result);
}
} catch (e) {
request.reject(e);
}
}
}
/**
* @param {import('dictionary').DictionaryEntry} dictionaryEntry
* @param {import('anki-note-builder').Requirement[]} requirements
* @param {import('anki-note-builder').MediaOptions} mediaOptions
* @returns {Promise<{media: import('anki-templates').Media, errors: import('core').SerializedError[]}>}
*/
async _injectMedia(dictionaryEntry, requirements, mediaOptions) {
const timestamp = Date.now();
// Parse requirements
let injectAudio = false;
let injectScreenshot = false;
let injectClipboardImage = false;
let injectClipboardText = false;
let injectPopupSelectionText = false;
/** @type {import('anki-note-builder').TextFuriganaDetails[]} */
const textFuriganaDetails = [];
/** @type {import('api').InjectAnkiNoteMediaDictionaryMediaDetails[]} */
const dictionaryMediaDetails = [];
for (const requirement of requirements) {
const {type} = requirement;
switch (type) {
case 'audio': injectAudio = true; break;
case 'screenshot': injectScreenshot = true; break;
case 'clipboardImage': injectClipboardImage = true; break;
case 'clipboardText': injectClipboardText = true; break;
case 'popupSelectionText': injectPopupSelectionText = true; break;
case 'textFurigana':
{
const {text, readingMode} = requirement;
textFuriganaDetails.push({text, readingMode});
}
break;
case 'dictionaryMedia':
{
const {dictionary, path} = requirement;
dictionaryMediaDetails.push({dictionary, path});
}
break;
}
}
// Generate request data
const dictionaryEntryDetails = this.getDictionaryEntryDetailsForNote(dictionaryEntry);
/** @type {?import('api').InjectAnkiNoteMediaAudioDetails} */
let audioDetails = null;
/** @type {?import('api').InjectAnkiNoteMediaScreenshotDetails} */
let screenshotDetails = null;
/** @type {import('api').InjectAnkiNoteMediaClipboardDetails} */
const clipboardDetails = {image: injectClipboardImage, text: injectClipboardText};
if (injectAudio && dictionaryEntryDetails.type !== 'kanji') {
const audioOptions = mediaOptions.audio;
if (typeof audioOptions === 'object' && audioOptions !== null) {
const {sources, preferredAudioIndex, idleTimeout, languageSummary, enableDefaultAudioSources} = audioOptions;
audioDetails = {sources, preferredAudioIndex, idleTimeout, languageSummary, enableDefaultAudioSources};
}
}
if (injectScreenshot) {
const screenshotOptions = mediaOptions.screenshot;
if (typeof screenshotOptions === 'object' && screenshotOptions !== null) {
const {format, quality, contentOrigin: {tabId, frameId}} = screenshotOptions;
if (typeof tabId === 'number' && typeof frameId === 'number') {
screenshotDetails = {tabId, frameId, format, quality};
}
}
}
let textFuriganaPromise = null;
if (textFuriganaDetails.length > 0) {
const textParsingOptions = mediaOptions.textParsing;
if (typeof textParsingOptions === 'object' && textParsingOptions !== null) {
const {optionsContext, scanLength} = textParsingOptions;
textFuriganaPromise = this._getTextFurigana(textFuriganaDetails, optionsContext, scanLength, dictionaryEntryDetails);
}
}
// Inject media
const popupSelectionText = injectPopupSelectionText ? this._getPopupSelectionText() : null;
const injectedMedia = await this._api.injectAnkiNoteMedia(
timestamp,
dictionaryEntryDetails,
audioDetails,
screenshotDetails,
clipboardDetails,
dictionaryMediaDetails,
);
const {audioFileName, screenshotFileName, clipboardImageFileName, clipboardText, dictionaryMedia: dictionaryMediaArray, errors} = injectedMedia;
const textFurigana = textFuriganaPromise !== null ? await textFuriganaPromise : [];
// Format results
/** @type {import('anki-templates').DictionaryMedia} */
const dictionaryMedia = {};
for (const {dictionary, path, fileName} of dictionaryMediaArray) {
if (fileName === null) { continue; }
const dictionaryMedia2 = (
Object.prototype.hasOwnProperty.call(dictionaryMedia, dictionary) ?
(dictionaryMedia[dictionary]) :
(dictionaryMedia[dictionary] = {})
);
dictionaryMedia2[path] = {value: fileName};
}
const media = {
audio: (typeof audioFileName === 'string' ? {value: audioFileName} : void 0),
screenshot: (typeof screenshotFileName === 'string' ? {value: screenshotFileName} : void 0),
clipboardImage: (typeof clipboardImageFileName === 'string' ? {value: clipboardImageFileName} : void 0),
clipboardText: (typeof clipboardText === 'string' ? {value: clipboardText} : void 0),
popupSelectionText: (typeof popupSelectionText === 'string' ? {value: popupSelectionText} : void 0),
textFurigana,
dictionaryMedia,
};
return {media, errors};
}
/**
* @returns {string}
*/
_getPopupSelectionText() {
const selection = document.getSelection();
return selection !== null ? selection.toString() : '';
}
/**
* @param {import('anki-note-builder').TextFuriganaDetails[]} entries
* @param {import('settings').OptionsContext} optionsContext
* @param {number} scanLength
* @param {?import('api.d.ts').InjectAnkiNoteMediaDefinitionDetails} readingOverride
* @returns {Promise<import('anki-templates').TextFuriganaSegment[]>}
*/
async _getTextFurigana(entries, optionsContext, scanLength, readingOverride) {
const results = [];
for (const {text, readingMode} of entries) {
const parseResults = await this._api.parseText(text, optionsContext, scanLength, true, false);
let data = null;
for (const {source, content} of parseResults) {
if (source !== 'scanning-parser') { continue; }
data = content;
break;
}
if (data !== null) {
const valueHtml = createFuriganaHtml(data, readingMode, readingOverride);
const valuePlain = createFuriganaPlain(data, readingMode, readingOverride);
results.push({text, readingMode, detailsHtml: {value: valueHtml}, detailsPlain: {value: valuePlain}});
}
}
return results;
}
}
/**
* @param {import('api').ParseTextLine[]} data
* @param {?import('anki-templates').TextFuriganaReadingMode} readingMode
* @param {?import('api.d.ts').InjectAnkiNoteMediaDefinitionDetails} readingOverride
* @returns {string}
*/
export function createFuriganaHtml(data, readingMode, readingOverride) {
let result = '';
for (const term of data) {
result += '<span class="term">';
for (const {text, reading} of term) {
if (reading.length > 0) {
const reading2 = getReading(text, reading, readingMode, readingOverride);
result += `<ruby>${text}<rt>${reading2}</rt></ruby>`;
} else {
result += text;
}
}
result += '</span>';
}
return result;
}
/**
* @param {import('api').ParseTextLine[]} data
* @param {?import('anki-templates').TextFuriganaReadingMode} readingMode
* @param {?import('api.d.ts').InjectAnkiNoteMediaDefinitionDetails} readingOverride
* @returns {string}
*/
export function createFuriganaPlain(data, readingMode, readingOverride) {
let result = '';
for (const term of data) {
for (const {text, reading} of term) {
if (reading.length > 0) {
const reading2 = getReading(text, reading, readingMode, readingOverride);
result += ` ${text}[${reading2}]`;
} else {
result += text;
}
}
}
result = result.trimStart();
return result;
}
/**
* @param {string} reading
* @param {?import('anki-templates').TextFuriganaReadingMode} readingMode
* @returns {string}
*/
function convertReading(reading, readingMode) {
switch (readingMode) {
case 'hiragana':
return convertKatakanaToHiragana(reading);
case 'katakana':
return convertHiraganaToKatakana(reading);
default:
return reading;
}
}
/**
* @param {string} text
* @param {string} reading
* @param {?import('anki-templates').TextFuriganaReadingMode} readingMode
* @param {?import('api.d.ts').InjectAnkiNoteMediaDefinitionDetails} readingOverride
* @returns {string}
*/
function getReading(text, reading, readingMode, readingOverride) {
const shouldOverride = readingOverride?.type === 'term' && readingOverride.term === text && readingOverride.reading.length > 0;
return convertReading(shouldOverride ? readingOverride.reading : reading, readingMode);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,206 @@
/*
* Copyright (C) 2024-2025 Yomitan 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 <https://www.gnu.org/licenses/>.
*/
/**
* Gets a list of field markers from the standard Handlebars template.
* @param {import('dictionary').DictionaryEntryType} type What type of dictionary entry to get the fields for.
* @param {string} language
* @returns {string[]} The list of field markers.
* @throws {Error}
*/
export function getStandardFieldMarkers(type, language = 'ja') {
switch (type) {
case 'term': {
const markers = [
'audio',
'clipboard-image',
'clipboard-text',
'cloze-body',
'cloze-prefix',
'cloze-suffix',
'conjugation',
'dictionary',
'dictionary-alias',
'document-title',
'expression',
'frequencies',
'frequency-harmonic-rank',
'frequency-harmonic-occurrence',
'frequency-average-rank',
'frequency-average-occurrence',
'furigana',
'furigana-plain',
'glossary',
'glossary-brief',
'glossary-no-dictionary',
'glossary-plain',
'glossary-plain-no-dictionary',
'glossary-first',
'glossary-first-brief',
'glossary-first-no-dictionary',
'part-of-speech',
'phonetic-transcriptions',
'reading',
'screenshot',
'search-query',
'popup-selection-text',
'sentence',
'sentence-furigana',
'sentence-furigana-plain',
'tags',
'url',
];
if (language === 'ja') {
markers.push(
'cloze-body-kana',
'pitch-accents',
'pitch-accent-graphs',
'pitch-accent-graphs-jj',
'pitch-accent-positions',
'pitch-accent-categories',
);
}
return markers;
}
case 'kanji':
return [
'character',
'clipboard-image',
'clipboard-text',
'cloze-body',
'cloze-prefix',
'cloze-suffix',
'dictionary',
'dictionary-alias',
'document-title',
'frequencies',
'frequency-harmonic-rank',
'frequency-harmonic-occurrence',
'frequency-average-rank',
'frequency-average-occurrence',
'glossary',
'kunyomi',
'onyomi',
'onyomi-hiragana',
'screenshot',
'search-query',
'popup-selection-text',
'sentence',
'sentence-furigana',
'sentence-furigana-plain',
'stroke-count',
'tags',
'url',
];
default:
throw new Error(`Unsupported type: ${type}`);
}
}
/**
* @param {import('settings').ProfileOptions} options
* @param {import('dictionary-importer').Summary[]} dictionaryInfo
* @returns {string}
*/
export function getDynamicTemplates(options, dictionaryInfo) {
let dynamicTemplates = '\n';
for (const dictionary of options.dictionaries) {
const currentDictionaryInfo = dictionaryInfo.find(({title}) => title === dictionary.name);
if (!dictionary.enabled) { continue; }
const totalTerms = currentDictionaryInfo?.counts?.terms?.total;
if (totalTerms && totalTerms > 0) {
dynamicTemplates += `
{{#*inline "single-glossary-${getKebabCase(dictionary.name)}"}}
{{~> glossary selectedDictionary='${escapeDictName(dictionary.name)}'}}
{{/inline}}
{{#*inline "single-glossary-${getKebabCase(dictionary.name)}-no-dictionary"}}
{{~> glossary selectedDictionary='${escapeDictName(dictionary.name)}' noDictionaryTag=true}}
{{/inline}}
{{#*inline "single-glossary-${getKebabCase(dictionary.name)}-brief"}}
{{~> glossary selectedDictionary='${escapeDictName(dictionary.name)}' brief=true}}
{{/inline}}
{{#*inline "single-glossary-${getKebabCase(dictionary.name)}-plain"}}
{{~> glossary-plain selectedDictionary='${escapeDictName(dictionary.name)}'}}
{{/inline}}
{{#*inline "single-glossary-${getKebabCase(dictionary.name)}-plain-no-dictionary"}}
{{~> glossary-plain-no-dictionary selectedDictionary='${escapeDictName(dictionary.name)}' noDictionaryTag=true}}
{{/inline}}
`;
}
const totalMeta = currentDictionaryInfo?.counts?.termMeta;
if (totalMeta && totalMeta.freq && totalMeta.freq > 0) {
dynamicTemplates += `
{{#*inline "single-frequency-number-${getKebabCase(dictionary.name)}"}}
{{~> single-frequency-number selectedDictionary='${escapeDictName(dictionary.name)}'}}
{{/inline}}
{{#*inline "single-frequency-${getKebabCase(dictionary.name)}"}}
{{~> frequencies selectedDictionary='${escapeDictName(dictionary.name)}'}}
{{/inline}}
`;
}
}
return dynamicTemplates;
}
/**
* @param {import('settings').DictionariesOptions} dictionaries
* @param {import('dictionary-importer').Summary[]} dictionaryInfo
* @returns {string[]} The list of field markers.
*/
export function getDynamicFieldMarkers(dictionaries, dictionaryInfo) {
const markers = [];
for (const dictionary of dictionaries) {
const currentDictionaryInfo = dictionaryInfo.find(({title}) => title === dictionary.name);
if (!dictionary.enabled) { continue; }
const totalTerms = currentDictionaryInfo?.counts?.terms?.total;
if (totalTerms && totalTerms > 0) {
markers.push(`single-glossary-${getKebabCase(dictionary.name)}`);
}
const totalMeta = currentDictionaryInfo?.counts?.termMeta;
if (totalMeta && totalMeta.freq && totalMeta.freq > 0) {
markers.push(`single-frequency-number-${getKebabCase(dictionary.name)}`);
}
}
return markers;
}
/**
* @param {string} str
* @returns {string}
*/
export function getKebabCase(str) {
return str
.replace(/[\s_\u3000]/g, '-')
.replace(/[^\p{L}\p{N}-]/gu, '')
.replace(/--+/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase();
}
/**
* @param {string} name
* @returns {string}
*/
function escapeDictName(name) {
return name
.replace(/\\/g, '\\\\')
.replace(/'/g, '\\\'');
}

128
vendor/yomitan/js/data/anki-util.js vendored Normal file
View File

@@ -0,0 +1,128 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 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 <https://www.gnu.org/licenses/>.
*/
import {isObjectNotArray} from '../core/object-utilities.js';
/** @type {RegExp} @readonly */
const markerPattern = /\{([\p{Letter}\p{Number}_-]+)\}/gu;
/**
* Gets the root deck name of a full deck name. If the deck is a root deck,
* the same name is returned. Nested decks are separated using '::'.
* @param {string} deckName A string of the deck name.
* @returns {string} A string corresponding to the name of the root deck.
*/
export function getRootDeckName(deckName) {
const index = deckName.indexOf('::');
return index >= 0 ? deckName.substring(0, index) : deckName;
}
/**
* Checks whether or not any marker is contained in a string.
* @param {string} string A string to check.
* @returns {boolean} `true` if the text contains an Anki field marker, `false` otherwise.
*/
export function stringContainsAnyFieldMarker(string) {
const result = markerPattern.test(string);
markerPattern.lastIndex = 0;
return result;
}
/**
* Gets a list of all markers that are contained in a string.
* @param {string} string A string to check.
* @returns {string[]} An array of marker strings.
*/
export function getFieldMarkers(string) {
const pattern = markerPattern;
const markers = [];
while (true) {
const match = pattern.exec(string);
if (match === null) { break; }
markers.push(match[1]);
}
return markers;
}
/**
* Returns a regular expression which can be used to find markers in a string.
* @param {boolean} global Whether or not the regular expression should have the global flag.
* @returns {RegExp} A new `RegExp` instance.
*/
export function cloneFieldMarkerPattern(global) {
return new RegExp(markerPattern.source, global ? 'gu' : 'u');
}
/**
* Checks whether or not a note object is valid.
* @param {import('anki').Note} note A note object to check.
* @returns {boolean} `true` if the note is valid, `false` otherwise.
*/
export function isNoteDataValid(note) {
if (!isObjectNotArray(note)) { return false; }
const {fields, deckName, modelName} = note;
return (
typeof deckName === 'string' && deckName.length > 0 &&
typeof modelName === 'string' && modelName.length > 0 &&
Object.entries(fields).length > 0
);
}
export const INVALID_NOTE_ID = -1;
/**
* @param {string} prefix
* @param {string} extension
* @param {number} timestamp
* @returns {string}
*/
export function generateAnkiNoteMediaFileName(prefix, extension, timestamp) {
let fileName = prefix;
fileName += `_${ankNoteDateToString(new Date(timestamp))}`;
fileName += extension;
fileName = replaceInvalidFileNameCharacters(fileName);
return fileName;
}
/**
* @param {string} fileName
* @returns {string}
*/
function replaceInvalidFileNameCharacters(fileName) {
// eslint-disable-next-line no-control-regex
return fileName.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-');
}
/**
* @param {Date} date
* @returns {string}
*/
function ankNoteDateToString(date) {
const year = date.getUTCFullYear();
const month = date.getUTCMonth().toString().padStart(2, '0');
const day = date.getUTCDate().toString().padStart(2, '0');
const hours = date.getUTCHours().toString().padStart(2, '0');
const minutes = date.getUTCMinutes().toString().padStart(2, '0');
const seconds = date.getUTCSeconds().toString().padStart(2, '0');
const milliseconds = date.getUTCMilliseconds().toString().padStart(3, '0');
return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}-${milliseconds}`;
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 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 <https://www.gnu.org/licenses/>.
*/
/**
* Decodes the contents of an ArrayBuffer using UTF8.
* @param {ArrayBuffer} arrayBuffer The input ArrayBuffer.
* @returns {string} A UTF8-decoded string.
*/
export function arrayBufferUtf8Decode(arrayBuffer) {
try {
return new TextDecoder('utf-8').decode(arrayBuffer);
} catch (e) {
return decodeURIComponent(escape(arrayBufferToBinaryString(arrayBuffer)));
}
}
/**
* Converts the contents of an ArrayBuffer to a base64 string.
* @param {ArrayBuffer} arrayBuffer The input ArrayBuffer.
* @returns {string} A base64 string representing the binary content.
*/
export function arrayBufferToBase64(arrayBuffer) {
return btoa(arrayBufferToBinaryString(arrayBuffer));
}
/**
* Converts the contents of an ArrayBuffer to a binary string.
* @param {ArrayBuffer} arrayBuffer The input ArrayBuffer.
* @returns {string} A string representing the binary content.
*/
export function arrayBufferToBinaryString(arrayBuffer) {
const bytes = new Uint8Array(arrayBuffer);
try {
return String.fromCharCode(...bytes);
} catch (e) {
let binary = '';
for (let i = 0, ii = bytes.byteLength; i < ii; ++i) {
binary += String.fromCharCode(bytes[i]);
}
return binary;
}
}
/**
* Converts a base64 string to an ArrayBuffer.
* @param {string} content The binary content string encoded in base64.
* @returns {ArrayBuffer} A new `ArrayBuffer` object corresponding to the specified content.
*/
export function base64ToArrayBuffer(content) {
const binaryContent = atob(content);
const length = binaryContent.length;
const array = new Uint8Array(length);
for (let i = 0; i < length; ++i) {
array[i] = binaryContent.charCodeAt(i);
}
return array.buffer;
}

618
vendor/yomitan/js/data/database.js vendored Normal file
View File

@@ -0,0 +1,618 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 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 <https://www.gnu.org/licenses/>.
*/
import {toError} from '../core/to-error.js';
/**
* @template {string} TObjectStoreName
*/
export class Database {
constructor() {
/** @type {?IDBDatabase} */
this._db = null;
/** @type {boolean} */
this._isOpening = false;
}
/**
* @param {string} databaseName
* @param {number} version
* @param {import('database').StructureDefinition<TObjectStoreName>[]?} structure
*/
async open(databaseName, version, structure) {
if (this._db !== null) {
throw new Error('Database already open');
}
if (this._isOpening) {
throw new Error('Already opening');
}
try {
this._isOpening = true;
this._db = await this._open(databaseName, version, (db, transaction, oldVersion) => {
if (structure !== null) {
this._upgrade(db, transaction, oldVersion, structure);
}
});
if (this._db.objectStoreNames.length === 0) {
this.close();
await Database.deleteDatabase(databaseName);
this._isOpening = false;
await this.open(databaseName, version, structure);
}
} finally {
this._isOpening = false;
}
}
/**
* @throws {Error}
*/
close() {
if (this._db === null) {
throw new Error('Database is not open');
}
this._db.close();
this._db = null;
}
/**
* Returns true if the database opening is in process.
* @returns {boolean}
*/
isOpening() {
return this._isOpening;
}
/**
* Returns true if the database is fully opened.
* @returns {boolean}
*/
isOpen() {
return this._db !== null;
}
/**
* Returns a new transaction with the given mode ("readonly" or "readwrite") and scope which can be a single object store name or an array of names.
* @param {string[]} storeNames
* @param {IDBTransactionMode} mode
* @returns {IDBTransaction}
* @throws {Error}
*/
transaction(storeNames, mode) {
if (this._db === null) {
throw new Error(this._isOpening ? 'Database not ready' : 'Database not open');
}
try {
return this._db.transaction(storeNames, mode);
} catch (e) {
throw new Error(toError(e).message + '\nDatabase transaction error, you may need to Delete All dictionaries to reset the database or manually delete the Indexed DB database.');
}
}
/**
* Add items in bulk to the object store.
* _count_ items will be added, starting from _start_ index of _items_ list.
* @param {TObjectStoreName} objectStoreName
* @param {unknown[]} items List of items to add.
* @param {number} start Start index. Added items begin at _items_[_start_].
* @param {number} count Count of items to add.
* @returns {Promise<void>}
*/
bulkAdd(objectStoreName, items, start, count) {
return new Promise((resolve, reject) => {
if (start + count > items.length) {
count = items.length - start;
}
if (count <= 0) {
resolve();
return;
}
const transaction = this._readWriteTransaction([objectStoreName], resolve, reject);
const objectStore = transaction.objectStore(objectStoreName);
for (let i = start, ii = start + count; i < ii; ++i) {
objectStore.add(items[i]);
}
transaction.commit();
});
}
/**
* Add a single item and return a promise containing the resulting primaryKey.
* Holding onto the result value makes the GC not clean up until much later even if the value is not used.
* Only call this method if the primaryKey of the added value is required.
* @param {TObjectStoreName} objectStoreName
* @param {unknown} item Item to add.
* @returns {Promise<IDBRequest<IDBValidKey>>}
*/
addWithResult(objectStoreName, item) {
return new Promise((resolve, reject) => {
const transaction = this._readWriteTransaction([objectStoreName], () => {}, reject);
const objectStore = transaction.objectStore(objectStoreName);
const result = objectStore.add(item);
transaction.commit();
resolve(result);
});
}
/**
* Update items in bulk to the object store.
* Items that do not exist will be added.
* _count_ items will be updated, starting from _start_ index of _items_ list.
* @param {TObjectStoreName} objectStoreName
* @param {import('dictionary-database').DatabaseUpdateItem[]} items List of items to update.
* @param {number} start Start index. Updated items begin at _items_[_start_].
* @param {number} count Count of items to update.
* @returns {Promise<void>}
*/
bulkUpdate(objectStoreName, items, start, count) {
return new Promise((resolve, reject) => {
if (start + count > items.length) {
count = items.length - start;
}
if (count <= 0) {
resolve();
return;
}
const transaction = this._readWriteTransaction([objectStoreName], resolve, reject);
const objectStore = transaction.objectStore(objectStoreName);
for (let i = start, ii = start + count; i < ii; ++i) {
objectStore.put(items[i].data, items[i].primaryKey);
}
transaction.commit();
});
}
/**
* @template [TData=unknown]
* @template [TResult=unknown]
* @param {IDBObjectStore|IDBIndex} objectStoreOrIndex
* @param {?IDBValidKey|IDBKeyRange} query
* @param {(results: TResult[], data: TData) => void} onSuccess
* @param {(reason: unknown, data: TData) => void} onError
* @param {TData} data
*/
getAll(objectStoreOrIndex, query, onSuccess, onError, data) {
if (typeof objectStoreOrIndex.getAll === 'function') {
this._getAllFast(objectStoreOrIndex, query, onSuccess, onError, data);
} else {
this._getAllUsingCursor(objectStoreOrIndex, query, onSuccess, onError, data);
}
}
/**
* @param {IDBObjectStore|IDBIndex} objectStoreOrIndex
* @param {IDBValidKey|IDBKeyRange} query
* @param {(value: IDBValidKey[]) => void} onSuccess
* @param {(reason?: unknown) => void} onError
*/
getAllKeys(objectStoreOrIndex, query, onSuccess, onError) {
if (typeof objectStoreOrIndex.getAllKeys === 'function') {
this._getAllKeysFast(objectStoreOrIndex, query, onSuccess, onError);
} else {
this._getAllKeysUsingCursor(objectStoreOrIndex, query, onSuccess, onError);
}
}
/**
* @template [TPredicateArg=unknown]
* @template [TResult=unknown]
* @template [TResultDefault=unknown]
* @param {TObjectStoreName} objectStoreName
* @param {?string} indexName
* @param {?IDBValidKey|IDBKeyRange} query
* @param {?((value: TResult|TResultDefault, predicateArg: TPredicateArg) => boolean)} predicate
* @param {TPredicateArg} predicateArg
* @param {TResultDefault} defaultValue
* @returns {Promise<TResult|TResultDefault>}
*/
find(objectStoreName, indexName, query, predicate, predicateArg, defaultValue) {
return new Promise((resolve, reject) => {
const transaction = this.transaction([objectStoreName], 'readonly');
const objectStore = transaction.objectStore(objectStoreName);
const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore;
this.findFirst(objectStoreOrIndex, query, resolve, reject, null, predicate, predicateArg, defaultValue);
});
}
/**
* @template [TData=unknown]
* @template [TPredicateArg=unknown]
* @template [TResult=unknown]
* @template [TResultDefault=unknown]
* @param {IDBObjectStore|IDBIndex} objectStoreOrIndex
* @param {?IDBValidKey|IDBKeyRange} query
* @param {(value: TResult|TResultDefault, data: TData) => void} resolve
* @param {(reason: unknown, data: TData) => void} reject
* @param {TData} data
* @param {?((value: TResult, predicateArg: TPredicateArg) => boolean)} predicate
* @param {TPredicateArg} predicateArg
* @param {TResultDefault} defaultValue
*/
findFirst(objectStoreOrIndex, query, resolve, reject, data, predicate, predicateArg, defaultValue) {
const noPredicate = (typeof predicate !== 'function');
const request = objectStoreOrIndex.openCursor(query, 'next');
request.onerror = (e) => reject(/** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).error, data);
request.onsuccess = (e) => {
const cursor = /** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).result;
if (cursor) {
/** @type {unknown} */
const value = cursor.value;
if (noPredicate || predicate(/** @type {TResult} */ (value), predicateArg)) {
resolve(/** @type {TResult} */ (value), data);
} else {
cursor.continue();
}
} else {
resolve(defaultValue, data);
}
};
}
/**
* @param {import('database').CountTarget[]} targets
* @param {(results: number[]) => void} resolve
* @param {(reason?: unknown) => void} reject
*/
bulkCount(targets, resolve, reject) {
const targetCount = targets.length;
if (targetCount <= 0) {
resolve([]);
return;
}
let completedCount = 0;
/** @type {number[]} */
const results = new Array(targetCount).fill(null);
/**
* @param {Event} e
* @returns {void}
*/
const onError = (e) => reject(/** @type {IDBRequest<number>} */ (e.target).error);
/**
* @param {Event} e
* @param {number} index
*/
const onSuccess = (e, index) => {
const count = /** @type {IDBRequest<number>} */ (e.target).result;
results[index] = count;
if (++completedCount >= targetCount) {
resolve(results);
}
};
for (let i = 0; i < targetCount; ++i) {
const index = i;
const [objectStoreOrIndex, query] = targets[i];
const request = objectStoreOrIndex.count(query);
request.onerror = onError;
request.onsuccess = (e) => onSuccess(e, index);
}
}
/**
* Deletes records in store with the given key or in the given key range in query.
* @param {TObjectStoreName} objectStoreName
* @param {IDBValidKey|IDBKeyRange} key
* @returns {Promise<void>}
*/
delete(objectStoreName, key) {
return new Promise((resolve, reject) => {
const transaction = this._readWriteTransaction([objectStoreName], resolve, reject);
const objectStore = transaction.objectStore(objectStoreName);
objectStore.delete(key);
transaction.commit();
});
}
/**
* Delete items in bulk from the object store.
* @param {TObjectStoreName} objectStoreName
* @param {?string} indexName
* @param {IDBKeyRange} query
* @param {?(keys: IDBValidKey[]) => IDBValidKey[]} filterKeys
* @param {?(completedCount: number, totalCount: number) => void} onProgress
* @returns {Promise<void>}
*/
bulkDelete(objectStoreName, indexName, query, filterKeys = null, onProgress = null) {
return new Promise((resolve, reject) => {
const transaction = this._readWriteTransaction([objectStoreName], resolve, reject);
const objectStore = transaction.objectStore(objectStoreName);
const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore;
/**
* @param {IDBValidKey[]} keys
*/
const onGetKeys = (keys) => {
try {
if (typeof filterKeys === 'function') {
keys = filterKeys(keys);
}
this._bulkDeleteInternal(objectStore, keys, 1000, 0, onProgress, (error) => {
if (error !== null) {
transaction.commit();
}
});
} catch (e) {
reject(e);
}
};
this.getAllKeys(objectStoreOrIndex, query, onGetKeys, reject);
});
}
/**
* Attempts to delete the named database.
* If the database already exists and there are open connections that don't close in response to a versionchange event, the request will be blocked until all they close.
* If the request is successful request's result will be null.
* @param {string} databaseName
* @returns {Promise<void>}
*/
static deleteDatabase(databaseName) {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(databaseName);
request.onerror = (e) => reject(/** @type {IDBRequest} */ (e.target).error);
request.onsuccess = () => resolve();
request.onblocked = () => reject(new Error('Database deletion blocked'));
});
}
// Private
/**
* @param {string} name
* @param {number} version
* @param {import('database').UpdateFunction} onUpgradeNeeded
* @returns {Promise<IDBDatabase>}
*/
_open(name, version, onUpgradeNeeded) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, version);
request.onupgradeneeded = (event) => {
try {
const transaction = /** @type {IDBTransaction} */ (request.transaction);
transaction.onerror = (e) => reject(/** @type {IDBRequest} */ (e.target).error);
onUpgradeNeeded(request.result, transaction, event.oldVersion, event.newVersion);
} catch (e) {
reject(e);
}
};
request.onerror = (e) => reject(/** @type {IDBRequest} */ (e.target).error);
request.onsuccess = () => resolve(request.result);
});
}
/**
* @param {IDBDatabase} db
* @param {IDBTransaction} transaction
* @param {number} oldVersion
* @param {import('database').StructureDefinition<TObjectStoreName>[]} upgrades
*/
_upgrade(db, transaction, oldVersion, upgrades) {
for (const {version, stores} of upgrades) {
if (oldVersion >= version) { continue; }
/** @type {[objectStoreName: string, value: import('database').StoreDefinition][]} */
const entries = Object.entries(stores);
for (const [objectStoreName, {primaryKey, indices}] of entries) {
const existingObjectStoreNames = transaction.objectStoreNames || db.objectStoreNames;
const objectStore = (
this._listContains(existingObjectStoreNames, objectStoreName) ?
transaction.objectStore(objectStoreName) :
db.createObjectStore(objectStoreName, primaryKey)
);
const existingIndexNames = objectStore.indexNames;
for (const indexName of indices) {
if (this._listContains(existingIndexNames, indexName)) { continue; }
objectStore.createIndex(indexName, indexName, {});
}
}
}
}
/**
* @param {DOMStringList} list
* @param {string} value
* @returns {boolean}
*/
_listContains(list, value) {
for (let i = 0, ii = list.length; i < ii; ++i) {
if (list[i] === value) { return true; }
}
return false;
}
/**
* @template [TData=unknown]
* @template [TResult=unknown]
* @param {IDBObjectStore|IDBIndex} objectStoreOrIndex
* @param {?IDBValidKey|IDBKeyRange} query
* @param {(results: TResult[], data: TData) => void} onSuccess
* @param {(reason: unknown, data: TData) => void} onReject
* @param {TData} data
*/
_getAllFast(objectStoreOrIndex, query, onSuccess, onReject, data) {
const request = objectStoreOrIndex.getAll(query);
request.onerror = (e) => {
const target = /** @type {IDBRequest<TResult[]>} */ (e.target);
onReject(target.error, data);
};
request.onsuccess = (e) => {
const target = /** @type {IDBRequest<TResult[]>} */ (e.target);
onSuccess(target.result, data);
};
}
/**
* @template [TData=unknown]
* @template [TResult=unknown]
* @param {IDBObjectStore|IDBIndex} objectStoreOrIndex
* @param {?IDBValidKey|IDBKeyRange} query
* @param {(results: TResult[], data: TData) => void} onSuccess
* @param {(reason: unknown, data: TData) => void} onReject
* @param {TData} data
*/
_getAllUsingCursor(objectStoreOrIndex, query, onSuccess, onReject, data) {
/** @type {TResult[]} */
const results = [];
const request = objectStoreOrIndex.openCursor(query, 'next');
request.onerror = (e) => onReject(/** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).error, data);
request.onsuccess = (e) => {
const cursor = /** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).result;
if (cursor) {
/** @type {unknown} */
const value = cursor.value;
results.push(/** @type {TResult} */ (value));
cursor.continue();
} else {
onSuccess(results, data);
}
};
}
/**
* @param {IDBObjectStore|IDBIndex} objectStoreOrIndex
* @param {IDBValidKey|IDBKeyRange} query
* @param {(value: IDBValidKey[]) => void} onSuccess
* @param {(reason?: unknown) => void} onError
*/
_getAllKeysFast(objectStoreOrIndex, query, onSuccess, onError) {
const request = objectStoreOrIndex.getAllKeys(query);
request.onerror = (e) => onError(/** @type {IDBRequest<IDBValidKey[]>} */ (e.target).error);
request.onsuccess = (e) => onSuccess(/** @type {IDBRequest<IDBValidKey[]>} */ (e.target).result);
}
/**
* @param {IDBObjectStore|IDBIndex} objectStoreOrIndex
* @param {IDBValidKey|IDBKeyRange} query
* @param {(value: IDBValidKey[]) => void} onSuccess
* @param {(reason?: unknown) => void} onError
*/
_getAllKeysUsingCursor(objectStoreOrIndex, query, onSuccess, onError) {
/** @type {IDBValidKey[]} */
const results = [];
const request = objectStoreOrIndex.openKeyCursor(query, 'next');
request.onerror = (e) => onError(/** @type {IDBRequest<?IDBCursor>} */ (e.target).error);
request.onsuccess = (e) => {
const cursor = /** @type {IDBRequest<?IDBCursor>} */ (e.target).result;
if (cursor) {
results.push(cursor.primaryKey);
cursor.continue();
} else {
onSuccess(results);
}
};
}
/**
* @param {IDBObjectStore} objectStore The object store from which items are being deleted.
* @param {IDBValidKey[]} keys An array of keys to delete from the object store.
* @param {number} maxActiveRequests The maximum number of concurrent requests.
* @param {number} maxActiveRequestsForContinue The maximum number of requests that can be active before the next set of requests is started.
* For example:
* - If this value is `0`, all of the `maxActiveRequests` requests must complete before another group of `maxActiveRequests` is started off.
* - If the value is greater than or equal to `maxActiveRequests-1`, every time a single request completes, a new single request will be started.
* @param {?(completedCount: number, totalCount: number) => void} onProgress An optional progress callback function.
* @param {(error: ?Error) => void} onComplete A function which is called after all operations have finished.
* If an error occured, the `error` parameter will be non-`null`. Otherwise, it will be `null`.
* @throws {Error} An error is thrown if the input parameters are invalid.
*/
_bulkDeleteInternal(objectStore, keys, maxActiveRequests, maxActiveRequestsForContinue, onProgress, onComplete) {
if (maxActiveRequests <= 0) { throw new Error(`maxActiveRequests has an invalid value: ${maxActiveRequests}`); }
if (maxActiveRequestsForContinue < 0) { throw new Error(`maxActiveRequestsForContinue has an invalid value: ${maxActiveRequestsForContinue}`); }
const count = keys.length;
if (count === 0) {
onComplete(null);
return;
}
let completedCount = 0;
let completed = false;
let index = 0;
let active = 0;
const onSuccess = () => {
if (completed) { return; }
--active;
++completedCount;
if (onProgress !== null) {
try {
onProgress(completedCount, count);
} catch (e) {
// NOP
}
}
if (completedCount >= count) {
completed = true;
onComplete(null);
} else if (active <= maxActiveRequestsForContinue) {
next();
}
};
/**
* @param {Event} event
*/
const onError = (event) => {
if (completed) { return; }
completed = true;
const request = /** @type {IDBRequest<undefined>} */ (event.target);
const {error} = request;
onComplete(error);
};
const next = () => {
for (; index < count && active < maxActiveRequests; ++index) {
const key = keys[index];
const request = objectStore.delete(key);
request.onsuccess = onSuccess;
request.onerror = onError;
++active;
}
};
next();
}
/**
* @param {string[]} storeNames
* @param {() => void} resolve
* @param {(reason?: unknown) => void} reject
* @returns {IDBTransaction}
*/
_readWriteTransaction(storeNames, resolve, reject) {
const transaction = this.transaction(storeNames, 'readwrite');
transaction.onerror = (e) => reject(/** @type {IDBTransaction} */ (e.target).error);
transaction.onabort = () => reject(new Error('Transaction aborted'));
transaction.oncomplete = () => resolve();
return transaction;
}
}

1360
vendor/yomitan/js/data/json-schema.js vendored Normal file

File diff suppressed because it is too large Load Diff

1853
vendor/yomitan/js/data/options-util.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,142 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 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 <https://www.gnu.org/licenses/>.
*/
import {getFieldMarkers} from './anki-util.js';
/**
* This function returns whether an Anki field marker might require clipboard permissions.
* This is speculative and may not guarantee that the field marker actually does require the permission,
* as the custom handlebars template is not deeply inspected.
* @param {string} marker
* @returns {boolean}
*/
function ankiFieldMarkerMayUseClipboard(marker) {
switch (marker) {
case 'clipboard-image':
case 'clipboard-text':
return true;
default:
return false;
}
}
/**
* @param {chrome.permissions.Permissions} permissions
* @returns {Promise<boolean>}
*/
export function hasPermissions(permissions) {
return new Promise((resolve, reject) => {
chrome.permissions.contains(permissions, (result) => {
const e = chrome.runtime.lastError;
if (e) {
reject(new Error(e.message));
} else {
resolve(result);
}
});
});
}
/**
* @param {chrome.permissions.Permissions} permissions
* @param {boolean} shouldHave
* @returns {Promise<boolean>}
*/
export function setPermissionsGranted(permissions, shouldHave) {
return (
shouldHave ?
new Promise((resolve, reject) => {
chrome.permissions.request(permissions, (result) => {
const e = chrome.runtime.lastError;
if (e) {
reject(new Error(e.message));
} else {
resolve(result);
}
});
}) :
new Promise((resolve, reject) => {
chrome.permissions.remove(permissions, (result) => {
const e = chrome.runtime.lastError;
if (e) {
reject(new Error(e.message));
} else {
resolve(!result);
}
});
})
);
}
/**
* @returns {Promise<chrome.permissions.Permissions>}
*/
export function getAllPermissions() {
// Electron workaround - chrome.permissions.getAll() not available
return Promise.resolve({
origins: ["<all_urls>"],
permissions: ["clipboardWrite", "storage", "unlimitedStorage", "scripting", "contextMenus"]
});
}
/**
* @param {string} fieldValue
* @returns {string[]}
*/
export function getRequiredPermissionsForAnkiFieldValue(fieldValue) {
const markers = getFieldMarkers(fieldValue);
for (const marker of markers) {
if (ankiFieldMarkerMayUseClipboard(marker)) {
return ['clipboardRead'];
}
}
return [];
}
/**
* @param {chrome.permissions.Permissions} permissions
* @param {import('settings').ProfileOptions} options
* @returns {boolean}
*/
export function hasRequiredPermissionsForOptions(permissions, options) {
const permissionsSet = new Set(permissions.permissions);
if (!permissionsSet.has('nativeMessaging') && (options.parsing.enableMecabParser || options.general.enableYomitanApi)) {
return false;
}
if (!permissionsSet.has('clipboardRead')) {
if (options.clipboard.enableBackgroundMonitor || options.clipboard.enableSearchPageMonitor) {
return false;
}
const fieldsList = options.anki.cardFormats.map((cardFormat) => cardFormat.fields);
for (const fields of fieldsList) {
for (const {value: fieldValue} of Object.values(fields)) {
const markers = getFieldMarkers(fieldValue);
for (const marker of markers) {
if (ankiFieldMarkerMayUseClipboard(marker)) {
return false;
}
}
}
}
}
return true;
}

37
vendor/yomitan/js/data/profiles-util.js vendored Normal file
View File

@@ -0,0 +1,37 @@
/*
* Copyright (C) 2024-2025 Yomitan 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 <https://www.gnu.org/licenses/>.
*/
/**
* @param {number} direction
* @param {import('../application.js').Application} application
*/
export async function setProfile(direction, application) {
const optionsFull = await application.api.optionsGetFull();
const profileCount = optionsFull.profiles.length;
const newProfile = (optionsFull.profileCurrent + direction + profileCount) % profileCount;
/** @type {import('settings-modifications').ScopedModificationSet} */
const modification = {
action: 'set',
path: 'profileCurrent',
value: newProfile,
scope: 'global',
optionsContext: null,
};
await application.api.modifySettings([modification], 'search');
}

81
vendor/yomitan/js/data/string-util.js vendored Normal file
View File

@@ -0,0 +1,81 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2022 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 <https://www.gnu.org/licenses/>.
*/
/**
* Reads code points from a string in the forward direction.
* @param {string} text The text to read the code points from.
* @param {number} position The index of the first character to read.
* @param {number} count The number of code points to read.
* @returns {string} The code points from the string.
*/
export function readCodePointsForward(text, position, count) {
const textLength = text.length;
let result = '';
for (; count > 0; --count) {
const char = text[position];
result += char;
if (++position >= textLength) { break; }
const charCode = char.charCodeAt(0);
if (charCode >= 0xd800 && charCode < 0xdc00) { // charCode is a high surrogate code
const char2 = text[position];
const charCode2 = char2.charCodeAt(0);
if (charCode2 >= 0xdc00 && charCode2 < 0xe000) { // charCode2 is a low surrogate code
result += char2;
if (++position >= textLength) { break; }
}
}
}
return result;
}
/**
* Reads code points from a string in the backward direction.
* @param {string} text The text to read the code points from.
* @param {number} position The index of the first character to read.
* @param {number} count The number of code points to read.
* @returns {string} The code points from the string.
*/
export function readCodePointsBackward(text, position, count) {
let result = '';
for (; count > 0; --count) {
const char = text[position];
result = char + result;
if (--position < 0) { break; }
const charCode = char.charCodeAt(0);
if (charCode >= 0xdc00 && charCode < 0xe000) { // charCode is a low surrogate code
const char2 = text[position];
const charCode2 = char2.charCodeAt(0);
if (charCode2 >= 0xd800 && charCode2 < 0xdc00) { // charCode2 is a high surrogate code
result = char2 + result;
if (--position < 0) { break; }
}
}
}
return result;
}
/**
* Trims and condenses trailing whitespace and adds a space on the end if it needed trimming.
* @param {string} text
* @returns {string}
*/
export function trimTrailingWhitespacePlusSpace(text) {
// Consense multiple leading and trailing newlines into one newline
// Trim trailing whitespace excluding newlines
return text.replaceAll(/(\n+$|^\n+)/g, '\n').replaceAll(/[^\S\n]+$/g, ' ');
}