Skip to content

Commit 3a7c5e5

Browse files
authored
Hash media names instead of using date when sending to Anki (#2370)
* Pull arrayBufferDigest out of audio-downloader * Allow arbitrary suffix on generateAnkiNoteMediaFileName * Use hash of media for filename, fallback on timestamp * Move hash or timestamp logic to function in anki-util * Allow specifying prefix * Use hash for api output and audio data
1 parent a6ea175 commit 3a7c5e5

5 files changed

Lines changed: 58 additions & 27 deletions

File tree

ext/js/background/backend.js

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {logErrorLevelToNumber} from '../core/log-utilities.js';
2828
import {log} from '../core/log.js';
2929
import {isObjectNotArray} from '../core/object-utilities.js';
3030
import {clone, deferPromise, promiseTimeout} from '../core/utilities.js';
31-
import {generateAnkiNoteMediaFileName, INVALID_NOTE_ID, isNoteDataValid} from '../data/anki-util.js';
31+
import {generateAnkiNoteMediaFileName, INVALID_NOTE_ID, isNoteDataValid, mediaFileNameHashOrTimestamp} from '../data/anki-util.js';
3232
import {arrayBufferToBase64} from '../data/array-buffer-util.js';
3333
import {OptionsUtil} from '../data/options-util.js';
3434
import {getAllPermissions, hasPermissions, hasRequiredPermissionsForOptions} from '../data/permissions-util.js';
@@ -2470,7 +2470,7 @@ export class Backend {
24702470

24712471
let extension = contentType !== null ? getFileExtensionFromAudioMediaType(contentType) : null;
24722472
if (extension === null) { extension = '.mp3'; }
2473-
let fileName = generateAnkiNoteMediaFileName('yomitan_audio', extension, timestamp);
2473+
let fileName = await mediaFileNameHashOrTimestamp('yomitan_audio', data, extension, null, timestamp);
24742474
fileName = fileName.replace(/\]/g, '');
24752475
return await ankiConnect.storeMediaFile(fileName, data);
24762476
}
@@ -2564,11 +2564,7 @@ export class Backend {
25642564
if (media !== null) {
25652565
const {content, mediaType} = media;
25662566
const extension = getFileExtensionFromImageMediaType(mediaType);
2567-
fileName = generateAnkiNoteMediaFileName(
2568-
`yomitan_dictionary_media_${i + 1}`,
2569-
extension !== null ? extension : '',
2570-
timestamp,
2571-
);
2567+
fileName = await mediaFileNameHashOrTimestamp('yomitan_dictionary_media', content, extension, i, timestamp);
25722568
try {
25732569
fileName = await ankiConnect.storeMediaFile(fileName, content);
25742570
} catch (e) {

ext/js/comm/yomitan-api.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {log} from '../core/log.js';
2626
import {toError} from '../core/to-error.js';
2727
import {createFuriganaHtml, createFuriganaPlain} from '../data/anki-note-builder.js';
2828
import {getDynamicTemplates} from '../data/anki-template-util.js';
29-
import {generateAnkiNoteMediaFileName} from '../data/anki-util.js';
29+
import {mediaFileNameHashOrTimestamp} from '../data/anki-util.js';
3030
import {getLanguageSummaries} from '../language/languages.js';
3131
import {AudioDownloader} from '../media/audio-downloader.js';
3232
import {getFileExtensionFromAudioMediaType, getFileExtensionFromImageMediaType} from '../media/media-util.js';
@@ -335,7 +335,7 @@ export class YomitanApi {
335335
for (const mediaFileData of mediaFilesData) {
336336
if (media.some((x) => x.dictionary === mediaFileData.dictionary && x.path === mediaFileData.path)) { continue; }
337337
const timestamp = Date.now();
338-
const ankiFilename = generateAnkiNoteMediaFileName(`yomitan_dictionary_media_${mediaCount}`, getFileExtensionFromImageMediaType(mediaFileData.mediaType) ?? '', timestamp);
338+
const ankiFilename = await mediaFileNameHashOrTimestamp('yomitan_dictionary_media', mediaFileData.content, getFileExtensionFromImageMediaType(mediaFileData.mediaType) ?? '', mediaCount, timestamp);
339339
media.push({
340340
dictionary: mediaFileData.dictionary,
341341
path: mediaFileData.path,
@@ -371,7 +371,7 @@ export class YomitanApi {
371371
const mediaType = audioData.contentType ?? '';
372372
let extension = mediaType !== null ? getFileExtensionFromAudioMediaType(mediaType) : null;
373373
if (extension === null) { extension = '.mp3'; }
374-
const ankiFilename = generateAnkiNoteMediaFileName('yomitan_audio', extension, timestamp);
374+
const ankiFilename = await mediaFileNameHashOrTimestamp('yomitan_audio', audioData.data, extension, null, timestamp);
375375
audioDatas.push({
376376
term: headword.term,
377377
reading: headword.reading,

ext/js/core/utilities.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,3 +320,27 @@ export function addScopeToCssLegacy(css, scopeSelector) {
320320
return addScopeToCss(css, scopeSelector);
321321
}
322322
}
323+
324+
/**
325+
* @param {'SHA-256'|'SHA-384'|'SHA-512'} algorithm
326+
* @param {ArrayBuffer} arrayBuffer
327+
* @returns {Promise<string>}
328+
*/
329+
export async function arrayBufferDigest(algorithm, arrayBuffer) {
330+
const hash = new Uint8Array(await crypto.subtle.digest(algorithm, new Uint8Array(arrayBuffer)));
331+
let digest = '';
332+
for (const byte of hash) {
333+
digest += byte.toString(16).padStart(2, '0');
334+
}
335+
return digest;
336+
}
337+
338+
/**
339+
* @param {'SHA-1'|'SHA-256'|'SHA-384'|'SHA-512'} algorithm
340+
* @param {ArrayBuffer} arrayBuffer
341+
* @returns {Promise<string>}
342+
*/
343+
export async function unsafeArrayBufferDigest(algorithm, arrayBuffer) {
344+
// @ts-expect-error - Allow SHA-1 here
345+
return arrayBufferDigest(algorithm, arrayBuffer);
346+
}

ext/js/data/anki-util.js

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
*/
1818

1919
import {isObjectNotArray} from '../core/object-utilities.js';
20+
import {unsafeArrayBufferDigest} from '../core/utilities.js';
2021

2122
/** @type {RegExp} @readonly */
2223
const markerPattern = /\{([\p{Letter}\p{Number}_-]+)\}/gu;
@@ -89,20 +90,42 @@ export const INVALID_NOTE_ID = -1;
8990
/**
9091
* @param {string} prefix
9192
* @param {string} extension
92-
* @param {number} timestamp
93+
* @param {number|string} suffix
9394
* @returns {string}
9495
*/
95-
export function generateAnkiNoteMediaFileName(prefix, extension, timestamp) {
96+
export function generateAnkiNoteMediaFileName(prefix, extension, suffix) {
9697
let fileName = prefix;
9798

98-
fileName += `_${ankNoteDateToString(new Date(timestamp))}`;
99+
fileName += typeof suffix === 'string' ? suffix : `_${ankNoteDateToString(new Date(suffix))}`;
99100
fileName += extension;
100101

101102
fileName = replaceInvalidFileNameCharacters(fileName);
102103

103104
return fileName;
104105
}
105106

107+
/**
108+
* @param {string} prefix
109+
* @param {string} content
110+
* @param {string?} extension
111+
* @param {number?} mediaCount
112+
* @param {number} timestamp
113+
* @returns {Promise<string>}
114+
*/
115+
export async function mediaFileNameHashOrTimestamp(prefix, content, extension, mediaCount, timestamp) {
116+
try {
117+
/** @type {string} */
118+
// @ts-expect-error - typescript-eslint does not recognize `Uint8Array.fromBase64` yet despite it already being available on all major browsers for over 6 months
119+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
120+
const contentHash = await unsafeArrayBufferDigest('SHA-1', Uint8Array.fromBase64(content));
121+
return generateAnkiNoteMediaFileName(`${prefix}_`, extension !== null ? extension : '', contentHash);
122+
} catch {
123+
const mediaCountInfix = mediaCount ? mediaCount + 1 : '';
124+
// fallback on using timestamp for older browser versions
125+
return generateAnkiNoteMediaFileName(`${prefix}_${mediaCountInfix}`, extension !== null ? extension : '', timestamp);
126+
}
127+
}
128+
106129
/**
107130
* @param {string} fileName
108131
* @returns {string}

ext/js/media/audio-downloader.js

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import {RequestBuilder} from '../background/request-builder.js';
2020
import {ExtensionError} from '../core/extension-error.js';
2121
import {readResponseJson} from '../core/json.js';
22+
import {arrayBufferDigest} from '../core/utilities.js';
2223
import {arrayBufferToBase64} from '../data/array-buffer-util.js';
2324
import {JsonSchema} from '../data/json-schema.js';
2425
import {NativeSimpleDOMParser} from '../dom/native-simple-dom-parser.js';
@@ -559,7 +560,7 @@ export class AudioDownloader {
559560
switch (sourceType) {
560561
case 'jpod101':
561562
{
562-
const digest = await this._arrayBufferDigest(arrayBuffer);
563+
const digest = await arrayBufferDigest('SHA-256', arrayBuffer);
563564
switch (digest) {
564565
case 'ae6398b5a27bc8c0a771df6c907ade794be15518174773c58c7c7ddd17098906': // Invalid audio
565566
return false;
@@ -572,19 +573,6 @@ export class AudioDownloader {
572573
}
573574
}
574575

575-
/**
576-
* @param {ArrayBuffer} arrayBuffer
577-
* @returns {Promise<string>}
578-
*/
579-
async _arrayBufferDigest(arrayBuffer) {
580-
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(arrayBuffer)));
581-
let digest = '';
582-
for (const byte of hash) {
583-
digest += byte.toString(16).padStart(2, '0');
584-
}
585-
return digest;
586-
}
587-
588576
/**
589577
* @param {string} content
590578
* @returns {import('simple-dom-parser').ISimpleDomParser}

0 commit comments

Comments
 (0)