refactor(core): normalize service naming across app runtime

This commit is contained in:
2026-02-17 19:00:27 -08:00
parent e38a1c945e
commit 1233e3630f
87 changed files with 2813 additions and 1636 deletions

View File

@@ -28,7 +28,10 @@ function createIntegrationTestContext(
}; };
const stateDir = fs.mkdtempSync( const stateDir = fs.mkdtempSync(
path.join(os.tmpdir(), options.stateDirPrefix ?? "subminer-anki-integration-"), path.join(
os.tmpdir(),
options.stateDirPrefix ?? "subminer-anki-integration-",
),
); );
const knownWordCacheStatePath = path.join(stateDir, "known-words-cache.json"); const knownWordCacheStatePath = path.join(stateDir, "known-words-cache.json");

View File

@@ -210,16 +210,8 @@ export class AnkiIntegration {
audioPadding, audioPadding,
audioStreamIndex, audioStreamIndex,
), ),
generateScreenshot: ( generateScreenshot: (videoPath, timestamp, options) =>
videoPath, this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
timestamp,
options,
) =>
this.mediaGenerator.generateScreenshot(
videoPath,
timestamp,
options,
),
generateAnimatedImage: ( generateAnimatedImage: (
videoPath, videoPath,
startTime, startTime,
@@ -243,8 +235,10 @@ export class AnkiIntegration {
beginUpdateProgress: (initialMessage: string) => beginUpdateProgress: (initialMessage: string) =>
this.beginUpdateProgress(initialMessage), this.beginUpdateProgress(initialMessage),
endUpdateProgress: () => this.endUpdateProgress(), endUpdateProgress: () => this.endUpdateProgress(),
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) => withUpdateProgress: <T>(
this.withUpdateProgress(initialMessage, action), initialMessage: string,
action: () => Promise<T>,
) => this.withUpdateProgress(initialMessage, action),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
this.resolveConfiguredFieldName(noteInfo, ...preferredNames), this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
resolveNoteFieldName: (noteInfo, preferredName) => resolveNoteFieldName: (noteInfo, preferredName) =>
@@ -272,11 +266,14 @@ export class AnkiIntegration {
}, },
}); });
this.fieldGroupingService = new FieldGroupingService({ this.fieldGroupingService = new FieldGroupingService({
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(), getEffectiveSentenceCardConfig: () =>
this.getEffectiveSentenceCardConfig(),
isUpdateInProgress: () => this.updateInProgress, isUpdateInProgress: () => this.updateInProgress,
getDeck: () => this.config.deck, getDeck: () => this.config.deck,
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) => withUpdateProgress: <T>(
this.withUpdateProgress(initialMessage, action), initialMessage: string,
action: () => Promise<T>,
) => this.withUpdateProgress(initialMessage, action),
showOsdNotification: (text: string) => this.showOsdNotification(text), showOsdNotification: (text: string) => this.showOsdNotification(text),
findNotes: async (query, options) => findNotes: async (query, options) =>
(await this.client.findNotes(query, options)) as number[], (await this.client.findNotes(query, options)) as number[],
@@ -287,8 +284,7 @@ export class AnkiIntegration {
this.findDuplicateNote(expression, noteId, noteInfo), this.findDuplicateNote(expression, noteId, noteInfo),
hasAllConfiguredFields: (noteInfo, configuredFieldNames) => hasAllConfiguredFields: (noteInfo, configuredFieldNames) =>
this.hasAllConfiguredFields(noteInfo, configuredFieldNames), this.hasAllConfiguredFields(noteInfo, configuredFieldNames),
processNewCard: (noteId, options) => processNewCard: (noteId, options) => this.processNewCard(noteId, options),
this.processNewCard(noteId, options),
getSentenceCardImageFieldName: () => this.config.fields?.image, getSentenceCardImageFieldName: () => this.config.fields?.image,
resolveFieldName: (availableFieldNames, preferredName) => resolveFieldName: (availableFieldNames, preferredName) =>
this.resolveFieldName(availableFieldNames, preferredName), this.resolveFieldName(availableFieldNames, preferredName),
@@ -307,7 +303,12 @@ export class AnkiIntegration {
includeGeneratedMedia, includeGeneratedMedia,
), ),
getNoteFieldMap: (noteInfo) => this.getNoteFieldMap(noteInfo), getNoteFieldMap: (noteInfo) => this.getNoteFieldMap(noteInfo),
handleFieldGroupingAuto: (originalNoteId, newNoteId, newNoteInfo, expression) => handleFieldGroupingAuto: (
originalNoteId,
newNoteId,
newNoteInfo,
expression,
) =>
this.handleFieldGroupingAuto( this.handleFieldGroupingAuto(
originalNoteId, originalNoteId,
newNoteId, newNoteId,
@@ -558,7 +559,8 @@ export class AnkiIntegration {
if (!imageFieldName) { if (!imageFieldName) {
log.warn("Image field not found on note, skipping image update"); log.warn("Image field not found on note, skipping image update");
} else { } else {
const existingImage = noteInfo.fields[imageFieldName]?.value || ""; const existingImage =
noteInfo.fields[imageFieldName]?.value || "";
updatedFields[imageFieldName] = this.mergeFieldValue( updatedFields[imageFieldName] = this.mergeFieldValue(
existingImage, existingImage,
`<img src="${imageFilename}">`, `<img src="${imageFilename}">`,
@@ -782,7 +784,9 @@ export class AnkiIntegration {
private generateImageFilename(): string { private generateImageFilename(): string {
const timestamp = Date.now(); const timestamp = Date.now();
const ext = const ext =
this.config.media?.imageType === "avif" ? "avif" : this.config.media?.imageFormat; this.config.media?.imageType === "avif"
? "avif"
: this.config.media?.imageFormat;
return `image_${timestamp}.${ext}`; return `image_${timestamp}.${ext}`;
} }
@@ -792,10 +796,7 @@ export class AnkiIntegration {
showOsd: (text: string) => { showOsd: (text: string) => {
this.showOsdNotification(text); this.showOsdNotification(text);
}, },
showSystemNotification: ( showSystemNotification: (title: string, options: NotificationOptions) => {
title: string,
options: NotificationOptions,
) => {
if (this.notificationCallback) { if (this.notificationCallback) {
this.notificationCallback(title, options); this.notificationCallback(title, options);
} }
@@ -804,9 +805,13 @@ export class AnkiIntegration {
} }
private beginUpdateProgress(initialMessage: string): void { private beginUpdateProgress(initialMessage: string): void {
beginUpdateProgress(this.uiFeedbackState, initialMessage, (text: string) => { beginUpdateProgress(
this.showOsdNotification(text); this.uiFeedbackState,
}); initialMessage,
(text: string) => {
this.showOsdNotification(text);
},
);
} }
private endUpdateProgress(): void { private endUpdateProgress(): void {
@@ -816,12 +821,9 @@ export class AnkiIntegration {
} }
private showProgressTick(): void { private showProgressTick(): void {
showProgressTick( showProgressTick(this.uiFeedbackState, (text: string) => {
this.uiFeedbackState, this.showOsdNotification(text);
(text: string) => { });
this.showOsdNotification(text);
},
);
} }
private async withUpdateProgress<T>( private async withUpdateProgress<T>(
@@ -893,9 +895,7 @@ export class AnkiIntegration {
if (this.parseWarningKeys.has(key)) return; if (this.parseWarningKeys.has(key)) return;
this.parseWarningKeys.add(key); this.parseWarningKeys.add(key);
const suffix = detail ? ` (${detail})` : ""; const suffix = detail ? ` (${detail})` : "";
log.warn( log.warn(`Field grouping parse warning [${fieldName}] ${reason}${suffix}`);
`Field grouping parse warning [${fieldName}] ${reason}${suffix}`,
);
} }
private setCardTypeFields( private setCardTypeFields(
@@ -1284,10 +1284,16 @@ export class AnkiIntegration {
private getStrictSpanGroupingFields(): Set<string> { private getStrictSpanGroupingFields(): Set<string> {
const strictFields = new Set(this.strictGroupingFieldDefaults); const strictFields = new Set(this.strictGroupingFieldDefaults);
const sentenceCardConfig = this.getEffectiveSentenceCardConfig(); const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
strictFields.add((sentenceCardConfig.sentenceField || "sentence").toLowerCase()); strictFields.add(
strictFields.add((sentenceCardConfig.audioField || "sentenceaudio").toLowerCase()); (sentenceCardConfig.sentenceField || "sentence").toLowerCase(),
if (this.config.fields?.image) strictFields.add(this.config.fields.image.toLowerCase()); );
if (this.config.fields?.miscInfo) strictFields.add(this.config.fields.miscInfo.toLowerCase()); strictFields.add(
(sentenceCardConfig.audioField || "sentenceaudio").toLowerCase(),
);
if (this.config.fields?.image)
strictFields.add(this.config.fields.image.toLowerCase());
if (this.config.fields?.miscInfo)
strictFields.add(this.config.fields.miscInfo.toLowerCase());
return strictFields; return strictFields;
} }
@@ -1445,7 +1451,8 @@ export class AnkiIntegration {
if (imageBuffer) { if (imageBuffer) {
await this.client.storeMediaFile(imageFilename, imageBuffer); await this.client.storeMediaFile(imageFilename, imageBuffer);
result.imageField = result.imageField =
this.config.fields?.image || DEFAULT_ANKI_CONNECT_CONFIG.fields.image; this.config.fields?.image ||
DEFAULT_ANKI_CONNECT_CONFIG.fields.image;
result.imageValue = `<img src="${imageFilename}">`; result.imageValue = `<img src="${imageFilename}">`;
if (this.config.fields?.miscInfo && !result.miscInfoValue) { if (this.config.fields?.miscInfo && !result.miscInfoValue) {
result.miscInfoValue = this.formatMiscInfoPattern( result.miscInfoValue = this.formatMiscInfoPattern(
@@ -1657,7 +1664,7 @@ export class AnkiIntegration {
const keepNotesInfoResult = await this.client.notesInfo([keepNoteId]); const keepNotesInfoResult = await this.client.notesInfo([keepNoteId]);
const keepNotesInfo = keepNotesInfoResult as unknown as NoteInfo[]; const keepNotesInfo = keepNotesInfoResult as unknown as NoteInfo[];
if (!keepNotesInfo || keepNotesInfo.length === 0) { if (!keepNotesInfo || keepNotesInfo.length === 0) {
log.warn("Keep note not found:", keepNoteId); log.warn("Keep note not found:", keepNoteId);
return; return;
} }
const keepNoteInfo = keepNotesInfo[0]; const keepNoteInfo = keepNotesInfo[0];
@@ -1703,10 +1710,7 @@ export class AnkiIntegration {
sentenceCardConfig.kikuDeleteDuplicateInAuto, sentenceCardConfig.kikuDeleteDuplicateInAuto,
); );
} catch (error) { } catch (error) {
log.error( log.error("Field grouping auto merge failed:", (error as Error).message);
"Field grouping auto merge failed:",
(error as Error).message,
);
this.showOsdNotification( this.showOsdNotification(
`Field grouping failed: ${(error as Error).message}`, `Field grouping failed: ${(error as Error).message}`,
); );
@@ -1720,9 +1724,7 @@ export class AnkiIntegration {
expression: string, expression: string,
): Promise<boolean> { ): Promise<boolean> {
if (!this.fieldGroupingCallback) { if (!this.fieldGroupingCallback) {
log.warn( log.warn("No field grouping callback registered, skipping manual mode");
"No field grouping callback registered, skipping manual mode",
);
this.showOsdNotification("Field grouping UI unavailable"); this.showOsdNotification("Field grouping UI unavailable");
return false; return false;
} }
@@ -1754,7 +1756,10 @@ export class AnkiIntegration {
hasAudio: hasAudio:
this.hasFieldValue(originalNoteInfo, this.config.fields?.audio) || this.hasFieldValue(originalNoteInfo, this.config.fields?.audio) ||
this.hasFieldValue(originalNoteInfo, sentenceCardConfig.audioField), this.hasFieldValue(originalNoteInfo, sentenceCardConfig.audioField),
hasImage: this.hasFieldValue(originalNoteInfo, this.config.fields?.image), hasImage: this.hasFieldValue(
originalNoteInfo,
this.config.fields?.image,
),
isOriginal: true, isOriginal: true,
}; };
@@ -1903,10 +1908,7 @@ export class AnkiIntegration {
: this.config.isKiku, : this.config.isKiku,
}; };
if ( if (wasEnabled && this.config.nPlusOne?.highlightEnabled === false) {
wasEnabled &&
this.config.nPlusOne?.highlightEnabled === false
) {
this.stopKnownWordCacheLifecycle(); this.stopKnownWordCacheLifecycle();
this.knownWordCache.clearKnownWordCacheState(); this.knownWordCache.clearKnownWordCacheState();
} else { } else {
@@ -1922,7 +1924,6 @@ export class AnkiIntegration {
} }
} }
destroy(): void { destroy(): void {
this.stop(); this.stop();
this.mediaGenerator.cleanup(); this.mediaGenerator.cleanup();

View File

@@ -83,8 +83,7 @@ export async function translateSentenceWithAi(
); );
const model = request.model || "openai/gpt-4o-mini"; const model = request.model || "openai/gpt-4o-mini";
const targetLanguage = request.targetLanguage || "English"; const targetLanguage = request.targetLanguage || "English";
const prompt = const prompt = request.systemPrompt?.trim() || DEFAULT_AI_SYSTEM_PROMPT;
request.systemPrompt?.trim() || DEFAULT_AI_SYSTEM_PROMPT;
try { try {
const response = await axios.post( const response = await axios.post(

View File

@@ -22,9 +22,15 @@ interface CardCreationClient {
fields: Record<string, string>, fields: Record<string, string>,
): Promise<number>; ): Promise<number>;
notesInfo(noteIds: number[]): Promise<unknown>; notesInfo(noteIds: number[]): Promise<unknown>;
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>; updateNoteFields(
noteId: number,
fields: Record<string, string>,
): Promise<void>;
storeMediaFile(filename: string, data: Buffer): Promise<void>; storeMediaFile(filename: string, data: Buffer): Promise<void>;
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>; findNotes(
query: string,
options?: { maxRetries?: number },
): Promise<number[]>;
} }
interface CardCreationMediaGenerator { interface CardCreationMediaGenerator {
@@ -68,10 +74,17 @@ interface CardCreationDeps {
mediaGenerator: CardCreationMediaGenerator; mediaGenerator: CardCreationMediaGenerator;
showOsdNotification: (text: string) => void; showOsdNotification: (text: string) => void;
showStatusNotification: (message: string) => void; showStatusNotification: (message: string) => void;
showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise<void>; showNotification: (
noteId: number,
label: string | number,
errorSuffix?: string,
) => Promise<void>;
beginUpdateProgress: (initialMessage: string) => void; beginUpdateProgress: (initialMessage: string) => void;
endUpdateProgress: () => void; endUpdateProgress: () => void;
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) => Promise<T>; withUpdateProgress: <T>(
initialMessage: string,
action: () => Promise<T>,
) => Promise<T>;
resolveConfiguredFieldName: ( resolveConfiguredFieldName: (
noteInfo: CardCreationNoteInfo, noteInfo: CardCreationNoteInfo,
...preferredNames: (string | undefined)[] ...preferredNames: (string | undefined)[]
@@ -80,15 +93,27 @@ interface CardCreationDeps {
noteInfo: CardCreationNoteInfo, noteInfo: CardCreationNoteInfo,
preferredName?: string, preferredName?: string,
) => string | null; ) => string | null;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>; extractFields: (
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string; fields: Record<string, { value: string }>,
) => Record<string, string>;
processSentence: (
mpvSentence: string,
noteFields: Record<string, string>,
) => string;
setCardTypeFields: ( setCardTypeFields: (
updatedFields: Record<string, string>, updatedFields: Record<string, string>,
availableFieldNames: string[], availableFieldNames: string[],
cardKind: CardKind, cardKind: CardKind,
) => void; ) => void;
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string; mergeFieldValue: (
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; existing: string,
newValue: string,
overwrite: boolean,
) => string;
formatMiscInfoPattern: (
fallbackFilename: string,
startTimeSeconds?: number,
) => string;
getEffectiveSentenceCardConfig: () => { getEffectiveSentenceCardConfig: () => {
model?: string; model?: string;
sentenceField: string; sentenceField: string;
@@ -141,14 +166,17 @@ export class CardCreationService {
} }
if (timings.length === 0) { if (timings.length === 0) {
this.deps.showOsdNotification("Subtitle timing not found; copy again while playing"); this.deps.showOsdNotification(
"Subtitle timing not found; copy again while playing",
);
return; return;
} }
const rangeStart = Math.min(...timings.map((entry) => entry.startTime)); const rangeStart = Math.min(...timings.map((entry) => entry.startTime));
let rangeEnd = Math.max(...timings.map((entry) => entry.endTime)); let rangeEnd = Math.max(...timings.map((entry) => entry.endTime));
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; const maxMediaDuration =
this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && rangeEnd - rangeStart > maxMediaDuration) { if (maxMediaDuration > 0 && rangeEnd - rangeStart > maxMediaDuration) {
log.warn( log.warn(
`Media range ${(rangeEnd - rangeStart).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`, `Media range ${(rangeEnd - rangeStart).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
@@ -172,7 +200,9 @@ export class CardCreationService {
} }
const noteId = Math.max(...noteIds); const noteId = Math.max(...noteIds);
const notesInfoResult = (await this.deps.client.notesInfo([noteId])) as CardCreationNoteInfo[]; const notesInfoResult = (await this.deps.client.notesInfo([
noteId,
])) as CardCreationNoteInfo[];
if (!notesInfoResult || notesInfoResult.length === 0) { if (!notesInfoResult || notesInfoResult.length === 0) {
this.deps.showOsdNotification("Card not found"); this.deps.showOsdNotification("Card not found");
return; return;
@@ -181,8 +211,10 @@ export class CardCreationService {
const noteInfo = notesInfoResult[0]; const noteInfo = notesInfoResult[0];
const fields = this.deps.extractFields(noteInfo.fields); const fields = this.deps.extractFields(noteInfo.fields);
const expressionText = fields.expression || fields.word || ""; const expressionText = fields.expression || fields.word || "";
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo); const sentenceAudioField =
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField; this.getResolvedSentenceAudioFieldName(noteInfo);
const sentenceField =
this.deps.getEffectiveSentenceCardConfig().sentenceField;
const sentence = blocks.join(" "); const sentence = blocks.join(" ");
const updatedFields: Record<string, string> = {}; const updatedFields: Record<string, string> = {};
@@ -212,7 +244,8 @@ export class CardCreationService {
if (audioBuffer) { if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer); await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
if (sentenceAudioField) { if (sentenceAudioField) {
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || ""; const existingAudio =
noteInfo.fields[sentenceAudioField]?.value || "";
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue( updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
existingAudio, existingAudio,
`[sound:${audioFilename}]`, `[sound:${audioFilename}]`,
@@ -223,10 +256,7 @@ export class CardCreationService {
updatePerformed = true; updatePerformed = true;
} }
} catch (error) { } catch (error) {
log.error( log.error("Failed to generate audio:", (error as Error).message);
"Failed to generate audio:",
(error as Error).message,
);
errors.push("audio"); errors.push("audio");
} }
} }
@@ -248,9 +278,12 @@ export class CardCreationService {
DEFAULT_ANKI_CONNECT_CONFIG.fields.image, DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
); );
if (!imageFieldName) { if (!imageFieldName) {
log.warn("Image field not found on note, skipping image update"); log.warn(
"Image field not found on note, skipping image update",
);
} else { } else {
const existingImage = noteInfo.fields[imageFieldName]?.value || ""; const existingImage =
noteInfo.fields[imageFieldName]?.value || "";
updatedFields[imageFieldName] = this.deps.mergeFieldValue( updatedFields[imageFieldName] = this.deps.mergeFieldValue(
existingImage, existingImage,
`<img src="${imageFilename}">`, `<img src="${imageFilename}">`,
@@ -261,10 +294,7 @@ export class CardCreationService {
} }
} }
} catch (error) { } catch (error) {
log.error( log.error("Failed to generate image:", (error as Error).message);
"Failed to generate image:",
(error as Error).message,
);
errors.push("image"); errors.push("image");
} }
} }
@@ -297,8 +327,13 @@ export class CardCreationService {
this.deps.endUpdateProgress(); this.deps.endUpdateProgress();
} }
} catch (error) { } catch (error) {
log.error("Error updating card from clipboard:", (error as Error).message); log.error(
this.deps.showOsdNotification(`Update failed: ${(error as Error).message}`); "Error updating card from clipboard:",
(error as Error).message,
);
this.deps.showOsdNotification(
`Update failed: ${(error as Error).message}`,
);
} }
} }
@@ -330,7 +365,8 @@ export class CardCreationService {
endTime = currentTime + fallback; endTime = currentTime + fallback;
} }
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; const maxMediaDuration =
this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) { if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) {
endTime = startTime + maxMediaDuration; endTime = startTime + maxMediaDuration;
} }
@@ -346,7 +382,9 @@ export class CardCreationService {
} }
const noteId = Math.max(...noteIds); const noteId = Math.max(...noteIds);
const notesInfoResult = (await this.deps.client.notesInfo([noteId])) as CardCreationNoteInfo[]; const notesInfoResult = (await this.deps.client.notesInfo([
noteId,
])) as CardCreationNoteInfo[];
if (!notesInfoResult || notesInfoResult.length === 0) { if (!notesInfoResult || notesInfoResult.length === 0) {
this.deps.showOsdNotification("Card not found"); this.deps.showOsdNotification("Card not found");
return; return;
@@ -410,8 +448,7 @@ export class CardCreationService {
const imageField = this.deps.getConfig().fields?.image; const imageField = this.deps.getConfig().fields?.image;
if (imageBuffer && imageField) { if (imageBuffer && imageField) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer); await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
updatedFields[imageField] = updatedFields[imageField] = `<img src="${imageFilename}">`;
`<img src="${imageFilename}">`;
miscInfoFilename = imageFilename; miscInfoFilename = imageFilename;
} }
} catch (error) { } catch (error) {
@@ -445,10 +482,7 @@ export class CardCreationService {
await this.deps.showNotification(noteId, label, errorSuffix); await this.deps.showNotification(noteId, label, errorSuffix);
}); });
} catch (error) { } catch (error) {
log.error( log.error("Error marking card as audio card:", (error as Error).message);
"Error marking card as audio card:",
(error as Error).message,
);
this.deps.showOsdNotification( this.deps.showOsdNotification(
`Audio card failed: ${(error as Error).message}`, `Audio card failed: ${(error as Error).message}`,
); );
@@ -479,7 +513,8 @@ export class CardCreationService {
return false; return false;
} }
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; const maxMediaDuration =
this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) { if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) {
log.warn( log.warn(
`Sentence card media range ${(endTime - startTime).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`, `Sentence card media range ${(endTime - startTime).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
@@ -489,162 +524,191 @@ export class CardCreationService {
this.deps.showOsdNotification("Creating sentence card..."); this.deps.showOsdNotification("Creating sentence card...");
try { try {
return await this.deps.withUpdateProgress("Creating sentence card", async () => { return await this.deps.withUpdateProgress(
const videoPath = mpvClient.currentVideoPath; "Creating sentence card",
const fields: Record<string, string> = {}; async () => {
const errors: string[] = []; const videoPath = mpvClient.currentVideoPath;
let miscInfoFilename: string | null = null; const fields: Record<string, string> = {};
const errors: string[] = [];
let miscInfoFilename: string | null = null;
const sentenceField = sentenceCardConfig.sentenceField; const sentenceField = sentenceCardConfig.sentenceField;
const audioFieldName = sentenceCardConfig.audioField || "SentenceAudio"; const audioFieldName =
const translationField = this.deps.getConfig().fields?.translation || "SelectionText"; sentenceCardConfig.audioField || "SentenceAudio";
let resolvedMiscInfoField: string | null = null; const translationField =
let resolvedSentenceAudioField: string = audioFieldName; this.deps.getConfig().fields?.translation || "SelectionText";
let resolvedExpressionAudioField: string | null = null; let resolvedMiscInfoField: string | null = null;
let resolvedSentenceAudioField: string = audioFieldName;
let resolvedExpressionAudioField: string | null = null;
fields[sentenceField] = sentence; fields[sentenceField] = sentence;
const backText = await resolveSentenceBackText( const backText = await resolveSentenceBackText(
{ {
sentence, sentence,
secondarySubText, secondarySubText,
config: this.deps.getConfig().ai || {}, config: this.deps.getConfig().ai || {},
}, },
{ {
logWarning: (message: string) => log.warn(message), logWarning: (message: string) => log.warn(message),
}, },
);
if (backText) {
fields[translationField] = backText;
}
if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) {
fields.IsSentenceCard = "x";
fields.Expression = sentence;
}
const deck = this.deps.getConfig().deck || "Default";
let noteId: number;
try {
noteId = await this.deps.client.addNote(deck, sentenceCardModel, fields);
log.info("Created sentence card:", noteId);
this.deps.trackLastAddedNoteId?.(noteId);
} catch (error) {
log.error("Failed to create sentence card:", (error as Error).message);
this.deps.showOsdNotification(
`Sentence card failed: ${(error as Error).message}`,
);
return false;
}
try {
const noteInfoResult = await this.deps.client.notesInfo([noteId]);
const noteInfos = noteInfoResult as CardCreationNoteInfo[];
if (noteInfos.length > 0) {
const createdNoteInfo = noteInfos[0];
this.deps.appendKnownWordsFromNoteInfo(createdNoteInfo);
resolvedSentenceAudioField =
this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) ||
audioFieldName;
resolvedExpressionAudioField = this.deps.resolveConfiguredFieldName(
createdNoteInfo,
this.deps.getConfig().fields?.audio || "ExpressionAudio",
); );
resolvedMiscInfoField = this.deps.resolveConfiguredFieldName( if (backText) {
createdNoteInfo, fields[translationField] = backText;
this.deps.getConfig().fields?.miscInfo,
);
const cardTypeFields: Record<string, string> = {};
this.deps.setCardTypeFields(
cardTypeFields,
Object.keys(createdNoteInfo.fields),
"sentence",
);
if (Object.keys(cardTypeFields).length > 0) {
await this.deps.client.updateNoteFields(noteId, cardTypeFields);
} }
}
} catch (error) {
log.error(
"Failed to normalize sentence card type fields:",
(error as Error).message,
);
errors.push("card type fields");
}
const mediaFields: Record<string, string> = {};
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.mediaGenerateAudio(videoPath, startTime, endTime);
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
const audioValue = `[sound:${audioFilename}]`;
mediaFields[resolvedSentenceAudioField] = audioValue;
if ( if (
resolvedExpressionAudioField && sentenceCardConfig.lapisEnabled ||
resolvedExpressionAudioField !== resolvedSentenceAudioField sentenceCardConfig.kikuEnabled
) { ) {
mediaFields[resolvedExpressionAudioField] = audioValue; fields.IsSentenceCard = "x";
fields.Expression = sentence;
} }
miscInfoFilename = audioFilename;
}
} catch (error) {
log.error("Failed to generate sentence audio:", (error as Error).message);
errors.push("audio");
}
try { const deck = this.deps.getConfig().deck || "Default";
const imageFilename = this.generateImageFilename(); let noteId: number;
const imageBuffer = await this.generateImageBuffer(videoPath, startTime, endTime); try {
noteId = await this.deps.client.addNote(
deck,
sentenceCardModel,
fields,
);
log.info("Created sentence card:", noteId);
this.deps.trackLastAddedNoteId?.(noteId);
} catch (error) {
log.error(
"Failed to create sentence card:",
(error as Error).message,
);
this.deps.showOsdNotification(
`Sentence card failed: ${(error as Error).message}`,
);
return false;
}
const imageField = this.deps.getConfig().fields?.image; try {
if (imageBuffer && imageField) { const noteInfoResult = await this.deps.client.notesInfo([noteId]);
await this.deps.client.storeMediaFile(imageFilename, imageBuffer); const noteInfos = noteInfoResult as CardCreationNoteInfo[];
mediaFields[imageField] = `<img src="${imageFilename}">`; if (noteInfos.length > 0) {
miscInfoFilename = imageFilename; const createdNoteInfo = noteInfos[0];
} this.deps.appendKnownWordsFromNoteInfo(createdNoteInfo);
} catch (error) { resolvedSentenceAudioField =
log.error("Failed to generate sentence image:", (error as Error).message); this.deps.resolveNoteFieldName(
errors.push("image"); createdNoteInfo,
} audioFieldName,
) || audioFieldName;
resolvedExpressionAudioField =
this.deps.resolveConfiguredFieldName(
createdNoteInfo,
this.deps.getConfig().fields?.audio || "ExpressionAudio",
);
resolvedMiscInfoField = this.deps.resolveConfiguredFieldName(
createdNoteInfo,
this.deps.getConfig().fields?.miscInfo,
);
if (this.deps.getConfig().fields?.miscInfo) { const cardTypeFields: Record<string, string> = {};
const miscInfo = this.deps.formatMiscInfoPattern( this.deps.setCardTypeFields(
miscInfoFilename || "", cardTypeFields,
startTime, Object.keys(createdNoteInfo.fields),
); "sentence",
if (miscInfo && resolvedMiscInfoField) { );
mediaFields[resolvedMiscInfoField] = miscInfo; if (Object.keys(cardTypeFields).length > 0) {
} await this.deps.client.updateNoteFields(noteId, cardTypeFields);
} }
}
} catch (error) {
log.error(
"Failed to normalize sentence card type fields:",
(error as Error).message,
);
errors.push("card type fields");
}
if (Object.keys(mediaFields).length > 0) { const mediaFields: Record<string, string> = {};
try {
await this.deps.client.updateNoteFields(noteId, mediaFields);
} catch (error) {
log.error(
"Failed to update sentence card media:",
(error as Error).message,
);
errors.push("media update");
}
}
const label = try {
sentence.length > 30 ? sentence.substring(0, 30) + "..." : sentence; const audioFilename = this.generateAudioFilename();
const errorSuffix = const audioBuffer = await this.mediaGenerateAudio(
errors.length > 0 ? `${errors.join(", ")} failed` : undefined; videoPath,
await this.deps.showNotification(noteId, label, errorSuffix); startTime,
return true; endTime,
}); );
} catch (error) {
log.error( if (audioBuffer) {
"Error creating sentence card:", await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
(error as Error).message, const audioValue = `[sound:${audioFilename}]`;
mediaFields[resolvedSentenceAudioField] = audioValue;
if (
resolvedExpressionAudioField &&
resolvedExpressionAudioField !== resolvedSentenceAudioField
) {
mediaFields[resolvedExpressionAudioField] = audioValue;
}
miscInfoFilename = audioFilename;
}
} catch (error) {
log.error(
"Failed to generate sentence audio:",
(error as Error).message,
);
errors.push("audio");
}
try {
const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImageBuffer(
videoPath,
startTime,
endTime,
);
const imageField = this.deps.getConfig().fields?.image;
if (imageBuffer && imageField) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
mediaFields[imageField] = `<img src="${imageFilename}">`;
miscInfoFilename = imageFilename;
}
} catch (error) {
log.error(
"Failed to generate sentence image:",
(error as Error).message,
);
errors.push("image");
}
if (this.deps.getConfig().fields?.miscInfo) {
const miscInfo = this.deps.formatMiscInfoPattern(
miscInfoFilename || "",
startTime,
);
if (miscInfo && resolvedMiscInfoField) {
mediaFields[resolvedMiscInfoField] = miscInfo;
}
}
if (Object.keys(mediaFields).length > 0) {
try {
await this.deps.client.updateNoteFields(noteId, mediaFields);
} catch (error) {
log.error(
"Failed to update sentence card media:",
(error as Error).message,
);
errors.push("media update");
}
}
const label =
sentence.length > 30 ? sentence.substring(0, 30) + "..." : sentence;
const errorSuffix =
errors.length > 0 ? `${errors.join(", ")} failed` : undefined;
await this.deps.showNotification(noteId, label, errorSuffix);
return true;
},
); );
} catch (error) {
log.error("Error creating sentence card:", (error as Error).message);
this.deps.showOsdNotification( this.deps.showOsdNotification(
`Sentence card failed: ${(error as Error).message}`, `Sentence card failed: ${(error as Error).message}`,
); );
@@ -652,13 +716,19 @@ export class CardCreationService {
} }
} }
private getResolvedSentenceAudioFieldName(noteInfo: CardCreationNoteInfo): string | null { private getResolvedSentenceAudioFieldName(
noteInfo: CardCreationNoteInfo,
): string | null {
return ( return (
this.deps.resolveNoteFieldName( this.deps.resolveNoteFieldName(
noteInfo, noteInfo,
this.deps.getEffectiveSentenceCardConfig().audioField || "SentenceAudio", this.deps.getEffectiveSentenceCardConfig().audioField ||
"SentenceAudio",
) || ) ||
this.deps.resolveConfiguredFieldName(noteInfo, this.deps.getConfig().fields?.audio) this.deps.resolveConfiguredFieldName(
noteInfo,
this.deps.getConfig().fields?.audio,
)
); );
} }
@@ -673,12 +743,12 @@ export class CardCreationService {
} }
return this.deps.mediaGenerator.generateAudio( return this.deps.mediaGenerator.generateAudio(
videoPath, videoPath,
startTime, startTime,
endTime, endTime,
this.deps.getConfig().media?.audioPadding, this.deps.getConfig().media?.audioPadding,
mpvClient.currentAudioStreamIndex ?? undefined, mpvClient.currentAudioStreamIndex ?? undefined,
); );
} }
private async generateImageBuffer( private async generateImageBuffer(
@@ -718,7 +788,10 @@ export class CardCreationService {
} }
return this.deps.mediaGenerator.generateScreenshot(videoPath, timestamp, { return this.deps.mediaGenerator.generateScreenshot(videoPath, timestamp, {
format: this.deps.getConfig().media?.imageFormat as "jpg" | "png" | "webp", format: this.deps.getConfig().media?.imageFormat as
| "jpg"
| "png"
| "webp",
quality: this.deps.getConfig().media?.imageQuality, quality: this.deps.getConfig().media?.imageQuality,
maxWidth: this.deps.getConfig().media?.imageMaxWidth, maxWidth: this.deps.getConfig().media?.imageMaxWidth,
maxHeight: this.deps.getConfig().media?.imageMaxHeight, maxHeight: this.deps.getConfig().media?.imageMaxHeight,
@@ -733,7 +806,9 @@ export class CardCreationService {
private generateImageFilename(): string { private generateImageFilename(): string {
const timestamp = Date.now(); const timestamp = Date.now();
const ext = const ext =
this.deps.getConfig().media?.imageType === "avif" ? "avif" : this.deps.getConfig().media?.imageFormat; this.deps.getConfig().media?.imageType === "avif"
? "avif"
: this.deps.getConfig().media?.imageFormat;
return `image_${timestamp}.${ext}`; return `image_${timestamp}.${ext}`;
} }
} }

View File

@@ -14,7 +14,10 @@ export interface DuplicateDetectionDeps {
) => Promise<unknown>; ) => Promise<unknown>;
notesInfo: (noteIds: number[]) => Promise<unknown>; notesInfo: (noteIds: number[]) => Promise<unknown>;
getDeck: () => string | null | undefined; getDeck: () => string | null | undefined;
resolveFieldName: (noteInfo: NoteInfo, preferredName: string) => string | null; resolveFieldName: (
noteInfo: NoteInfo,
preferredName: string,
) => string | null;
logWarn: (message: string, error: unknown) => void; logWarn: (message: string, error: unknown) => void;
} }
@@ -44,7 +47,9 @@ export async function findDuplicateNote(
const query = `${deckPrefix}"${escapedFieldName}:${escapedExpression}"`; const query = `${deckPrefix}"${escapedFieldName}:${escapedExpression}"`;
try { try {
const noteIds = (await deps.findNotes(query, { maxRetries: 0 }) as number[]); const noteIds = (await deps.findNotes(query, {
maxRetries: 0,
})) as number[];
return await findFirstExactDuplicateNoteId( return await findFirstExactDuplicateNoteId(
noteIds, noteIds,
excludeNoteId, excludeNoteId,

View File

@@ -20,7 +20,10 @@ interface FieldGroupingDeps {
}; };
isUpdateInProgress: () => boolean; isUpdateInProgress: () => boolean;
getDeck?: () => string | undefined; getDeck?: () => string | undefined;
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) => Promise<T>; withUpdateProgress: <T>(
initialMessage: string,
action: () => Promise<T>,
) => Promise<T>;
showOsdNotification: (text: string) => void; showOsdNotification: (text: string) => void;
findNotes: ( findNotes: (
query: string, query: string,
@@ -29,7 +32,9 @@ interface FieldGroupingDeps {
}, },
) => Promise<number[]>; ) => Promise<number[]>;
notesInfo: (noteIds: number[]) => Promise<FieldGroupingNoteInfo[]>; notesInfo: (noteIds: number[]) => Promise<FieldGroupingNoteInfo[]>;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>; extractFields: (
fields: Record<string, { value: string }>,
) => Record<string, string>;
findDuplicateNote: ( findDuplicateNote: (
expression: string, expression: string,
excludeNoteId: number, excludeNoteId: number,
@@ -90,81 +95,83 @@ export class FieldGroupingService {
} }
try { try {
await this.deps.withUpdateProgress("Grouping duplicate cards", async () => { await this.deps.withUpdateProgress(
const deck = this.deps.getDeck ? this.deps.getDeck() : undefined; "Grouping duplicate cards",
const query = deck ? `"deck:${deck}" added:1` : "added:1"; async () => {
const noteIds = await this.deps.findNotes(query); const deck = this.deps.getDeck ? this.deps.getDeck() : undefined;
if (!noteIds || noteIds.length === 0) { const query = deck ? `"deck:${deck}" added:1` : "added:1";
this.deps.showOsdNotification("No recently added cards found"); const noteIds = await this.deps.findNotes(query);
return; if (!noteIds || noteIds.length === 0) {
} this.deps.showOsdNotification("No recently added cards found");
return;
}
const noteId = Math.max(...noteIds); const noteId = Math.max(...noteIds);
const notesInfoResult = await this.deps.notesInfo([noteId]); const notesInfoResult = await this.deps.notesInfo([noteId]);
const notesInfo = notesInfoResult as FieldGroupingNoteInfo[]; const notesInfo = notesInfoResult as FieldGroupingNoteInfo[];
if (!notesInfo || notesInfo.length === 0) { if (!notesInfo || notesInfo.length === 0) {
this.deps.showOsdNotification("Card not found"); this.deps.showOsdNotification("Card not found");
return; return;
} }
const noteInfoBeforeUpdate = notesInfo[0]; const noteInfoBeforeUpdate = notesInfo[0];
const fields = this.deps.extractFields(noteInfoBeforeUpdate.fields); const fields = this.deps.extractFields(noteInfoBeforeUpdate.fields);
const expressionText = fields.expression || fields.word || ""; const expressionText = fields.expression || fields.word || "";
if (!expressionText) { if (!expressionText) {
this.deps.showOsdNotification("No expression/word field found"); this.deps.showOsdNotification("No expression/word field found");
return; return;
} }
const duplicateNoteId = await this.deps.findDuplicateNote( const duplicateNoteId = await this.deps.findDuplicateNote(
expressionText, expressionText,
noteId, noteId,
noteInfoBeforeUpdate, noteInfoBeforeUpdate,
); );
if (duplicateNoteId === null) { if (duplicateNoteId === null) {
this.deps.showOsdNotification("No duplicate card found"); this.deps.showOsdNotification("No duplicate card found");
return; return;
} }
if ( if (
!this.deps.hasAllConfiguredFields(noteInfoBeforeUpdate, [ !this.deps.hasAllConfiguredFields(noteInfoBeforeUpdate, [
this.deps.getSentenceCardImageFieldName(), this.deps.getSentenceCardImageFieldName(),
]) ])
) { ) {
await this.deps.processNewCard(noteId, { skipKikuFieldGrouping: true }); await this.deps.processNewCard(noteId, {
} skipKikuFieldGrouping: true,
});
}
const refreshedInfoResult = await this.deps.notesInfo([noteId]); const refreshedInfoResult = await this.deps.notesInfo([noteId]);
const refreshedInfo = refreshedInfoResult as FieldGroupingNoteInfo[]; const refreshedInfo = refreshedInfoResult as FieldGroupingNoteInfo[];
if (!refreshedInfo || refreshedInfo.length === 0) { if (!refreshedInfo || refreshedInfo.length === 0) {
this.deps.showOsdNotification("Card not found"); this.deps.showOsdNotification("Card not found");
return; return;
} }
const noteInfo = refreshedInfo[0]; const noteInfo = refreshedInfo[0];
if (sentenceCardConfig.kikuFieldGrouping === "auto") { if (sentenceCardConfig.kikuFieldGrouping === "auto") {
await this.deps.handleFieldGroupingAuto( await this.deps.handleFieldGroupingAuto(
duplicateNoteId,
noteId,
noteInfo,
expressionText,
);
return;
}
const handled = await this.deps.handleFieldGroupingManual(
duplicateNoteId, duplicateNoteId,
noteId, noteId,
noteInfo, noteInfo,
expressionText, expressionText,
); );
return; if (!handled) {
} this.deps.showOsdNotification("Field grouping cancelled");
const handled = await this.deps.handleFieldGroupingManual( }
duplicateNoteId, },
noteId,
noteInfo,
expressionText,
);
if (!handled) {
this.deps.showOsdNotification("Field grouping cancelled");
}
});
} catch (error) {
log.error(
"Error triggering field grouping:",
(error as Error).message,
); );
} catch (error) {
log.error("Error triggering field grouping:", (error as Error).message);
this.deps.showOsdNotification( this.deps.showOsdNotification(
`Field grouping failed: ${(error as Error).message}`, `Field grouping failed: ${(error as Error).message}`,
); );

View File

@@ -46,7 +46,8 @@ export class KnownWordCacheManager {
constructor(private readonly deps: KnownWordCacheDeps) { constructor(private readonly deps: KnownWordCacheDeps) {
this.statePath = path.normalize( this.statePath = path.normalize(
deps.knownWordCacheStatePath || path.join(process.cwd(), "known-words-cache.json"), deps.knownWordCacheStatePath ||
path.join(process.cwd(), "known-words-cache.json"),
); );
} }
@@ -140,7 +141,10 @@ export class KnownWordCacheManager {
fs.unlinkSync(this.statePath); fs.unlinkSync(this.statePath);
} }
} catch (error) { } catch (error) {
log.warn("Failed to clear known-word cache state:", (error as Error).message); log.warn(
"Failed to clear known-word cache state:",
(error as Error).message,
);
} }
} }
@@ -171,7 +175,9 @@ export class KnownWordCacheManager {
const chunkSize = 50; const chunkSize = 50;
for (let i = 0; i < noteIds.length; i += chunkSize) { for (let i = 0; i < noteIds.length; i += chunkSize) {
const chunk = noteIds.slice(i, i + chunkSize); const chunk = noteIds.slice(i, i + chunkSize);
const notesInfoResult = (await this.deps.client.notesInfo(chunk)) as unknown[]; const notesInfoResult = (await this.deps.client.notesInfo(
chunk,
)) as unknown[];
const notesInfo = notesInfoResult as KnownWordCacheNoteInfo[]; const notesInfo = notesInfoResult as KnownWordCacheNoteInfo[];
for (const noteInfo of notesInfo) { for (const noteInfo of notesInfo) {
@@ -196,7 +202,9 @@ export class KnownWordCacheManager {
); );
} catch (error) { } catch (error) {
log.warn("Failed to refresh known-word cache:", (error as Error).message); log.warn("Failed to refresh known-word cache:", (error as Error).message);
this.deps.showStatusNotification("AnkiConnect: unable to refresh known words"); this.deps.showStatusNotification(
"AnkiConnect: unable to refresh known words",
);
} finally { } finally {
this.isRefreshingKnownWords = false; this.isRefreshingKnownWords = false;
} }
@@ -313,7 +321,10 @@ export class KnownWordCacheManager {
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs; this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
this.knownWordsScope = parsed.scope; this.knownWordsScope = parsed.scope;
} catch (error) { } catch (error) {
log.warn("Failed to load known-word cache state:", (error as Error).message); log.warn(
"Failed to load known-word cache state:",
(error as Error).message,
);
this.knownWords = new Set(); this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0; this.knownWordsLastRefreshedAtMs = 0;
this.knownWordsScope = this.getKnownWordCacheScope(); this.knownWordsScope = this.getKnownWordCacheScope();
@@ -330,7 +341,10 @@ export class KnownWordCacheManager {
}; };
fs.writeFileSync(this.statePath, JSON.stringify(state), "utf-8"); fs.writeFileSync(this.statePath, JSON.stringify(state), "utf-8");
} catch (error) { } catch (error) {
log.warn("Failed to persist known-word cache state:", (error as Error).message); log.warn(
"Failed to persist known-word cache state:",
(error as Error).message,
);
} }
} }
@@ -349,11 +363,16 @@ export class KnownWordCacheManager {
return true; return true;
} }
private extractKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] { private extractKnownWordsFromNoteInfo(
noteInfo: KnownWordCacheNoteInfo,
): string[] {
const words: string[] = []; const words: string[] = [];
const preferredFields = ["Expression", "Word"]; const preferredFields = ["Expression", "Word"];
for (const preferredField of preferredFields) { for (const preferredField of preferredFields) {
const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField); const fieldName = resolveFieldName(
Object.keys(noteInfo.fields),
preferredField,
);
if (!fieldName) continue; if (!fieldName) continue;
const raw = noteInfo.fields[fieldName]?.value; const raw = noteInfo.fields[fieldName]?.value;
@@ -387,12 +406,14 @@ function resolveFieldName(
if (exact) return exact; if (exact) return exact;
const lower = preferredName.toLowerCase(); const lower = preferredName.toLowerCase();
return availableFieldNames.find((name) => name.toLowerCase() === lower) || null; return (
availableFieldNames.find((name) => name.toLowerCase() === lower) || null
);
} }
function escapeAnkiSearchValue(value: string): string { function escapeAnkiSearchValue(value: string): string {
return value return value
.replace(/\\/g, "\\\\") .replace(/\\/g, "\\\\")
.replace(/\"/g, "\\\"") .replace(/\"/g, '\\"')
.replace(/([:*?()\[\]{}])/g, "\\$1"); .replace(/([:*?()\[\]{}])/g, "\\$1");
} }

View File

@@ -56,7 +56,9 @@ export class PollingRunner {
this.deps.setUpdateInProgress(true); this.deps.setUpdateInProgress(true);
try { try {
const query = this.deps.getDeck() ? `"deck:${this.deps.getDeck()}" added:1` : "added:1"; const query = this.deps.getDeck()
? `"deck:${this.deps.getDeck()}" added:1`
: "added:1";
const noteIds = await this.deps.findNotes(query, { const noteIds = await this.deps.findNotes(query, {
maxRetries: 0, maxRetries: 0,
}); });

View File

@@ -10,10 +10,7 @@ export interface UiFeedbackState {
export interface UiFeedbackNotificationContext { export interface UiFeedbackNotificationContext {
getNotificationType: () => string | undefined; getNotificationType: () => string | undefined;
showOsd: (text: string) => void; showOsd: (text: string) => void;
showSystemNotification: ( showSystemNotification: (title: string, options: NotificationOptions) => void;
title: string,
options: NotificationOptions,
) => void;
} }
export interface UiFeedbackOptions { export interface UiFeedbackOptions {
@@ -57,7 +54,9 @@ export function beginUpdateProgress(
state.progressFrame = 0; state.progressFrame = 0;
showProgressTick(`${state.progressMessage}`); showProgressTick(`${state.progressMessage}`);
state.progressTimer = setInterval(() => { state.progressTimer = setInterval(() => {
showProgressTick(`${state.progressMessage} ${["|", "/", "-", "\\"][state.progressFrame % 4]}`); showProgressTick(
`${state.progressMessage} ${["|", "/", "-", "\\"][state.progressFrame % 4]}`,
);
state.progressFrame += 1; state.progressFrame += 1;
}, 180); }, 180);
} }

View File

@@ -34,7 +34,8 @@ const hasSafeStorage =
const originalSafeStorage: SafeStorageLike | null = hasSafeStorage const originalSafeStorage: SafeStorageLike | null = hasSafeStorage
? { ? {
isEncryptionAvailable: safeStorageApi.isEncryptionAvailable as () => boolean, isEncryptionAvailable:
safeStorageApi.isEncryptionAvailable as () => boolean,
encryptString: safeStorageApi.encryptString as (value: string) => Buffer, encryptString: safeStorageApi.encryptString as (value: string) => Buffer,
decryptString: safeStorageApi.decryptString as (value: Buffer) => string, decryptString: safeStorageApi.decryptString as (value: Buffer) => string,
} }
@@ -87,76 +88,92 @@ function restoreSafeStorage(): void {
).decryptString = originalSafeStorage.decryptString; ).decryptString = originalSafeStorage.decryptString;
} }
test("anilist token store saves and loads encrypted token", { skip: !hasSafeStorage }, () => { test(
mockSafeStorage(true); "anilist token store saves and loads encrypted token",
try { { skip: !hasSafeStorage },
() => {
mockSafeStorage(true);
try {
const filePath = createTempTokenFile();
const store = createAnilistTokenStore(filePath, createLogger());
store.saveToken(" demo-token ");
const payload = JSON.parse(fs.readFileSync(filePath, "utf-8")) as {
encryptedToken?: string;
plaintextToken?: string;
};
assert.equal(typeof payload.encryptedToken, "string");
assert.equal(payload.plaintextToken, undefined);
assert.equal(store.loadToken(), "demo-token");
} finally {
restoreSafeStorage();
}
},
);
test(
"anilist token store falls back to plaintext when encryption unavailable",
{ skip: !hasSafeStorage },
() => {
mockSafeStorage(false);
try {
const filePath = createTempTokenFile();
const store = createAnilistTokenStore(filePath, createLogger());
store.saveToken("plain-token");
const payload = JSON.parse(fs.readFileSync(filePath, "utf-8")) as {
plaintextToken?: string;
};
assert.equal(payload.plaintextToken, "plain-token");
assert.equal(store.loadToken(), "plain-token");
} finally {
restoreSafeStorage();
}
},
);
test(
"anilist token store migrates legacy plaintext to encrypted",
{ skip: !hasSafeStorage },
() => {
const filePath = createTempTokenFile(); const filePath = createTempTokenFile();
const store = createAnilistTokenStore(filePath, createLogger()); fs.writeFileSync(
store.saveToken(" demo-token "); filePath,
JSON.stringify({ plaintextToken: "legacy-token", updatedAt: Date.now() }),
"utf-8",
);
const payload = JSON.parse(fs.readFileSync(filePath, "utf-8")) as { mockSafeStorage(true);
encryptedToken?: string; try {
plaintextToken?: string; const store = createAnilistTokenStore(filePath, createLogger());
}; assert.equal(store.loadToken(), "legacy-token");
assert.equal(typeof payload.encryptedToken, "string");
assert.equal(payload.plaintextToken, undefined);
assert.equal(store.loadToken(), "demo-token");
} finally {
restoreSafeStorage();
}
});
test("anilist token store falls back to plaintext when encryption unavailable", { skip: !hasSafeStorage }, () => { const payload = JSON.parse(fs.readFileSync(filePath, "utf-8")) as {
mockSafeStorage(false); encryptedToken?: string;
try { plaintextToken?: string;
const filePath = createTempTokenFile(); };
const store = createAnilistTokenStore(filePath, createLogger()); assert.equal(typeof payload.encryptedToken, "string");
store.saveToken("plain-token"); assert.equal(payload.plaintextToken, undefined);
} finally {
restoreSafeStorage();
}
},
);
const payload = JSON.parse(fs.readFileSync(filePath, "utf-8")) as { test(
plaintextToken?: string; "anilist token store clears persisted token file",
}; { skip: !hasSafeStorage },
assert.equal(payload.plaintextToken, "plain-token"); () => {
assert.equal(store.loadToken(), "plain-token"); mockSafeStorage(true);
} finally { try {
restoreSafeStorage(); const filePath = createTempTokenFile();
} const store = createAnilistTokenStore(filePath, createLogger());
}); store.saveToken("to-clear");
assert.equal(fs.existsSync(filePath), true);
test("anilist token store migrates legacy plaintext to encrypted", { skip: !hasSafeStorage }, () => { store.clearToken();
const filePath = createTempTokenFile(); assert.equal(fs.existsSync(filePath), false);
fs.writeFileSync( } finally {
filePath, restoreSafeStorage();
JSON.stringify({ plaintextToken: "legacy-token", updatedAt: Date.now() }), }
"utf-8", },
); );
mockSafeStorage(true);
try {
const store = createAnilistTokenStore(filePath, createLogger());
assert.equal(store.loadToken(), "legacy-token");
const payload = JSON.parse(fs.readFileSync(filePath, "utf-8")) as {
encryptedToken?: string;
plaintextToken?: string;
};
assert.equal(typeof payload.encryptedToken, "string");
assert.equal(payload.plaintextToken, undefined);
} finally {
restoreSafeStorage();
}
});
test("anilist token store clears persisted token file", { skip: !hasSafeStorage }, () => {
mockSafeStorage(true);
try {
const filePath = createTempTokenFile();
const store = createAnilistTokenStore(filePath, createLogger());
store.saveToken("to-clear");
assert.equal(fs.existsSync(filePath), true);
store.clearToken();
assert.equal(fs.existsSync(filePath), false);
} finally {
restoreSafeStorage();
}
});

View File

@@ -43,7 +43,11 @@ test("anilist update queue enqueues, snapshots, and dequeues success", () => {
ready: 0, ready: 0,
deadLetter: 0, deadLetter: 0,
}); });
assert.ok(loggerState.info.some((message) => message.includes("Queued AniList retry"))); assert.ok(
loggerState.info.some((message) =>
message.includes("Queued AniList retry"),
),
);
}); });
test("anilist update queue applies retry backoff and dead-letter", () => { test("anilist update queue applies retry backoff and dead-letter", () => {
@@ -89,5 +93,8 @@ test("anilist update queue persists and reloads from disk", () => {
ready: 1, ready: 1,
deadLetter: 0, deadLetter: 0,
}); });
assert.equal(queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title, "Persist Demo"); assert.equal(
queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title,
"Persist Demo",
);
}); });

View File

@@ -43,7 +43,8 @@ function ensureDir(filePath: string): void {
} }
function clampBackoffMs(attemptCount: number): number { function clampBackoffMs(attemptCount: number): number {
const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1)); const computed =
INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
return Math.min(MAX_BACKOFF_MS, computed); return Math.min(MAX_BACKOFF_MS, computed);
} }
@@ -184,7 +185,9 @@ export function createAnilistUpdateQueue(
}, },
getSnapshot(nowMs: number = Date.now()): AnilistRetryQueueSnapshot { getSnapshot(nowMs: number = Date.now()): AnilistRetryQueueSnapshot {
const ready = pending.filter((item) => item.nextAttemptAt <= nowMs).length; const ready = pending.filter(
(item) => item.nextAttemptAt <= nowMs,
).length;
return { return {
pending: pending.length, pending: pending.length,
ready, ready,

View File

@@ -22,9 +22,14 @@ test("guessAnilistMediaInfo uses guessit output when available", async () => {
} }
).execFile = ((...args: unknown[]) => { ).execFile = ((...args: unknown[]) => {
const callback = args[args.length - 1]; const callback = args[args.length - 1];
const cb = typeof callback === "function" const cb =
? (callback as (error: Error | null, stdout: string, stderr: string) => void) typeof callback === "function"
: null; ? (callback as (
error: Error | null,
stdout: string,
stderr: string,
) => void)
: null;
cb?.(null, JSON.stringify({ title: "Guessit Title", episode: 7 }), ""); cb?.(null, JSON.stringify({ title: "Guessit Title", episode: 7 }), "");
return {} as childProcess.ChildProcess; return {} as childProcess.ChildProcess;
}) as typeof childProcess.execFile; }) as typeof childProcess.execFile;
@@ -53,9 +58,14 @@ test("guessAnilistMediaInfo falls back to parser when guessit fails", async () =
} }
).execFile = ((...args: unknown[]) => { ).execFile = ((...args: unknown[]) => {
const callback = args[args.length - 1]; const callback = args[args.length - 1];
const cb = typeof callback === "function" const cb =
? (callback as (error: Error | null, stdout: string, stderr: string) => void) typeof callback === "function"
: null; ? (callback as (
error: Error | null,
stdout: string,
stderr: string,
) => void)
: null;
cb?.(new Error("guessit not found"), "", ""); cb?.(new Error("guessit not found"), "", "");
return {} as childProcess.ChildProcess; return {} as childProcess.ChildProcess;
}) as typeof childProcess.execFile; }) as typeof childProcess.execFile;
@@ -115,7 +125,11 @@ test("updateAnilistPostWatchProgress updates progress when behind", async () =>
}) as typeof fetch; }) as typeof fetch;
try { try {
const result = await updateAnilistPostWatchProgress("token", "Demo Show", 3); const result = await updateAnilistPostWatchProgress(
"token",
"Demo Show",
3,
);
assert.equal(result.status, "updated"); assert.equal(result.status, "updated");
assert.match(result.message, /episode 3/i); assert.match(result.message, /episode 3/i);
} finally { } finally {
@@ -145,7 +159,11 @@ test("updateAnilistPostWatchProgress skips when progress already reached", async
}) as typeof fetch; }) as typeof fetch;
try { try {
const result = await updateAnilistPostWatchProgress("token", "Skip Show", 10); const result = await updateAnilistPostWatchProgress(
"token",
"Skip Show",
10,
);
assert.equal(result.status, "skipped"); assert.equal(result.status, "skipped");
assert.match(result.message, /already at episode/i); assert.match(result.message, /already at episode/i);
} finally { } finally {

View File

@@ -128,15 +128,16 @@ async function anilistGraphQl<T>(
return { return {
errors: [ errors: [
{ {
message: message: error instanceof Error ? error.message : String(error),
error instanceof Error ? error.message : String(error),
}, },
], ],
}; };
} }
} }
function firstErrorMessage<T>(response: AnilistGraphQlResponse<T>): string | null { function firstErrorMessage<T>(
response: AnilistGraphQlResponse<T>,
): string | null {
const firstError = response.errors?.find((item) => Boolean(item?.message)); const firstError = response.errors?.find((item) => Boolean(item?.message));
return firstError?.message ?? null; return firstError?.message ?? null;
} }
@@ -163,11 +164,7 @@ function pickBestSearchResult(
const normalizedTarget = normalizeTitle(title); const normalizedTarget = normalizeTitle(title);
const exact = candidates.find((item) => { const exact = candidates.find((item) => {
const titles = [ const titles = [item.title?.romaji, item.title?.english, item.title?.native]
item.title?.romaji,
item.title?.english,
item.title?.native,
]
.filter((value): value is string => typeof value === "string") .filter((value): value is string => typeof value === "string")
.map((value) => normalizeTitle(value)); .map((value) => normalizeTitle(value));
return titles.includes(normalizedTarget); return titles.includes(normalizedTarget);
@@ -240,7 +237,10 @@ export async function updateAnilistPostWatchProgress(
); );
const searchError = firstErrorMessage(searchResponse); const searchError = firstErrorMessage(searchResponse);
if (searchError) { if (searchError) {
return { status: "error", message: `AniList search failed: ${searchError}` }; return {
status: "error",
message: `AniList search failed: ${searchError}`,
};
} }
const media = searchResponse.data?.Page?.media ?? []; const media = searchResponse.data?.Page?.media ?? [];
@@ -266,10 +266,14 @@ export async function updateAnilistPostWatchProgress(
); );
const entryError = firstErrorMessage(entryResponse); const entryError = firstErrorMessage(entryResponse);
if (entryError) { if (entryError) {
return { status: "error", message: `AniList entry lookup failed: ${entryError}` }; return {
status: "error",
message: `AniList entry lookup failed: ${entryError}`,
};
} }
const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0; const currentProgress =
entryResponse.data?.Media?.mediaListEntry?.progress ?? 0;
if (typeof currentProgress === "number" && currentProgress >= episode) { if (typeof currentProgress === "number" && currentProgress >= episode) {
return { return {
status: "skipped", status: "skipped",

View File

@@ -45,9 +45,7 @@ export interface AnkiJimakuIpcDeps {
onDownloadedSubtitle: (pathToSubtitle: string) => void; onDownloadedSubtitle: (pathToSubtitle: string) => void;
} }
export function registerAnkiJimakuIpcHandlers( export function registerAnkiJimakuIpcHandlers(deps: AnkiJimakuIpcDeps): void {
deps: AnkiJimakuIpcDeps,
): void {
ipcMain.on( ipcMain.on(
"set-anki-connect-enabled", "set-anki-connect-enabled",
(_event: IpcMainEvent, enabled: boolean) => { (_event: IpcMainEvent, enabled: boolean) => {
@@ -106,7 +104,10 @@ export function registerAnkiJimakuIpcHandlers(
ipcMain.handle( ipcMain.handle(
"jimaku:download-file", "jimaku:download-file",
async (_event, query: JimakuDownloadQuery): Promise<JimakuDownloadResult> => { async (
_event,
query: JimakuDownloadQuery,
): Promise<JimakuDownloadResult> => {
const apiKey = await deps.resolveJimakuApiKey(); const apiKey = await deps.resolveJimakuApiKey();
if (!apiKey) { if (!apiKey) {
return { return {

View File

@@ -24,7 +24,10 @@ function createHarness(): RuntimeHarness {
fieldGroupingResolver: null as ((choice: unknown) => void) | null, fieldGroupingResolver: null as ((choice: unknown) => void) | null,
patches: [] as boolean[], patches: [] as boolean[],
broadcasts: 0, broadcasts: 0,
fetchCalls: [] as Array<{ endpoint: string; query?: Record<string, unknown> }>, fetchCalls: [] as Array<{
endpoint: string;
query?: Record<string, unknown>;
}>,
sentCommands: [] as Array<{ command: string[] }>, sentCommands: [] as Array<{ command: string[] }>,
}; };
@@ -45,8 +48,7 @@ function createHarness(): RuntimeHarness {
setAnkiIntegration: (integration) => { setAnkiIntegration: (integration) => {
state.ankiIntegration = integration; state.ankiIntegration = integration;
}, },
getKnownWordCacheStatePath: () => getKnownWordCacheStatePath: () => "/tmp/subminer-known-words-cache.json",
"/tmp/subminer-known-words-cache.json",
showDesktopNotification: () => {}, showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({ createFieldGroupingCallback: () => async () => ({
keepNoteId: 1, keepNoteId: 1,
@@ -71,7 +73,10 @@ function createHarness(): RuntimeHarness {
}), }),
getCurrentMediaPath: () => "/tmp/video.mkv", getCurrentMediaPath: () => "/tmp/video.mkv",
jimakuFetchJson: async (endpoint, query) => { jimakuFetchJson: async (endpoint, query) => {
state.fetchCalls.push({ endpoint, query: query as Record<string, unknown> }); state.fetchCalls.push({
endpoint,
query: query as Record<string, unknown>,
});
return { return {
ok: true, ok: true,
data: [ data: [
@@ -92,12 +97,12 @@ function createHarness(): RuntimeHarness {
}; };
let registered: Record<string, (...args: unknown[]) => unknown> = {}; let registered: Record<string, (...args: unknown[]) => unknown> = {};
registerAnkiJimakuIpcRuntime( registerAnkiJimakuIpcRuntime(options, (deps) => {
options, registered = deps as unknown as Record<
(deps) => { string,
registered = deps as unknown as Record<string, (...args: unknown[]) => unknown>; (...args: unknown[]) => unknown
}, >;
); });
return { options, registered, state }; return { options, registered, state };
} }
@@ -177,9 +182,11 @@ test("clearAnkiHistory and respondFieldGrouping execute runtime callbacks", () =
const originalGetTracker = options.getSubtitleTimingTracker; const originalGetTracker = options.getSubtitleTimingTracker;
options.getSubtitleTimingTracker = () => options.getSubtitleTimingTracker = () =>
({ cleanup: () => { ({
cleaned += 1; cleanup: () => {
} }) as never; cleaned += 1;
},
}) as never;
const choice = { const choice = {
keepNoteId: 10, keepNoteId: 10,

View File

@@ -23,7 +23,9 @@ interface MpvClientLike {
} }
interface RuntimeOptionsManagerLike { interface RuntimeOptionsManagerLike {
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig; getEffectiveAnkiConnectConfig: (
config?: AnkiConnectConfig,
) => AnkiConnectConfig;
} }
interface SubtitleTimingTrackerLike { interface SubtitleTimingTrackerLike {
@@ -39,13 +41,20 @@ export interface AnkiJimakuIpcRuntimeOptions {
getAnkiIntegration: () => AnkiIntegration | null; getAnkiIntegration: () => AnkiIntegration | null;
setAnkiIntegration: (integration: AnkiIntegration | null) => void; setAnkiIntegration: (integration: AnkiIntegration | null) => void;
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (
title: string,
options: { body?: string; icon?: string },
) => void;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
broadcastRuntimeOptionsChanged: () => void; broadcastRuntimeOptionsChanged: () => void;
getFieldGroupingResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; getFieldGroupingResolver: () =>
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; | ((choice: KikuFieldGroupingChoice) => void)
| null;
setFieldGroupingResolver: (
resolver: ((choice: KikuFieldGroupingChoice) => void) | null,
) => void;
parseMediaInfo: (mediaPath: string | null) => JimakuMediaInfo; parseMediaInfo: (mediaPath: string | null) => JimakuMediaInfo;
getCurrentMediaPath: () => string | null; getCurrentMediaPath: () => string | null;
jimakuFetchJson: <T>( jimakuFetchJson: <T>(
@@ -60,7 +69,13 @@ export interface AnkiJimakuIpcRuntimeOptions {
url: string, url: string,
destPath: string, destPath: string,
headers: Record<string, string>, headers: Record<string, string>,
) => Promise<{ ok: true; path: string } | { ok: false; error: { error: string; code?: number; retryAfter?: number } }>; ) => Promise<
| { ok: true; path: string }
| {
ok: false;
error: { error: string; code?: number; retryAfter?: number };
}
>;
} }
const logger = createLogger("main:anki-jimaku"); const logger = createLogger("main:anki-jimaku");
@@ -80,7 +95,9 @@ export function registerAnkiJimakuIpcRuntime(
if (enabled && !ankiIntegration && subtitleTimingTracker && mpvClient) { if (enabled && !ankiIntegration && subtitleTimingTracker && mpvClient) {
const runtimeOptionsManager = options.getRuntimeOptionsManager(); const runtimeOptionsManager = options.getRuntimeOptionsManager();
const effectiveAnkiConfig = runtimeOptionsManager const effectiveAnkiConfig = runtimeOptionsManager
? runtimeOptionsManager.getEffectiveAnkiConnectConfig(config.ankiConnect) ? runtimeOptionsManager.getEffectiveAnkiConnectConfig(
config.ankiConnect,
)
: config.ankiConnect; : config.ankiConnect;
const integration = new AnkiIntegration( const integration = new AnkiIntegration(
effectiveAnkiConfig as never, effectiveAnkiConfig as never,
@@ -140,7 +157,8 @@ export function registerAnkiJimakuIpcRuntime(
request.deleteDuplicate, request.deleteDuplicate,
); );
}, },
getJimakuMediaInfo: () => options.parseMediaInfo(options.getCurrentMediaPath()), getJimakuMediaInfo: () =>
options.parseMediaInfo(options.getCurrentMediaPath()),
searchJimakuEntries: async (query) => { searchJimakuEntries: async (query) => {
logger.info(`[jimaku] search-entries query: "${query.query}"`); logger.info(`[jimaku] search-entries query: "${query.query}"`);
const response = await options.jimakuFetchJson<JimakuEntry[]>( const response = await options.jimakuFetchJson<JimakuEntry[]>(

View File

@@ -8,7 +8,9 @@ export interface AppLifecycleServiceDeps {
parseArgs: (argv: string[]) => CliArgs; parseArgs: (argv: string[]) => CliArgs;
requestSingleInstanceLock: () => boolean; requestSingleInstanceLock: () => boolean;
quitApp: () => void; quitApp: () => void;
onSecondInstance: (handler: (_event: unknown, argv: string[]) => void) => void; onSecondInstance: (
handler: (_event: unknown, argv: string[]) => void,
) => void;
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void; handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void; printHelp: () => void;
logNoRunningInstance: () => void; logNoRunningInstance: () => void;
@@ -53,18 +55,27 @@ export function createAppLifecycleDepsRuntime(
requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(), requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(),
quitApp: () => options.app.quit(), quitApp: () => options.app.quit(),
onSecondInstance: (handler) => { onSecondInstance: (handler) => {
options.app.on("second-instance", handler as (...args: unknown[]) => void); options.app.on(
"second-instance",
handler as (...args: unknown[]) => void,
);
}, },
handleCliCommand: options.handleCliCommand, handleCliCommand: options.handleCliCommand,
printHelp: options.printHelp, printHelp: options.printHelp,
logNoRunningInstance: options.logNoRunningInstance, logNoRunningInstance: options.logNoRunningInstance,
whenReady: (handler) => { whenReady: (handler) => {
options.app.whenReady().then(handler).catch((error) => { options.app
logger.error("App ready handler failed:", error); .whenReady()
}); .then(handler)
.catch((error) => {
logger.error("App ready handler failed:", error);
});
}, },
onWindowAllClosed: (handler) => { onWindowAllClosed: (handler) => {
options.app.on("window-all-closed", handler as (...args: unknown[]) => void); options.app.on(
"window-all-closed",
handler as (...args: unknown[]) => void,
);
}, },
onWillQuit: (handler) => { onWillQuit: (handler) => {
options.app.on("will-quit", handler as (...args: unknown[]) => void); options.app.on("will-quit", handler as (...args: unknown[]) => void);

View File

@@ -9,22 +9,31 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
resolveKeybindings: () => calls.push("resolveKeybindings"), resolveKeybindings: () => calls.push("resolveKeybindings"),
createMpvClient: () => calls.push("createMpvClient"), createMpvClient: () => calls.push("createMpvClient"),
reloadConfig: () => calls.push("reloadConfig"), reloadConfig: () => calls.push("reloadConfig"),
getResolvedConfig: () => ({ websocket: { enabled: "auto" }, secondarySub: {} }), getResolvedConfig: () => ({
websocket: { enabled: "auto" },
secondarySub: {},
}),
getConfigWarnings: () => [], getConfigWarnings: () => [],
logConfigWarning: () => calls.push("logConfigWarning"), logConfigWarning: () => calls.push("logConfigWarning"),
setLogLevel: (level, source) => calls.push(`setLogLevel:${level}:${source}`), setLogLevel: (level, source) =>
calls.push(`setLogLevel:${level}:${source}`),
initRuntimeOptionsManager: () => calls.push("initRuntimeOptionsManager"), initRuntimeOptionsManager: () => calls.push("initRuntimeOptionsManager"),
setSecondarySubMode: (mode) => calls.push(`setSecondarySubMode:${mode}`), setSecondarySubMode: (mode) => calls.push(`setSecondarySubMode:${mode}`),
defaultSecondarySubMode: "hover", defaultSecondarySubMode: "hover",
defaultWebsocketPort: 9001, defaultWebsocketPort: 9001,
hasMpvWebsocketPlugin: () => true, hasMpvWebsocketPlugin: () => true,
startSubtitleWebsocket: (port) => calls.push(`startSubtitleWebsocket:${port}`), startSubtitleWebsocket: (port) =>
calls.push(`startSubtitleWebsocket:${port}`),
log: (message) => calls.push(`log:${message}`), log: (message) => calls.push(`log:${message}`),
createMecabTokenizerAndCheck: async () => { createMecabTokenizerAndCheck: async () => {
calls.push("createMecabTokenizerAndCheck"); calls.push("createMecabTokenizerAndCheck");
}, },
createSubtitleTimingTracker: () => calls.push("createSubtitleTimingTracker"), createSubtitleTimingTracker: () =>
calls.push("createSubtitleTimingTracker"),
createImmersionTracker: () => calls.push("createImmersionTracker"), createImmersionTracker: () => calls.push("createImmersionTracker"),
startJellyfinRemoteSession: async () => {
calls.push("startJellyfinRemoteSession");
},
loadYomitanExtension: async () => { loadYomitanExtension: async () => {
calls.push("loadYomitanExtension"); calls.push("loadYomitanExtension");
}, },
@@ -45,16 +54,37 @@ test("runAppReadyRuntime starts websocket in auto mode when plugin missing", asy
assert.ok(calls.includes("startSubtitleWebsocket:9001")); assert.ok(calls.includes("startSubtitleWebsocket:9001"));
assert.ok(calls.includes("initializeOverlayRuntime")); assert.ok(calls.includes("initializeOverlayRuntime"));
assert.ok(calls.includes("createImmersionTracker")); assert.ok(calls.includes("createImmersionTracker"));
assert.ok(calls.includes("startJellyfinRemoteSession"));
assert.ok( assert.ok(
calls.includes("log:Runtime ready: invoking createImmersionTracker."), calls.includes("log:Runtime ready: invoking createImmersionTracker."),
); );
}); });
test("runAppReadyRuntimeService logs when createImmersionTracker dependency is missing", async () => { test("runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired", async () => {
const { deps, calls } = makeDeps({
startJellyfinRemoteSession: undefined,
});
await runAppReadyRuntime(deps);
assert.equal(calls.includes("startJellyfinRemoteSession"), false);
assert.ok(calls.includes("createMecabTokenizerAndCheck"));
assert.ok(calls.includes("createMpvClient"));
assert.ok(calls.includes("createSubtitleTimingTracker"));
assert.ok(calls.includes("handleInitialArgs"));
assert.ok(
calls.includes("initializeOverlayRuntime") ||
calls.includes(
"log:Overlay runtime deferred: waiting for explicit overlay command.",
),
);
});
test("runAppReadyRuntime logs when createImmersionTracker dependency is missing", async () => {
const { deps, calls } = makeDeps({ const { deps, calls } = makeDeps({
createImmersionTracker: undefined, createImmersionTracker: undefined,
}); });
await runAppReadyRuntimeService(deps); await runAppReadyRuntime(deps);
assert.ok( assert.ok(
calls.includes( calls.includes(
"log:Runtime ready: createImmersionTracker dependency is missing.", "log:Runtime ready: createImmersionTracker dependency is missing.",
@@ -62,14 +92,14 @@ test("runAppReadyRuntimeService logs when createImmersionTracker dependency is m
); );
}); });
test("runAppReadyRuntimeService logs and continues when createImmersionTracker throws", async () => { test("runAppReadyRuntime logs and continues when createImmersionTracker throws", async () => {
const { deps, calls } = makeDeps({ const { deps, calls } = makeDeps({
createImmersionTracker: () => { createImmersionTracker: () => {
calls.push("createImmersionTracker"); calls.push("createImmersionTracker");
throw new Error("immersion init failed"); throw new Error("immersion init failed");
}, },
}); });
await runAppReadyRuntimeService(deps); await runAppReadyRuntime(deps);
assert.ok(calls.includes("createImmersionTracker")); assert.ok(calls.includes("createImmersionTracker"));
assert.ok( assert.ok(
calls.includes( calls.includes(

View File

@@ -8,8 +8,9 @@ test("createFieldGroupingOverlayRuntime sends overlay messages and sets restore
let visible = false; let visible = false;
const restore = new Set<"runtime-options" | "subsync">(); const restore = new Set<"runtime-options" | "subsync">();
const runtime = const runtime = createFieldGroupingOverlayRuntime<
createFieldGroupingOverlayRuntime<"runtime-options" | "subsync">({ "runtime-options" | "subsync"
>({
getMainWindow: () => ({ getMainWindow: () => ({
isDestroyed: () => false, isDestroyed: () => false,
webContents: { webContents: {
@@ -28,7 +29,7 @@ test("createFieldGroupingOverlayRuntime sends overlay messages and sets restore
getResolver: () => null, getResolver: () => null,
setResolver: () => {}, setResolver: () => {},
getRestoreVisibleOverlayOnModalClose: () => restore, getRestoreVisibleOverlayOnModalClose: () => restore,
}); });
const ok = runtime.sendToVisibleOverlay("runtime-options:open", undefined, { const ok = runtime.sendToVisibleOverlay("runtime-options:open", undefined, {
restoreOnModalClose: "runtime-options", restoreOnModalClose: "runtime-options",
@@ -42,20 +43,21 @@ test("createFieldGroupingOverlayRuntime sends overlay messages and sets restore
test("createFieldGroupingOverlayRuntime callback cancels when send fails", async () => { test("createFieldGroupingOverlayRuntime callback cancels when send fails", async () => {
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null; let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
const runtime = const runtime = createFieldGroupingOverlayRuntime<
createFieldGroupingOverlayRuntime<"runtime-options" | "subsync">({ "runtime-options" | "subsync"
getMainWindow: () => null, >({
getVisibleOverlayVisible: () => false, getMainWindow: () => null,
getInvisibleOverlayVisible: () => false, getVisibleOverlayVisible: () => false,
setVisibleOverlayVisible: () => {}, getInvisibleOverlayVisible: () => false,
setInvisibleOverlayVisible: () => {}, setVisibleOverlayVisible: () => {},
getResolver: () => resolver, setInvisibleOverlayVisible: () => {},
setResolver: (next) => { getResolver: () => resolver,
resolver = next; setResolver: (next) => {
}, resolver = next;
getRestoreVisibleOverlayOnModalClose: () => },
new Set<"runtime-options" | "subsync">(), getRestoreVisibleOverlayOnModalClose: () =>
}); new Set<"runtime-options" | "subsync">(),
});
const callback = runtime.createFieldGroupingCallback(); const callback = runtime.createFieldGroupingCallback();
const result = await callback({ const result = await callback({

View File

@@ -9,7 +9,9 @@ export function createFieldGroupingCallback(options: {
setVisibleOverlayVisible: (visible: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void; setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; setResolver: (
resolver: ((choice: KikuFieldGroupingChoice) => void) | null,
) => void;
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean; sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> { }): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return async ( return async (

View File

@@ -8,7 +8,9 @@ import { createFrequencyDictionaryLookup } from "./frequency-dictionary";
test("createFrequencyDictionaryLookup logs parse errors and returns no-op for invalid dictionaries", async () => { test("createFrequencyDictionaryLookup logs parse errors and returns no-op for invalid dictionaries", async () => {
const logs: string[] = []; const logs: string[] = [];
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "subminer-frequency-dict-")); const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), "subminer-frequency-dict-"),
);
const bankPath = path.join(tempDir, "term_meta_bank_1.json"); const bankPath = path.join(tempDir, "term_meta_bank_1.json");
fs.writeFileSync(bankPath, "{ invalid json"); fs.writeFileSync(bankPath, "{ invalid json");
@@ -23,9 +25,10 @@ test("createFrequencyDictionaryLookup logs parse errors and returns no-op for in
assert.equal(rank, null); assert.equal(rank, null);
assert.equal( assert.equal(
logs.some((entry) => logs.some(
entry.includes("Failed to parse frequency dictionary file as JSON") && (entry) =>
entry.includes("term_meta_bank_1.json") entry.includes("Failed to parse frequency dictionary file as JSON") &&
entry.includes("term_meta_bank_1.json"),
), ),
true, true,
); );
@@ -33,7 +36,10 @@ test("createFrequencyDictionaryLookup logs parse errors and returns no-op for in
test("createFrequencyDictionaryLookup continues with no-op lookup when search path is missing", async () => { test("createFrequencyDictionaryLookup continues with no-op lookup when search path is missing", async () => {
const logs: string[] = []; const logs: string[] = [];
const missingPath = path.join(os.tmpdir(), "subminer-frequency-dict-missing-dir"); const missingPath = path.join(
os.tmpdir(),
"subminer-frequency-dict-missing-dir",
);
const lookup = await createFrequencyDictionaryLookup({ const lookup = await createFrequencyDictionaryLookup({
searchPaths: [missingPath], searchPaths: [missingPath],
log: (message) => { log: (message) => {

View File

@@ -44,11 +44,7 @@ function asFrequencyDictionaryEntry(
return null; return null;
} }
const [term, _id, meta] = entry as [ const [term, _id, meta] = entry as [unknown, unknown, unknown];
unknown,
unknown,
unknown,
];
if (typeof term !== "string") { if (typeof term !== "string") {
return null; return null;
} }

View File

@@ -3,11 +3,36 @@ import assert from "node:assert/strict";
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { DatabaseSync } from "node:sqlite"; import type { DatabaseSync as NodeDatabaseSync } from "node:sqlite";
import { ImmersionTrackerService } from "./immersion-tracker-service";
type ImmersionTrackerService = import("./immersion-tracker-service").ImmersionTrackerService;
type ImmersionTrackerServiceCtor = typeof import("./immersion-tracker-service").ImmersionTrackerService;
type DatabaseSyncCtor = typeof NodeDatabaseSync;
const DatabaseSync: DatabaseSyncCtor | null = (() => {
try {
return (
require("node:sqlite") as { DatabaseSync?: DatabaseSyncCtor }
).DatabaseSync ?? null;
} catch {
return null;
}
})();
const testIfSqlite = DatabaseSync ? test : test.skip;
let trackerCtor: ImmersionTrackerServiceCtor | null = null;
async function loadTrackerCtor(): Promise<ImmersionTrackerServiceCtor> {
if (trackerCtor) return trackerCtor;
const mod = await import("./immersion-tracker-service");
trackerCtor = mod.ImmersionTrackerService;
return trackerCtor;
}
function makeDbPath(): string { function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "subminer-immersion-test-")); const dir = fs.mkdtempSync(
path.join(os.tmpdir(), "subminer-immersion-test-"),
);
return path.join(dir, "immersion.sqlite"); return path.join(dir, "immersion.sqlite");
} }
@@ -18,12 +43,13 @@ function cleanupDbPath(dbPath: string): void {
} }
} }
test("startSession generates UUID-like session identifiers", () => { testIfSqlite("startSession generates UUID-like session identifiers", async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;
try { try {
tracker = new ImmersionTrackerService({ dbPath }); const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange("/tmp/episode.mkv", "Episode"); tracker.handleMediaChange("/tmp/episode.mkv", "Episode");
const privateApi = tracker as unknown as { const privateApi = tracker as unknown as {
@@ -33,7 +59,7 @@ test("startSession generates UUID-like session identifiers", () => {
privateApi.flushTelemetry(true); privateApi.flushTelemetry(true);
privateApi.flushNow(); privateApi.flushNow();
const db = new DatabaseSync(dbPath); const db = new DatabaseSync!(dbPath);
const row = db const row = db
.prepare("SELECT session_uuid FROM imm_sessions LIMIT 1") .prepare("SELECT session_uuid FROM imm_sessions LIMIT 1")
.get() as { session_uuid: string } | null; .get() as { session_uuid: string } | null;
@@ -48,18 +74,19 @@ test("startSession generates UUID-like session identifiers", () => {
} }
}); });
test("destroy finalizes active session and persists final telemetry", () => { testIfSqlite("destroy finalizes active session and persists final telemetry", async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;
try { try {
tracker = new ImmersionTrackerService({ dbPath }); const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange("/tmp/episode-2.mkv", "Episode 2"); tracker.handleMediaChange("/tmp/episode-2.mkv", "Episode 2");
tracker.recordSubtitleLine("Hello immersion", 0, 1); tracker.recordSubtitleLine("Hello immersion", 0, 1);
tracker.destroy(); tracker.destroy();
const db = new DatabaseSync(dbPath); const db = new DatabaseSync!(dbPath);
const sessionRow = db const sessionRow = db
.prepare("SELECT ended_at_ms FROM imm_sessions LIMIT 1") .prepare("SELECT ended_at_ms FROM imm_sessions LIMIT 1")
.get() as { ended_at_ms: number | null } | null; .get() as { ended_at_ms: number | null } | null;
@@ -77,14 +104,137 @@ test("destroy finalizes active session and persists final telemetry", () => {
} }
}); });
test("monthly rollups are grouped by calendar month", async () => { testIfSqlite("persists and retrieves minimum immersion tracking fields", async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;
try { try {
tracker = new ImmersionTrackerService({ dbPath }); const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange("/tmp/episode-3.mkv", "Episode 3");
tracker.recordSubtitleLine("alpha beta", 0, 1.2);
tracker.recordCardsMined(2);
tracker.recordLookup(true);
tracker.recordPlaybackPosition(12.5);
const privateApi = tracker as unknown as { const privateApi = tracker as unknown as {
db: DatabaseSync; flushTelemetry: (force?: boolean) => void;
flushNow: () => void;
};
privateApi.flushTelemetry(true);
privateApi.flushNow();
const summaries = await tracker.getSessionSummaries(10);
assert.ok(summaries.length >= 1);
assert.ok(summaries[0].linesSeen >= 1);
assert.ok(summaries[0].cardsMined >= 2);
tracker.destroy();
const db = new DatabaseSync!(dbPath);
const videoRow = db
.prepare(
"SELECT canonical_title, source_path, duration_ms FROM imm_videos LIMIT 1",
)
.get() as {
canonical_title: string;
source_path: string | null;
duration_ms: number;
} | null;
const telemetryRow = db
.prepare(
`SELECT lines_seen, words_seen, tokens_seen, cards_mined
FROM imm_session_telemetry
ORDER BY sample_ms DESC
LIMIT 1`,
)
.get() as {
lines_seen: number;
words_seen: number;
tokens_seen: number;
cards_mined: number;
} | null;
db.close();
assert.ok(videoRow);
assert.equal(videoRow?.canonical_title, "Episode 3");
assert.equal(videoRow?.source_path, "/tmp/episode-3.mkv");
assert.ok(Number(videoRow?.duration_ms ?? -1) >= 0);
assert.ok(telemetryRow);
assert.ok(Number(telemetryRow?.lines_seen ?? 0) >= 1);
assert.ok(Number(telemetryRow?.words_seen ?? 0) >= 2);
assert.ok(Number(telemetryRow?.tokens_seen ?? 0) >= 2);
assert.ok(Number(telemetryRow?.cards_mined ?? 0) >= 2);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
testIfSqlite("applies configurable queue, flush, and retention policy", async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({
dbPath,
policy: {
batchSize: 10,
flushIntervalMs: 250,
queueCap: 1500,
payloadCapBytes: 512,
maintenanceIntervalMs: 2 * 60 * 60 * 1000,
retention: {
eventsDays: 14,
telemetryDays: 45,
dailyRollupsDays: 730,
monthlyRollupsDays: 3650,
vacuumIntervalDays: 14,
},
},
});
const privateApi = tracker as unknown as {
batchSize: number;
flushIntervalMs: number;
queueCap: number;
maxPayloadBytes: number;
maintenanceIntervalMs: number;
eventsRetentionMs: number;
telemetryRetentionMs: number;
dailyRollupRetentionMs: number;
monthlyRollupRetentionMs: number;
vacuumIntervalMs: number;
};
assert.equal(privateApi.batchSize, 10);
assert.equal(privateApi.flushIntervalMs, 250);
assert.equal(privateApi.queueCap, 1500);
assert.equal(privateApi.maxPayloadBytes, 512);
assert.equal(privateApi.maintenanceIntervalMs, 7_200_000);
assert.equal(privateApi.eventsRetentionMs, 14 * 86_400_000);
assert.equal(privateApi.telemetryRetentionMs, 45 * 86_400_000);
assert.equal(privateApi.dailyRollupRetentionMs, 730 * 86_400_000);
assert.equal(privateApi.monthlyRollupRetentionMs, 3650 * 86_400_000);
assert.equal(privateApi.vacuumIntervalMs, 14 * 86_400_000);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
testIfSqlite("monthly rollups are grouped by calendar month", async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as {
db: NodeDatabaseSync;
runRollupMaintenance: () => void; runRollupMaintenance: () => void;
}; };
@@ -239,15 +389,16 @@ test("monthly rollups are grouped by calendar month", async () => {
} }
}); });
test("flushSingle reuses cached prepared statements", () => { testIfSqlite("flushSingle reuses cached prepared statements", async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;
let originalPrepare: DatabaseSync["prepare"] | null = null; let originalPrepare: NodeDatabaseSync["prepare"] | null = null;
try { try {
tracker = new ImmersionTrackerService({ dbPath }); const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { const privateApi = tracker as unknown as {
db: DatabaseSync; db: NodeDatabaseSync;
flushSingle: (write: { flushSingle: (write: {
kind: "telemetry" | "event"; kind: "telemetry" | "event";
sessionId: number; sessionId: number;
@@ -277,7 +428,7 @@ test("flushSingle reuses cached prepared statements", () => {
originalPrepare = privateApi.db.prepare; originalPrepare = privateApi.db.prepare;
let prepareCalls = 0; let prepareCalls = 0;
privateApi.db.prepare = (...args: Parameters<DatabaseSync["prepare"]>) => { privateApi.db.prepare = (...args: Parameters<NodeDatabaseSync["prepare"]>) => {
prepareCalls += 1; prepareCalls += 1;
return originalPrepare!.apply(privateApi.db, args); return originalPrepare!.apply(privateApi.db, args);
}; };
@@ -362,7 +513,7 @@ test("flushSingle reuses cached prepared statements", () => {
assert.equal(prepareCalls, 0); assert.equal(prepareCalls, 0);
} finally { } finally {
if (tracker && originalPrepare) { if (tracker && originalPrepare) {
const privateApi = tracker as unknown as { db: DatabaseSync }; const privateApi = tracker as unknown as { db: NodeDatabaseSync };
privateApi.db.prepare = originalPrepare; privateApi.db.prepare = originalPrepare;
} }
tracker?.destroy(); tracker?.destroy();

View File

@@ -11,12 +11,12 @@ const DEFAULT_BATCH_SIZE = 25;
const DEFAULT_FLUSH_INTERVAL_MS = 500; const DEFAULT_FLUSH_INTERVAL_MS = 500;
const DEFAULT_MAINTENANCE_INTERVAL_MS = 24 * 60 * 60 * 1000; const DEFAULT_MAINTENANCE_INTERVAL_MS = 24 * 60 * 60 * 1000;
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
const EVENTS_RETENTION_MS = ONE_WEEK_MS; const DEFAULT_EVENTS_RETENTION_MS = ONE_WEEK_MS;
const VACUUM_INTERVAL_MS = ONE_WEEK_MS; const DEFAULT_VACUUM_INTERVAL_MS = ONE_WEEK_MS;
const TELEMETRY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; const DEFAULT_TELEMETRY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const DAILY_ROLLUP_RETENTION_MS = 365 * 24 * 60 * 60 * 1000; const DEFAULT_DAILY_ROLLUP_RETENTION_MS = 365 * 24 * 60 * 60 * 1000;
const MONTHLY_ROLLUP_RETENTION_MS = 5 * 365 * 24 * 60 * 60 * 1000; const DEFAULT_MONTHLY_ROLLUP_RETENTION_MS = 5 * 365 * 24 * 60 * 60 * 1000;
const MAX_PAYLOAD_BYTES = 256; const DEFAULT_MAX_PAYLOAD_BYTES = 256;
const SOURCE_TYPE_LOCAL = 1; const SOURCE_TYPE_LOCAL = 1;
const SOURCE_TYPE_REMOTE = 2; const SOURCE_TYPE_REMOTE = 2;
@@ -35,6 +35,22 @@ const EVENT_PAUSE_END = 8;
export interface ImmersionTrackerOptions { export interface ImmersionTrackerOptions {
dbPath: string; dbPath: string;
policy?: ImmersionTrackerPolicy;
}
export interface ImmersionTrackerPolicy {
queueCap?: number;
batchSize?: number;
flushIntervalMs?: number;
maintenanceIntervalMs?: number;
payloadCapBytes?: number;
retention?: {
eventsDays?: number;
telemetryDays?: number;
dailyRollupsDays?: number;
monthlyRollupsDays?: number;
vacuumIntervalDays?: number;
};
} }
interface TelemetryAccumulator { interface TelemetryAccumulator {
@@ -154,6 +170,12 @@ export class ImmersionTrackerService {
private readonly batchSize: number; private readonly batchSize: number;
private readonly flushIntervalMs: number; private readonly flushIntervalMs: number;
private readonly maintenanceIntervalMs: number; private readonly maintenanceIntervalMs: number;
private readonly maxPayloadBytes: number;
private readonly eventsRetentionMs: number;
private readonly telemetryRetentionMs: number;
private readonly dailyRollupRetentionMs: number;
private readonly monthlyRollupRetentionMs: number;
private readonly vacuumIntervalMs: number;
private readonly dbPath: string; private readonly dbPath: string;
private readonly writeLock = { locked: false }; private readonly writeLock = { locked: false };
private flushTimer: ReturnType<typeof setTimeout> | null = null; private flushTimer: ReturnType<typeof setTimeout> | null = null;
@@ -177,10 +199,69 @@ export class ImmersionTrackerService {
fs.mkdirSync(parentDir, { recursive: true }); fs.mkdirSync(parentDir, { recursive: true });
} }
this.queueCap = DEFAULT_QUEUE_CAP; const policy = options.policy ?? {};
this.batchSize = DEFAULT_BATCH_SIZE; this.queueCap = this.resolveBoundedInt(
this.flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS; policy.queueCap,
this.maintenanceIntervalMs = DEFAULT_MAINTENANCE_INTERVAL_MS; DEFAULT_QUEUE_CAP,
100,
100_000,
);
this.batchSize = this.resolveBoundedInt(
policy.batchSize,
DEFAULT_BATCH_SIZE,
1,
10_000,
);
this.flushIntervalMs = this.resolveBoundedInt(
policy.flushIntervalMs,
DEFAULT_FLUSH_INTERVAL_MS,
50,
60_000,
);
this.maintenanceIntervalMs = this.resolveBoundedInt(
policy.maintenanceIntervalMs,
DEFAULT_MAINTENANCE_INTERVAL_MS,
60_000,
7 * 24 * 60 * 60 * 1000,
);
this.maxPayloadBytes = this.resolveBoundedInt(
policy.payloadCapBytes,
DEFAULT_MAX_PAYLOAD_BYTES,
64,
8192,
);
const retention = policy.retention ?? {};
this.eventsRetentionMs = this.resolveBoundedInt(
retention.eventsDays,
Math.floor(DEFAULT_EVENTS_RETENTION_MS / 86_400_000),
1,
3650,
) * 86_400_000;
this.telemetryRetentionMs = this.resolveBoundedInt(
retention.telemetryDays,
Math.floor(DEFAULT_TELEMETRY_RETENTION_MS / 86_400_000),
1,
3650,
) * 86_400_000;
this.dailyRollupRetentionMs = this.resolveBoundedInt(
retention.dailyRollupsDays,
Math.floor(DEFAULT_DAILY_ROLLUP_RETENTION_MS / 86_400_000),
1,
36500,
) * 86_400_000;
this.monthlyRollupRetentionMs = this.resolveBoundedInt(
retention.monthlyRollupsDays,
Math.floor(DEFAULT_MONTHLY_ROLLUP_RETENTION_MS / 86_400_000),
1,
36500,
) * 86_400_000;
this.vacuumIntervalMs = this.resolveBoundedInt(
retention.vacuumIntervalDays,
Math.floor(DEFAULT_VACUUM_INTERVAL_MS / 86_400_000),
1,
3650,
) * 86_400_000;
this.lastMaintenanceMs = Date.now(); this.lastMaintenanceMs = Date.now();
this.db = new DatabaseSync(this.dbPath); this.db = new DatabaseSync(this.dbPath);
@@ -223,9 +304,7 @@ export class ImmersionTrackerService {
this.db.close(); this.db.close();
} }
async getSessionSummaries( async getSessionSummaries(limit = 50): Promise<SessionSummaryQueryRow[]> {
limit = 50,
): Promise<SessionSummaryQueryRow[]> {
const prepared = this.db.prepare(` const prepared = this.db.prepare(`
SELECT SELECT
s.video_id AS videoId, s.video_id AS videoId,
@@ -273,7 +352,9 @@ export class ImmersionTrackerService {
totalSessions: number; totalSessions: number;
activeSessions: number; activeSessions: number;
}> { }> {
const sessions = this.db.prepare("SELECT COUNT(*) AS total FROM imm_sessions"); const sessions = this.db.prepare(
"SELECT COUNT(*) AS total FROM imm_sessions",
);
const active = this.db.prepare( const active = this.db.prepare(
"SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NULL", "SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NULL",
); );
@@ -282,9 +363,7 @@ export class ImmersionTrackerService {
return { totalSessions, activeSessions }; return { totalSessions, activeSessions };
} }
async getDailyRollups( async getDailyRollups(limit = 60): Promise<ImmersionSessionRollupRow[]> {
limit = 60,
): Promise<ImmersionSessionRollupRow[]> {
const prepared = this.db.prepare(` const prepared = this.db.prepare(`
SELECT SELECT
rollup_day AS rollupDayOrMonth, rollup_day AS rollupDayOrMonth,
@@ -305,9 +384,7 @@ export class ImmersionTrackerService {
return prepared.all(limit) as unknown as ImmersionSessionRollupRow[]; return prepared.all(limit) as unknown as ImmersionSessionRollupRow[];
} }
async getMonthlyRollups( async getMonthlyRollups(limit = 24): Promise<ImmersionSessionRollupRow[]> {
limit = 24,
): Promise<ImmersionSessionRollupRow[]> {
const prepared = this.db.prepare(` const prepared = this.db.prepare(`
SELECT SELECT
rollup_month AS rollupDayOrMonth, rollup_month AS rollupDayOrMonth,
@@ -352,9 +429,12 @@ export class ImmersionTrackerService {
return; return;
} }
const sourceType = this.isRemoteSource(normalizedPath) ? SOURCE_TYPE_REMOTE : SOURCE_TYPE_LOCAL; const sourceType = this.isRemoteSource(normalizedPath)
? SOURCE_TYPE_REMOTE
: SOURCE_TYPE_LOCAL;
const videoKey = this.buildVideoKey(normalizedPath, sourceType); const videoKey = this.buildVideoKey(normalizedPath, sourceType);
const canonicalTitle = normalizedTitle || this.deriveCanonicalTitle(normalizedPath); const canonicalTitle =
normalizedTitle || this.deriveCanonicalTitle(normalizedPath);
const sourcePath = sourceType === SOURCE_TYPE_LOCAL ? normalizedPath : null; const sourcePath = sourceType === SOURCE_TYPE_LOCAL ? normalizedPath : null;
const sourceUrl = sourceType === SOURCE_TYPE_REMOTE ? normalizedPath : null; const sourceUrl = sourceType === SOURCE_TYPE_REMOTE ? normalizedPath : null;
@@ -372,7 +452,11 @@ export class ImmersionTrackerService {
`Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`, `Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`,
); );
this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs); this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs);
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath); this.captureVideoMetadataAsync(
sessionInfo.videoId,
sourceType,
normalizedPath,
);
} }
handleMediaTitleUpdate(mediaTitle: string | null): void { handleMediaTitleUpdate(mediaTitle: string | null): void {
@@ -383,11 +467,7 @@ export class ImmersionTrackerService {
this.updateVideoTitleForActiveSession(normalizedTitle); this.updateVideoTitleForActiveSession(normalizedTitle);
} }
recordSubtitleLine( recordSubtitleLine(text: string, startSec: number, endSec: number): void {
text: string,
startSec: number,
endSec: number,
): void {
if (!this.sessionState || !text.trim()) return; if (!this.sessionState || !text.trim()) return;
const cleaned = this.normalizeText(text); const cleaned = this.normalizeText(text);
if (!cleaned) return; if (!cleaned) return;
@@ -418,7 +498,11 @@ export class ImmersionTrackerService {
} }
recordPlaybackPosition(mediaTimeSec: number | null): void { recordPlaybackPosition(mediaTimeSec: number | null): void {
if (!this.sessionState || mediaTimeSec === null || !Number.isFinite(mediaTimeSec)) { if (
!this.sessionState ||
mediaTimeSec === null ||
!Number.isFinite(mediaTimeSec)
) {
return; return;
} }
const nowMs = Date.now(); const nowMs = Date.now();
@@ -637,7 +721,10 @@ export class ImmersionTrackerService {
return; return;
} }
const batch = this.queue.splice(0, Math.min(this.batchSize, this.queue.length)); const batch = this.queue.splice(
0,
Math.min(this.batchSize, this.queue.length),
);
this.writeLock.locked = true; this.writeLock.locked = true;
try { try {
this.db.exec("BEGIN IMMEDIATE"); this.db.exec("BEGIN IMMEDIATE");
@@ -648,7 +735,10 @@ export class ImmersionTrackerService {
} catch (error) { } catch (error) {
this.db.exec("ROLLBACK"); this.db.exec("ROLLBACK");
this.queue.unshift(...batch); this.queue.unshift(...batch);
this.logger.warn("Immersion tracker flush failed, retrying later", error as Error); this.logger.warn(
"Immersion tracker flush failed, retrying later",
error as Error,
);
} finally { } finally {
this.writeLock.locked = false; this.writeLock.locked = false;
this.flushScheduled = false; this.flushScheduled = false;
@@ -850,6 +940,18 @@ export class ImmersionTrackerService {
`); `);
} }
private resolveBoundedInt(
value: number | undefined,
fallback: number,
min: number,
max: number,
): number {
if (!Number.isFinite(value)) return fallback;
const candidate = Math.floor(value as number);
if (candidate < min || candidate > max) return fallback;
return candidate;
}
private scheduleMaintenance(): void { private scheduleMaintenance(): void {
this.maintenanceTimer = setInterval(() => { this.maintenanceTimer = setInterval(() => {
this.runMaintenance(); this.runMaintenance();
@@ -863,26 +965,33 @@ export class ImmersionTrackerService {
this.flushTelemetry(true); this.flushTelemetry(true);
this.flushNow(); this.flushNow();
const nowMs = Date.now(); const nowMs = Date.now();
const eventCutoff = nowMs - EVENTS_RETENTION_MS; const eventCutoff = nowMs - this.eventsRetentionMs;
const telemetryCutoff = nowMs - TELEMETRY_RETENTION_MS; const telemetryCutoff = nowMs - this.telemetryRetentionMs;
const dailyCutoff = nowMs - DAILY_ROLLUP_RETENTION_MS; const dailyCutoff = nowMs - this.dailyRollupRetentionMs;
const monthlyCutoff = nowMs - MONTHLY_ROLLUP_RETENTION_MS; const monthlyCutoff = nowMs - this.monthlyRollupRetentionMs;
const dayCutoff = Math.floor(dailyCutoff / 86_400_000); const dayCutoff = Math.floor(dailyCutoff / 86_400_000);
const monthCutoff = this.toMonthKey(monthlyCutoff); const monthCutoff = this.toMonthKey(monthlyCutoff);
this.db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff);
this.db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff);
this.db.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`).run(dayCutoff);
this.db.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`).run(monthCutoff);
this.db this.db
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`) .prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
.run(eventCutoff);
this.db
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
.run(telemetryCutoff);
this.db
.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`)
.run(dayCutoff);
this.db
.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`)
.run(monthCutoff);
this.db
.prepare(
`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`,
)
.run(telemetryCutoff); .run(telemetryCutoff);
this.runRollupMaintenance(); this.runRollupMaintenance();
if ( if (nowMs - this.lastVacuumMs >= this.vacuumIntervalMs && !this.writeLock.locked) {
nowMs - this.lastVacuumMs >= VACUUM_INTERVAL_MS
&& !this.writeLock.locked
) {
this.db.exec("VACUUM"); this.db.exec("VACUUM");
this.lastVacuumMs = nowMs; this.lastVacuumMs = nowMs;
} }
@@ -1007,16 +1116,21 @@ export class ImmersionTrackerService {
this.scheduleFlush(0); this.scheduleFlush(0);
} }
private startSessionStatement(videoId: number, startedAtMs: number): { private startSessionStatement(
videoId: number,
startedAtMs: number,
): {
lastInsertRowid: number | bigint; lastInsertRowid: number | bigint;
} { } {
const sessionUuid = crypto.randomUUID(); const sessionUuid = crypto.randomUUID();
return this.db return this.db
.prepare(` .prepare(
`
INSERT INTO imm_sessions ( INSERT INTO imm_sessions (
session_uuid, video_id, started_at_ms, status, created_at_ms, updated_at_ms session_uuid, video_id, started_at_ms, status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?)
`) `,
)
.run( .run(
sessionUuid, sessionUuid,
videoId, videoId,
@@ -1055,16 +1169,24 @@ export class ImmersionTrackerService {
.prepare( .prepare(
"UPDATE imm_sessions SET ended_at_ms = ?, status = ?, updated_at_ms = ? WHERE session_id = ?", "UPDATE imm_sessions SET ended_at_ms = ?, status = ?, updated_at_ms = ? WHERE session_id = ?",
) )
.run(endedAt, SESSION_STATUS_ENDED, Date.now(), this.sessionState.sessionId); .run(
endedAt,
SESSION_STATUS_ENDED,
Date.now(),
this.sessionState.sessionId,
);
this.sessionState = null; this.sessionState = null;
} }
private getOrCreateVideo(videoKey: string, details: { private getOrCreateVideo(
canonicalTitle: string; videoKey: string,
sourcePath: string | null; details: {
sourceUrl: string | null; canonicalTitle: string;
sourceType: number; sourcePath: string | null;
}): number { sourceUrl: string | null;
sourceType: number;
},
): number {
const existing = this.db const existing = this.db
.prepare("SELECT video_id FROM imm_videos WHERE video_key = ?") .prepare("SELECT video_id FROM imm_videos WHERE video_key = ?")
.get(videoKey) as { video_id: number } | null; .get(videoKey) as { video_id: number } | null;
@@ -1073,7 +1195,11 @@ export class ImmersionTrackerService {
.prepare( .prepare(
"UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?", "UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?",
) )
.run(details.canonicalTitle || "unknown", Date.now(), existing.video_id); .run(
details.canonicalTitle || "unknown",
Date.now(),
existing.video_id,
);
return existing.video_id; return existing.video_id;
} }
@@ -1112,7 +1238,8 @@ export class ImmersionTrackerService {
private updateVideoMetadata(videoId: number, metadata: VideoMetadata): void { private updateVideoMetadata(videoId: number, metadata: VideoMetadata): void {
this.db this.db
.prepare(` .prepare(
`
UPDATE imm_videos UPDATE imm_videos
SET SET
duration_ms = ?, duration_ms = ?,
@@ -1129,7 +1256,8 @@ export class ImmersionTrackerService {
metadata_json = ?, metadata_json = ?,
updated_at_ms = ? updated_at_ms = ?
WHERE video_id = ? WHERE video_id = ?
`) `,
)
.run( .run(
metadata.durationMs, metadata.durationMs,
metadata.fileSizeBytes, metadata.fileSizeBytes,
@@ -1167,7 +1295,9 @@ export class ImmersionTrackerService {
})(); })();
} }
private async getLocalVideoMetadata(mediaPath: string): Promise<VideoMetadata> { private async getLocalVideoMetadata(
mediaPath: string,
): Promise<VideoMetadata> {
const hash = await this.computeSha256(mediaPath); const hash = await this.computeSha256(mediaPath);
const info = await this.runFfprobe(mediaPath); const info = await this.runFfprobe(mediaPath);
const stat = await fs.promises.stat(mediaPath); const stat = await fs.promises.stat(mediaPath);
@@ -1342,14 +1472,17 @@ export class ImmersionTrackerService {
private sanitizePayload(payload: Record<string, unknown>): string { private sanitizePayload(payload: Record<string, unknown>): string {
const json = JSON.stringify(payload); const json = JSON.stringify(payload);
return json.length <= MAX_PAYLOAD_BYTES return json.length <= this.maxPayloadBytes
? json ? json
: JSON.stringify({ truncated: true }); : JSON.stringify({ truncated: true });
} }
private calculateTextMetrics(value: string): { words: number; tokens: number } { private calculateTextMetrics(value: string): {
words: number;
tokens: number;
} {
const words = value.split(/\s+/).filter(Boolean).length; const words = value.split(/\s+/).filter(Boolean).length;
const cjkCount = (value.match(/[\u3040-\u30ff\u4e00-\u9fff]/g)?.length ?? 0); const cjkCount = value.match(/[\u3040-\u30ff\u4e00-\u9fff]/g)?.length ?? 0;
const tokens = Math.max(words, cjkCount); const tokens = Math.max(words, cjkCount);
return { words, tokens }; return { words, tokens };
} }
@@ -1401,7 +1534,8 @@ export class ImmersionTrackerService {
} }
private toNullableInt(value: number | null | undefined): number | null { private toNullableInt(value: number | null | undefined): number | null {
if (value === null || value === undefined || !Number.isFinite(value)) return null; if (value === null || value === undefined || !Number.isFinite(value))
return null;
return value; return value;
} }

View File

@@ -72,8 +72,12 @@ export async function runSubsyncManualFromIpc(
isSubsyncInProgress: () => boolean; isSubsyncInProgress: () => boolean;
setSubsyncInProgress: (inProgress: boolean) => void; setSubsyncInProgress: (inProgress: boolean) => void;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
runWithSpinner: (task: () => Promise<SubsyncResult>) => Promise<SubsyncResult>; runWithSpinner: (
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>; task: () => Promise<SubsyncResult>,
) => Promise<SubsyncResult>;
runSubsyncManual: (
request: SubsyncManualRunRequest,
) => Promise<SubsyncResult>;
}, },
): Promise<SubsyncResult> { ): Promise<SubsyncResult> {
if (options.isSubsyncInProgress()) { if (options.isSubsyncInProgress()) {

View File

@@ -55,6 +55,13 @@ test("createIpcDepsRuntime wires AniList handlers", async () => {
ready: 0, ready: 0,
deadLetter: 0, deadLetter: 0,
}); });
assert.deepEqual(await deps.retryAnilistQueueNow(), { ok: true, message: "done" }); assert.deepEqual(await deps.retryAnilistQueueNow(), {
assert.deepEqual(calls, ["clearAnilistToken", "openAnilistSetup", "retryAnilistQueueNow"]); ok: true,
message: "done",
});
assert.deepEqual(calls, [
"clearAnilistToken",
"openAnilistSetup",
"retryAnilistQueueNow",
]);
}); });

View File

@@ -3,7 +3,10 @@ import { BrowserWindow, ipcMain, IpcMainEvent } from "electron";
export interface IpcServiceDeps { export interface IpcServiceDeps {
getInvisibleWindow: () => WindowLike | null; getInvisibleWindow: () => WindowLike | null;
isVisibleOverlayVisible: () => boolean; isVisibleOverlayVisible: () => boolean;
setInvisibleIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; setInvisibleIgnoreMouseEvents: (
ignore: boolean,
options?: { forward?: boolean },
) => void;
onOverlayModalClosed: (modal: string) => void; onOverlayModalClosed: (modal: string) => void;
openYomitanSettings: () => void; openYomitanSettings: () => void;
quitApp: () => void; quitApp: () => void;
@@ -17,7 +20,11 @@ export interface IpcServiceDeps {
getSubtitlePosition: () => unknown; getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown; getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: unknown) => void; saveSubtitlePosition: (position: unknown) => void;
getMecabStatus: () => { available: boolean; enabled: boolean; path: string | null }; getMecabStatus: () => {
available: boolean;
enabled: boolean;
path: string | null;
};
setMecabEnabled: (enabled: boolean) => void; setMecabEnabled: (enabled: boolean) => void;
handleMpvCommand: (command: Array<string | number>) => void; handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown; getKeybindings: () => unknown;
@@ -51,7 +58,11 @@ interface WindowLike {
} }
interface MecabTokenizerLike { interface MecabTokenizerLike {
getStatus: () => { available: boolean; enabled: boolean; path: string | null }; getStatus: () => {
available: boolean;
enabled: boolean;
path: string | null;
};
setEnabled: (enabled: boolean) => void; setEnabled: (enabled: boolean) => void;
} }
@@ -235,9 +246,12 @@ export function registerIpcHandlers(deps: IpcServiceDeps): void {
return deps.getSubtitleStyle(); return deps.getSubtitleStyle();
}); });
ipcMain.on("save-subtitle-position", (_event: IpcMainEvent, position: unknown) => { ipcMain.on(
deps.saveSubtitlePosition(position); "save-subtitle-position",
}); (_event: IpcMainEvent, position: unknown) => {
deps.saveSubtitlePosition(position);
},
);
ipcMain.handle("get-mecab-status", () => { ipcMain.handle("get-mecab-status", () => {
return deps.getMecabStatus(); return deps.getMecabStatus();
@@ -247,9 +261,12 @@ export function registerIpcHandlers(deps: IpcServiceDeps): void {
deps.setMecabEnabled(enabled); deps.setMecabEnabled(enabled);
}); });
ipcMain.on("mpv-command", (_event: IpcMainEvent, command: (string | number)[]) => { ipcMain.on(
deps.handleMpvCommand(command); "mpv-command",
}); (_event: IpcMainEvent, command: (string | number)[]) => {
deps.handleMpvCommand(command);
},
);
ipcMain.handle("get-keybindings", () => { ipcMain.handle("get-keybindings", () => {
return deps.getKeybindings(); return deps.getKeybindings();
@@ -283,17 +300,26 @@ export function registerIpcHandlers(deps: IpcServiceDeps): void {
return deps.getRuntimeOptions(); return deps.getRuntimeOptions();
}); });
ipcMain.handle("runtime-options:set", (_event, id: string, value: unknown) => { ipcMain.handle(
return deps.setRuntimeOption(id, value); "runtime-options:set",
}); (_event, id: string, value: unknown) => {
return deps.setRuntimeOption(id, value);
},
);
ipcMain.handle("runtime-options:cycle", (_event, id: string, direction: 1 | -1) => { ipcMain.handle(
return deps.cycleRuntimeOption(id, direction); "runtime-options:cycle",
}); (_event, id: string, direction: 1 | -1) => {
return deps.cycleRuntimeOption(id, direction);
},
);
ipcMain.on("overlay-content-bounds:report", (_event: IpcMainEvent, payload: unknown) => { ipcMain.on(
deps.reportOverlayContentBounds(payload); "overlay-content-bounds:report",
}); (_event: IpcMainEvent, payload: unknown) => {
deps.reportOverlayContentBounds(payload);
},
);
ipcMain.handle("anilist:get-status", () => { ipcMain.handle("anilist:get-status", () => {
return deps.getAnilistStatus(); return deps.getAnilistStatus();

View File

@@ -38,11 +38,13 @@ export function shouldIgnoreJlptByTerm(term: string): boolean {
export const JLPT_IGNORED_MECAB_POS1_ENTRIES = [ export const JLPT_IGNORED_MECAB_POS1_ENTRIES = [
{ {
pos1: "助詞", pos1: "助詞",
reason: "Particles (ko/kara/nagara etc.): mostly grammatical glue, not independent vocabulary.", reason:
"Particles (ko/kara/nagara etc.): mostly grammatical glue, not independent vocabulary.",
}, },
{ {
pos1: "助動詞", pos1: "助動詞",
reason: "Auxiliary verbs (past tense, politeness, modality): grammar helpers.", reason:
"Auxiliary verbs (past tense, politeness, modality): grammar helpers.",
}, },
{ {
pos1: "記号", pos1: "記号",
@@ -54,7 +56,7 @@ export const JLPT_IGNORED_MECAB_POS1_ENTRIES = [
}, },
{ {
pos1: "連体詞", pos1: "連体詞",
reason: "Adnominal forms (e.g. demonstratives like \"この\").", reason: 'Adnominal forms (e.g. demonstratives like "この").',
}, },
{ {
pos1: "感動詞", pos1: "感動詞",
@@ -62,7 +64,8 @@ export const JLPT_IGNORED_MECAB_POS1_ENTRIES = [
}, },
{ {
pos1: "接続詞", pos1: "接続詞",
reason: "Conjunctions that connect clauses, usually not target vocab items.", reason:
"Conjunctions that connect clauses, usually not target vocab items.",
}, },
{ {
pos1: "接頭詞", pos1: "接頭詞",

View File

@@ -50,8 +50,7 @@ function addEntriesToMap(
incomingLevel: JlptLevel, incomingLevel: JlptLevel,
): boolean => ): boolean =>
existingLevel === undefined || existingLevel === undefined ||
JLPT_LEVEL_PRECEDENCE[incomingLevel] > JLPT_LEVEL_PRECEDENCE[incomingLevel] > JLPT_LEVEL_PRECEDENCE[existingLevel];
JLPT_LEVEL_PRECEDENCE[existingLevel];
if (!Array.isArray(rawEntries)) { if (!Array.isArray(rawEntries)) {
return; return;
@@ -163,7 +162,7 @@ export async function createJlptVocabularyLookup(
return (term: string): JlptLevel | null => { return (term: string): JlptLevel | null => {
if (!term) return null; if (!term) return null;
const normalized = normalizeJlptTerm(term); const normalized = normalizeJlptTerm(term);
return normalized ? terms.get(normalized) ?? null : null; return normalized ? (terms.get(normalized) ?? null) : null;
}; };
} }
@@ -181,7 +180,9 @@ export async function createJlptVocabularyLookup(
); );
} }
if (resolvedBanks.length > 0 && foundBankCount > 0) { if (resolvedBanks.length > 0 && foundBankCount > 0) {
options.log(`JLPT dictionary search matched path(s): ${resolvedBanks.join(", ")}`); options.log(
`JLPT dictionary search matched path(s): ${resolvedBanks.join(", ")}`,
);
} }
return NOOP_LOOKUP; return NOOP_LOOKUP;
} }

View File

@@ -97,7 +97,12 @@ test("mineSentenceCard creates sentence card from mpv subtitle state", async ()
updateLastAddedFromClipboard: async () => {}, updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {}, triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {}, markLastCardAsAudioCard: async () => {},
createSentenceCard: async (sentence, startTime, endTime, secondarySub) => { createSentenceCard: async (
sentence,
startTime,
endTime,
secondarySub,
) => {
created.push({ sentence, startTime, endTime, secondarySub }); created.push({ sentence, startTime, endTime, secondarySub });
return true; return true;
}, },
@@ -176,7 +181,9 @@ test("handleMineSentenceDigit reports async create failures", async () => {
assert.equal(logs.length, 1); assert.equal(logs.length, 1);
assert.equal(logs[0]?.message, "mineSentenceMultiple failed:"); assert.equal(logs[0]?.message, "mineSentenceMultiple failed:");
assert.equal((logs[0]?.err as Error).message, "mine boom"); assert.equal((logs[0]?.err as Error).message, "mine boom");
assert.ok(osd.some((entry) => entry.includes("Mine sentence failed: mine boom"))); assert.ok(
osd.some((entry) => entry.includes("Mine sentence failed: mine boom")),
);
assert.equal(cardsMined, 0); assert.equal(cardsMined, 0);
}); });

View File

@@ -44,7 +44,9 @@ export function handleMultiCopyDigit(
const actualCount = blocks.length; const actualCount = blocks.length;
deps.writeClipboardText(blocks.join("\n\n")); deps.writeClipboardText(blocks.join("\n\n"));
if (actualCount < count) { if (actualCount < count) {
deps.showMpvOsd(`Only ${actualCount} lines available, copied ${actualCount}`); deps.showMpvOsd(
`Only ${actualCount} lines available, copied ${actualCount}`,
);
} else { } else {
deps.showMpvOsd(`Copied ${actualCount} lines`); deps.showMpvOsd(`Copied ${actualCount} lines`);
} }

View File

@@ -76,7 +76,10 @@ export function updateMpvSubtitleRenderMetrics(
100, 100,
), ),
subAssOverride: asString(patch.subAssOverride, current.subAssOverride), subAssOverride: asString(patch.subAssOverride, current.subAssOverride),
subScaleByWindow: asBoolean(patch.subScaleByWindow, current.subScaleByWindow), subScaleByWindow: asBoolean(
patch.subScaleByWindow,
current.subScaleByWindow,
),
subUseMargins: asBoolean(patch.subUseMargins, current.subUseMargins), subUseMargins: asBoolean(patch.subUseMargins, current.subUseMargins),
osdHeight: asFiniteNumber(patch.osdHeight, current.osdHeight, 1, 10000), osdHeight: asFiniteNumber(patch.osdHeight, current.osdHeight, 1, 10000),
osdDimensions: nextOsdDimensions, osdDimensions: nextOsdDimensions,
@@ -104,6 +107,7 @@ export function applyMpvSubtitleRenderMetricsPatch(
next.subScaleByWindow !== current.subScaleByWindow || next.subScaleByWindow !== current.subScaleByWindow ||
next.subUseMargins !== current.subUseMargins || next.subUseMargins !== current.subUseMargins ||
next.osdHeight !== current.osdHeight || next.osdHeight !== current.osdHeight ||
JSON.stringify(next.osdDimensions) !== JSON.stringify(current.osdDimensions); JSON.stringify(next.osdDimensions) !==
JSON.stringify(current.osdDimensions);
return { next, changed }; return { next, changed };
} }

View File

@@ -41,7 +41,10 @@ export interface NumericShortcutSessionMessages {
export interface NumericShortcutSessionDeps { export interface NumericShortcutSessionDeps {
registerShortcut: (accelerator: string, handler: () => void) => boolean; registerShortcut: (accelerator: string, handler: () => void) => boolean;
unregisterShortcut: (accelerator: string) => void; unregisterShortcut: (accelerator: string) => void;
setTimer: (handler: () => void, timeoutMs: number) => ReturnType<typeof setTimeout>; setTimer: (
handler: () => void,
timeoutMs: number,
) => ReturnType<typeof setTimeout>;
clearTimer: (timer: ReturnType<typeof setTimeout>) => void; clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
} }
@@ -52,9 +55,7 @@ export interface NumericShortcutSessionStartParams {
messages: NumericShortcutSessionMessages; messages: NumericShortcutSessionMessages;
} }
export function createNumericShortcutSession( export function createNumericShortcutSession(deps: NumericShortcutSessionDeps) {
deps: NumericShortcutSessionDeps,
) {
let active = false; let active = false;
let timeout: ReturnType<typeof setTimeout> | null = null; let timeout: ReturnType<typeof setTimeout> | null = null;
let digitShortcuts: string[] = []; let digitShortcuts: string[] = [];

View File

@@ -45,23 +45,21 @@ export function sendToVisibleOverlayRuntime<T extends string>(options: {
return true; return true;
} }
export function createFieldGroupingCallbackRuntime<T extends string>( export function createFieldGroupingCallbackRuntime<T extends string>(options: {
options: { getVisibleOverlayVisible: () => boolean;
getVisibleOverlayVisible: () => boolean; getInvisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean; setVisibleOverlayVisible: (visible: boolean) => void;
setVisibleOverlayVisible: (visible: boolean) => void; setInvisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void; getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; setResolver: (
setResolver: ( resolver: ((choice: KikuFieldGroupingChoice) => void) | null,
resolver: ((choice: KikuFieldGroupingChoice) => void) | null, ) => void;
) => void; sendToVisibleOverlay: (
sendToVisibleOverlay: ( channel: string,
channel: string, payload?: unknown,
payload?: unknown, runtimeOptions?: { restoreOnModalClose?: T },
runtimeOptions?: { restoreOnModalClose?: T }, ) => boolean;
) => boolean; }): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
},
): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return createFieldGroupingCallback({ return createFieldGroupingCallback({
getVisibleOverlayVisible: options.getVisibleOverlayVisible, getVisibleOverlayVisible: options.getVisibleOverlayVisible,
getInvisibleOverlayVisible: options.getInvisibleOverlayVisible, getInvisibleOverlayVisible: options.getInvisibleOverlayVisible,

View File

@@ -1,4 +1,8 @@
import { OverlayContentMeasurement, OverlayContentRect, OverlayLayer } from "../../types"; import {
OverlayContentMeasurement,
OverlayContentRect,
OverlayLayer,
} from "../../types";
import { createLogger } from "../../logger"; import { createLogger } from "../../logger";
const logger = createLogger("main:overlay-content-measurement"); const logger = createLogger("main:overlay-content-measurement");
@@ -8,7 +12,10 @@ const MAX_RECT_OFFSET = 50000;
const MAX_FUTURE_TIMESTAMP_MS = 60_000; const MAX_FUTURE_TIMESTAMP_MS = 60_000;
const INVALID_LOG_THROTTLE_MS = 10_000; const INVALID_LOG_THROTTLE_MS = 10_000;
type OverlayMeasurementStore = Record<OverlayLayer, OverlayContentMeasurement | null>; type OverlayMeasurementStore = Record<
OverlayLayer,
OverlayContentMeasurement | null
>;
export function sanitizeOverlayContentMeasurement( export function sanitizeOverlayContentMeasurement(
payload: unknown, payload: unknown,
@@ -20,15 +27,28 @@ export function sanitizeOverlayContentMeasurement(
layer?: unknown; layer?: unknown;
measuredAtMs?: unknown; measuredAtMs?: unknown;
viewport?: { width?: unknown; height?: unknown }; viewport?: { width?: unknown; height?: unknown };
contentRect?: { x?: unknown; y?: unknown; width?: unknown; height?: unknown } | null; contentRect?: {
x?: unknown;
y?: unknown;
width?: unknown;
height?: unknown;
} | null;
}; };
if (candidate.layer !== "visible" && candidate.layer !== "invisible") { if (candidate.layer !== "visible" && candidate.layer !== "invisible") {
return null; return null;
} }
const viewportWidth = readFiniteInRange(candidate.viewport?.width, 1, MAX_VIEWPORT); const viewportWidth = readFiniteInRange(
const viewportHeight = readFiniteInRange(candidate.viewport?.height, 1, MAX_VIEWPORT); candidate.viewport?.width,
1,
MAX_VIEWPORT,
);
const viewportHeight = readFiniteInRange(
candidate.viewport?.height,
1,
MAX_VIEWPORT,
);
if (!Number.isFinite(viewportWidth) || !Number.isFinite(viewportHeight)) { if (!Number.isFinite(viewportWidth) || !Number.isFinite(viewportHeight)) {
return null; return null;
@@ -56,9 +76,7 @@ export function sanitizeOverlayContentMeasurement(
}; };
} }
function sanitizeOverlayContentRect( function sanitizeOverlayContentRect(rect: unknown): OverlayContentRect | null {
rect: unknown,
): OverlayContentRect | null {
if (rect === null || rect === undefined) { if (rect === null || rect === undefined) {
return null; return null;
} }
@@ -91,11 +109,7 @@ function sanitizeOverlayContentRect(
return { x, y, width, height }; return { x, y, width, height };
} }
function readFiniteInRange( function readFiniteInRange(value: unknown, min: number, max: number): number {
value: unknown,
min: number,
max: number,
): number {
if (typeof value !== "number" || !Number.isFinite(value)) { if (typeof value !== "number" || !Number.isFinite(value)) {
return Number.NaN; return Number.NaN;
} }
@@ -141,7 +155,9 @@ export function createOverlayContentMeasurementStore(options?: {
return measurement; return measurement;
} }
function getLatestByLayer(layer: OverlayLayer): OverlayContentMeasurement | null { function getLatestByLayer(
layer: OverlayLayer,
): OverlayContentMeasurement | null {
return latestByLayer[layer]; return latestByLayer[layer];
} }

View File

@@ -17,8 +17,12 @@ test("overlay manager initializes with empty windows and hidden overlays", () =>
test("overlay manager stores window references and returns stable window order", () => { test("overlay manager stores window references and returns stable window order", () => {
const manager = createOverlayManager(); const manager = createOverlayManager();
const visibleWindow = { isDestroyed: () => false } as unknown as Electron.BrowserWindow; const visibleWindow = {
const invisibleWindow = { isDestroyed: () => false } as unknown as Electron.BrowserWindow; isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
const invisibleWindow = {
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
manager.setMainWindow(visibleWindow); manager.setMainWindow(visibleWindow);
manager.setInvisibleWindow(invisibleWindow); manager.setInvisibleWindow(invisibleWindow);
@@ -27,13 +31,20 @@ test("overlay manager stores window references and returns stable window order",
assert.equal(manager.getInvisibleWindow(), invisibleWindow); assert.equal(manager.getInvisibleWindow(), invisibleWindow);
assert.equal(manager.getOverlayWindow("visible"), visibleWindow); assert.equal(manager.getOverlayWindow("visible"), visibleWindow);
assert.equal(manager.getOverlayWindow("invisible"), invisibleWindow); assert.equal(manager.getOverlayWindow("invisible"), invisibleWindow);
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow]); assert.deepEqual(manager.getOverlayWindows(), [
visibleWindow,
invisibleWindow,
]);
}); });
test("overlay manager excludes destroyed windows", () => { test("overlay manager excludes destroyed windows", () => {
const manager = createOverlayManager(); const manager = createOverlayManager();
manager.setMainWindow({ isDestroyed: () => true } as unknown as Electron.BrowserWindow); manager.setMainWindow({
manager.setInvisibleWindow({ isDestroyed: () => false } as unknown as Electron.BrowserWindow); isDestroyed: () => true,
} as unknown as Electron.BrowserWindow);
manager.setInvisibleWindow({
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow);
assert.equal(manager.getOverlayWindows().length, 1); assert.equal(manager.getOverlayWindows().length, 1);
}); });

View File

@@ -10,7 +10,10 @@ export interface OverlayManager {
getInvisibleWindow: () => BrowserWindow | null; getInvisibleWindow: () => BrowserWindow | null;
setInvisibleWindow: (window: BrowserWindow | null) => void; setInvisibleWindow: (window: BrowserWindow | null) => void;
getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null; getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null;
setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void; setOverlayWindowBounds: (
layer: OverlayLayer,
geometry: WindowGeometry,
) => void;
getVisibleOverlayVisible: () => boolean; getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void;
getInvisibleOverlayVisible: () => boolean; getInvisibleOverlayVisible: () => boolean;
@@ -79,7 +82,10 @@ export function broadcastRuntimeOptionsChangedRuntime(
getRuntimeOptionsState: () => RuntimeOptionState[], getRuntimeOptionsState: () => RuntimeOptionState[],
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void, broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
): void { ): void {
broadcastToOverlayWindows("runtime-options:changed", getRuntimeOptionsState()); broadcastToOverlayWindows(
"runtime-options:changed",
getRuntimeOptionsState(),
);
} }
export function setOverlayDebugVisualizationEnabledRuntime( export function setOverlayDebugVisualizationEnabledRuntime(

View File

@@ -26,12 +26,19 @@ export function initializeOverlayRuntime(options: {
getMpvSocketPath: () => string; getMpvSocketPath: () => string;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getSubtitleTimingTracker: () => unknown | null; getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null; getMpvClient: () => {
send?: (payload: { command: string[] }) => void;
} | null;
getRuntimeOptionsManager: () => { getRuntimeOptionsManager: () => {
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig; getEffectiveAnkiConnectConfig: (
config?: AnkiConnectConfig,
) => AnkiConnectConfig;
} | null; } | null;
setAnkiIntegration: (integration: unknown | null) => void; setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (
title: string,
options: { body?: string; icon?: string },
) => void;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
@@ -41,7 +48,8 @@ export function initializeOverlayRuntime(options: {
} { } {
options.createMainWindow(); options.createMainWindow();
options.createInvisibleWindow(); options.createInvisibleWindow();
const invisibleOverlayVisible = options.getInitialInvisibleOverlayVisibility(); const invisibleOverlayVisible =
options.getInitialInvisibleOverlayVisibility();
options.registerGlobalShortcuts(); options.registerGlobalShortcuts();
const windowTracker = createWindowTracker( const windowTracker = createWindowTracker(

View File

@@ -123,10 +123,10 @@ test("createOverlayShortcutRuntimeHandlers reports async failures via OSD", asyn
assert.equal(logs.length, 1); assert.equal(logs.length, 1);
assert.equal(typeof logs[0]?.[0], "string"); assert.equal(typeof logs[0]?.[0], "string");
assert.ok(String(logs[0]?.[0]).includes("markLastCardAsAudioCard failed:")); assert.ok(String(logs[0]?.[0]).includes("markLastCardAsAudioCard failed:"));
assert.ok(String(logs[0]?.[0]).includes("audio boom"));
assert.ok( assert.ok(
String(logs[0]?.[0]).includes("audio boom"), osd.some((entry) => entry.includes("Audio card failed: audio boom")),
); );
assert.ok(osd.some((entry) => entry.includes("Audio card failed: audio boom")));
} finally { } finally {
console.error = originalError; console.error = originalError;
} }
@@ -134,7 +134,8 @@ test("createOverlayShortcutRuntimeHandlers reports async failures via OSD", asyn
test("runOverlayShortcutLocalFallback dispatches matching actions with timeout", () => { test("runOverlayShortcutLocalFallback dispatches matching actions with timeout", () => {
const handled: string[] = []; const handled: string[] = [];
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = []; const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> =
[];
const shortcuts = makeShortcuts({ const shortcuts = makeShortcuts({
copySubtitleMultiple: "Ctrl+M", copySubtitleMultiple: "Ctrl+M",
multiCopyTimeoutMs: 4321, multiCopyTimeoutMs: 4321,
@@ -170,11 +171,14 @@ test("runOverlayShortcutLocalFallback dispatches matching actions with timeout",
assert.equal(result, true); assert.equal(result, true);
assert.deepEqual(handled, ["copySubtitleMultiple:4321"]); assert.deepEqual(handled, ["copySubtitleMultiple:4321"]);
assert.deepEqual(matched, [{ accelerator: "Ctrl+M", allowWhenRegistered: false }]); assert.deepEqual(matched, [
{ accelerator: "Ctrl+M", allowWhenRegistered: false },
]);
}); });
test("runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle", () => { test("runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle", () => {
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = []; const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> =
[];
const shortcuts = makeShortcuts({ const shortcuts = makeShortcuts({
toggleSecondarySub: "Ctrl+2", toggleSecondarySub: "Ctrl+2",
}); });
@@ -205,11 +209,14 @@ test("runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
); );
assert.equal(result, true); assert.equal(result, true);
assert.deepEqual(matched, [{ accelerator: "Ctrl+2", allowWhenRegistered: true }]); assert.deepEqual(matched, [
{ accelerator: "Ctrl+2", allowWhenRegistered: true },
]);
}); });
test("runOverlayShortcutLocalFallback allows registered-global jimaku shortcut", () => { test("runOverlayShortcutLocalFallback allows registered-global jimaku shortcut", () => {
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = []; const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> =
[];
const shortcuts = makeShortcuts({ const shortcuts = makeShortcuts({
openJimaku: "Ctrl+J", openJimaku: "Ctrl+J",
}); });
@@ -240,7 +247,9 @@ test("runOverlayShortcutLocalFallback allows registered-global jimaku shortcut",
); );
assert.equal(result, true); assert.equal(result, true);
assert.deepEqual(matched, [{ accelerator: "Ctrl+J", allowWhenRegistered: true }]); assert.deepEqual(matched, [
{ accelerator: "Ctrl+J", allowWhenRegistered: true },
]);
}); });
test("runOverlayShortcutLocalFallback returns false when no action matches", () => { test("runOverlayShortcutLocalFallback returns false when no action matches", () => {

View File

@@ -205,11 +205,7 @@ export function runOverlayShortcutLocalFallback(
for (const action of actions) { for (const action of actions) {
if (!action.accelerator) continue; if (!action.accelerator) continue;
if ( if (
matcher( matcher(input, action.accelerator, action.allowWhenRegistered === true)
input,
action.accelerator,
action.allowWhenRegistered === true,
)
) { ) {
action.run(); action.run();
return true; return true;

View File

@@ -214,9 +214,6 @@ export function refreshOverlayShortcutsRuntime(
shortcutsRegistered: boolean, shortcutsRegistered: boolean,
deps: OverlayShortcutLifecycleDeps, deps: OverlayShortcutLifecycleDeps,
): boolean { ): boolean {
const cleared = unregisterOverlayShortcutsRuntime( const cleared = unregisterOverlayShortcutsRuntime(shortcutsRegistered, deps);
shortcutsRegistered,
deps,
);
return syncOverlayShortcutsRuntime(shouldBeActive, cleared, deps); return syncOverlayShortcutsRuntime(shouldBeActive, cleared, deps);
} }

View File

@@ -37,7 +37,8 @@ export function enforceOverlayLayerOrder(options: {
invisibleWindow: BrowserWindow | null; invisibleWindow: BrowserWindow | null;
ensureOverlayWindowLevel: (window: BrowserWindow) => void; ensureOverlayWindowLevel: (window: BrowserWindow) => void;
}): void { }): void {
if (!options.visibleOverlayVisible || !options.invisibleOverlayVisible) return; if (!options.visibleOverlayVisible || !options.invisibleOverlayVisible)
return;
if (!options.mainWindow || options.mainWindow.isDestroyed()) return; if (!options.mainWindow || options.mainWindow.isDestroyed()) return;
if (!options.invisibleWindow || options.invisibleWindow.isDestroyed()) return; if (!options.invisibleWindow || options.invisibleWindow.isDestroyed()) return;

View File

@@ -1,6 +1,10 @@
import { CliArgs } from "../../cli/args"; import { CliArgs } from "../../cli/args";
import type { LogLevelSource } from "../../logger"; import type { LogLevelSource } from "../../logger";
import { ConfigValidationWarning, ResolvedConfig, SecondarySubMode } from "../../types"; import {
ConfigValidationWarning,
ResolvedConfig,
SecondarySubMode,
} from "../../types";
export interface StartupBootstrapRuntimeState { export interface StartupBootstrapRuntimeState {
initialArgs: CliArgs; initialArgs: CliArgs;
@@ -100,6 +104,7 @@ export interface AppReadyRuntimeDeps {
createMecabTokenizerAndCheck: () => Promise<void>; createMecabTokenizerAndCheck: () => Promise<void>;
createSubtitleTimingTracker: () => void; createSubtitleTimingTracker: () => void;
createImmersionTracker?: () => void; createImmersionTracker?: () => void;
startJellyfinRemoteSession?: () => Promise<void>;
loadYomitanExtension: () => Promise<void>; loadYomitanExtension: () => Promise<void>;
texthookerOnlyMode: boolean; texthookerOnlyMode: boolean;
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean; shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
@@ -136,9 +141,14 @@ export function isAutoUpdateEnabledRuntime(
config: ResolvedConfig | RuntimeConfigLike, config: ResolvedConfig | RuntimeConfigLike,
runtimeOptionsManager: RuntimeAutoUpdateOptionManagerLike | null, runtimeOptionsManager: RuntimeAutoUpdateOptionManagerLike | null,
): boolean { ): boolean {
const value = runtimeOptionsManager?.getOptionValue("anki.autoUpdateNewCards"); const value = runtimeOptionsManager?.getOptionValue(
"anki.autoUpdateNewCards",
);
if (typeof value === "boolean") return value; if (typeof value === "boolean") return value;
return (config as ResolvedConfig).ankiConnect?.behavior?.autoUpdateNewCards !== false; return (
(config as ResolvedConfig).ankiConnect?.behavior?.autoUpdateNewCards !==
false
);
} }
export async function runAppReadyRuntime( export async function runAppReadyRuntime(
@@ -179,12 +189,17 @@ export async function runAppReadyRuntime(
try { try {
deps.createImmersionTracker(); deps.createImmersionTracker();
} catch (error) { } catch (error) {
deps.log(`Runtime ready: createImmersionTracker failed: ${(error as Error).message}`); deps.log(
`Runtime ready: createImmersionTracker failed: ${(error as Error).message}`,
);
} }
} else { } else {
deps.log("Runtime ready: createImmersionTracker dependency is missing."); deps.log("Runtime ready: createImmersionTracker dependency is missing.");
} }
await deps.loadYomitanExtension(); await deps.loadYomitanExtension();
if (deps.startJellyfinRemoteSession) {
await deps.startJellyfinRemoteSession();
}
if (deps.texthookerOnlyMode) { if (deps.texthookerOnlyMode) {
deps.log("Texthooker-only mode enabled; skipping overlay window."); deps.log("Texthooker-only mode enabled; skipping overlay window.");

View File

@@ -77,8 +77,7 @@ export async function runSubsyncManualFromIpcRuntime(
isSubsyncInProgress: triggerDeps.isSubsyncInProgress, isSubsyncInProgress: triggerDeps.isSubsyncInProgress,
setSubsyncInProgress: triggerDeps.setSubsyncInProgress, setSubsyncInProgress: triggerDeps.setSubsyncInProgress,
showMpvOsd: triggerDeps.showMpvOsd, showMpvOsd: triggerDeps.showMpvOsd,
runWithSpinner: (task) => runWithSpinner: (task) => triggerDeps.runWithSubsyncSpinner(() => task()),
triggerDeps.runWithSubsyncSpinner(() => task()),
runSubsyncManual: (subsyncRequest) => runSubsyncManual: (subsyncRequest) =>
runSubsyncManual(subsyncRequest, triggerDeps), runSubsyncManual(subsyncRequest, triggerDeps),
}); });

View File

@@ -103,7 +103,9 @@ test("triggerSubsyncFromConfig reports failures to OSD", async () => {
}), }),
); );
assert.ok(osd.some((line) => line.startsWith("Subsync failed: MPV not connected"))); assert.ok(
osd.some((line) => line.startsWith("Subsync failed: MPV not connected")),
);
}); });
test("runSubsyncManual requires a source track for alass", async () => { test("runSubsyncManual requires a source track for alass", async () => {
@@ -163,14 +165,8 @@ test("runSubsyncManual constructs ffsubsync command and returns success", async
fs.writeFileSync(videoPath, "video"); fs.writeFileSync(videoPath, "video");
fs.writeFileSync(primaryPath, "sub"); fs.writeFileSync(primaryPath, "sub");
writeExecutableScript( writeExecutableScript(ffmpegPath, "#!/bin/sh\nexit 0\n");
ffmpegPath, writeExecutableScript(alassPath, "#!/bin/sh\nexit 0\n");
"#!/bin/sh\nexit 0\n",
);
writeExecutableScript(
alassPath,
"#!/bin/sh\nexit 0\n",
);
writeExecutableScript( writeExecutableScript(
ffsubsyncPath, ffsubsyncPath,
`#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`, `#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`,

View File

@@ -28,7 +28,10 @@ interface FileExtractionResult {
temporary: boolean; temporary: boolean;
} }
function summarizeCommandFailure(command: string, result: CommandResult): string { function summarizeCommandFailure(
command: string,
result: CommandResult,
): string {
const parts = [ const parts = [
`code=${result.code ?? "n/a"}`, `code=${result.code ?? "n/a"}`,
result.stderr ? `stderr: ${result.stderr}` : "", result.stderr ? `stderr: ${result.stderr}` : "",
@@ -62,7 +65,9 @@ function parseTrackId(value: unknown): number | null {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed.length) return null; if (!trimmed.length) return null;
const parsed = Number(trimmed); const parsed = Number(trimmed);
return Number.isInteger(parsed) && String(parsed) === trimmed ? parsed : null; return Number.isInteger(parsed) && String(parsed) === trimmed
? parsed
: null;
} }
return null; return null;
} }
@@ -261,10 +266,7 @@ async function runFfsubsyncSync(
return runCommand(ffsubsyncPath, args); return runCommand(ffsubsyncPath, args);
} }
function loadSyncedSubtitle( function loadSyncedSubtitle(client: MpvClientLike, pathToLoad: string): void {
client: MpvClientLike,
pathToLoad: string,
): void {
if (!client.connected) { if (!client.connected) {
throw new Error("MPV disconnected while loading subtitle"); throw new Error("MPV disconnected while loading subtitle");
} }
@@ -411,7 +413,10 @@ export async function runSubsyncManual(
try { try {
validateFfsubsyncReference(context.videoPath); validateFfsubsyncReference(context.videoPath);
} catch (error) { } catch (error) {
return { ok: false, message: `ffsubsync synchronization failed: ${(error as Error).message}` }; return {
ok: false,
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
};
} }
return subsyncToReference( return subsyncToReference(
"ffsubsync", "ffsubsync",

View File

@@ -19,18 +19,20 @@ export interface CycleSecondarySubModeDeps {
const SECONDARY_SUB_CYCLE: SecondarySubMode[] = ["hidden", "visible", "hover"]; const SECONDARY_SUB_CYCLE: SecondarySubMode[] = ["hidden", "visible", "hover"];
const SECONDARY_SUB_TOGGLE_DEBOUNCE_MS = 120; const SECONDARY_SUB_TOGGLE_DEBOUNCE_MS = 120;
export function cycleSecondarySubMode( export function cycleSecondarySubMode(deps: CycleSecondarySubModeDeps): void {
deps: CycleSecondarySubModeDeps,
): void {
const now = deps.now ? deps.now() : Date.now(); const now = deps.now ? deps.now() : Date.now();
if (now - deps.getLastSecondarySubToggleAtMs() < SECONDARY_SUB_TOGGLE_DEBOUNCE_MS) { if (
now - deps.getLastSecondarySubToggleAtMs() <
SECONDARY_SUB_TOGGLE_DEBOUNCE_MS
) {
return; return;
} }
deps.setLastSecondarySubToggleAtMs(now); deps.setLastSecondarySubToggleAtMs(now);
const currentMode = deps.getSecondarySubMode(); const currentMode = deps.getSecondarySubMode();
const currentIndex = SECONDARY_SUB_CYCLE.indexOf(currentMode); const currentIndex = SECONDARY_SUB_CYCLE.indexOf(currentMode);
const nextMode = SECONDARY_SUB_CYCLE[(currentIndex + 1) % SECONDARY_SUB_CYCLE.length]; const nextMode =
SECONDARY_SUB_CYCLE[(currentIndex + 1) % SECONDARY_SUB_CYCLE.length];
deps.setSecondarySubMode(nextMode); deps.setSecondarySubMode(nextMode);
deps.broadcastSecondarySubMode(nextMode); deps.broadcastSecondarySubMode(nextMode);
deps.showMpvOsd(`Secondary subtitle: ${nextMode}`); deps.showMpvOsd(`Secondary subtitle: ${nextMode}`);
@@ -89,10 +91,12 @@ function persistSubtitlePosition(
fs.writeFileSync(positionPath, JSON.stringify(position, null, 2)); fs.writeFileSync(positionPath, JSON.stringify(position, null, 2));
} }
export function loadSubtitlePosition(options: { export function loadSubtitlePosition(
currentMediaPath: string | null; options: {
fallbackPosition: SubtitlePosition; currentMediaPath: string | null;
} & { subtitlePositionsDir: string }): SubtitlePosition | null { fallbackPosition: SubtitlePosition;
} & { subtitlePositionsDir: string },
): SubtitlePosition | null {
if (!options.currentMediaPath) { if (!options.currentMediaPath) {
return options.fallbackPosition; return options.fallbackPosition;
} }
@@ -187,7 +191,7 @@ export function updateCurrentMediaPath(options: {
); );
options.setSubtitlePosition(options.pendingSubtitlePosition); options.setSubtitlePosition(options.pendingSubtitlePosition);
options.clearPendingSubtitlePosition(); options.clearPendingSubtitlePosition();
} catch (err) { } catch (err) {
logger.error( logger.error(
"Failed to persist queued subtitle position:", "Failed to persist queued subtitle position:",
(err as Error).message, (err as Error).message,

File diff suppressed because it is too large Load Diff

View File

@@ -400,7 +400,10 @@ function isJlptEligibleToken(token: MergedToken): boolean {
token.surface, token.surface,
token.reading, token.reading,
token.headword, token.headword,
].filter((candidate): candidate is string => typeof candidate === "string" && candidate.length > 0); ].filter(
(candidate): candidate is string =>
typeof candidate === "string" && candidate.length > 0,
);
for (const candidate of candidates) { for (const candidate of candidates) {
const normalizedCandidate = normalizeJlptTextForExclusion(candidate); const normalizedCandidate = normalizeJlptTextForExclusion(candidate);
@@ -457,14 +460,17 @@ function isYomitanParseLine(value: unknown): value is YomitanParseLine {
}); });
} }
function isYomitanHeadwordRows(value: unknown): value is YomitanParseHeadword[][] { function isYomitanHeadwordRows(
value: unknown,
): value is YomitanParseHeadword[][] {
return ( return (
Array.isArray(value) && Array.isArray(value) &&
value.every( value.every(
(group) => (group) =>
Array.isArray(group) && Array.isArray(group) &&
group.every((item) => group.every(
isObject(item) && isString((item as YomitanParseHeadword).term), (item) =>
isObject(item) && isString((item as YomitanParseHeadword).term),
), ),
) )
); );
@@ -502,7 +508,9 @@ function applyJlptMarking(
getJlptLevel, getJlptLevel,
); );
const fallbackLevel = const fallbackLevel =
primaryLevel === null ? getCachedJlptLevel(token.surface, getJlptLevel) : null; primaryLevel === null
? getCachedJlptLevel(token.surface, getJlptLevel)
: null;
return { return {
...token, ...token,
@@ -615,20 +623,22 @@ function selectBestYomitanParseCandidate(
const getBestByTokenCount = ( const getBestByTokenCount = (
items: YomitanParseCandidate[], items: YomitanParseCandidate[],
): YomitanParseCandidate | null => items.length === 0 ): YomitanParseCandidate | null =>
? null items.length === 0
: items.reduce((best, current) => ? null
current.tokens.length > best.tokens.length ? current : best, : items.reduce((best, current) =>
); current.tokens.length > best.tokens.length ? current : best,
);
const getCandidateScore = (candidate: YomitanParseCandidate): number => { const getCandidateScore = (candidate: YomitanParseCandidate): number => {
const readableTokenCount = candidate.tokens.filter( const readableTokenCount = candidate.tokens.filter(
(token) => token.reading.trim().length > 0, (token) => token.reading.trim().length > 0,
).length; ).length;
const suspiciousKanaFragmentCount = candidate.tokens.filter((token) => const suspiciousKanaFragmentCount = candidate.tokens.filter(
token.reading.trim().length === 0 && (token) =>
token.surface.length >= 2 && token.reading.trim().length === 0 &&
Array.from(token.surface).every((char) => isKanaChar(char)) token.surface.length >= 2 &&
Array.from(token.surface).every((char) => isKanaChar(char)),
).length; ).length;
return ( return (
@@ -680,7 +690,8 @@ function selectBestYomitanParseCandidate(
const multiTokenCandidates = candidates.filter( const multiTokenCandidates = candidates.filter(
(candidate) => candidate.tokens.length > 1, (candidate) => candidate.tokens.length > 1,
); );
const pool = multiTokenCandidates.length > 0 ? multiTokenCandidates : candidates; const pool =
multiTokenCandidates.length > 0 ? multiTokenCandidates : candidates;
const bestCandidate = chooseBestCandidate(pool); const bestCandidate = chooseBestCandidate(pool);
return bestCandidate ? bestCandidate.tokens : null; return bestCandidate ? bestCandidate.tokens : null;
} }
@@ -705,7 +716,9 @@ function mapYomitanParseResultsToMergedTokens(
knownWordMatchMode, knownWordMatchMode,
), ),
) )
.filter((candidate): candidate is YomitanParseCandidate => candidate !== null); .filter(
(candidate): candidate is YomitanParseCandidate => candidate !== null,
);
const bestCandidate = selectBestYomitanParseCandidate(candidates); const bestCandidate = selectBestYomitanParseCandidate(candidates);
return bestCandidate; return bestCandidate;
@@ -752,7 +765,8 @@ function pickClosestMecabPos1(
} }
const mecabStart = mecabToken.startPos ?? 0; const mecabStart = mecabToken.startPos ?? 0;
const mecabEnd = mecabToken.endPos ?? mecabStart + mecabToken.surface.length; const mecabEnd =
mecabToken.endPos ?? mecabStart + mecabToken.surface.length;
const overlapStart = Math.max(tokenStart, mecabStart); const overlapStart = Math.max(tokenStart, mecabStart);
const overlapEnd = Math.min(tokenEnd, mecabEnd); const overlapEnd = Math.min(tokenEnd, mecabEnd);
const overlap = Math.max(0, overlapEnd - overlapStart); const overlap = Math.max(0, overlapEnd - overlapStart);
@@ -764,8 +778,7 @@ function pickClosestMecabPos1(
if ( if (
overlap > bestOverlap || overlap > bestOverlap ||
(overlap === bestOverlap && (overlap === bestOverlap &&
(span > bestSpan || (span > bestSpan || (span === bestSpan && mecabStart < bestStart)))
(span === bestSpan && mecabStart < bestStart)))
) { ) {
bestOverlap = overlap; bestOverlap = overlap;
bestSpan = span; bestSpan = span;
@@ -879,7 +892,9 @@ async function ensureYomitanParserWindow(
}); });
try { try {
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/search.html`); await parserWindow.loadURL(
`chrome-extension://${yomitanExt.id}/search.html`,
);
const readyPromise = deps.getYomitanParserReadyPromise(); const readyPromise = deps.getYomitanParserReadyPromise();
if (readyPromise) { if (readyPromise) {
await readyPromise; await readyPromise;
@@ -963,7 +978,7 @@ async function parseWithYomitanInternalParser(
script, script,
true, true,
); );
const yomitanTokens = mapYomitanParseResultsToMergedTokens( const yomitanTokens = mapYomitanParseResultsToMergedTokens(
parseResults, parseResults,
deps.isKnownWord, deps.isKnownWord,
deps.getKnownWordMatchMode(), deps.getKnownWordMatchMode(),
@@ -977,7 +992,7 @@ async function parseWithYomitanInternalParser(
} }
return enrichYomitanPos1(yomitanTokens, deps, text); return enrichYomitanPos1(yomitanTokens, deps, text);
} catch (err) { } catch (err) {
logger.error("Yomitan parser request failed:", (err as Error).message); logger.error("Yomitan parser request failed:", (err as Error).message);
return null; return null;
} }
@@ -1013,7 +1028,10 @@ export async function tokenizeSubtitle(
const frequencyEnabled = deps.getFrequencyDictionaryEnabled?.() !== false; const frequencyEnabled = deps.getFrequencyDictionaryEnabled?.() !== false;
const frequencyLookup = deps.getFrequencyRank; const frequencyLookup = deps.getFrequencyRank;
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps); const yomitanTokens = await parseWithYomitanInternalParser(
tokenizeText,
deps,
);
if (yomitanTokens && yomitanTokens.length > 0) { if (yomitanTokens && yomitanTokens.length > 0) {
const knownMarkedTokens = applyKnownWordMarking( const knownMarkedTokens = applyKnownWordMarking(
yomitanTokens, yomitanTokens,
@@ -1024,12 +1042,15 @@ export async function tokenizeSubtitle(
frequencyEnabled && frequencyLookup frequencyEnabled && frequencyLookup
? applyFrequencyMarking(knownMarkedTokens, frequencyLookup) ? applyFrequencyMarking(knownMarkedTokens, frequencyLookup)
: knownMarkedTokens.map((token) => ({ : knownMarkedTokens.map((token) => ({
...token, ...token,
frequencyRank: undefined, frequencyRank: undefined,
})); }));
const jlptMarkedTokens = jlptEnabled const jlptMarkedTokens = jlptEnabled
? applyJlptMarking(frequencyMarkedTokens, deps.getJlptLevel) ? applyJlptMarking(frequencyMarkedTokens, deps.getJlptLevel)
: frequencyMarkedTokens.map((token) => ({ ...token, jlptLevel: undefined })); : frequencyMarkedTokens.map((token) => ({
...token,
jlptLevel: undefined,
}));
return { return {
text: displayText, text: displayText,
tokens: markNPlusOneTargets( tokens: markNPlusOneTargets(
@@ -1051,12 +1072,15 @@ export async function tokenizeSubtitle(
frequencyEnabled && frequencyLookup frequencyEnabled && frequencyLookup
? applyFrequencyMarking(knownMarkedTokens, frequencyLookup) ? applyFrequencyMarking(knownMarkedTokens, frequencyLookup)
: knownMarkedTokens.map((token) => ({ : knownMarkedTokens.map((token) => ({
...token, ...token,
frequencyRank: undefined, frequencyRank: undefined,
})); }));
const jlptMarkedTokens = jlptEnabled const jlptMarkedTokens = jlptEnabled
? applyJlptMarking(frequencyMarkedTokens, deps.getJlptLevel) ? applyJlptMarking(frequencyMarkedTokens, deps.getJlptLevel)
: frequencyMarkedTokens.map((token) => ({ ...token, jlptLevel: undefined })); : frequencyMarkedTokens.map((token) => ({
...token,
jlptLevel: undefined,
}));
return { return {
text: displayText, text: displayText,
tokens: markNPlusOneTargets( tokens: markNPlusOneTargets(

View File

@@ -32,7 +32,10 @@ export function openYomitanSettingsWindow(
return; return;
} }
logger.info("Creating new settings window for extension:", options.yomitanExt.id); logger.info(
"Creating new settings window for extension:",
options.yomitanExt.id,
);
const settingsWindow = new BrowserWindow({ const settingsWindow = new BrowserWindow({
width: 1200, width: 1200,

View File

@@ -1,5 +1,8 @@
export { generateDefaultConfigFile } from "./config-gen"; export { generateDefaultConfigFile } from "./config-gen";
export { enforceUnsupportedWaylandMode, forceX11Backend } from "./electron-backend"; export {
enforceUnsupportedWaylandMode,
forceX11Backend,
} from "./electron-backend";
export { asBoolean, asFiniteNumber, asString } from "./coerce"; export { asBoolean, asFiniteNumber, asString } from "./coerce";
export { resolveKeybindings } from "./keybindings"; export { resolveKeybindings } from "./keybindings";
export { resolveConfiguredShortcuts } from "./shortcut-config"; export { resolveConfiguredShortcuts } from "./shortcut-config";

View File

@@ -55,7 +55,8 @@ export function resolveConfiguredShortcuts(
defaultConfig.shortcuts?.triggerFieldGrouping, defaultConfig.shortcuts?.triggerFieldGrouping,
), ),
triggerSubsync: normalizeShortcut( triggerSubsync: normalizeShortcut(
config.shortcuts?.triggerSubsync ?? defaultConfig.shortcuts?.triggerSubsync, config.shortcuts?.triggerSubsync ??
defaultConfig.shortcuts?.triggerSubsync,
), ),
mineSentence: normalizeShortcut( mineSentence: normalizeShortcut(
config.shortcuts?.mineSentence ?? defaultConfig.shortcuts?.mineSentence, config.shortcuts?.mineSentence ?? defaultConfig.shortcuts?.mineSentence,

View File

@@ -239,7 +239,8 @@ export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo {
titlePart = name.slice(0, parsed.index); titlePart = name.slice(0, parsed.index);
} }
const seasonFromDir = parsed.season ?? detectSeasonFromDir(normalizedMediaPath); const seasonFromDir =
parsed.season ?? detectSeasonFromDir(normalizedMediaPath);
const title = cleanupTitle(titlePart || name); const title = cleanupTitle(titlePart || name);
return { return {
@@ -277,7 +278,9 @@ function normalizeMediaPathForJimaku(mediaPath: string): string {
); );
}); });
return decodeURIComponent(candidate || parsedUrl.hostname.replace(/^www\./, "")); return decodeURIComponent(
candidate || parsedUrl.hostname.replace(/^www\./, ""),
);
} catch { } catch {
return trimmed; return trimmed;
} }

View File

@@ -20,7 +20,9 @@ test("allows only AniList https URLs for external opens", () => {
test("allows only AniList https or data URLs for setup navigation", () => { test("allows only AniList https or data URLs for setup navigation", () => {
assert.equal( assert.equal(
isAllowedAnilistSetupNavigationUrl("https://anilist.co/api/v2/oauth/authorize"), isAllowedAnilistSetupNavigationUrl(
"https://anilist.co/api/v2/oauth/authorize",
),
true, true,
); );
assert.equal( assert.equal(
@@ -33,5 +35,8 @@ test("allows only AniList https or data URLs for setup navigation", () => {
isAllowedAnilistSetupNavigationUrl("https://example.com/redirect"), isAllowedAnilistSetupNavigationUrl("https://example.com/redirect"),
false, false,
); );
assert.equal(isAllowedAnilistSetupNavigationUrl("javascript:alert(1)"), false); assert.equal(
isAllowedAnilistSetupNavigationUrl("javascript:alert(1)"),
false,
);
}); });

View File

@@ -36,6 +36,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
createMecabTokenizerAndCheck: AppReadyRuntimeDeps["createMecabTokenizerAndCheck"]; createMecabTokenizerAndCheck: AppReadyRuntimeDeps["createMecabTokenizerAndCheck"];
createSubtitleTimingTracker: AppReadyRuntimeDeps["createSubtitleTimingTracker"]; createSubtitleTimingTracker: AppReadyRuntimeDeps["createSubtitleTimingTracker"];
createImmersionTracker?: AppReadyRuntimeDeps["createImmersionTracker"]; createImmersionTracker?: AppReadyRuntimeDeps["createImmersionTracker"];
startJellyfinRemoteSession?: AppReadyRuntimeDeps["startJellyfinRemoteSession"];
loadYomitanExtension: AppReadyRuntimeDeps["loadYomitanExtension"]; loadYomitanExtension: AppReadyRuntimeDeps["loadYomitanExtension"];
texthookerOnlyMode: AppReadyRuntimeDeps["texthookerOnlyMode"]; texthookerOnlyMode: AppReadyRuntimeDeps["texthookerOnlyMode"];
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps["shouldAutoInitializeOverlayRuntimeFromConfig"]; shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps["shouldAutoInitializeOverlayRuntimeFromConfig"];
@@ -83,6 +84,7 @@ export function createAppReadyRuntimeDeps(
createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck, createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck,
createSubtitleTimingTracker: params.createSubtitleTimingTracker, createSubtitleTimingTracker: params.createSubtitleTimingTracker,
createImmersionTracker: params.createImmersionTracker, createImmersionTracker: params.createImmersionTracker,
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
loadYomitanExtension: params.loadYomitanExtension, loadYomitanExtension: params.loadYomitanExtension,
texthookerOnlyMode: params.texthookerOnlyMode, texthookerOnlyMode: params.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: shouldAutoInitializeOverlayRuntimeFromConfig:

View File

@@ -32,13 +32,17 @@ export function getFrequencyDictionarySearchPaths(
if (sourcePath && sourcePath.trim()) { if (sourcePath && sourcePath.trim()) {
rawSearchPaths.push(sourcePath.trim()); rawSearchPaths.push(sourcePath.trim());
rawSearchPaths.push(path.join(sourcePath.trim(), "frequency-dictionary")); rawSearchPaths.push(path.join(sourcePath.trim(), "frequency-dictionary"));
rawSearchPaths.push(path.join(sourcePath.trim(), "vendor", "frequency-dictionary")); rawSearchPaths.push(
path.join(sourcePath.trim(), "vendor", "frequency-dictionary"),
);
} }
for (const dictionaryRoot of dictionaryRoots) { for (const dictionaryRoot of dictionaryRoots) {
rawSearchPaths.push(dictionaryRoot); rawSearchPaths.push(dictionaryRoot);
rawSearchPaths.push(path.join(dictionaryRoot, "frequency-dictionary")); rawSearchPaths.push(path.join(dictionaryRoot, "frequency-dictionary"));
rawSearchPaths.push(path.join(dictionaryRoot, "vendor", "frequency-dictionary")); rawSearchPaths.push(
path.join(dictionaryRoot, "vendor", "frequency-dictionary"),
);
} }
return [...new Set(rawSearchPaths)]; return [...new Set(rawSearchPaths)];
@@ -64,15 +68,18 @@ export async function ensureFrequencyDictionaryLookup(
return; return;
} }
if (!frequencyDictionaryLookupInitialization) { if (!frequencyDictionaryLookupInitialization) {
frequencyDictionaryLookupInitialization = initializeFrequencyDictionaryLookup(deps) frequencyDictionaryLookupInitialization =
.then(() => { initializeFrequencyDictionaryLookup(deps)
frequencyDictionaryLookupInitialized = true; .then(() => {
}) frequencyDictionaryLookupInitialized = true;
.catch((error) => { })
frequencyDictionaryLookupInitialized = true; .catch((error) => {
deps.log(`Failed to initialize frequency dictionary: ${String(error)}`); frequencyDictionaryLookupInitialized = true;
deps.setFrequencyRankLookup(() => null); deps.log(
}); `Failed to initialize frequency dictionary: ${String(error)}`,
);
deps.setFrequencyRankLookup(() => null);
});
} }
await frequencyDictionaryLookupInitialization; await frequencyDictionaryLookupInitialization;
} }
@@ -81,6 +88,7 @@ export function createFrequencyDictionaryRuntimeService(
deps: FrequencyDictionaryRuntimeDeps, deps: FrequencyDictionaryRuntimeDeps,
): { ensureFrequencyDictionaryLookup: () => Promise<void> } { ): { ensureFrequencyDictionaryLookup: () => Promise<void> } {
return { return {
ensureFrequencyDictionaryLookup: () => ensureFrequencyDictionaryLookup(deps), ensureFrequencyDictionaryLookup: () =>
ensureFrequencyDictionaryLookup(deps),
}; };
} }

View File

@@ -62,7 +62,9 @@ export function createMediaRuntimeService(
}, },
resolveMediaPathForJimaku(mediaPath: string | null): string | null { resolveMediaPathForJimaku(mediaPath: string | null): string | null {
return mediaPath && deps.isRemoteMediaPath(mediaPath) && deps.getCurrentMediaTitle() return mediaPath &&
deps.isRemoteMediaPath(mediaPath) &&
deps.getCurrentMediaTitle()
? deps.getCurrentMediaTitle() ? deps.getCurrentMediaTitle()
: mediaPath; : mediaPath;
}, },

View File

@@ -23,7 +23,10 @@ export function createOverlayModalRuntimeService(
deps: OverlayWindowResolver, deps: OverlayWindowResolver,
): OverlayModalRuntime { ): OverlayModalRuntime {
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>(); const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
const overlayModalAutoShownLayer = new Map<OverlayHostedModal, OverlayHostLayer>(); const overlayModalAutoShownLayer = new Map<
OverlayHostedModal,
OverlayHostLayer
>();
const getTargetOverlayWindow = (): { const getTargetOverlayWindow = (): {
window: BrowserWindow; window: BrowserWindow;
@@ -43,7 +46,10 @@ export function createOverlayModalRuntimeService(
return null; return null;
}; };
const showOverlayWindowForModal = (window: BrowserWindow, layer: OverlayHostLayer): void => { const showOverlayWindowForModal = (
window: BrowserWindow,
layer: OverlayHostLayer,
): void => {
if (layer === "invisible" && typeof window.showInactive === "function") { if (layer === "invisible" && typeof window.showInactive === "function") {
window.showInactive(); window.showInactive();
} else { } else {
@@ -133,7 +139,8 @@ export function createOverlayModalRuntimeService(
sendToActiveOverlayWindow, sendToActiveOverlayWindow,
openRuntimeOptionsPalette, openRuntimeOptionsPalette,
handleOverlayModalClosed, handleOverlayModalClosed,
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose, getRestoreVisibleOverlayOnModalClose: () =>
restoreVisibleOverlayOnModalClose,
}; };
} }

View File

@@ -90,7 +90,8 @@ export function createOverlayShortcutsRuntimeService(
}; };
}; };
const shouldOverlayShortcutsBeActive = () => input.isOverlayRuntimeInitialized(); const shouldOverlayShortcutsBeActive = () =>
input.isOverlayRuntimeInitialized();
return { return {
tryHandleOverlayShortcutLocalFallback: (inputEvent) => tryHandleOverlayShortcutLocalFallback: (inputEvent) =>

View File

@@ -1,5 +1,9 @@
import { SubsyncResolvedConfig } from "../subsync/utils"; import { SubsyncResolvedConfig } from "../subsync/utils";
import type { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult } from "../types"; import type {
SubsyncManualPayload,
SubsyncManualRunRequest,
SubsyncResult,
} from "../types";
import type { SubsyncRuntimeDeps } from "../core/services/subsync-runner"; import type { SubsyncRuntimeDeps } from "../core/services/subsync-runner";
import { createSubsyncRuntimeDeps } from "./dependencies"; import { createSubsyncRuntimeDeps } from "./dependencies";
import { import {
@@ -54,7 +58,9 @@ export function createSubsyncRuntimeServiceDeps(
export function triggerSubsyncFromConfigRuntime( export function triggerSubsyncFromConfigRuntime(
params: SubsyncRuntimeServiceInput, params: SubsyncRuntimeServiceInput,
): Promise<void> { ): Promise<void> {
return triggerSubsyncFromConfigRuntimeCore(createSubsyncRuntimeServiceDeps(params)); return triggerSubsyncFromConfigRuntimeCore(
createSubsyncRuntimeServiceDeps(params),
);
} }
export async function runSubsyncManualFromIpcRuntime( export async function runSubsyncManualFromIpcRuntime(

View File

@@ -62,10 +62,7 @@ export class MediaGenerator {
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
} }
} catch (err) { } catch (err) {
log.debug( log.debug(`Failed to clean up ${filePath}:`, (err as Error).message);
`Failed to clean up ${filePath}:`,
(err as Error).message,
);
} }
} }
} catch (err) { } catch (err) {
@@ -374,12 +371,7 @@ export class MediaGenerator {
"8", "8",
); );
} else if (av1Encoder === "libsvtav1") { } else if (av1Encoder === "libsvtav1") {
encoderArgs.push( encoderArgs.push("-crf", clampedCrf.toString(), "-preset", "8");
"-crf",
clampedCrf.toString(),
"-preset",
"8",
);
} else { } else {
// librav1e // librav1e
encoderArgs.push("-qp", clampedCrf.toString(), "-speed", "8"); encoderArgs.push("-qp", clampedCrf.toString(), "-speed", "8");

View File

@@ -24,13 +24,28 @@ export function createKeyboardHandlers(
// Timeout for the modal chord capture window (e.g. Y followed by H/K). // Timeout for the modal chord capture window (e.g. Y followed by H/K).
const CHORD_TIMEOUT_MS = 1000; const CHORD_TIMEOUT_MS = 1000;
const CHORD_MAP = new Map<string, { type: "mpv" | "electron"; command?: string[]; action?: () => void }>([ const CHORD_MAP = new Map<
string,
{ type: "mpv" | "electron"; command?: string[]; action?: () => void }
>([
["KeyS", { type: "mpv", command: ["script-message", "subminer-start"] }], ["KeyS", { type: "mpv", command: ["script-message", "subminer-start"] }],
["Shift+KeyS", { type: "mpv", command: ["script-message", "subminer-stop"] }], [
"Shift+KeyS",
{ type: "mpv", command: ["script-message", "subminer-stop"] },
],
["KeyT", { type: "mpv", command: ["script-message", "subminer-toggle"] }], ["KeyT", { type: "mpv", command: ["script-message", "subminer-toggle"] }],
["KeyI", { type: "mpv", command: ["script-message", "subminer-toggle-invisible"] }], [
["Shift+KeyI", { type: "mpv", command: ["script-message", "subminer-show-invisible"] }], "KeyI",
["KeyU", { type: "mpv", command: ["script-message", "subminer-hide-invisible"] }], { type: "mpv", command: ["script-message", "subminer-toggle-invisible"] },
],
[
"Shift+KeyI",
{ type: "mpv", command: ["script-message", "subminer-show-invisible"] },
],
[
"KeyU",
{ type: "mpv", command: ["script-message", "subminer-hide-invisible"] },
],
["KeyO", { type: "mpv", command: ["script-message", "subminer-options"] }], ["KeyO", { type: "mpv", command: ["script-message", "subminer-options"] }],
["KeyR", { type: "mpv", command: ["script-message", "subminer-restart"] }], ["KeyR", { type: "mpv", command: ["script-message", "subminer-restart"] }],
["KeyC", { type: "mpv", command: ["script-message", "subminer-status"] }], ["KeyC", { type: "mpv", command: ["script-message", "subminer-status"] }],
@@ -48,7 +63,8 @@ export function createKeyboardHandlers(
if (target.tagName === "IFRAME" && target.id?.startsWith("yomitan-popup")) { if (target.tagName === "IFRAME" && target.id?.startsWith("yomitan-popup")) {
return true; return true;
} }
if (target.closest && target.closest('iframe[id^="yomitan-popup"]')) return true; if (target.closest && target.closest('iframe[id^="yomitan-popup"]'))
return true;
return false; return false;
} }
@@ -193,7 +209,9 @@ export function createKeyboardHandlers(
} }
document.addEventListener("keydown", (e: KeyboardEvent) => { document.addEventListener("keydown", (e: KeyboardEvent) => {
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]'); const yomitanPopup = document.querySelector(
'iframe[id^="yomitan-popup"]',
);
if (yomitanPopup) return; if (yomitanPopup) return;
if (handleInvisiblePositionEditKeydown(e)) return; if (handleInvisiblePositionEditKeydown(e)) return;

View File

@@ -4,7 +4,10 @@ export function createMouseHandlers(
ctx: RendererContext, ctx: RendererContext,
options: { options: {
modalStateReader: ModalStateReader; modalStateReader: ModalStateReader;
applyInvisibleSubtitleLayoutFromMpvMetrics: (metrics: any, source: string) => void; applyInvisibleSubtitleLayoutFromMpvMetrics: (
metrics: any,
source: string,
) => void;
applyYPercent: (yPercent: number) => void; applyYPercent: (yPercent: number) => void;
getCurrentYPercent: () => number; getCurrentYPercent: () => number;
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void; persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
@@ -26,7 +29,11 @@ export function createMouseHandlers(
function handleMouseLeave(): void { function handleMouseLeave(): void {
ctx.state.isOverSubtitle = false; ctx.state.isOverSubtitle = false;
const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]'); const yomitanPopup = document.querySelector('iframe[id^="yomitan-popup"]');
if (!yomitanPopup && !options.modalStateReader.isAnyModalOpen() && !ctx.state.invisiblePositionEditMode) { if (
!yomitanPopup &&
!options.modalStateReader.isAnyModalOpen() &&
!ctx.state.invisiblePositionEditMode
) {
ctx.dom.overlay.classList.remove("interactive"); ctx.dom.overlay.classList.remove("interactive");
if (ctx.platform.shouldToggleMouseIgnore) { if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
@@ -70,7 +77,10 @@ export function createMouseHandlers(
}); });
} }
function getCaretTextPointRange(clientX: number, clientY: number): Range | null { function getCaretTextPointRange(
clientX: number,
clientY: number,
): Range | null {
const documentWithCaretApi = document as Document & { const documentWithCaretApi = document as Document & {
caretRangeFromPoint?: (x: number, y: number) => Range | null; caretRangeFromPoint?: (x: number, y: number) => Range | null;
caretPositionFromPoint?: ( caretPositionFromPoint?: (
@@ -84,7 +94,10 @@ export function createMouseHandlers(
} }
if (typeof documentWithCaretApi.caretPositionFromPoint === "function") { if (typeof documentWithCaretApi.caretPositionFromPoint === "function") {
const caretPosition = documentWithCaretApi.caretPositionFromPoint(clientX, clientY); const caretPosition = documentWithCaretApi.caretPositionFromPoint(
clientX,
clientY,
);
if (!caretPosition) return null; if (!caretPosition) return null;
const range = document.createRange(); const range = document.createRange();
range.setStart(caretPosition.offsetNode, caretPosition.offset); range.setStart(caretPosition.offsetNode, caretPosition.offset);
@@ -103,7 +116,9 @@ export function createMouseHandlers(
const clampedOffset = Math.max(0, Math.min(offset, text.length)); const clampedOffset = Math.max(0, Math.min(offset, text.length));
const probeIndex = const probeIndex =
clampedOffset >= text.length ? Math.max(0, text.length - 1) : clampedOffset; clampedOffset >= text.length
? Math.max(0, text.length - 1)
: clampedOffset;
if (wordSegmenter) { if (wordSegmenter) {
for (const part of wordSegmenter.segment(text)) { for (const part of wordSegmenter.segment(text)) {
@@ -117,7 +132,9 @@ export function createMouseHandlers(
} }
const isBoundary = (char: string): boolean => const isBoundary = (char: string): boolean =>
/[\s\u3000.,!?;:()[\]{}"'`~<>/\\|@#$%^&*+=\-、。・「」『』【】〈〉《》]/.test(char); /[\s\u3000.,!?;:()[\]{}"'`~<>/\\|@#$%^&*+=\-、。・「」『』【】〈〉《》]/.test(
char,
);
const probeChar = text[probeIndex]; const probeChar = text[probeIndex];
if (!probeChar || isBoundary(probeChar)) return null; if (!probeChar || isBoundary(probeChar)) return null;
@@ -148,7 +165,10 @@ export function createMouseHandlers(
if (!ctx.dom.subtitleRoot.contains(caretRange.startContainer)) return; if (!ctx.dom.subtitleRoot.contains(caretRange.startContainer)) return;
const textNode = caretRange.startContainer as Text; const textNode = caretRange.startContainer as Text;
const wordBounds = getWordBoundsAtOffset(textNode.data, caretRange.startOffset); const wordBounds = getWordBoundsAtOffset(
textNode.data,
caretRange.startOffset,
);
if (!wordBounds) return; if (!wordBounds) return;
const selectionKey = `${wordBounds.start}:${wordBounds.end}:${textNode.data.slice( const selectionKey = `${wordBounds.start}:${wordBounds.end}:${textNode.data.slice(
@@ -242,10 +262,15 @@ export function createMouseHandlers(
element.id && element.id &&
element.id.startsWith("yomitan-popup") element.id.startsWith("yomitan-popup")
) { ) {
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive"); ctx.dom.overlay.classList.remove("interactive");
if (ctx.platform.shouldToggleMouseIgnore) { if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); window.electronAPI.setIgnoreMouseEvents(true, {
forward: true,
});
} }
} }
} }

View File

@@ -158,7 +158,10 @@ export function createJimakuModal(
} }
} }
async function loadFiles(entryId: number, episode: number | null): Promise<void> { async function loadFiles(
entryId: number,
episode: number | null,
): Promise<void> {
setJimakuStatus("Loading files..."); setJimakuStatus("Loading files...");
ctx.state.jimakuFiles = []; ctx.state.jimakuFiles = [];
ctx.state.selectedFileIndex = 0; ctx.state.selectedFileIndex = 0;
@@ -224,11 +227,12 @@ export function createJimakuModal(
const file = ctx.state.jimakuFiles[index]; const file = ctx.state.jimakuFiles[index];
setJimakuStatus("Downloading subtitle..."); setJimakuStatus("Downloading subtitle...");
const result: JimakuDownloadResult = await window.electronAPI.jimakuDownloadFile({ const result: JimakuDownloadResult =
entryId: ctx.state.currentEntryId, await window.electronAPI.jimakuDownloadFile({
url: file.url, entryId: ctx.state.currentEntryId,
name: file.name, url: file.url,
}); name: file.name,
});
if (result.ok) { if (result.ok) {
setJimakuStatus(`Downloaded and loaded: ${result.path}`); setJimakuStatus(`Downloaded and loaded: ${result.path}`);
@@ -265,8 +269,12 @@ export function createJimakuModal(
.getJimakuMediaInfo() .getJimakuMediaInfo()
.then((info: JimakuMediaInfo) => { .then((info: JimakuMediaInfo) => {
ctx.dom.jimakuTitleInput.value = info.title || ""; ctx.dom.jimakuTitleInput.value = info.title || "";
ctx.dom.jimakuSeasonInput.value = info.season ? String(info.season) : ""; ctx.dom.jimakuSeasonInput.value = info.season
ctx.dom.jimakuEpisodeInput.value = info.episode ? String(info.episode) : ""; ? String(info.season)
: "";
ctx.dom.jimakuEpisodeInput.value = info.episode
? String(info.episode)
: "";
ctx.state.currentEpisodeFilter = info.episode ?? null; ctx.state.currentEpisodeFilter = info.episode ?? null;
if (info.confidence === "high" && info.title && info.episode) { if (info.confidence === "high" && info.title && info.episode) {
@@ -291,7 +299,10 @@ export function createJimakuModal(
ctx.dom.jimakuModal.setAttribute("aria-hidden", "true"); ctx.dom.jimakuModal.setAttribute("aria-hidden", "true");
window.electronAPI.notifyOverlayModalClosed("jimaku"); window.electronAPI.notifyOverlayModalClosed("jimaku");
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive"); ctx.dom.overlay.classList.remove("interactive");
} }
@@ -334,10 +345,16 @@ export function createJimakuModal(
if (e.key === "ArrowUp") { if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
if (ctx.state.jimakuFiles.length > 0) { if (ctx.state.jimakuFiles.length > 0) {
ctx.state.selectedFileIndex = Math.max(0, ctx.state.selectedFileIndex - 1); ctx.state.selectedFileIndex = Math.max(
0,
ctx.state.selectedFileIndex - 1,
);
renderFiles(); renderFiles();
} else if (ctx.state.jimakuEntries.length > 0) { } else if (ctx.state.jimakuEntries.length > 0) {
ctx.state.selectedEntryIndex = Math.max(0, ctx.state.selectedEntryIndex - 1); ctx.state.selectedEntryIndex = Math.max(
0,
ctx.state.selectedEntryIndex - 1,
);
renderEntries(); renderEntries();
} }
return true; return true;

View File

@@ -20,8 +20,14 @@ export function createKikuModal(
} }
function updateKikuCardSelection(): void { function updateKikuCardSelection(): void {
ctx.dom.kikuCard1.classList.toggle("active", ctx.state.kikuSelectedCard === 1); ctx.dom.kikuCard1.classList.toggle(
ctx.dom.kikuCard2.classList.toggle("active", ctx.state.kikuSelectedCard === 2); "active",
ctx.state.kikuSelectedCard === 1,
);
ctx.dom.kikuCard2.classList.toggle(
"active",
ctx.state.kikuSelectedCard === 2,
);
} }
function setKikuModalStep(step: "select" | "preview"): void { function setKikuModalStep(step: "select" | "preview"): void {
@@ -50,7 +56,9 @@ export function createKikuModal(
ctx.state.kikuPreviewMode === "compact" ctx.state.kikuPreviewMode === "compact"
? ctx.state.kikuPreviewCompactData ? ctx.state.kikuPreviewCompactData
: ctx.state.kikuPreviewFullData; : ctx.state.kikuPreviewFullData;
ctx.dom.kikuPreviewJson.textContent = payload ? JSON.stringify(payload, null, 2) : "{}"; ctx.dom.kikuPreviewJson.textContent = payload
? JSON.stringify(payload, null, 2)
: "{}";
updateKikuPreviewToggle(); updateKikuPreviewToggle();
} }
@@ -78,7 +86,8 @@ export function createKikuModal(
ctx.state.kikuSelectedCard = 1; ctx.state.kikuSelectedCard = 1;
ctx.dom.kikuCard1Expression.textContent = data.original.expression; ctx.dom.kikuCard1Expression.textContent = data.original.expression;
ctx.dom.kikuCard1Sentence.textContent = data.original.sentencePreview || "(no sentence)"; ctx.dom.kikuCard1Sentence.textContent =
data.original.sentencePreview || "(no sentence)";
ctx.dom.kikuCard1Meta.textContent = formatMediaMeta(data.original); ctx.dom.kikuCard1Meta.textContent = formatMediaMeta(data.original);
ctx.dom.kikuCard2Expression.textContent = data.duplicate.expression; ctx.dom.kikuCard2Expression.textContent = data.duplicate.expression;
@@ -123,7 +132,10 @@ export function createKikuModal(
ctx.state.kikuOriginalData = null; ctx.state.kikuOriginalData = null;
ctx.state.kikuDuplicateData = null; ctx.state.kikuDuplicateData = null;
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive"); ctx.dom.overlay.classList.remove("interactive");
} }
} }

View File

@@ -26,7 +26,8 @@ export function createSubsyncModal(
option.textContent = track.label; option.textContent = track.label;
ctx.dom.subsyncSourceSelect.appendChild(option); ctx.dom.subsyncSourceSelect.appendChild(option);
} }
ctx.dom.subsyncSourceSelect.disabled = ctx.state.subsyncSourceTracks.length === 0; ctx.dom.subsyncSourceSelect.disabled =
ctx.state.subsyncSourceTracks.length === 0;
} }
function closeSubsyncModal(): void { function closeSubsyncModal(): void {
@@ -39,7 +40,10 @@ export function createSubsyncModal(
ctx.dom.subsyncModal.setAttribute("aria-hidden", "true"); ctx.dom.subsyncModal.setAttribute("aria-hidden", "true");
window.electronAPI.notifyOverlayModalClosed("subsync"); window.electronAPI.notifyOverlayModalClosed("subsync");
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive"); ctx.dom.overlay.classList.remove("interactive");
} }
} }

View File

@@ -26,7 +26,10 @@ function toMeasuredRect(rect: DOMRect): OverlayContentRect | null {
}; };
} }
function unionRects(a: OverlayContentRect, b: OverlayContentRect): OverlayContentRect { function unionRects(
a: OverlayContentRect,
b: OverlayContentRect,
): OverlayContentRect {
const left = Math.min(a.x, b.x); const left = Math.min(a.x, b.x);
const top = Math.min(a.y, b.y); const top = Math.min(a.y, b.y);
const right = Math.max(a.x + a.width, b.x + b.width); const right = Math.max(a.x + a.width, b.x + b.width);
@@ -48,7 +51,9 @@ function collectContentRect(ctx: RendererContext): OverlayContentRect | null {
const subtitleHasContent = hasVisibleTextContent(ctx.dom.subtitleRoot); const subtitleHasContent = hasVisibleTextContent(ctx.dom.subtitleRoot);
if (subtitleHasContent) { if (subtitleHasContent) {
const subtitleRect = toMeasuredRect(ctx.dom.subtitleRoot.getBoundingClientRect()); const subtitleRect = toMeasuredRect(
ctx.dom.subtitleRoot.getBoundingClientRect(),
);
if (subtitleRect) { if (subtitleRect) {
combinedRect = subtitleRect; combinedRect = subtitleRect;
} }

View File

@@ -32,7 +32,8 @@ export function createPositioningController(
{ {
applyInvisibleSubtitleOffsetPosition: applyInvisibleSubtitleOffsetPosition:
invisibleOffset.applyInvisibleSubtitleOffsetPosition, invisibleOffset.applyInvisibleSubtitleOffsetPosition,
updateInvisiblePositionEditHud: invisibleOffset.updateInvisiblePositionEditHud, updateInvisiblePositionEditHud:
invisibleOffset.updateInvisiblePositionEditHud,
}, },
); );

View File

@@ -6,12 +6,15 @@ const INVISIBLE_MACOS_LINE_HEIGHT_SINGLE = "0.92";
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI = "1.2"; const INVISIBLE_MACOS_LINE_HEIGHT_MULTI = "1.2";
const INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE = "1.3"; const INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE = "1.3";
export function applyContainerBaseLayout(ctx: RendererContext, params: { export function applyContainerBaseLayout(
horizontalAvailable: number; ctx: RendererContext,
leftInset: number; params: {
marginX: number; horizontalAvailable: number;
hAlign: 0 | 1 | 2; leftInset: number;
}): void { marginX: number;
hAlign: 0 | 1 | 2;
},
): void {
const { horizontalAvailable, leftInset, marginX, hAlign } = params; const { horizontalAvailable, leftInset, marginX, hAlign } = params;
ctx.dom.subtitleContainer.style.position = "absolute"; ctx.dom.subtitleContainer.style.position = "absolute";
@@ -42,19 +45,26 @@ export function applyContainerBaseLayout(ctx: RendererContext, params: {
ctx.dom.subtitleRoot.style.pointerEvents = "auto"; ctx.dom.subtitleRoot.style.pointerEvents = "auto";
} }
export function applyVerticalPosition(ctx: RendererContext, params: { export function applyVerticalPosition(
metrics: MpvSubtitleRenderMetrics; ctx: RendererContext,
renderAreaHeight: number; params: {
topInset: number; metrics: MpvSubtitleRenderMetrics;
bottomInset: number; renderAreaHeight: number;
marginY: number; topInset: number;
effectiveFontSize: number; bottomInset: number;
vAlign: 0 | 1 | 2; marginY: number;
}): void { effectiveFontSize: number;
vAlign: 0 | 1 | 2;
},
): void {
const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount); const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount);
const multiline = lineCount > 1; const multiline = lineCount > 1;
const baselineCompensationFactor = lineCount >= 3 ? 0.46 : multiline ? 0.58 : 0.7; const baselineCompensationFactor =
const baselineCompensationPx = Math.max(0, params.effectiveFontSize * baselineCompensationFactor); lineCount >= 3 ? 0.46 : multiline ? 0.58 : 0.7;
const baselineCompensationPx = Math.max(
0,
params.effectiveFontSize * baselineCompensationFactor,
);
if (params.vAlign === 2) { if (params.vAlign === 2) {
ctx.dom.subtitleContainer.style.top = `${Math.max( ctx.dom.subtitleContainer.style.top = `${Math.max(
@@ -72,7 +82,8 @@ export function applyVerticalPosition(ctx: RendererContext, params: {
return; return;
} }
const subPosMargin = ((100 - params.metrics.subPos) / 100) * params.renderAreaHeight; const subPosMargin =
((100 - params.metrics.subPos) / 100) * params.renderAreaHeight;
const effectiveMargin = Math.max(params.marginY, subPosMargin); const effectiveMargin = Math.max(params.marginY, subPosMargin);
const bottomPx = Math.max( const bottomPx = Math.max(
0, 0,
@@ -96,7 +107,10 @@ function resolveFontFamily(rawFont: string): string {
: `"${rawFont}", sans-serif`; : `"${rawFont}", sans-serif`;
} }
function resolveLineHeight(lineCount: number, isMacOSPlatform: boolean): string { function resolveLineHeight(
lineCount: number,
isMacOSPlatform: boolean,
): string {
if (!isMacOSPlatform) return "normal"; if (!isMacOSPlatform) return "normal";
if (lineCount >= 3) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE; if (lineCount >= 3) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE;
if (lineCount >= 2) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI; if (lineCount >= 2) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI;
@@ -115,8 +129,13 @@ function resolveLetterSpacing(
return isMacOSPlatform ? "-0.02em" : "0px"; return isMacOSPlatform ? "-0.02em" : "0px";
} }
function applyComputedLineHeightCompensation(ctx: RendererContext, effectiveFontSize: number): void { function applyComputedLineHeightCompensation(
const computedLineHeight = parseFloat(getComputedStyle(ctx.dom.subtitleRoot).lineHeight); ctx: RendererContext,
effectiveFontSize: number,
): void {
const computedLineHeight = parseFloat(
getComputedStyle(ctx.dom.subtitleRoot).lineHeight,
);
if ( if (
!Number.isFinite(computedLineHeight) || !Number.isFinite(computedLineHeight) ||
computedLineHeight <= effectiveFontSize computedLineHeight <= effectiveFontSize
@@ -151,11 +170,14 @@ function applyMacOSAdjustments(ctx: RendererContext): void {
)}px`; )}px`;
} }
export function applyTypography(ctx: RendererContext, params: { export function applyTypography(
metrics: MpvSubtitleRenderMetrics; ctx: RendererContext,
pxPerScaledPixel: number; params: {
effectiveFontSize: number; metrics: MpvSubtitleRenderMetrics;
}): void { pxPerScaledPixel: number;
effectiveFontSize: number;
},
): void {
const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount); const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount);
const isMacOSPlatform = ctx.platform.isMacOSPlatform; const isMacOSPlatform = ctx.platform.isMacOSPlatform;
@@ -164,7 +186,9 @@ export function applyTypography(ctx: RendererContext, params: {
resolveLineHeight(lineCount, isMacOSPlatform), resolveLineHeight(lineCount, isMacOSPlatform),
isMacOSPlatform ? "important" : "", isMacOSPlatform ? "important" : "",
); );
ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(params.metrics.subFont); ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(
params.metrics.subFont,
);
ctx.dom.subtitleRoot.style.setProperty( ctx.dom.subtitleRoot.style.setProperty(
"letter-spacing", "letter-spacing",
resolveLetterSpacing( resolveLetterSpacing(
@@ -175,8 +199,12 @@ export function applyTypography(ctx: RendererContext, params: {
isMacOSPlatform ? "important" : "", isMacOSPlatform ? "important" : "",
); );
ctx.dom.subtitleRoot.style.fontKerning = isMacOSPlatform ? "auto" : "none"; ctx.dom.subtitleRoot.style.fontKerning = isMacOSPlatform ? "auto" : "none";
ctx.dom.subtitleRoot.style.fontWeight = params.metrics.subBold ? "700" : "400"; ctx.dom.subtitleRoot.style.fontWeight = params.metrics.subBold
ctx.dom.subtitleRoot.style.fontStyle = params.metrics.subItalic ? "italic" : "normal"; ? "700"
: "400";
ctx.dom.subtitleRoot.style.fontStyle = params.metrics.subItalic
? "italic"
: "normal";
ctx.dom.subtitleRoot.style.transform = ""; ctx.dom.subtitleRoot.style.transform = "";
ctx.dom.subtitleRoot.style.transformOrigin = ""; ctx.dom.subtitleRoot.style.transformOrigin = "";

View File

@@ -74,7 +74,10 @@ export function applyPlatformFontCompensation(
function calculateGeometry( function calculateGeometry(
metrics: MpvSubtitleRenderMetrics, metrics: MpvSubtitleRenderMetrics,
osdToCssScale: number, osdToCssScale: number,
): Omit<SubtitleLayoutGeometry, "marginY" | "marginX" | "pxPerScaledPixel" | "effectiveFontSize"> { ): Omit<
SubtitleLayoutGeometry,
"marginY" | "marginX" | "pxPerScaledPixel" | "effectiveFontSize"
> {
const dims = metrics.osdDimensions; const dims = metrics.osdDimensions;
const renderAreaHeight = dims ? dims.h / osdToCssScale : window.innerHeight; const renderAreaHeight = dims ? dims.h / osdToCssScale : window.innerHeight;
const renderAreaWidth = dims ? dims.w / osdToCssScale : window.innerWidth; const renderAreaWidth = dims ? dims.w / osdToCssScale : window.innerWidth;
@@ -88,7 +91,10 @@ function calculateGeometry(
const rightInset = anchorToVideoArea ? videoRightInset : 0; const rightInset = anchorToVideoArea ? videoRightInset : 0;
const topInset = anchorToVideoArea ? videoTopInset : 0; const topInset = anchorToVideoArea ? videoTopInset : 0;
const bottomInset = anchorToVideoArea ? videoBottomInset : 0; const bottomInset = anchorToVideoArea ? videoBottomInset : 0;
const horizontalAvailable = Math.max(0, renderAreaWidth - leftInset - rightInset); const horizontalAvailable = Math.max(
0,
renderAreaWidth - leftInset - rightInset,
);
return { return {
renderAreaHeight, renderAreaHeight,
@@ -113,11 +119,16 @@ export function calculateSubtitleMetrics(
window.devicePixelRatio || 1, window.devicePixelRatio || 1,
); );
const geometry = calculateGeometry(metrics, osdToCssScale); const geometry = calculateGeometry(metrics, osdToCssScale);
const videoHeight = geometry.renderAreaHeight - geometry.topInset - geometry.bottomInset; const videoHeight =
const scaleRefHeight = metrics.subScaleByWindow ? geometry.renderAreaHeight : videoHeight; geometry.renderAreaHeight - geometry.topInset - geometry.bottomInset;
const scaleRefHeight = metrics.subScaleByWindow
? geometry.renderAreaHeight
: videoHeight;
const pxPerScaledPixel = Math.max(0.1, scaleRefHeight / 720); const pxPerScaledPixel = Math.max(0.1, scaleRefHeight / 720);
const computedFontSize = const computedFontSize =
metrics.subFontSize * metrics.subScale * (ctx.platform.isLinuxPlatform ? 1 : pxPerScaledPixel); metrics.subFontSize *
metrics.subScale *
(ctx.platform.isLinuxPlatform ? 1 : pxPerScaledPixel);
const effectiveFontSize = applyPlatformFontCompensation( const effectiveFontSize = applyPlatformFontCompensation(
computedFontSize, computedFontSize,
ctx.platform.isMacOSPlatform, ctx.platform.isMacOSPlatform,

View File

@@ -11,7 +11,10 @@ import {
} from "./invisible-layout-metrics.js"; } from "./invisible-layout-metrics.js";
export type MpvSubtitleLayoutController = { export type MpvSubtitleLayoutController = {
applyInvisibleSubtitleLayoutFromMpvMetrics: (metrics: MpvSubtitleRenderMetrics, source: string) => void; applyInvisibleSubtitleLayoutFromMpvMetrics: (
metrics: MpvSubtitleRenderMetrics,
source: string,
) => void;
}; };
export function createMpvSubtitleLayoutController( export function createMpvSubtitleLayoutController(
@@ -29,10 +32,15 @@ export function createMpvSubtitleLayoutController(
ctx.state.mpvSubtitleRenderMetrics = metrics; ctx.state.mpvSubtitleRenderMetrics = metrics;
const geometry = calculateSubtitleMetrics(ctx, metrics); const geometry = calculateSubtitleMetrics(ctx, metrics);
const alignment = calculateSubtitlePosition(metrics, geometry.pxPerScaledPixel, 2); const alignment = calculateSubtitlePosition(
metrics,
geometry.pxPerScaledPixel,
2,
);
applySubtitleFontSize(geometry.effectiveFontSize); applySubtitleFontSize(geometry.effectiveFontSize);
const effectiveBorderSize = metrics.subBorderSize * geometry.pxPerScaledPixel; const effectiveBorderSize =
metrics.subBorderSize * geometry.pxPerScaledPixel;
document.documentElement.style.setProperty( document.documentElement.style.setProperty(
"--sub-border-size", "--sub-border-size",
@@ -81,7 +89,10 @@ export function createMpvSubtitleLayoutController(
options.applyInvisibleSubtitleOffsetPosition(); options.applyInvisibleSubtitleOffsetPosition();
options.updateInvisiblePositionEditHud(); options.updateInvisiblePositionEditHud();
console.log("[invisible-overlay] Applied mpv subtitle render metrics from", source); console.log(
"[invisible-overlay] Applied mpv subtitle render metrics from",
source,
);
} }
return { return {

View File

@@ -2,7 +2,10 @@ import type { SubtitlePosition } from "../../types";
import type { ModalStateReader, RendererContext } from "../context"; import type { ModalStateReader, RendererContext } from "../context";
export type InvisibleOffsetController = { export type InvisibleOffsetController = {
applyInvisibleStoredSubtitlePosition: (position: SubtitlePosition | null, source: string) => void; applyInvisibleStoredSubtitlePosition: (
position: SubtitlePosition | null,
source: string,
) => void;
applyInvisibleSubtitleOffsetPosition: () => void; applyInvisibleSubtitleOffsetPosition: () => void;
updateInvisiblePositionEditHud: () => void; updateInvisiblePositionEditHud: () => void;
setInvisiblePositionEditMode: (enabled: boolean) => void; setInvisiblePositionEditMode: (enabled: boolean) => void;
@@ -15,9 +18,7 @@ function formatEditHudText(offsetX: number, offsetY: number): string {
return `Position Edit Ctrl/Cmd+Shift+P toggle Arrow keys move Enter/Ctrl+S save Esc cancel x:${Math.round(offsetX)} y:${Math.round(offsetY)}`; return `Position Edit Ctrl/Cmd+Shift+P toggle Arrow keys move Enter/Ctrl+S save Esc cancel x:${Math.round(offsetX)} y:${Math.round(offsetY)}`;
} }
function createEditPositionText( function createEditPositionText(ctx: RendererContext): string {
ctx: RendererContext,
): string {
return formatEditHudText( return formatEditHudText(
ctx.state.invisibleSubtitleOffsetXPx, ctx.state.invisibleSubtitleOffsetXPx,
ctx.state.invisibleSubtitleOffsetYPx, ctx.state.invisibleSubtitleOffsetYPx,
@@ -32,7 +33,8 @@ function applyOffsetByBasePosition(ctx: RendererContext): void {
if (ctx.state.invisibleLayoutBaseBottomPx !== null) { if (ctx.state.invisibleLayoutBaseBottomPx !== null) {
ctx.dom.subtitleContainer.style.bottom = `${Math.max( ctx.dom.subtitleContainer.style.bottom = `${Math.max(
0, 0,
ctx.state.invisibleLayoutBaseBottomPx + ctx.state.invisibleSubtitleOffsetYPx, ctx.state.invisibleLayoutBaseBottomPx +
ctx.state.invisibleSubtitleOffsetYPx,
)}px`; )}px`;
ctx.dom.subtitleContainer.style.top = ""; ctx.dom.subtitleContainer.style.top = "";
return; return;
@@ -59,14 +61,19 @@ export function createInvisibleOffsetController(
document.body.classList.toggle("invisible-position-edit", enabled); document.body.classList.toggle("invisible-position-edit", enabled);
if (enabled) { if (enabled) {
ctx.state.invisiblePositionEditStartX = ctx.state.invisibleSubtitleOffsetXPx; ctx.state.invisiblePositionEditStartX =
ctx.state.invisiblePositionEditStartY = ctx.state.invisibleSubtitleOffsetYPx; ctx.state.invisibleSubtitleOffsetXPx;
ctx.state.invisiblePositionEditStartY =
ctx.state.invisibleSubtitleOffsetYPx;
ctx.dom.overlay.classList.add("interactive"); ctx.dom.overlay.classList.add("interactive");
if (ctx.platform.shouldToggleMouseIgnore) { if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false); window.electronAPI.setIgnoreMouseEvents(false);
} }
} else { } else {
if (!ctx.state.isOverSubtitle && !modalStateReader.isAnySettingsModalOpen()) { if (
!ctx.state.isOverSubtitle &&
!modalStateReader.isAnySettingsModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive"); ctx.dom.overlay.classList.remove("interactive");
if (ctx.platform.shouldToggleMouseIgnore) { if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
@@ -79,14 +86,18 @@ export function createInvisibleOffsetController(
function updateInvisiblePositionEditHud(): void { function updateInvisiblePositionEditHud(): void {
if (!ctx.state.invisiblePositionEditHud) return; if (!ctx.state.invisiblePositionEditHud) return;
ctx.state.invisiblePositionEditHud.textContent = createEditPositionText(ctx); ctx.state.invisiblePositionEditHud.textContent =
createEditPositionText(ctx);
} }
function applyInvisibleSubtitleOffsetPosition(): void { function applyInvisibleSubtitleOffsetPosition(): void {
applyOffsetByBasePosition(ctx); applyOffsetByBasePosition(ctx);
} }
function applyInvisibleStoredSubtitlePosition(position: SubtitlePosition | null, source: string): void { function applyInvisibleStoredSubtitlePosition(
position: SubtitlePosition | null,
source: string,
): void {
if ( if (
position && position &&
typeof position.yPercent === "number" && typeof position.yPercent === "number" &&
@@ -100,11 +111,13 @@ export function createInvisibleOffsetController(
if (position) { if (position) {
const nextX = const nextX =
typeof position.invisibleOffsetXPx === "number" && Number.isFinite(position.invisibleOffsetXPx) typeof position.invisibleOffsetXPx === "number" &&
Number.isFinite(position.invisibleOffsetXPx)
? position.invisibleOffsetXPx ? position.invisibleOffsetXPx
: 0; : 0;
const nextY = const nextY =
typeof position.invisibleOffsetYPx === "number" && Number.isFinite(position.invisibleOffsetYPx) typeof position.invisibleOffsetYPx === "number" &&
Number.isFinite(position.invisibleOffsetYPx)
? position.invisibleOffsetYPx ? position.invisibleOffsetYPx
: 0; : 0;
ctx.state.invisibleSubtitleOffsetXPx = nextX; ctx.state.invisibleSubtitleOffsetXPx = nextX;
@@ -135,8 +148,10 @@ export function createInvisibleOffsetController(
} }
function cancelInvisiblePositionEdit(): void { function cancelInvisiblePositionEdit(): void {
ctx.state.invisibleSubtitleOffsetXPx = ctx.state.invisiblePositionEditStartX; ctx.state.invisibleSubtitleOffsetXPx =
ctx.state.invisibleSubtitleOffsetYPx = ctx.state.invisiblePositionEditStartY; ctx.state.invisiblePositionEditStartX;
ctx.state.invisibleSubtitleOffsetYPx =
ctx.state.invisiblePositionEditStartY;
applyOffsetByBasePosition(ctx); applyOffsetByBasePosition(ctx);
setInvisiblePositionEditMode(false); setInvisiblePositionEditMode(false);
} }

View File

@@ -5,21 +5,31 @@ const PREFERRED_Y_PERCENT_MIN = 2;
const PREFERRED_Y_PERCENT_MAX = 80; const PREFERRED_Y_PERCENT_MAX = 80;
export type SubtitlePositionController = { export type SubtitlePositionController = {
applyStoredSubtitlePosition: (position: SubtitlePosition | null, source: string) => void; applyStoredSubtitlePosition: (
position: SubtitlePosition | null,
source: string,
) => void;
getCurrentYPercent: () => number; getCurrentYPercent: () => number;
applyYPercent: (yPercent: number) => void; applyYPercent: (yPercent: number) => void;
persistSubtitlePositionPatch: (patch: Partial<SubtitlePosition>) => void; persistSubtitlePositionPatch: (patch: Partial<SubtitlePosition>) => void;
}; };
function clampYPercent(yPercent: number): number { function clampYPercent(yPercent: number): number {
return Math.max(PREFERRED_Y_PERCENT_MIN, Math.min(PREFERRED_Y_PERCENT_MAX, yPercent)); return Math.max(
PREFERRED_Y_PERCENT_MIN,
Math.min(PREFERRED_Y_PERCENT_MAX, yPercent),
);
} }
function getPersistedYPercent( function getPersistedYPercent(
ctx: RendererContext, ctx: RendererContext,
position: SubtitlePosition | null, position: SubtitlePosition | null,
): number { ): number {
if (!position || typeof position.yPercent !== "number" || !Number.isFinite(position.yPercent)) { if (
!position ||
typeof position.yPercent !== "number" ||
!Number.isFinite(position.yPercent)
) {
return ctx.state.persistedSubtitlePosition.yPercent; return ctx.state.persistedSubtitlePosition.yPercent;
} }
@@ -66,12 +76,12 @@ function getNextPersistedPosition(
typeof patch.invisibleOffsetXPx === "number" && typeof patch.invisibleOffsetXPx === "number" &&
Number.isFinite(patch.invisibleOffsetXPx) Number.isFinite(patch.invisibleOffsetXPx)
? patch.invisibleOffsetXPx ? patch.invisibleOffsetXPx
: ctx.state.persistedSubtitlePosition.invisibleOffsetXPx ?? 0, : (ctx.state.persistedSubtitlePosition.invisibleOffsetXPx ?? 0),
invisibleOffsetYPx: invisibleOffsetYPx:
typeof patch.invisibleOffsetYPx === "number" && typeof patch.invisibleOffsetYPx === "number" &&
Number.isFinite(patch.invisibleOffsetYPx) Number.isFinite(patch.invisibleOffsetYPx)
? patch.invisibleOffsetYPx ? patch.invisibleOffsetYPx
: ctx.state.persistedSubtitlePosition.invisibleOffsetYPx ?? 0, : (ctx.state.persistedSubtitlePosition.invisibleOffsetYPx ?? 0),
}; };
} }
@@ -83,8 +93,11 @@ export function createInMemorySubtitlePositionController(
return ctx.state.currentYPercent; return ctx.state.currentYPercent;
} }
const marginBottom = parseFloat(ctx.dom.subtitleContainer.style.marginBottom) || 60; const marginBottom =
ctx.state.currentYPercent = clampYPercent((marginBottom / window.innerHeight) * 100); parseFloat(ctx.dom.subtitleContainer.style.marginBottom) || 60;
ctx.state.currentYPercent = clampYPercent(
(marginBottom / window.innerHeight) * 100,
);
return ctx.state.currentYPercent; return ctx.state.currentYPercent;
} }
@@ -101,13 +114,18 @@ export function createInMemorySubtitlePositionController(
ctx.dom.subtitleContainer.style.marginBottom = `${marginBottom}px`; ctx.dom.subtitleContainer.style.marginBottom = `${marginBottom}px`;
} }
function persistSubtitlePositionPatch(patch: Partial<SubtitlePosition>): void { function persistSubtitlePositionPatch(
patch: Partial<SubtitlePosition>,
): void {
const nextPosition = getNextPersistedPosition(ctx, patch); const nextPosition = getNextPersistedPosition(ctx, patch);
ctx.state.persistedSubtitlePosition = nextPosition; ctx.state.persistedSubtitlePosition = nextPosition;
window.electronAPI.saveSubtitlePosition(nextPosition); window.electronAPI.saveSubtitlePosition(nextPosition);
} }
function applyStoredSubtitlePosition(position: SubtitlePosition | null, source: string): void { function applyStoredSubtitlePosition(
position: SubtitlePosition | null,
source: string,
): void {
updatePersistedSubtitlePosition(ctx, position); updatePersistedSubtitlePosition(ctx, position);
if (position && position.yPercent !== undefined) { if (position && position.yPercent !== undefined) {
applyYPercent(position.yPercent); applyYPercent(position.yPercent);

View File

@@ -132,7 +132,10 @@ async function init(): Promise<void> {
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => { window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
if (ctx.platform.isInvisibleLayer) { if (ctx.platform.isInvisibleLayer) {
positioning.applyInvisibleStoredSubtitlePosition(position, "media-change"); positioning.applyInvisibleStoredSubtitlePosition(
position,
"media-change",
);
} else { } else {
positioning.applyStoredSubtitlePosition(position, "media-change"); positioning.applyStoredSubtitlePosition(position, "media-change");
} }
@@ -140,10 +143,15 @@ async function init(): Promise<void> {
}); });
if (ctx.platform.isInvisibleLayer) { if (ctx.platform.isInvisibleLayer) {
window.electronAPI.onMpvSubtitleRenderMetrics((metrics: MpvSubtitleRenderMetrics) => { window.electronAPI.onMpvSubtitleRenderMetrics(
positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, "event"); (metrics: MpvSubtitleRenderMetrics) => {
measurementReporter.schedule(); positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(
}); metrics,
"event",
);
measurementReporter.schedule();
},
);
window.electronAPI.onOverlayDebugVisualization((enabled: boolean) => { window.electronAPI.onOverlayDebugVisualization((enabled: boolean) => {
document.body.classList.toggle("debug-invisible-visualization", enabled); document.body.classList.toggle("debug-invisible-visualization", enabled);
}); });
@@ -162,8 +170,12 @@ async function init(): Promise<void> {
measurementReporter.schedule(); measurementReporter.schedule();
}); });
subtitleRenderer.updateSecondarySubMode(await window.electronAPI.getSecondarySubMode()); subtitleRenderer.updateSecondarySubMode(
subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub()); await window.electronAPI.getSecondarySubMode(),
);
subtitleRenderer.renderSecondarySub(
await window.electronAPI.getCurrentSecondarySub(),
);
measurementReporter.schedule(); measurementReporter.schedule();
const hoverTarget = ctx.platform.isInvisibleLayer const hoverTarget = ctx.platform.isInvisibleLayer
@@ -171,8 +183,14 @@ async function init(): Promise<void> {
: ctx.dom.subtitleContainer; : ctx.dom.subtitleContainer;
hoverTarget.addEventListener("mouseenter", mouseHandlers.handleMouseEnter); hoverTarget.addEventListener("mouseenter", mouseHandlers.handleMouseEnter);
hoverTarget.addEventListener("mouseleave", mouseHandlers.handleMouseLeave); hoverTarget.addEventListener("mouseleave", mouseHandlers.handleMouseLeave);
ctx.dom.secondarySubContainer.addEventListener("mouseenter", mouseHandlers.handleMouseEnter); ctx.dom.secondarySubContainer.addEventListener(
ctx.dom.secondarySubContainer.addEventListener("mouseleave", mouseHandlers.handleMouseLeave); "mouseenter",
mouseHandlers.handleMouseEnter,
);
ctx.dom.secondarySubContainer.addEventListener(
"mouseleave",
mouseHandlers.handleMouseLeave,
);
mouseHandlers.setupInvisibleHoverSelection(); mouseHandlers.setupInvisibleHoverSelection();
positioning.setupInvisiblePositionEditHud(); positioning.setupInvisiblePositionEditHud();
@@ -189,9 +207,11 @@ async function init(): Promise<void> {
subsyncModal.wireDomEvents(); subsyncModal.wireDomEvents();
sessionHelpModal.wireDomEvents(); sessionHelpModal.wireDomEvents();
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => { window.electronAPI.onRuntimeOptionsChanged(
runtimeOptionsModal.updateRuntimeOptions(options); (options: RuntimeOptionState[]) => {
}); runtimeOptionsModal.updateRuntimeOptions(options);
},
);
window.electronAPI.onOpenRuntimeOptions(() => { window.electronAPI.onOpenRuntimeOptions(() => {
runtimeOptionsModal.openRuntimeOptionsModal().catch(() => { runtimeOptionsModal.openRuntimeOptionsModal().catch(() => {
runtimeOptionsModal.setRuntimeOptionsStatus( runtimeOptionsModal.setRuntimeOptionsStatus(
@@ -209,7 +229,10 @@ async function init(): Promise<void> {
subsyncModal.openSubsyncModal(payload); subsyncModal.openSubsyncModal(payload);
}); });
window.electronAPI.onKikuFieldGroupingRequest( window.electronAPI.onKikuFieldGroupingRequest(
(data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => { (data: {
original: KikuDuplicateCardInfo;
duplicate: KikuDuplicateCardInfo;
}) => {
kikuModal.openKikuFieldGroupingModal(data); kikuModal.openKikuFieldGroupingModal(data);
}, },
); );
@@ -220,7 +243,9 @@ async function init(): Promise<void> {
await keyboardHandlers.setupMpvInputForwarding(); await keyboardHandlers.setupMpvInputForwarding();
subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle()); subtitleRenderer.applySubtitleStyle(
await window.electronAPI.getSubtitleStyle(),
);
if (ctx.platform.isInvisibleLayer) { if (ctx.platform.isInvisibleLayer) {
positioning.applyInvisibleStoredSubtitlePosition( positioning.applyInvisibleStoredSubtitlePosition(

View File

@@ -95,7 +95,13 @@ test("computeWordClass does not add frequency class to known or N+1 terms", () =
topX: 100, topX: 100,
mode: "single", mode: "single",
singleColor: "#000000", singleColor: "#000000",
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const, bandedColors: [
"#000000",
"#000000",
"#000000",
"#000000",
"#000000",
] as const,
}), }),
"word word-known", "word word-known",
); );
@@ -105,7 +111,13 @@ test("computeWordClass does not add frequency class to known or N+1 terms", () =
topX: 100, topX: 100,
mode: "single", mode: "single",
singleColor: "#000000", singleColor: "#000000",
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const, bandedColors: [
"#000000",
"#000000",
"#000000",
"#000000",
"#000000",
] as const,
}), }),
"word word-n-plus-one", "word word-n-plus-one",
); );
@@ -115,7 +127,13 @@ test("computeWordClass does not add frequency class to known or N+1 terms", () =
topX: 100, topX: 100,
mode: "single", mode: "single",
singleColor: "#000000", singleColor: "#000000",
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const, bandedColors: [
"#000000",
"#000000",
"#000000",
"#000000",
"#000000",
] as const,
}), }),
"word word-frequency-single", "word word-frequency-single",
); );
@@ -127,16 +145,19 @@ test("computeWordClass adds frequency class for single mode when rank is within
frequencyRank: 50, frequencyRank: 50,
}); });
const actual = computeWordClass( const actual = computeWordClass(token, {
token, enabled: true,
{ topX: 100,
enabled: true, mode: "single",
topX: 100, singleColor: "#000000",
mode: "single", bandedColors: [
singleColor: "#000000", "#000000",
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const, "#000000",
}, "#000000",
); "#000000",
"#000000",
] as const,
});
assert.equal(actual, "word word-frequency-single"); assert.equal(actual, "word word-frequency-single");
}); });
@@ -147,16 +168,19 @@ test("computeWordClass adds frequency class when rank equals topX", () => {
frequencyRank: 100, frequencyRank: 100,
}); });
const actual = computeWordClass( const actual = computeWordClass(token, {
token, enabled: true,
{ topX: 100,
enabled: true, mode: "single",
topX: 100, singleColor: "#000000",
mode: "single", bandedColors: [
singleColor: "#000000", "#000000",
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const, "#000000",
}, "#000000",
); "#000000",
"#000000",
] as const,
});
assert.equal(actual, "word word-frequency-single"); assert.equal(actual, "word word-frequency-single");
}); });
@@ -167,17 +191,19 @@ test("computeWordClass adds frequency class for banded mode", () => {
frequencyRank: 250, frequencyRank: 250,
}); });
const actual = computeWordClass( const actual = computeWordClass(token, {
token, enabled: true,
{ topX: 1000,
enabled: true, mode: "banded",
topX: 1000, singleColor: "#000000",
mode: "banded", bandedColors: [
singleColor: "#000000", "#111111",
bandedColors: "#222222",
["#111111", "#222222", "#333333", "#444444", "#555555"] as const, "#333333",
}, "#444444",
); "#555555",
] as const,
});
assert.equal(actual, "word word-frequency-band-2"); assert.equal(actual, "word word-frequency-band-2");
}); });
@@ -193,13 +219,7 @@ test("computeWordClass uses configured band count for banded mode", () => {
topX: 4, topX: 4,
mode: "banded", mode: "banded",
singleColor: "#000000", singleColor: "#000000",
bandedColors: [ bandedColors: ["#111111", "#222222", "#333333", "#444444", "#555555"],
"#111111",
"#222222",
"#333333",
"#444444",
"#555555",
],
} as any); } as any);
assert.equal(actual, "word word-frequency-band-3"); assert.equal(actual, "word word-frequency-band-3");
@@ -211,16 +231,19 @@ test("computeWordClass skips frequency class when rank is out of topX", () => {
frequencyRank: 1200, frequencyRank: 1200,
}); });
const actual = computeWordClass( const actual = computeWordClass(token, {
token, enabled: true,
{ topX: 1000,
enabled: true, mode: "single",
topX: 1000, singleColor: "#000000",
mode: "single", bandedColors: [
singleColor: "#000000", "#000000",
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const, "#000000",
}, "#000000",
); "#000000",
"#000000",
] as const,
});
assert.equal(actual, "word"); assert.equal(actual, "word");
}); });
@@ -229,9 +252,7 @@ test("JLPT CSS rules use underline-only styling in renderer stylesheet", () => {
const distCssPath = path.join(process.cwd(), "dist", "renderer", "style.css"); const distCssPath = path.join(process.cwd(), "dist", "renderer", "style.css");
const srcCssPath = path.join(process.cwd(), "src", "renderer", "style.css"); const srcCssPath = path.join(process.cwd(), "src", "renderer", "style.css");
const cssPath = fs.existsSync(distCssPath) const cssPath = fs.existsSync(distCssPath) ? distCssPath : srcCssPath;
? distCssPath
: srcCssPath;
if (!fs.existsSync(cssPath)) { if (!fs.existsSync(cssPath)) {
assert.fail( assert.fail(
"JLPT CSS file missing. Run `pnpm run build` first, or ensure src/renderer/style.css exists.", "JLPT CSS file missing. Run `pnpm run build` first, or ensure src/renderer/style.css exists.",
@@ -259,7 +280,10 @@ test("JLPT CSS rules use underline-only styling in renderer stylesheet", () => {
? "#subtitleRoot .word.word-frequency-single" ? "#subtitleRoot .word.word-frequency-single"
: `#subtitleRoot .word.word-frequency-band-${band}`, : `#subtitleRoot .word.word-frequency-band-${band}`,
); );
assert.ok(block.length > 0, `frequency class word-frequency-${band === 1 ? "single" : `band-${band}`} should exist`); assert.ok(
block.length > 0,
`frequency class word-frequency-${band === 1 ? "single" : `band-${band}`} should exist`,
);
assert.match(block, /color:\s*var\(/); assert.match(block, /color:\s*var\(/);
} }
}); });

View File

@@ -72,12 +72,18 @@ function getFrequencyDictionaryClass(
return ""; return "";
} }
if (typeof token.frequencyRank !== "number" || !Number.isFinite(token.frequencyRank)) { if (
typeof token.frequencyRank !== "number" ||
!Number.isFinite(token.frequencyRank)
) {
return ""; return "";
} }
const rank = Math.max(1, Math.floor(token.frequencyRank)); const rank = Math.max(1, Math.floor(token.frequencyRank));
const topX = sanitizeFrequencyTopX(settings.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX); const topX = sanitizeFrequencyTopX(
settings.topX,
DEFAULT_FREQUENCY_RENDER_SETTINGS.topX,
);
if (rank > topX) { if (rank > topX) {
return ""; return "";
} }
@@ -121,16 +127,16 @@ function renderWithTokens(
if (surface.includes("\n")) { if (surface.includes("\n")) {
const parts = surface.split("\n"); const parts = surface.split("\n");
for (let i = 0; i < parts.length; i += 1) { for (let i = 0; i < parts.length; i += 1) {
if (parts[i]) { if (parts[i]) {
const span = document.createElement("span"); const span = document.createElement("span");
span.className = computeWordClass( span.className = computeWordClass(
token, token,
resolvedFrequencyRenderSettings, resolvedFrequencyRenderSettings,
); );
span.textContent = parts[i]; span.textContent = parts[i];
if (token.reading) span.dataset.reading = token.reading; if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword; if (token.headword) span.dataset.headword = token.headword;
fragment.appendChild(span); fragment.appendChild(span);
} }
if (i < parts.length - 1) { if (i < parts.length - 1) {
@@ -214,7 +220,10 @@ function renderCharacterLevel(root: HTMLElement, text: string): void {
root.appendChild(fragment); root.appendChild(fragment);
} }
function renderPlainTextPreserveLineBreaks(root: HTMLElement, text: string): void { function renderPlainTextPreserveLineBreaks(
root: HTMLElement,
text: string,
): void {
const lines = text.split("\n"); const lines = text.split("\n");
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
@@ -255,7 +264,10 @@ export function createSubtitleRenderer(ctx: RendererContext) {
1, 1,
normalizedInvisible.split("\n").length, normalizedInvisible.split("\n").length,
); );
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible); renderPlainTextPreserveLineBreaks(
ctx.dom.subtitleRoot,
normalizedInvisible,
);
return; return;
} }
@@ -331,10 +343,13 @@ export function createSubtitleRenderer(ctx: RendererContext) {
function applySubtitleStyle(style: SubtitleStyleConfig | null): void { function applySubtitleStyle(style: SubtitleStyleConfig | null): void {
if (!style) return; if (!style) return;
if (style.fontFamily) ctx.dom.subtitleRoot.style.fontFamily = style.fontFamily; if (style.fontFamily)
if (style.fontSize) ctx.dom.subtitleRoot.style.fontSize = `${style.fontSize}px`; ctx.dom.subtitleRoot.style.fontFamily = style.fontFamily;
if (style.fontSize)
ctx.dom.subtitleRoot.style.fontSize = `${style.fontSize}px`;
if (style.fontColor) ctx.dom.subtitleRoot.style.color = style.fontColor; if (style.fontColor) ctx.dom.subtitleRoot.style.color = style.fontColor;
if (style.fontWeight) ctx.dom.subtitleRoot.style.fontWeight = style.fontWeight; if (style.fontWeight)
ctx.dom.subtitleRoot.style.fontWeight = style.fontWeight;
if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle; if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle;
if (style.backgroundColor) { if (style.backgroundColor) {
ctx.dom.subtitleContainer.style.background = style.backgroundColor; ctx.dom.subtitleContainer.style.background = style.backgroundColor;
@@ -352,12 +367,12 @@ export function createSubtitleRenderer(ctx: RendererContext) {
N5: ctx.state.jlptN5Color ?? "#8aadf4", N5: ctx.state.jlptN5Color ?? "#8aadf4",
...(style.jlptColors ...(style.jlptColors
? { ? {
N1: sanitizeHexColor(style.jlptColors?.N1, ctx.state.jlptN1Color), N1: sanitizeHexColor(style.jlptColors?.N1, ctx.state.jlptN1Color),
N2: sanitizeHexColor(style.jlptColors?.N2, ctx.state.jlptN2Color), N2: sanitizeHexColor(style.jlptColors?.N2, ctx.state.jlptN2Color),
N3: sanitizeHexColor(style.jlptColors?.N3, ctx.state.jlptN3Color), N3: sanitizeHexColor(style.jlptColors?.N3, ctx.state.jlptN3Color),
N4: sanitizeHexColor(style.jlptColors?.N4, ctx.state.jlptN4Color), N4: sanitizeHexColor(style.jlptColors?.N4, ctx.state.jlptN4Color),
N5: sanitizeHexColor(style.jlptColors?.N5, ctx.state.jlptN5Color), N5: sanitizeHexColor(style.jlptColors?.N5, ctx.state.jlptN5Color),
} }
: {}), : {}),
}; };
@@ -367,20 +382,39 @@ export function createSubtitleRenderer(ctx: RendererContext) {
"--subtitle-known-word-color", "--subtitle-known-word-color",
knownWordColor, knownWordColor,
); );
ctx.dom.subtitleRoot.style.setProperty("--subtitle-n-plus-one-color", nPlusOneColor); ctx.dom.subtitleRoot.style.setProperty(
"--subtitle-n-plus-one-color",
nPlusOneColor,
);
ctx.state.jlptN1Color = jlptColors.N1; ctx.state.jlptN1Color = jlptColors.N1;
ctx.state.jlptN2Color = jlptColors.N2; ctx.state.jlptN2Color = jlptColors.N2;
ctx.state.jlptN3Color = jlptColors.N3; ctx.state.jlptN3Color = jlptColors.N3;
ctx.state.jlptN4Color = jlptColors.N4; ctx.state.jlptN4Color = jlptColors.N4;
ctx.state.jlptN5Color = jlptColors.N5; ctx.state.jlptN5Color = jlptColors.N5;
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n1-color", jlptColors.N1); ctx.dom.subtitleRoot.style.setProperty(
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n2-color", jlptColors.N2); "--subtitle-jlpt-n1-color",
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n3-color", jlptColors.N3); jlptColors.N1,
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n4-color", jlptColors.N4); );
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n5-color", jlptColors.N5); ctx.dom.subtitleRoot.style.setProperty(
"--subtitle-jlpt-n2-color",
jlptColors.N2,
);
ctx.dom.subtitleRoot.style.setProperty(
"--subtitle-jlpt-n3-color",
jlptColors.N3,
);
ctx.dom.subtitleRoot.style.setProperty(
"--subtitle-jlpt-n4-color",
jlptColors.N4,
);
ctx.dom.subtitleRoot.style.setProperty(
"--subtitle-jlpt-n5-color",
jlptColors.N5,
);
const frequencyDictionarySettings = style.frequencyDictionary ?? {}; const frequencyDictionarySettings = style.frequencyDictionary ?? {};
const frequencyEnabled = const frequencyEnabled =
frequencyDictionarySettings.enabled ?? ctx.state.frequencyDictionaryEnabled; frequencyDictionarySettings.enabled ??
ctx.state.frequencyDictionaryEnabled;
const frequencyTopX = sanitizeFrequencyTopX( const frequencyTopX = sanitizeFrequencyTopX(
frequencyDictionarySettings.topX, frequencyDictionarySettings.topX,
ctx.state.frequencyDictionaryTopX, ctx.state.frequencyDictionaryTopX,
@@ -458,7 +492,8 @@ export function createSubtitleRenderer(ctx: RendererContext) {
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle; ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
} }
if (secondaryStyle.backgroundColor) { if (secondaryStyle.backgroundColor) {
ctx.dom.secondarySubContainer.style.background = secondaryStyle.backgroundColor; ctx.dom.secondarySubContainer.style.background =
secondaryStyle.backgroundColor;
} }
} }

View File

@@ -77,8 +77,9 @@ export function resolveRendererDom(): RendererDom {
subtitleRoot: getRequiredElement<HTMLElement>("subtitleRoot"), subtitleRoot: getRequiredElement<HTMLElement>("subtitleRoot"),
subtitleContainer: getRequiredElement<HTMLElement>("subtitleContainer"), subtitleContainer: getRequiredElement<HTMLElement>("subtitleContainer"),
overlay: getRequiredElement<HTMLElement>("overlay"), overlay: getRequiredElement<HTMLElement>("overlay"),
secondarySubContainer: secondarySubContainer: getRequiredElement<HTMLElement>(
getRequiredElement<HTMLElement>("secondarySubContainer"), "secondarySubContainer",
),
secondarySubRoot: getRequiredElement<HTMLElement>("secondarySubRoot"), secondarySubRoot: getRequiredElement<HTMLElement>("secondarySubRoot"),
jimakuModal: getRequiredElement<HTMLDivElement>("jimakuModal"), jimakuModal: getRequiredElement<HTMLDivElement>("jimakuModal"),
@@ -88,60 +89,89 @@ export function resolveRendererDom(): RendererDom {
jimakuSearchButton: getRequiredElement<HTMLButtonElement>("jimakuSearch"), jimakuSearchButton: getRequiredElement<HTMLButtonElement>("jimakuSearch"),
jimakuCloseButton: getRequiredElement<HTMLButtonElement>("jimakuClose"), jimakuCloseButton: getRequiredElement<HTMLButtonElement>("jimakuClose"),
jimakuStatus: getRequiredElement<HTMLDivElement>("jimakuStatus"), jimakuStatus: getRequiredElement<HTMLDivElement>("jimakuStatus"),
jimakuEntriesSection: getRequiredElement<HTMLDivElement>("jimakuEntriesSection"), jimakuEntriesSection: getRequiredElement<HTMLDivElement>(
"jimakuEntriesSection",
),
jimakuEntriesList: getRequiredElement<HTMLUListElement>("jimakuEntries"), jimakuEntriesList: getRequiredElement<HTMLUListElement>("jimakuEntries"),
jimakuFilesSection: getRequiredElement<HTMLDivElement>("jimakuFilesSection"), jimakuFilesSection:
getRequiredElement<HTMLDivElement>("jimakuFilesSection"),
jimakuFilesList: getRequiredElement<HTMLUListElement>("jimakuFiles"), jimakuFilesList: getRequiredElement<HTMLUListElement>("jimakuFiles"),
jimakuBroadenButton: getRequiredElement<HTMLButtonElement>("jimakuBroaden"), jimakuBroadenButton: getRequiredElement<HTMLButtonElement>("jimakuBroaden"),
kikuModal: getRequiredElement<HTMLDivElement>("kikuFieldGroupingModal"), kikuModal: getRequiredElement<HTMLDivElement>("kikuFieldGroupingModal"),
kikuCard1: getRequiredElement<HTMLDivElement>("kikuCard1"), kikuCard1: getRequiredElement<HTMLDivElement>("kikuCard1"),
kikuCard2: getRequiredElement<HTMLDivElement>("kikuCard2"), kikuCard2: getRequiredElement<HTMLDivElement>("kikuCard2"),
kikuCard1Expression: getRequiredElement<HTMLDivElement>("kikuCard1Expression"), kikuCard1Expression: getRequiredElement<HTMLDivElement>(
kikuCard2Expression: getRequiredElement<HTMLDivElement>("kikuCard2Expression"), "kikuCard1Expression",
),
kikuCard2Expression: getRequiredElement<HTMLDivElement>(
"kikuCard2Expression",
),
kikuCard1Sentence: getRequiredElement<HTMLDivElement>("kikuCard1Sentence"), kikuCard1Sentence: getRequiredElement<HTMLDivElement>("kikuCard1Sentence"),
kikuCard2Sentence: getRequiredElement<HTMLDivElement>("kikuCard2Sentence"), kikuCard2Sentence: getRequiredElement<HTMLDivElement>("kikuCard2Sentence"),
kikuCard1Meta: getRequiredElement<HTMLDivElement>("kikuCard1Meta"), kikuCard1Meta: getRequiredElement<HTMLDivElement>("kikuCard1Meta"),
kikuCard2Meta: getRequiredElement<HTMLDivElement>("kikuCard2Meta"), kikuCard2Meta: getRequiredElement<HTMLDivElement>("kikuCard2Meta"),
kikuConfirmButton: getRequiredElement<HTMLButtonElement>("kikuConfirmButton"), kikuConfirmButton:
getRequiredElement<HTMLButtonElement>("kikuConfirmButton"),
kikuCancelButton: getRequiredElement<HTMLButtonElement>("kikuCancelButton"), kikuCancelButton: getRequiredElement<HTMLButtonElement>("kikuCancelButton"),
kikuDeleteDuplicateCheckbox: kikuDeleteDuplicateCheckbox: getRequiredElement<HTMLInputElement>(
getRequiredElement<HTMLInputElement>("kikuDeleteDuplicate"), "kikuDeleteDuplicate",
),
kikuSelectionStep: getRequiredElement<HTMLDivElement>("kikuSelectionStep"), kikuSelectionStep: getRequiredElement<HTMLDivElement>("kikuSelectionStep"),
kikuPreviewStep: getRequiredElement<HTMLDivElement>("kikuPreviewStep"), kikuPreviewStep: getRequiredElement<HTMLDivElement>("kikuPreviewStep"),
kikuPreviewJson: getRequiredElement<HTMLPreElement>("kikuPreviewJson"), kikuPreviewJson: getRequiredElement<HTMLPreElement>("kikuPreviewJson"),
kikuPreviewCompactButton: kikuPreviewCompactButton:
getRequiredElement<HTMLButtonElement>("kikuPreviewCompact"), getRequiredElement<HTMLButtonElement>("kikuPreviewCompact"),
kikuPreviewFullButton: getRequiredElement<HTMLButtonElement>("kikuPreviewFull"), kikuPreviewFullButton:
getRequiredElement<HTMLButtonElement>("kikuPreviewFull"),
kikuPreviewError: getRequiredElement<HTMLDivElement>("kikuPreviewError"), kikuPreviewError: getRequiredElement<HTMLDivElement>("kikuPreviewError"),
kikuBackButton: getRequiredElement<HTMLButtonElement>("kikuBackButton"), kikuBackButton: getRequiredElement<HTMLButtonElement>("kikuBackButton"),
kikuFinalConfirmButton: kikuFinalConfirmButton: getRequiredElement<HTMLButtonElement>(
getRequiredElement<HTMLButtonElement>("kikuFinalConfirmButton"), "kikuFinalConfirmButton",
kikuFinalCancelButton: ),
getRequiredElement<HTMLButtonElement>("kikuFinalCancelButton"), kikuFinalCancelButton: getRequiredElement<HTMLButtonElement>(
"kikuFinalCancelButton",
),
kikuHint: getRequiredElement<HTMLDivElement>("kikuHint"), kikuHint: getRequiredElement<HTMLDivElement>("kikuHint"),
runtimeOptionsModal: getRequiredElement<HTMLDivElement>("runtimeOptionsModal"), runtimeOptionsModal: getRequiredElement<HTMLDivElement>(
runtimeOptionsClose: getRequiredElement<HTMLButtonElement>("runtimeOptionsClose"), "runtimeOptionsModal",
runtimeOptionsList: getRequiredElement<HTMLUListElement>("runtimeOptionsList"), ),
runtimeOptionsStatus: getRequiredElement<HTMLDivElement>("runtimeOptionsStatus"), runtimeOptionsClose: getRequiredElement<HTMLButtonElement>(
"runtimeOptionsClose",
),
runtimeOptionsList:
getRequiredElement<HTMLUListElement>("runtimeOptionsList"),
runtimeOptionsStatus: getRequiredElement<HTMLDivElement>(
"runtimeOptionsStatus",
),
subsyncModal: getRequiredElement<HTMLDivElement>("subsyncModal"), subsyncModal: getRequiredElement<HTMLDivElement>("subsyncModal"),
subsyncCloseButton: getRequiredElement<HTMLButtonElement>("subsyncClose"), subsyncCloseButton: getRequiredElement<HTMLButtonElement>("subsyncClose"),
subsyncEngineAlass: getRequiredElement<HTMLInputElement>("subsyncEngineAlass"), subsyncEngineAlass:
subsyncEngineFfsubsync: getRequiredElement<HTMLInputElement>("subsyncEngineAlass"),
getRequiredElement<HTMLInputElement>("subsyncEngineFfsubsync"), subsyncEngineFfsubsync: getRequiredElement<HTMLInputElement>(
subsyncSourceLabel: getRequiredElement<HTMLLabelElement>("subsyncSourceLabel"), "subsyncEngineFfsubsync",
subsyncSourceSelect: getRequiredElement<HTMLSelectElement>("subsyncSourceSelect"), ),
subsyncSourceLabel:
getRequiredElement<HTMLLabelElement>("subsyncSourceLabel"),
subsyncSourceSelect: getRequiredElement<HTMLSelectElement>(
"subsyncSourceSelect",
),
subsyncRunButton: getRequiredElement<HTMLButtonElement>("subsyncRun"), subsyncRunButton: getRequiredElement<HTMLButtonElement>("subsyncRun"),
subsyncStatus: getRequiredElement<HTMLDivElement>("subsyncStatus"), subsyncStatus: getRequiredElement<HTMLDivElement>("subsyncStatus"),
sessionHelpModal: getRequiredElement<HTMLDivElement>("sessionHelpModal"), sessionHelpModal: getRequiredElement<HTMLDivElement>("sessionHelpModal"),
sessionHelpClose: getRequiredElement<HTMLButtonElement>("sessionHelpClose"), sessionHelpClose: getRequiredElement<HTMLButtonElement>("sessionHelpClose"),
sessionHelpShortcut: getRequiredElement<HTMLDivElement>("sessionHelpShortcut"), sessionHelpShortcut: getRequiredElement<HTMLDivElement>(
sessionHelpWarning: getRequiredElement<HTMLDivElement>("sessionHelpWarning"), "sessionHelpShortcut",
),
sessionHelpWarning:
getRequiredElement<HTMLDivElement>("sessionHelpWarning"),
sessionHelpStatus: getRequiredElement<HTMLDivElement>("sessionHelpStatus"), sessionHelpStatus: getRequiredElement<HTMLDivElement>("sessionHelpStatus"),
sessionHelpFilter: getRequiredElement<HTMLInputElement>("sessionHelpFilter"), sessionHelpFilter:
sessionHelpContent: getRequiredElement<HTMLDivElement>("sessionHelpContent"), getRequiredElement<HTMLInputElement>("sessionHelpFilter"),
sessionHelpContent:
getRequiredElement<HTMLDivElement>("sessionHelpContent"),
}; };
} }

View File

@@ -22,7 +22,10 @@ export interface SubsyncEngineExecutionContext {
alassPath: string; alassPath: string;
ffsubsyncPath: string; ffsubsyncPath: string;
}; };
runCommand: (command: string, args: string[]) => Promise<SubsyncCommandResult>; runCommand: (
command: string,
args: string[],
) => Promise<SubsyncCommandResult>;
} }
export interface SubsyncEngineProvider { export interface SubsyncEngineProvider {
@@ -34,7 +37,10 @@ export interface SubsyncEngineProvider {
type SubsyncEngineProviderFactory = () => SubsyncEngineProvider; type SubsyncEngineProviderFactory = () => SubsyncEngineProvider;
const subsyncEngineProviderFactories = new Map<SubsyncEngine, SubsyncEngineProviderFactory>(); const subsyncEngineProviderFactories = new Map<
SubsyncEngine,
SubsyncEngineProviderFactory
>();
export function registerSubsyncEngineProvider( export function registerSubsyncEngineProvider(
engine: SubsyncEngine, engine: SubsyncEngine,

View File

@@ -33,7 +33,10 @@ export class SubtitlePipeline {
const tokenizeText = normalizeTokenizerInput(displayText); const tokenizeText = normalizeTokenizerInput(displayText);
try { try {
const tokens = await tokenizeStage(this.deps.getTokenizer(), tokenizeText); const tokens = await tokenizeStage(
this.deps.getTokenizer(),
tokenizeText,
);
const mergedTokens = mergeStage(this.deps.getTokenMerger(), tokens); const mergedTokens = mergeStage(this.deps.getTokenMerger(), tokens);
if (!mergedTokens || mergedTokens.length === 0) { if (!mergedTokens || mergedTokens.length === 0) {
return { text: displayText, tokens: null }; return { text: displayText, tokens: null };

View File

@@ -7,8 +7,5 @@ export function normalizeDisplayText(text: string): string {
} }
export function normalizeTokenizerInput(displayText: string): string { export function normalizeTokenizerInput(displayText: string): string {
return displayText return displayText.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
.replace(/\n/g, " ")
.replace(/\s+/g, " ")
.trim();
} }

View File

@@ -216,46 +216,46 @@ export function mergeTokens(
} }
return mergedHeadword; return mergedHeadword;
})(); })();
result.push({ result.push({
surface: prev.surface + token.word, surface: prev.surface + token.word,
reading: prev.reading + tokenReading, reading: prev.reading + tokenReading,
headword: prev.headword, headword: prev.headword,
startPos: prev.startPos, startPos: prev.startPos,
endPos: end, endPos: end,
partOfSpeech: prev.partOfSpeech, partOfSpeech: prev.partOfSpeech,
pos1: prev.pos1 ?? token.pos1, pos1: prev.pos1 ?? token.pos1,
pos2: prev.pos2 ?? token.pos2, pos2: prev.pos2 ?? token.pos2,
pos3: prev.pos3 ?? token.pos3, pos3: prev.pos3 ?? token.pos3,
isMerged: true, isMerged: true,
isKnown: headwordForKnownMatch isKnown: headwordForKnownMatch
? isKnownWord(headwordForKnownMatch) ? isKnownWord(headwordForKnownMatch)
: false, : false,
isNPlusOneTarget: false, isNPlusOneTarget: false,
}); });
} else { } else {
const headwordForKnownMatch = (() => { const headwordForKnownMatch = (() => {
if (knownWordMatchMode === "surface") { if (knownWordMatchMode === "surface") {
return token.word; return token.word;
} }
return token.headword; return token.headword;
})(); })();
result.push({ result.push({
surface: token.word, surface: token.word,
reading: tokenReading, reading: tokenReading,
headword: token.headword, headword: token.headword,
startPos: start, startPos: start,
endPos: end, endPos: end,
partOfSpeech: token.partOfSpeech, partOfSpeech: token.partOfSpeech,
pos1: token.pos1, pos1: token.pos1,
pos2: token.pos2, pos2: token.pos2,
pos3: token.pos3, pos3: token.pos3,
isMerged: false, isMerged: false,
isKnown: headwordForKnownMatch isKnown: headwordForKnownMatch
? isKnownWord(headwordForKnownMatch) ? isKnownWord(headwordForKnownMatch)
: false, : false,
isNPlusOneTarget: false, isNPlusOneTarget: false,
}); });
} }
lastStandaloneToken = token; lastStandaloneToken = token;
} }
@@ -263,7 +263,15 @@ export function mergeTokens(
return result; return result;
} }
const SENTENCE_BOUNDARY_SURFACES = new Set(["。", "", "", "?", "!", "…", "\u2026"]); const SENTENCE_BOUNDARY_SURFACES = new Set([
"。",
"",
"",
"?",
"!",
"…",
"\u2026",
]);
export function isNPlusOneCandidateToken(token: MergedToken): boolean { export function isNPlusOneCandidateToken(token: MergedToken): boolean {
if (token.isKnown) { if (token.isKnown) {

View File

@@ -8,7 +8,10 @@ export interface TokenMergerProvider {
type TokenMergerProviderFactory = () => TokenMergerProvider; type TokenMergerProviderFactory = () => TokenMergerProvider;
const tokenMergerProviderFactories = new Map<string, TokenMergerProviderFactory>(); const tokenMergerProviderFactories = new Map<
string,
TokenMergerProviderFactory
>();
export function registerTokenMergerProvider( export function registerTokenMergerProvider(
id: string, id: string,

View File

@@ -17,7 +17,10 @@ export interface TranslationProvider {
type TranslationProviderFactory = () => TranslationProvider; type TranslationProviderFactory = () => TranslationProvider;
const translationProviderFactories = new Map<string, TranslationProviderFactory>(); const translationProviderFactories = new Map<
string,
TranslationProviderFactory
>();
export function registerTranslationProvider( export function registerTranslationProvider(
id: string, id: string,
@@ -94,9 +97,8 @@ function registerDefaultTranslationProviders(): void {
}, },
); );
const content = (response.data as { choices?: unknown[] })?.choices?.[0] as const content = (response.data as { choices?: unknown[] })
| { message?: { content?: unknown } } ?.choices?.[0] as { message?: { content?: unknown } } | undefined;
| undefined;
const translated = extractAiText(content?.message?.content); const translated = extractAiText(content?.message?.content);
return translated || null; return translated || null;
}, },

View File

@@ -136,7 +136,9 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
} }
if ( if (
commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) || commandLine.includes(
`--input-ipc-server=${this.targetMpvSocketPath}`,
) ||
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`) commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`)
) { ) {
return mpvWindow; return mpvWindow;

View File

@@ -69,17 +69,11 @@ export function createWindowTracker(
targetMpvSocketPath?.trim() || undefined, targetMpvSocketPath?.trim() || undefined,
); );
case "sway": case "sway":
return new SwayWindowTracker( return new SwayWindowTracker(targetMpvSocketPath?.trim() || undefined);
targetMpvSocketPath?.trim() || undefined,
);
case "x11": case "x11":
return new X11WindowTracker( return new X11WindowTracker(targetMpvSocketPath?.trim() || undefined);
targetMpvSocketPath?.trim() || undefined,
);
case "macos": case "macos":
return new MacOSWindowTracker( return new MacOSWindowTracker(targetMpvSocketPath?.trim() || undefined);
targetMpvSocketPath?.trim() || undefined,
);
default: default:
log.warn("No supported compositor detected. Window tracking disabled."); log.warn("No supported compositor detected. Window tracking disabled.");
return null; return null;

View File

@@ -83,9 +83,10 @@ export class SwayWindowTracker extends BaseWindowTracker {
return windows[0] || null; return windows[0] || null;
} }
return windows.find((candidate) => return (
this.isWindowForTargetSocket(candidate), windows.find((candidate) => this.isWindowForTargetSocket(candidate)) ||
) || null; null
);
} }
private isWindowForTargetSocket(node: SwayNode): boolean { private isWindowForTargetSocket(node: SwayNode): boolean {