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(
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");

View File

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

View File

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

View File

@@ -22,9 +22,15 @@ interface CardCreationClient {
fields: Record<string, string>,
): Promise<number>;
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>;
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
findNotes(
query: string,
options?: { maxRetries?: number },
): Promise<number[]>;
}
interface CardCreationMediaGenerator {
@@ -68,10 +74,17 @@ interface CardCreationDeps {
mediaGenerator: CardCreationMediaGenerator;
showOsdNotification: (text: 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;
endUpdateProgress: () => void;
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) => Promise<T>;
withUpdateProgress: <T>(
initialMessage: string,
action: () => Promise<T>,
) => Promise<T>;
resolveConfiguredFieldName: (
noteInfo: CardCreationNoteInfo,
...preferredNames: (string | undefined)[]
@@ -80,15 +93,27 @@ interface CardCreationDeps {
noteInfo: CardCreationNoteInfo,
preferredName?: string,
) => string | null;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
extractFields: (
fields: Record<string, { value: string }>,
) => Record<string, string>;
processSentence: (
mpvSentence: string,
noteFields: Record<string, string>,
) => string;
setCardTypeFields: (
updatedFields: Record<string, string>,
availableFieldNames: string[],
cardKind: CardKind,
) => void;
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
mergeFieldValue: (
existing: string,
newValue: string,
overwrite: boolean,
) => string;
formatMiscInfoPattern: (
fallbackFilename: string,
startTimeSeconds?: number,
) => string;
getEffectiveSentenceCardConfig: () => {
model?: string;
sentenceField: string;
@@ -141,14 +166,17 @@ export class CardCreationService {
}
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;
}
const rangeStart = Math.min(...timings.map((entry) => entry.startTime));
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) {
log.warn(
`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 notesInfoResult = (await this.deps.client.notesInfo([noteId])) as CardCreationNoteInfo[];
const notesInfoResult = (await this.deps.client.notesInfo([
noteId,
])) as CardCreationNoteInfo[];
if (!notesInfoResult || notesInfoResult.length === 0) {
this.deps.showOsdNotification("Card not found");
return;
@@ -181,8 +211,10 @@ export class CardCreationService {
const noteInfo = notesInfoResult[0];
const fields = this.deps.extractFields(noteInfo.fields);
const expressionText = fields.expression || fields.word || "";
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
const sentenceAudioField =
this.getResolvedSentenceAudioFieldName(noteInfo);
const sentenceField =
this.deps.getEffectiveSentenceCardConfig().sentenceField;
const sentence = blocks.join(" ");
const updatedFields: Record<string, string> = {};
@@ -212,7 +244,8 @@ export class CardCreationService {
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
if (sentenceAudioField) {
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || "";
const existingAudio =
noteInfo.fields[sentenceAudioField]?.value || "";
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
existingAudio,
`[sound:${audioFilename}]`,
@@ -223,10 +256,7 @@ export class CardCreationService {
updatePerformed = true;
}
} catch (error) {
log.error(
"Failed to generate audio:",
(error as Error).message,
);
log.error("Failed to generate audio:", (error as Error).message);
errors.push("audio");
}
}
@@ -248,9 +278,12 @@ export class CardCreationService {
DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
);
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 {
const existingImage = noteInfo.fields[imageFieldName]?.value || "";
const existingImage =
noteInfo.fields[imageFieldName]?.value || "";
updatedFields[imageFieldName] = this.deps.mergeFieldValue(
existingImage,
`<img src="${imageFilename}">`,
@@ -261,10 +294,7 @@ export class CardCreationService {
}
}
} catch (error) {
log.error(
"Failed to generate image:",
(error as Error).message,
);
log.error("Failed to generate image:", (error as Error).message);
errors.push("image");
}
}
@@ -297,8 +327,13 @@ export class CardCreationService {
this.deps.endUpdateProgress();
}
} catch (error) {
log.error("Error updating card from clipboard:", (error as Error).message);
this.deps.showOsdNotification(`Update failed: ${(error as Error).message}`);
log.error(
"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;
}
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
const maxMediaDuration =
this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) {
endTime = startTime + maxMediaDuration;
}
@@ -346,7 +382,9 @@ export class CardCreationService {
}
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) {
this.deps.showOsdNotification("Card not found");
return;
@@ -410,8 +448,7 @@ export class CardCreationService {
const imageField = this.deps.getConfig().fields?.image;
if (imageBuffer && imageField) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
updatedFields[imageField] =
`<img src="${imageFilename}">`;
updatedFields[imageField] = `<img src="${imageFilename}">`;
miscInfoFilename = imageFilename;
}
} catch (error) {
@@ -445,10 +482,7 @@ export class CardCreationService {
await this.deps.showNotification(noteId, label, errorSuffix);
});
} catch (error) {
log.error(
"Error marking card as audio card:",
(error as Error).message,
);
log.error("Error marking card as audio card:", (error as Error).message);
this.deps.showOsdNotification(
`Audio card failed: ${(error as Error).message}`,
);
@@ -479,7 +513,8 @@ export class CardCreationService {
return false;
}
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
const maxMediaDuration =
this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) {
log.warn(
`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...");
try {
return await this.deps.withUpdateProgress("Creating sentence card", async () => {
const videoPath = mpvClient.currentVideoPath;
const fields: Record<string, string> = {};
const errors: string[] = [];
let miscInfoFilename: string | null = null;
return await this.deps.withUpdateProgress(
"Creating sentence card",
async () => {
const videoPath = mpvClient.currentVideoPath;
const fields: Record<string, string> = {};
const errors: string[] = [];
let miscInfoFilename: string | null = null;
const sentenceField = sentenceCardConfig.sentenceField;
const audioFieldName = sentenceCardConfig.audioField || "SentenceAudio";
const translationField = this.deps.getConfig().fields?.translation || "SelectionText";
let resolvedMiscInfoField: string | null = null;
let resolvedSentenceAudioField: string = audioFieldName;
let resolvedExpressionAudioField: string | null = null;
const sentenceField = sentenceCardConfig.sentenceField;
const audioFieldName =
sentenceCardConfig.audioField || "SentenceAudio";
const translationField =
this.deps.getConfig().fields?.translation || "SelectionText";
let resolvedMiscInfoField: string | null = null;
let resolvedSentenceAudioField: string = audioFieldName;
let resolvedExpressionAudioField: string | null = null;
fields[sentenceField] = sentence;
fields[sentenceField] = sentence;
const backText = await resolveSentenceBackText(
{
sentence,
secondarySubText,
config: this.deps.getConfig().ai || {},
},
{
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",
const backText = await resolveSentenceBackText(
{
sentence,
secondarySubText,
config: this.deps.getConfig().ai || {},
},
{
logWarning: (message: string) => log.warn(message),
},
);
resolvedMiscInfoField = this.deps.resolveConfiguredFieldName(
createdNoteInfo,
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);
if (backText) {
fields[translationField] = backText;
}
}
} 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 (
resolvedExpressionAudioField &&
resolvedExpressionAudioField !== resolvedSentenceAudioField
sentenceCardConfig.lapisEnabled ||
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 imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImageBuffer(videoPath, startTime, endTime);
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;
}
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");
}
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(
createdNoteInfo,
this.deps.getConfig().fields?.miscInfo,
);
if (this.deps.getConfig().fields?.miscInfo) {
const miscInfo = this.deps.formatMiscInfoPattern(
miscInfoFilename || "",
startTime,
);
if (miscInfo && resolvedMiscInfoField) {
mediaFields[resolvedMiscInfoField] = 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");
}
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 mediaFields: Record<string, string> = {};
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,
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 (
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(
`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 (
this.deps.resolveNoteFieldName(
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(
videoPath,
startTime,
endTime,
this.deps.getConfig().media?.audioPadding,
mpvClient.currentAudioStreamIndex ?? undefined,
);
videoPath,
startTime,
endTime,
this.deps.getConfig().media?.audioPadding,
mpvClient.currentAudioStreamIndex ?? undefined,
);
}
private async generateImageBuffer(
@@ -718,7 +788,10 @@ export class CardCreationService {
}
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,
maxWidth: this.deps.getConfig().media?.imageMaxWidth,
maxHeight: this.deps.getConfig().media?.imageMaxHeight,
@@ -733,7 +806,9 @@ export class CardCreationService {
private generateImageFilename(): string {
const timestamp = Date.now();
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}`;
}
}

View File

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

View File

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

View File

@@ -46,7 +46,8 @@ export class KnownWordCacheManager {
constructor(private readonly deps: KnownWordCacheDeps) {
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);
}
} 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;
for (let i = 0; i < noteIds.length; 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[];
for (const noteInfo of notesInfo) {
@@ -196,7 +202,9 @@ export class KnownWordCacheManager {
);
} catch (error) {
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 {
this.isRefreshingKnownWords = false;
}
@@ -313,7 +321,10 @@ export class KnownWordCacheManager {
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
this.knownWordsScope = parsed.scope;
} 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.knownWordsLastRefreshedAtMs = 0;
this.knownWordsScope = this.getKnownWordCacheScope();
@@ -330,7 +341,10 @@ export class KnownWordCacheManager {
};
fs.writeFileSync(this.statePath, JSON.stringify(state), "utf-8");
} 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;
}
private extractKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] {
private extractKnownWordsFromNoteInfo(
noteInfo: KnownWordCacheNoteInfo,
): string[] {
const words: string[] = [];
const preferredFields = ["Expression", "Word"];
for (const preferredField of preferredFields) {
const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField);
const fieldName = resolveFieldName(
Object.keys(noteInfo.fields),
preferredField,
);
if (!fieldName) continue;
const raw = noteInfo.fields[fieldName]?.value;
@@ -387,12 +406,14 @@ function resolveFieldName(
if (exact) return exact;
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 {
return value
.replace(/\\/g, "\\\\")
.replace(/\"/g, "\\\"")
.replace(/\"/g, '\\"')
.replace(/([:*?()\[\]{}])/g, "\\$1");
}

View File

@@ -56,7 +56,9 @@ export class PollingRunner {
this.deps.setUpdateInProgress(true);
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, {
maxRetries: 0,
});

View File

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

View File

@@ -34,7 +34,8 @@ const hasSafeStorage =
const originalSafeStorage: SafeStorageLike | null = hasSafeStorage
? {
isEncryptionAvailable: safeStorageApi.isEncryptionAvailable as () => boolean,
isEncryptionAvailable:
safeStorageApi.isEncryptionAvailable as () => boolean,
encryptString: safeStorageApi.encryptString as (value: string) => Buffer,
decryptString: safeStorageApi.decryptString as (value: Buffer) => string,
}
@@ -87,76 +88,92 @@ function restoreSafeStorage(): void {
).decryptString = originalSafeStorage.decryptString;
}
test("anilist token store saves and loads encrypted token", { skip: !hasSafeStorage }, () => {
mockSafeStorage(true);
try {
test(
"anilist token store saves and loads encrypted token",
{ 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 store = createAnilistTokenStore(filePath, createLogger());
store.saveToken(" demo-token ");
fs.writeFileSync(
filePath,
JSON.stringify({ plaintextToken: "legacy-token", updatedAt: Date.now() }),
"utf-8",
);
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();
}
});
mockSafeStorage(true);
try {
const store = createAnilistTokenStore(filePath, createLogger());
assert.equal(store.loadToken(), "legacy-token");
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 {
encryptedToken?: string;
plaintextToken?: string;
};
assert.equal(typeof payload.encryptedToken, "string");
assert.equal(payload.plaintextToken, undefined);
} finally {
restoreSafeStorage();
}
},
);
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();
fs.writeFileSync(
filePath,
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();
}
});
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,
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", () => {
@@ -89,5 +93,8 @@ test("anilist update queue persists and reloads from disk", () => {
ready: 1,
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 {
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);
}
@@ -184,7 +185,9 @@ export function createAnilistUpdateQueue(
},
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 {
pending: pending.length,
ready,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,9 @@ export interface AppLifecycleServiceDeps {
parseArgs: (argv: string[]) => CliArgs;
requestSingleInstanceLock: () => boolean;
quitApp: () => void;
onSecondInstance: (handler: (_event: unknown, argv: string[]) => void) => void;
onSecondInstance: (
handler: (_event: unknown, argv: string[]) => void,
) => void;
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
@@ -53,18 +55,27 @@ export function createAppLifecycleDepsRuntime(
requestSingleInstanceLock: () => options.app.requestSingleInstanceLock(),
quitApp: () => options.app.quit(),
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,
printHelp: options.printHelp,
logNoRunningInstance: options.logNoRunningInstance,
whenReady: (handler) => {
options.app.whenReady().then(handler).catch((error) => {
logger.error("App ready handler failed:", error);
});
options.app
.whenReady()
.then(handler)
.catch((error) => {
logger.error("App ready handler failed:", error);
});
},
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) => {
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"),
createMpvClient: () => calls.push("createMpvClient"),
reloadConfig: () => calls.push("reloadConfig"),
getResolvedConfig: () => ({ websocket: { enabled: "auto" }, secondarySub: {} }),
getResolvedConfig: () => ({
websocket: { enabled: "auto" },
secondarySub: {},
}),
getConfigWarnings: () => [],
logConfigWarning: () => calls.push("logConfigWarning"),
setLogLevel: (level, source) => calls.push(`setLogLevel:${level}:${source}`),
setLogLevel: (level, source) =>
calls.push(`setLogLevel:${level}:${source}`),
initRuntimeOptionsManager: () => calls.push("initRuntimeOptionsManager"),
setSecondarySubMode: (mode) => calls.push(`setSecondarySubMode:${mode}`),
defaultSecondarySubMode: "hover",
defaultWebsocketPort: 9001,
hasMpvWebsocketPlugin: () => true,
startSubtitleWebsocket: (port) => calls.push(`startSubtitleWebsocket:${port}`),
startSubtitleWebsocket: (port) =>
calls.push(`startSubtitleWebsocket:${port}`),
log: (message) => calls.push(`log:${message}`),
createMecabTokenizerAndCheck: async () => {
calls.push("createMecabTokenizerAndCheck");
},
createSubtitleTimingTracker: () => calls.push("createSubtitleTimingTracker"),
createSubtitleTimingTracker: () =>
calls.push("createSubtitleTimingTracker"),
createImmersionTracker: () => calls.push("createImmersionTracker"),
startJellyfinRemoteSession: async () => {
calls.push("startJellyfinRemoteSession");
},
loadYomitanExtension: async () => {
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("initializeOverlayRuntime"));
assert.ok(calls.includes("createImmersionTracker"));
assert.ok(calls.includes("startJellyfinRemoteSession"));
assert.ok(
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({
createImmersionTracker: undefined,
});
await runAppReadyRuntimeService(deps);
await runAppReadyRuntime(deps);
assert.ok(
calls.includes(
"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({
createImmersionTracker: () => {
calls.push("createImmersionTracker");
throw new Error("immersion init failed");
},
});
await runAppReadyRuntimeService(deps);
await runAppReadyRuntime(deps);
assert.ok(calls.includes("createImmersionTracker"));
assert.ok(
calls.includes(

View File

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

View File

@@ -9,7 +9,9 @@ export function createFieldGroupingCallback(options: {
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
setResolver: (
resolver: ((choice: KikuFieldGroupingChoice) => void) | null,
) => void;
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
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 () => {
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");
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(
logs.some((entry) =>
entry.includes("Failed to parse frequency dictionary file as JSON") &&
entry.includes("term_meta_bank_1.json")
logs.some(
(entry) =>
entry.includes("Failed to parse frequency dictionary file as JSON") &&
entry.includes("term_meta_bank_1.json"),
),
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 () => {
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({
searchPaths: [missingPath],
log: (message) => {

View File

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

View File

@@ -3,11 +3,36 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { ImmersionTrackerService } from "./immersion-tracker-service";
import type { DatabaseSync as NodeDatabaseSync } from "node:sqlite";
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 {
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");
}
@@ -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();
let tracker: ImmersionTrackerService | null = null;
try {
tracker = new ImmersionTrackerService({ dbPath });
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange("/tmp/episode.mkv", "Episode");
const privateApi = tracker as unknown as {
@@ -33,7 +59,7 @@ test("startSession generates UUID-like session identifiers", () => {
privateApi.flushTelemetry(true);
privateApi.flushNow();
const db = new DatabaseSync(dbPath);
const db = new DatabaseSync!(dbPath);
const row = db
.prepare("SELECT session_uuid FROM imm_sessions LIMIT 1")
.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();
let tracker: ImmersionTrackerService | null = null;
try {
tracker = new ImmersionTrackerService({ dbPath });
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.handleMediaChange("/tmp/episode-2.mkv", "Episode 2");
tracker.recordSubtitleLine("Hello immersion", 0, 1);
tracker.destroy();
const db = new DatabaseSync(dbPath);
const db = new DatabaseSync!(dbPath);
const sessionRow = db
.prepare("SELECT ended_at_ms FROM imm_sessions LIMIT 1")
.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();
let tracker: ImmersionTrackerService | null = null;
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 {
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;
};
@@ -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();
let tracker: ImmersionTrackerService | null = null;
let originalPrepare: DatabaseSync["prepare"] | null = null;
let originalPrepare: NodeDatabaseSync["prepare"] | null = null;
try {
tracker = new ImmersionTrackerService({ dbPath });
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as {
db: DatabaseSync;
db: NodeDatabaseSync;
flushSingle: (write: {
kind: "telemetry" | "event";
sessionId: number;
@@ -277,7 +428,7 @@ test("flushSingle reuses cached prepared statements", () => {
originalPrepare = privateApi.db.prepare;
let prepareCalls = 0;
privateApi.db.prepare = (...args: Parameters<DatabaseSync["prepare"]>) => {
privateApi.db.prepare = (...args: Parameters<NodeDatabaseSync["prepare"]>) => {
prepareCalls += 1;
return originalPrepare!.apply(privateApi.db, args);
};
@@ -362,7 +513,7 @@ test("flushSingle reuses cached prepared statements", () => {
assert.equal(prepareCalls, 0);
} finally {
if (tracker && originalPrepare) {
const privateApi = tracker as unknown as { db: DatabaseSync };
const privateApi = tracker as unknown as { db: NodeDatabaseSync };
privateApi.db.prepare = originalPrepare;
}
tracker?.destroy();

View File

@@ -11,12 +11,12 @@ const DEFAULT_BATCH_SIZE = 25;
const DEFAULT_FLUSH_INTERVAL_MS = 500;
const DEFAULT_MAINTENANCE_INTERVAL_MS = 24 * 60 * 60 * 1000;
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
const EVENTS_RETENTION_MS = ONE_WEEK_MS;
const VACUUM_INTERVAL_MS = ONE_WEEK_MS;
const TELEMETRY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const DAILY_ROLLUP_RETENTION_MS = 365 * 24 * 60 * 60 * 1000;
const MONTHLY_ROLLUP_RETENTION_MS = 5 * 365 * 24 * 60 * 60 * 1000;
const MAX_PAYLOAD_BYTES = 256;
const DEFAULT_EVENTS_RETENTION_MS = ONE_WEEK_MS;
const DEFAULT_VACUUM_INTERVAL_MS = ONE_WEEK_MS;
const DEFAULT_TELEMETRY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const DEFAULT_DAILY_ROLLUP_RETENTION_MS = 365 * 24 * 60 * 60 * 1000;
const DEFAULT_MONTHLY_ROLLUP_RETENTION_MS = 5 * 365 * 24 * 60 * 60 * 1000;
const DEFAULT_MAX_PAYLOAD_BYTES = 256;
const SOURCE_TYPE_LOCAL = 1;
const SOURCE_TYPE_REMOTE = 2;
@@ -35,6 +35,22 @@ const EVENT_PAUSE_END = 8;
export interface ImmersionTrackerOptions {
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 {
@@ -154,6 +170,12 @@ export class ImmersionTrackerService {
private readonly batchSize: number;
private readonly flushIntervalMs: 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 writeLock = { locked: false };
private flushTimer: ReturnType<typeof setTimeout> | null = null;
@@ -177,10 +199,69 @@ export class ImmersionTrackerService {
fs.mkdirSync(parentDir, { recursive: true });
}
this.queueCap = DEFAULT_QUEUE_CAP;
this.batchSize = DEFAULT_BATCH_SIZE;
this.flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS;
this.maintenanceIntervalMs = DEFAULT_MAINTENANCE_INTERVAL_MS;
const policy = options.policy ?? {};
this.queueCap = this.resolveBoundedInt(
policy.queueCap,
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.db = new DatabaseSync(this.dbPath);
@@ -223,9 +304,7 @@ export class ImmersionTrackerService {
this.db.close();
}
async getSessionSummaries(
limit = 50,
): Promise<SessionSummaryQueryRow[]> {
async getSessionSummaries(limit = 50): Promise<SessionSummaryQueryRow[]> {
const prepared = this.db.prepare(`
SELECT
s.video_id AS videoId,
@@ -273,7 +352,9 @@ export class ImmersionTrackerService {
totalSessions: 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(
"SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NULL",
);
@@ -282,9 +363,7 @@ export class ImmersionTrackerService {
return { totalSessions, activeSessions };
}
async getDailyRollups(
limit = 60,
): Promise<ImmersionSessionRollupRow[]> {
async getDailyRollups(limit = 60): Promise<ImmersionSessionRollupRow[]> {
const prepared = this.db.prepare(`
SELECT
rollup_day AS rollupDayOrMonth,
@@ -305,9 +384,7 @@ export class ImmersionTrackerService {
return prepared.all(limit) as unknown as ImmersionSessionRollupRow[];
}
async getMonthlyRollups(
limit = 24,
): Promise<ImmersionSessionRollupRow[]> {
async getMonthlyRollups(limit = 24): Promise<ImmersionSessionRollupRow[]> {
const prepared = this.db.prepare(`
SELECT
rollup_month AS rollupDayOrMonth,
@@ -352,9 +429,12 @@ export class ImmersionTrackerService {
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 canonicalTitle = normalizedTitle || this.deriveCanonicalTitle(normalizedPath);
const canonicalTitle =
normalizedTitle || this.deriveCanonicalTitle(normalizedPath);
const sourcePath = sourceType === SOURCE_TYPE_LOCAL ? 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}`,
);
this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs);
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
this.captureVideoMetadataAsync(
sessionInfo.videoId,
sourceType,
normalizedPath,
);
}
handleMediaTitleUpdate(mediaTitle: string | null): void {
@@ -383,11 +467,7 @@ export class ImmersionTrackerService {
this.updateVideoTitleForActiveSession(normalizedTitle);
}
recordSubtitleLine(
text: string,
startSec: number,
endSec: number,
): void {
recordSubtitleLine(text: string, startSec: number, endSec: number): void {
if (!this.sessionState || !text.trim()) return;
const cleaned = this.normalizeText(text);
if (!cleaned) return;
@@ -418,7 +498,11 @@ export class ImmersionTrackerService {
}
recordPlaybackPosition(mediaTimeSec: number | null): void {
if (!this.sessionState || mediaTimeSec === null || !Number.isFinite(mediaTimeSec)) {
if (
!this.sessionState ||
mediaTimeSec === null ||
!Number.isFinite(mediaTimeSec)
) {
return;
}
const nowMs = Date.now();
@@ -637,7 +721,10 @@ export class ImmersionTrackerService {
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;
try {
this.db.exec("BEGIN IMMEDIATE");
@@ -648,7 +735,10 @@ export class ImmersionTrackerService {
} catch (error) {
this.db.exec("ROLLBACK");
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 {
this.writeLock.locked = 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 {
this.maintenanceTimer = setInterval(() => {
this.runMaintenance();
@@ -863,26 +965,33 @@ export class ImmersionTrackerService {
this.flushTelemetry(true);
this.flushNow();
const nowMs = Date.now();
const eventCutoff = nowMs - EVENTS_RETENTION_MS;
const telemetryCutoff = nowMs - TELEMETRY_RETENTION_MS;
const dailyCutoff = nowMs - DAILY_ROLLUP_RETENTION_MS;
const monthlyCutoff = nowMs - MONTHLY_ROLLUP_RETENTION_MS;
const eventCutoff = nowMs - this.eventsRetentionMs;
const telemetryCutoff = nowMs - this.telemetryRetentionMs;
const dailyCutoff = nowMs - this.dailyRollupRetentionMs;
const monthlyCutoff = nowMs - this.monthlyRollupRetentionMs;
const dayCutoff = Math.floor(dailyCutoff / 86_400_000);
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
.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);
this.runRollupMaintenance();
if (
nowMs - this.lastVacuumMs >= VACUUM_INTERVAL_MS
&& !this.writeLock.locked
) {
if (nowMs - this.lastVacuumMs >= this.vacuumIntervalMs && !this.writeLock.locked) {
this.db.exec("VACUUM");
this.lastVacuumMs = nowMs;
}
@@ -1007,16 +1116,21 @@ export class ImmersionTrackerService {
this.scheduleFlush(0);
}
private startSessionStatement(videoId: number, startedAtMs: number): {
private startSessionStatement(
videoId: number,
startedAtMs: number,
): {
lastInsertRowid: number | bigint;
} {
const sessionUuid = crypto.randomUUID();
return this.db
.prepare(`
.prepare(
`
INSERT INTO imm_sessions (
session_uuid, video_id, started_at_ms, status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?)
`)
`,
)
.run(
sessionUuid,
videoId,
@@ -1055,16 +1169,24 @@ export class ImmersionTrackerService {
.prepare(
"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;
}
private getOrCreateVideo(videoKey: string, details: {
canonicalTitle: string;
sourcePath: string | null;
sourceUrl: string | null;
sourceType: number;
}): number {
private getOrCreateVideo(
videoKey: string,
details: {
canonicalTitle: string;
sourcePath: string | null;
sourceUrl: string | null;
sourceType: number;
},
): number {
const existing = this.db
.prepare("SELECT video_id FROM imm_videos WHERE video_key = ?")
.get(videoKey) as { video_id: number } | null;
@@ -1073,7 +1195,11 @@ export class ImmersionTrackerService {
.prepare(
"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;
}
@@ -1112,7 +1238,8 @@ export class ImmersionTrackerService {
private updateVideoMetadata(videoId: number, metadata: VideoMetadata): void {
this.db
.prepare(`
.prepare(
`
UPDATE imm_videos
SET
duration_ms = ?,
@@ -1129,7 +1256,8 @@ export class ImmersionTrackerService {
metadata_json = ?,
updated_at_ms = ?
WHERE video_id = ?
`)
`,
)
.run(
metadata.durationMs,
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 info = await this.runFfprobe(mediaPath);
const stat = await fs.promises.stat(mediaPath);
@@ -1342,14 +1472,17 @@ export class ImmersionTrackerService {
private sanitizePayload(payload: Record<string, unknown>): string {
const json = JSON.stringify(payload);
return json.length <= MAX_PAYLOAD_BYTES
return json.length <= this.maxPayloadBytes
? json
: 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 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);
return { words, tokens };
}
@@ -1401,7 +1534,8 @@ export class ImmersionTrackerService {
}
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;
}

View File

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

View File

@@ -55,6 +55,13 @@ test("createIpcDepsRuntime wires AniList handlers", async () => {
ready: 0,
deadLetter: 0,
});
assert.deepEqual(await deps.retryAnilistQueueNow(), { ok: true, message: "done" });
assert.deepEqual(calls, ["clearAnilistToken", "openAnilistSetup", "retryAnilistQueueNow"]);
assert.deepEqual(await deps.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 {
getInvisibleWindow: () => WindowLike | null;
isVisibleOverlayVisible: () => boolean;
setInvisibleIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
setInvisibleIgnoreMouseEvents: (
ignore: boolean,
options?: { forward?: boolean },
) => void;
onOverlayModalClosed: (modal: string) => void;
openYomitanSettings: () => void;
quitApp: () => void;
@@ -17,7 +20,11 @@ export interface IpcServiceDeps {
getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: unknown) => void;
getMecabStatus: () => { available: boolean; enabled: boolean; path: string | null };
getMecabStatus: () => {
available: boolean;
enabled: boolean;
path: string | null;
};
setMecabEnabled: (enabled: boolean) => void;
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
@@ -51,7 +58,11 @@ interface WindowLike {
}
interface MecabTokenizerLike {
getStatus: () => { available: boolean; enabled: boolean; path: string | null };
getStatus: () => {
available: boolean;
enabled: boolean;
path: string | null;
};
setEnabled: (enabled: boolean) => void;
}
@@ -235,9 +246,12 @@ export function registerIpcHandlers(deps: IpcServiceDeps): void {
return deps.getSubtitleStyle();
});
ipcMain.on("save-subtitle-position", (_event: IpcMainEvent, position: unknown) => {
deps.saveSubtitlePosition(position);
});
ipcMain.on(
"save-subtitle-position",
(_event: IpcMainEvent, position: unknown) => {
deps.saveSubtitlePosition(position);
},
);
ipcMain.handle("get-mecab-status", () => {
return deps.getMecabStatus();
@@ -247,9 +261,12 @@ export function registerIpcHandlers(deps: IpcServiceDeps): void {
deps.setMecabEnabled(enabled);
});
ipcMain.on("mpv-command", (_event: IpcMainEvent, command: (string | number)[]) => {
deps.handleMpvCommand(command);
});
ipcMain.on(
"mpv-command",
(_event: IpcMainEvent, command: (string | number)[]) => {
deps.handleMpvCommand(command);
},
);
ipcMain.handle("get-keybindings", () => {
return deps.getKeybindings();
@@ -283,17 +300,26 @@ export function registerIpcHandlers(deps: IpcServiceDeps): void {
return deps.getRuntimeOptions();
});
ipcMain.handle("runtime-options:set", (_event, id: string, value: unknown) => {
return deps.setRuntimeOption(id, value);
});
ipcMain.handle(
"runtime-options:set",
(_event, id: string, value: unknown) => {
return deps.setRuntimeOption(id, value);
},
);
ipcMain.handle("runtime-options:cycle", (_event, id: string, direction: 1 | -1) => {
return deps.cycleRuntimeOption(id, direction);
});
ipcMain.handle(
"runtime-options:cycle",
(_event, id: string, direction: 1 | -1) => {
return deps.cycleRuntimeOption(id, direction);
},
);
ipcMain.on("overlay-content-bounds:report", (_event: IpcMainEvent, payload: unknown) => {
deps.reportOverlayContentBounds(payload);
});
ipcMain.on(
"overlay-content-bounds:report",
(_event: IpcMainEvent, payload: unknown) => {
deps.reportOverlayContentBounds(payload);
},
);
ipcMain.handle("anilist:get-status", () => {
return deps.getAnilistStatus();

View File

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

View File

@@ -50,8 +50,7 @@ function addEntriesToMap(
incomingLevel: JlptLevel,
): boolean =>
existingLevel === undefined ||
JLPT_LEVEL_PRECEDENCE[incomingLevel] >
JLPT_LEVEL_PRECEDENCE[existingLevel];
JLPT_LEVEL_PRECEDENCE[incomingLevel] > JLPT_LEVEL_PRECEDENCE[existingLevel];
if (!Array.isArray(rawEntries)) {
return;
@@ -163,7 +162,7 @@ export async function createJlptVocabularyLookup(
return (term: string): JlptLevel | null => {
if (!term) return null;
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) {
options.log(`JLPT dictionary search matched path(s): ${resolvedBanks.join(", ")}`);
options.log(
`JLPT dictionary search matched path(s): ${resolvedBanks.join(", ")}`,
);
}
return NOOP_LOOKUP;
}

View File

@@ -97,7 +97,12 @@ test("mineSentenceCard creates sentence card from mpv subtitle state", async ()
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async (sentence, startTime, endTime, secondarySub) => {
createSentenceCard: async (
sentence,
startTime,
endTime,
secondarySub,
) => {
created.push({ sentence, startTime, endTime, secondarySub });
return true;
},
@@ -176,7 +181,9 @@ test("handleMineSentenceDigit reports async create failures", async () => {
assert.equal(logs.length, 1);
assert.equal(logs[0]?.message, "mineSentenceMultiple failed:");
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);
});

View File

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

View File

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

View File

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

View File

@@ -45,23 +45,21 @@ export function sendToVisibleOverlayRuntime<T extends string>(options: {
return true;
}
export function createFieldGroupingCallbackRuntime<T extends string>(
options: {
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (
resolver: ((choice: KikuFieldGroupingChoice) => void) | null,
) => void;
sendToVisibleOverlay: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T },
) => boolean;
},
): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
export function createFieldGroupingCallbackRuntime<T extends string>(options: {
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
setResolver: (
resolver: ((choice: KikuFieldGroupingChoice) => void) | null,
) => void;
sendToVisibleOverlay: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: T },
) => boolean;
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
return createFieldGroupingCallback({
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
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";
const logger = createLogger("main:overlay-content-measurement");
@@ -8,7 +12,10 @@ const MAX_RECT_OFFSET = 50000;
const MAX_FUTURE_TIMESTAMP_MS = 60_000;
const INVALID_LOG_THROTTLE_MS = 10_000;
type OverlayMeasurementStore = Record<OverlayLayer, OverlayContentMeasurement | null>;
type OverlayMeasurementStore = Record<
OverlayLayer,
OverlayContentMeasurement | null
>;
export function sanitizeOverlayContentMeasurement(
payload: unknown,
@@ -20,15 +27,28 @@ export function sanitizeOverlayContentMeasurement(
layer?: unknown;
measuredAtMs?: 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") {
return null;
}
const viewportWidth = readFiniteInRange(candidate.viewport?.width, 1, MAX_VIEWPORT);
const viewportHeight = readFiniteInRange(candidate.viewport?.height, 1, MAX_VIEWPORT);
const viewportWidth = readFiniteInRange(
candidate.viewport?.width,
1,
MAX_VIEWPORT,
);
const viewportHeight = readFiniteInRange(
candidate.viewport?.height,
1,
MAX_VIEWPORT,
);
if (!Number.isFinite(viewportWidth) || !Number.isFinite(viewportHeight)) {
return null;
@@ -56,9 +76,7 @@ export function sanitizeOverlayContentMeasurement(
};
}
function sanitizeOverlayContentRect(
rect: unknown,
): OverlayContentRect | null {
function sanitizeOverlayContentRect(rect: unknown): OverlayContentRect | null {
if (rect === null || rect === undefined) {
return null;
}
@@ -91,11 +109,7 @@ function sanitizeOverlayContentRect(
return { x, y, width, height };
}
function readFiniteInRange(
value: unknown,
min: number,
max: number,
): number {
function readFiniteInRange(value: unknown, min: number, max: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return Number.NaN;
}
@@ -141,7 +155,9 @@ export function createOverlayContentMeasurementStore(options?: {
return measurement;
}
function getLatestByLayer(layer: OverlayLayer): OverlayContentMeasurement | null {
function getLatestByLayer(
layer: OverlayLayer,
): OverlayContentMeasurement | null {
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", () => {
const manager = createOverlayManager();
const visibleWindow = { isDestroyed: () => false } as unknown as Electron.BrowserWindow;
const invisibleWindow = { isDestroyed: () => false } as unknown as Electron.BrowserWindow;
const visibleWindow = {
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
const invisibleWindow = {
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
manager.setMainWindow(visibleWindow);
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.getOverlayWindow("visible"), visibleWindow);
assert.equal(manager.getOverlayWindow("invisible"), invisibleWindow);
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow]);
assert.deepEqual(manager.getOverlayWindows(), [
visibleWindow,
invisibleWindow,
]);
});
test("overlay manager excludes destroyed windows", () => {
const manager = createOverlayManager();
manager.setMainWindow({ isDestroyed: () => true } as unknown as Electron.BrowserWindow);
manager.setInvisibleWindow({ isDestroyed: () => false } as unknown as Electron.BrowserWindow);
manager.setMainWindow({
isDestroyed: () => true,
} as unknown as Electron.BrowserWindow);
manager.setInvisibleWindow({
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow);
assert.equal(manager.getOverlayWindows().length, 1);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,8 +77,7 @@ export async function runSubsyncManualFromIpcRuntime(
isSubsyncInProgress: triggerDeps.isSubsyncInProgress,
setSubsyncInProgress: triggerDeps.setSubsyncInProgress,
showMpvOsd: triggerDeps.showMpvOsd,
runWithSpinner: (task) =>
triggerDeps.runWithSubsyncSpinner(() => task()),
runWithSpinner: (task) => triggerDeps.runWithSubsyncSpinner(() => task()),
runSubsyncManual: (subsyncRequest) =>
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 () => {
@@ -163,14 +165,8 @@ test("runSubsyncManual constructs ffsubsync command and returns success", async
fs.writeFileSync(videoPath, "video");
fs.writeFileSync(primaryPath, "sub");
writeExecutableScript(
ffmpegPath,
"#!/bin/sh\nexit 0\n",
);
writeExecutableScript(
alassPath,
"#!/bin/sh\nexit 0\n",
);
writeExecutableScript(ffmpegPath, "#!/bin/sh\nexit 0\n");
writeExecutableScript(alassPath, "#!/bin/sh\nexit 0\n");
writeExecutableScript(
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`,

View File

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

View File

@@ -19,18 +19,20 @@ export interface CycleSecondarySubModeDeps {
const SECONDARY_SUB_CYCLE: SecondarySubMode[] = ["hidden", "visible", "hover"];
const SECONDARY_SUB_TOGGLE_DEBOUNCE_MS = 120;
export function cycleSecondarySubMode(
deps: CycleSecondarySubModeDeps,
): void {
export function cycleSecondarySubMode(deps: CycleSecondarySubModeDeps): void {
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;
}
deps.setLastSecondarySubToggleAtMs(now);
const currentMode = deps.getSecondarySubMode();
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.broadcastSecondarySubMode(nextMode);
deps.showMpvOsd(`Secondary subtitle: ${nextMode}`);
@@ -89,10 +91,12 @@ function persistSubtitlePosition(
fs.writeFileSync(positionPath, JSON.stringify(position, null, 2));
}
export function loadSubtitlePosition(options: {
currentMediaPath: string | null;
fallbackPosition: SubtitlePosition;
} & { subtitlePositionsDir: string }): SubtitlePosition | null {
export function loadSubtitlePosition(
options: {
currentMediaPath: string | null;
fallbackPosition: SubtitlePosition;
} & { subtitlePositionsDir: string },
): SubtitlePosition | null {
if (!options.currentMediaPath) {
return options.fallbackPosition;
}
@@ -187,7 +191,7 @@ export function updateCurrentMediaPath(options: {
);
options.setSubtitlePosition(options.pendingSubtitlePosition);
options.clearPendingSubtitlePosition();
} catch (err) {
} catch (err) {
logger.error(
"Failed to persist queued subtitle position:",
(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.reading,
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) {
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 (
Array.isArray(value) &&
value.every(
(group) =>
Array.isArray(group) &&
group.every((item) =>
isObject(item) && isString((item as YomitanParseHeadword).term),
group.every(
(item) =>
isObject(item) && isString((item as YomitanParseHeadword).term),
),
)
);
@@ -502,7 +508,9 @@ function applyJlptMarking(
getJlptLevel,
);
const fallbackLevel =
primaryLevel === null ? getCachedJlptLevel(token.surface, getJlptLevel) : null;
primaryLevel === null
? getCachedJlptLevel(token.surface, getJlptLevel)
: null;
return {
...token,
@@ -615,20 +623,22 @@ function selectBestYomitanParseCandidate(
const getBestByTokenCount = (
items: YomitanParseCandidate[],
): YomitanParseCandidate | null => items.length === 0
? null
: items.reduce((best, current) =>
current.tokens.length > best.tokens.length ? current : best,
);
): YomitanParseCandidate | null =>
items.length === 0
? null
: items.reduce((best, current) =>
current.tokens.length > best.tokens.length ? current : best,
);
const getCandidateScore = (candidate: YomitanParseCandidate): number => {
const readableTokenCount = candidate.tokens.filter(
(token) => token.reading.trim().length > 0,
).length;
const suspiciousKanaFragmentCount = candidate.tokens.filter((token) =>
token.reading.trim().length === 0 &&
token.surface.length >= 2 &&
Array.from(token.surface).every((char) => isKanaChar(char))
const suspiciousKanaFragmentCount = candidate.tokens.filter(
(token) =>
token.reading.trim().length === 0 &&
token.surface.length >= 2 &&
Array.from(token.surface).every((char) => isKanaChar(char)),
).length;
return (
@@ -680,7 +690,8 @@ function selectBestYomitanParseCandidate(
const multiTokenCandidates = candidates.filter(
(candidate) => candidate.tokens.length > 1,
);
const pool = multiTokenCandidates.length > 0 ? multiTokenCandidates : candidates;
const pool =
multiTokenCandidates.length > 0 ? multiTokenCandidates : candidates;
const bestCandidate = chooseBestCandidate(pool);
return bestCandidate ? bestCandidate.tokens : null;
}
@@ -705,7 +716,9 @@ function mapYomitanParseResultsToMergedTokens(
knownWordMatchMode,
),
)
.filter((candidate): candidate is YomitanParseCandidate => candidate !== null);
.filter(
(candidate): candidate is YomitanParseCandidate => candidate !== null,
);
const bestCandidate = selectBestYomitanParseCandidate(candidates);
return bestCandidate;
@@ -752,7 +765,8 @@ function pickClosestMecabPos1(
}
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 overlapEnd = Math.min(tokenEnd, mecabEnd);
const overlap = Math.max(0, overlapEnd - overlapStart);
@@ -764,8 +778,7 @@ function pickClosestMecabPos1(
if (
overlap > bestOverlap ||
(overlap === bestOverlap &&
(span > bestSpan ||
(span === bestSpan && mecabStart < bestStart)))
(span > bestSpan || (span === bestSpan && mecabStart < bestStart)))
) {
bestOverlap = overlap;
bestSpan = span;
@@ -879,7 +892,9 @@ async function ensureYomitanParserWindow(
});
try {
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/search.html`);
await parserWindow.loadURL(
`chrome-extension://${yomitanExt.id}/search.html`,
);
const readyPromise = deps.getYomitanParserReadyPromise();
if (readyPromise) {
await readyPromise;
@@ -963,7 +978,7 @@ async function parseWithYomitanInternalParser(
script,
true,
);
const yomitanTokens = mapYomitanParseResultsToMergedTokens(
const yomitanTokens = mapYomitanParseResultsToMergedTokens(
parseResults,
deps.isKnownWord,
deps.getKnownWordMatchMode(),
@@ -977,7 +992,7 @@ async function parseWithYomitanInternalParser(
}
return enrichYomitanPos1(yomitanTokens, deps, text);
} catch (err) {
} catch (err) {
logger.error("Yomitan parser request failed:", (err as Error).message);
return null;
}
@@ -1013,7 +1028,10 @@ export async function tokenizeSubtitle(
const frequencyEnabled = deps.getFrequencyDictionaryEnabled?.() !== false;
const frequencyLookup = deps.getFrequencyRank;
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps);
const yomitanTokens = await parseWithYomitanInternalParser(
tokenizeText,
deps,
);
if (yomitanTokens && yomitanTokens.length > 0) {
const knownMarkedTokens = applyKnownWordMarking(
yomitanTokens,
@@ -1024,12 +1042,15 @@ export async function tokenizeSubtitle(
frequencyEnabled && frequencyLookup
? applyFrequencyMarking(knownMarkedTokens, frequencyLookup)
: knownMarkedTokens.map((token) => ({
...token,
frequencyRank: undefined,
}));
...token,
frequencyRank: undefined,
}));
const jlptMarkedTokens = jlptEnabled
? applyJlptMarking(frequencyMarkedTokens, deps.getJlptLevel)
: frequencyMarkedTokens.map((token) => ({ ...token, jlptLevel: undefined }));
: frequencyMarkedTokens.map((token) => ({
...token,
jlptLevel: undefined,
}));
return {
text: displayText,
tokens: markNPlusOneTargets(
@@ -1051,12 +1072,15 @@ export async function tokenizeSubtitle(
frequencyEnabled && frequencyLookup
? applyFrequencyMarking(knownMarkedTokens, frequencyLookup)
: knownMarkedTokens.map((token) => ({
...token,
frequencyRank: undefined,
}));
...token,
frequencyRank: undefined,
}));
const jlptMarkedTokens = jlptEnabled
? applyJlptMarking(frequencyMarkedTokens, deps.getJlptLevel)
: frequencyMarkedTokens.map((token) => ({ ...token, jlptLevel: undefined }));
: frequencyMarkedTokens.map((token) => ({
...token,
jlptLevel: undefined,
}));
return {
text: displayText,
tokens: markNPlusOneTargets(

View File

@@ -32,7 +32,10 @@ export function openYomitanSettingsWindow(
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({
width: 1200,

View File

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

View File

@@ -55,7 +55,8 @@ export function resolveConfiguredShortcuts(
defaultConfig.shortcuts?.triggerFieldGrouping,
),
triggerSubsync: normalizeShortcut(
config.shortcuts?.triggerSubsync ?? defaultConfig.shortcuts?.triggerSubsync,
config.shortcuts?.triggerSubsync ??
defaultConfig.shortcuts?.triggerSubsync,
),
mineSentence: normalizeShortcut(
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);
}
const seasonFromDir = parsed.season ?? detectSeasonFromDir(normalizedMediaPath);
const seasonFromDir =
parsed.season ?? detectSeasonFromDir(normalizedMediaPath);
const title = cleanupTitle(titlePart || name);
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 {
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", () => {
assert.equal(
isAllowedAnilistSetupNavigationUrl("https://anilist.co/api/v2/oauth/authorize"),
isAllowedAnilistSetupNavigationUrl(
"https://anilist.co/api/v2/oauth/authorize",
),
true,
);
assert.equal(
@@ -33,5 +35,8 @@ test("allows only AniList https or data URLs for setup navigation", () => {
isAllowedAnilistSetupNavigationUrl("https://example.com/redirect"),
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"];
createSubtitleTimingTracker: AppReadyRuntimeDeps["createSubtitleTimingTracker"];
createImmersionTracker?: AppReadyRuntimeDeps["createImmersionTracker"];
startJellyfinRemoteSession?: AppReadyRuntimeDeps["startJellyfinRemoteSession"];
loadYomitanExtension: AppReadyRuntimeDeps["loadYomitanExtension"];
texthookerOnlyMode: AppReadyRuntimeDeps["texthookerOnlyMode"];
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps["shouldAutoInitializeOverlayRuntimeFromConfig"];
@@ -83,6 +84,7 @@ export function createAppReadyRuntimeDeps(
createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck,
createSubtitleTimingTracker: params.createSubtitleTimingTracker,
createImmersionTracker: params.createImmersionTracker,
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
loadYomitanExtension: params.loadYomitanExtension,
texthookerOnlyMode: params.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig:

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,9 @@
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 { createSubsyncRuntimeDeps } from "./dependencies";
import {
@@ -54,7 +58,9 @@ export function createSubsyncRuntimeServiceDeps(
export function triggerSubsyncFromConfigRuntime(
params: SubsyncRuntimeServiceInput,
): Promise<void> {
return triggerSubsyncFromConfigRuntimeCore(createSubsyncRuntimeServiceDeps(params));
return triggerSubsyncFromConfigRuntimeCore(
createSubsyncRuntimeServiceDeps(params),
);
}
export async function runSubsyncManualFromIpcRuntime(

View File

@@ -62,10 +62,7 @@ export class MediaGenerator {
fs.unlinkSync(filePath);
}
} catch (err) {
log.debug(
`Failed to clean up ${filePath}:`,
(err as Error).message,
);
log.debug(`Failed to clean up ${filePath}:`, (err as Error).message);
}
}
} catch (err) {
@@ -374,12 +371,7 @@ export class MediaGenerator {
"8",
);
} else if (av1Encoder === "libsvtav1") {
encoderArgs.push(
"-crf",
clampedCrf.toString(),
"-preset",
"8",
);
encoderArgs.push("-crf", clampedCrf.toString(), "-preset", "8");
} else {
// librav1e
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).
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"] }],
["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"] }],
["KeyI", { 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"] }],
[
"KeyI",
{ 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"] }],
["KeyR", { type: "mpv", command: ["script-message", "subminer-restart"] }],
["KeyC", { type: "mpv", command: ["script-message", "subminer-status"] }],
@@ -48,7 +63,8 @@ export function createKeyboardHandlers(
if (target.tagName === "IFRAME" && target.id?.startsWith("yomitan-popup")) {
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;
}
@@ -193,7 +209,9 @@ export function createKeyboardHandlers(
}
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 (handleInvisiblePositionEditKeydown(e)) return;

View File

@@ -4,7 +4,10 @@ export function createMouseHandlers(
ctx: RendererContext,
options: {
modalStateReader: ModalStateReader;
applyInvisibleSubtitleLayoutFromMpvMetrics: (metrics: any, source: string) => void;
applyInvisibleSubtitleLayoutFromMpvMetrics: (
metrics: any,
source: string,
) => void;
applyYPercent: (yPercent: number) => void;
getCurrentYPercent: () => number;
persistSubtitlePositionPatch: (patch: { yPercent: number }) => void;
@@ -26,7 +29,11 @@ export function createMouseHandlers(
function handleMouseLeave(): void {
ctx.state.isOverSubtitle = false;
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");
if (ctx.platform.shouldToggleMouseIgnore) {
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 & {
caretRangeFromPoint?: (x: number, y: number) => Range | null;
caretPositionFromPoint?: (
@@ -84,7 +94,10 @@ export function createMouseHandlers(
}
if (typeof documentWithCaretApi.caretPositionFromPoint === "function") {
const caretPosition = documentWithCaretApi.caretPositionFromPoint(clientX, clientY);
const caretPosition = documentWithCaretApi.caretPositionFromPoint(
clientX,
clientY,
);
if (!caretPosition) return null;
const range = document.createRange();
range.setStart(caretPosition.offsetNode, caretPosition.offset);
@@ -103,7 +116,9 @@ export function createMouseHandlers(
const clampedOffset = Math.max(0, Math.min(offset, text.length));
const probeIndex =
clampedOffset >= text.length ? Math.max(0, text.length - 1) : clampedOffset;
clampedOffset >= text.length
? Math.max(0, text.length - 1)
: clampedOffset;
if (wordSegmenter) {
for (const part of wordSegmenter.segment(text)) {
@@ -117,7 +132,9 @@ export function createMouseHandlers(
}
const isBoundary = (char: string): boolean =>
/[\s\u3000.,!?;:()[\]{}"'`~<>/\\|@#$%^&*+=\-、。・「」『』【】〈〉《》]/.test(char);
/[\s\u3000.,!?;:()[\]{}"'`~<>/\\|@#$%^&*+=\-、。・「」『』【】〈〉《》]/.test(
char,
);
const probeChar = text[probeIndex];
if (!probeChar || isBoundary(probeChar)) return null;
@@ -148,7 +165,10 @@ export function createMouseHandlers(
if (!ctx.dom.subtitleRoot.contains(caretRange.startContainer)) return;
const textNode = caretRange.startContainer as Text;
const wordBounds = getWordBoundsAtOffset(textNode.data, caretRange.startOffset);
const wordBounds = getWordBoundsAtOffset(
textNode.data,
caretRange.startOffset,
);
if (!wordBounds) return;
const selectionKey = `${wordBounds.start}:${wordBounds.end}:${textNode.data.slice(
@@ -242,10 +262,15 @@ export function createMouseHandlers(
element.id &&
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");
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...");
ctx.state.jimakuFiles = [];
ctx.state.selectedFileIndex = 0;
@@ -224,11 +227,12 @@ export function createJimakuModal(
const file = ctx.state.jimakuFiles[index];
setJimakuStatus("Downloading subtitle...");
const result: JimakuDownloadResult = await window.electronAPI.jimakuDownloadFile({
entryId: ctx.state.currentEntryId,
url: file.url,
name: file.name,
});
const result: JimakuDownloadResult =
await window.electronAPI.jimakuDownloadFile({
entryId: ctx.state.currentEntryId,
url: file.url,
name: file.name,
});
if (result.ok) {
setJimakuStatus(`Downloaded and loaded: ${result.path}`);
@@ -265,8 +269,12 @@ export function createJimakuModal(
.getJimakuMediaInfo()
.then((info: JimakuMediaInfo) => {
ctx.dom.jimakuTitleInput.value = info.title || "";
ctx.dom.jimakuSeasonInput.value = info.season ? String(info.season) : "";
ctx.dom.jimakuEpisodeInput.value = info.episode ? String(info.episode) : "";
ctx.dom.jimakuSeasonInput.value = info.season
? String(info.season)
: "";
ctx.dom.jimakuEpisodeInput.value = info.episode
? String(info.episode)
: "";
ctx.state.currentEpisodeFilter = info.episode ?? null;
if (info.confidence === "high" && info.title && info.episode) {
@@ -291,7 +299,10 @@ export function createJimakuModal(
ctx.dom.jimakuModal.setAttribute("aria-hidden", "true");
window.electronAPI.notifyOverlayModalClosed("jimaku");
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
ctx.dom.overlay.classList.remove("interactive");
}
@@ -334,10 +345,16 @@ export function createJimakuModal(
if (e.key === "ArrowUp") {
e.preventDefault();
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();
} 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();
}
return true;

View File

@@ -20,8 +20,14 @@ export function createKikuModal(
}
function updateKikuCardSelection(): void {
ctx.dom.kikuCard1.classList.toggle("active", ctx.state.kikuSelectedCard === 1);
ctx.dom.kikuCard2.classList.toggle("active", ctx.state.kikuSelectedCard === 2);
ctx.dom.kikuCard1.classList.toggle(
"active",
ctx.state.kikuSelectedCard === 1,
);
ctx.dom.kikuCard2.classList.toggle(
"active",
ctx.state.kikuSelectedCard === 2,
);
}
function setKikuModalStep(step: "select" | "preview"): void {
@@ -50,7 +56,9 @@ export function createKikuModal(
ctx.state.kikuPreviewMode === "compact"
? ctx.state.kikuPreviewCompactData
: ctx.state.kikuPreviewFullData;
ctx.dom.kikuPreviewJson.textContent = payload ? JSON.stringify(payload, null, 2) : "{}";
ctx.dom.kikuPreviewJson.textContent = payload
? JSON.stringify(payload, null, 2)
: "{}";
updateKikuPreviewToggle();
}
@@ -78,7 +86,8 @@ export function createKikuModal(
ctx.state.kikuSelectedCard = 1;
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.kikuCard2Expression.textContent = data.duplicate.expression;
@@ -123,7 +132,10 @@ export function createKikuModal(
ctx.state.kikuOriginalData = 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");
}
}

View File

@@ -26,7 +26,8 @@ export function createSubsyncModal(
option.textContent = track.label;
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 {
@@ -39,7 +40,10 @@ export function createSubsyncModal(
ctx.dom.subsyncModal.setAttribute("aria-hidden", "true");
window.electronAPI.notifyOverlayModalClosed("subsync");
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
if (
!ctx.state.isOverSubtitle &&
!options.modalStateReader.isAnyModalOpen()
) {
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 top = Math.min(a.y, b.y);
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);
if (subtitleHasContent) {
const subtitleRect = toMeasuredRect(ctx.dom.subtitleRoot.getBoundingClientRect());
const subtitleRect = toMeasuredRect(
ctx.dom.subtitleRoot.getBoundingClientRect(),
);
if (subtitleRect) {
combinedRect = subtitleRect;
}

View File

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

View File

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

View File

@@ -11,7 +11,10 @@ import {
} from "./invisible-layout-metrics.js";
export type MpvSubtitleLayoutController = {
applyInvisibleSubtitleLayoutFromMpvMetrics: (metrics: MpvSubtitleRenderMetrics, source: string) => void;
applyInvisibleSubtitleLayoutFromMpvMetrics: (
metrics: MpvSubtitleRenderMetrics,
source: string,
) => void;
};
export function createMpvSubtitleLayoutController(
@@ -29,10 +32,15 @@ export function createMpvSubtitleLayoutController(
ctx.state.mpvSubtitleRenderMetrics = metrics;
const geometry = calculateSubtitleMetrics(ctx, metrics);
const alignment = calculateSubtitlePosition(metrics, geometry.pxPerScaledPixel, 2);
const alignment = calculateSubtitlePosition(
metrics,
geometry.pxPerScaledPixel,
2,
);
applySubtitleFontSize(geometry.effectiveFontSize);
const effectiveBorderSize = metrics.subBorderSize * geometry.pxPerScaledPixel;
const effectiveBorderSize =
metrics.subBorderSize * geometry.pxPerScaledPixel;
document.documentElement.style.setProperty(
"--sub-border-size",
@@ -81,7 +89,10 @@ export function createMpvSubtitleLayoutController(
options.applyInvisibleSubtitleOffsetPosition();
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 {

View File

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

View File

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

View File

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

View File

@@ -95,7 +95,13 @@ test("computeWordClass does not add frequency class to known or N+1 terms", () =
topX: 100,
mode: "single",
singleColor: "#000000",
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
bandedColors: [
"#000000",
"#000000",
"#000000",
"#000000",
"#000000",
] as const,
}),
"word word-known",
);
@@ -105,7 +111,13 @@ test("computeWordClass does not add frequency class to known or N+1 terms", () =
topX: 100,
mode: "single",
singleColor: "#000000",
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
bandedColors: [
"#000000",
"#000000",
"#000000",
"#000000",
"#000000",
] as const,
}),
"word word-n-plus-one",
);
@@ -115,7 +127,13 @@ test("computeWordClass does not add frequency class to known or N+1 terms", () =
topX: 100,
mode: "single",
singleColor: "#000000",
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
bandedColors: [
"#000000",
"#000000",
"#000000",
"#000000",
"#000000",
] as const,
}),
"word word-frequency-single",
);
@@ -127,16 +145,19 @@ test("computeWordClass adds frequency class for single mode when rank is within
frequencyRank: 50,
});
const actual = computeWordClass(
token,
{
enabled: true,
topX: 100,
mode: "single",
singleColor: "#000000",
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
},
);
const actual = computeWordClass(token, {
enabled: true,
topX: 100,
mode: "single",
singleColor: "#000000",
bandedColors: [
"#000000",
"#000000",
"#000000",
"#000000",
"#000000",
] as const,
});
assert.equal(actual, "word word-frequency-single");
});
@@ -147,16 +168,19 @@ test("computeWordClass adds frequency class when rank equals topX", () => {
frequencyRank: 100,
});
const actual = computeWordClass(
token,
{
enabled: true,
topX: 100,
mode: "single",
singleColor: "#000000",
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
},
);
const actual = computeWordClass(token, {
enabled: true,
topX: 100,
mode: "single",
singleColor: "#000000",
bandedColors: [
"#000000",
"#000000",
"#000000",
"#000000",
"#000000",
] as const,
});
assert.equal(actual, "word word-frequency-single");
});
@@ -167,17 +191,19 @@ test("computeWordClass adds frequency class for banded mode", () => {
frequencyRank: 250,
});
const actual = computeWordClass(
token,
{
enabled: true,
topX: 1000,
mode: "banded",
singleColor: "#000000",
bandedColors:
["#111111", "#222222", "#333333", "#444444", "#555555"] as const,
},
);
const actual = computeWordClass(token, {
enabled: true,
topX: 1000,
mode: "banded",
singleColor: "#000000",
bandedColors: [
"#111111",
"#222222",
"#333333",
"#444444",
"#555555",
] as const,
});
assert.equal(actual, "word word-frequency-band-2");
});
@@ -193,13 +219,7 @@ test("computeWordClass uses configured band count for banded mode", () => {
topX: 4,
mode: "banded",
singleColor: "#000000",
bandedColors: [
"#111111",
"#222222",
"#333333",
"#444444",
"#555555",
],
bandedColors: ["#111111", "#222222", "#333333", "#444444", "#555555"],
} as any);
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,
});
const actual = computeWordClass(
token,
{
enabled: true,
topX: 1000,
mode: "single",
singleColor: "#000000",
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
},
);
const actual = computeWordClass(token, {
enabled: true,
topX: 1000,
mode: "single",
singleColor: "#000000",
bandedColors: [
"#000000",
"#000000",
"#000000",
"#000000",
"#000000",
] as const,
});
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 srcCssPath = path.join(process.cwd(), "src", "renderer", "style.css");
const cssPath = fs.existsSync(distCssPath)
? distCssPath
: srcCssPath;
const cssPath = fs.existsSync(distCssPath) ? distCssPath : srcCssPath;
if (!fs.existsSync(cssPath)) {
assert.fail(
"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-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\(/);
}
});

View File

@@ -72,12 +72,18 @@ function getFrequencyDictionaryClass(
return "";
}
if (typeof token.frequencyRank !== "number" || !Number.isFinite(token.frequencyRank)) {
if (
typeof token.frequencyRank !== "number" ||
!Number.isFinite(token.frequencyRank)
) {
return "";
}
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) {
return "";
}
@@ -121,16 +127,16 @@ function renderWithTokens(
if (surface.includes("\n")) {
const parts = surface.split("\n");
for (let i = 0; i < parts.length; i += 1) {
if (parts[i]) {
const span = document.createElement("span");
span.className = computeWordClass(
token,
resolvedFrequencyRenderSettings,
);
span.textContent = parts[i];
if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword;
for (let i = 0; i < parts.length; i += 1) {
if (parts[i]) {
const span = document.createElement("span");
span.className = computeWordClass(
token,
resolvedFrequencyRenderSettings,
);
span.textContent = parts[i];
if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword;
fragment.appendChild(span);
}
if (i < parts.length - 1) {
@@ -214,7 +220,10 @@ function renderCharacterLevel(root: HTMLElement, text: string): void {
root.appendChild(fragment);
}
function renderPlainTextPreserveLineBreaks(root: HTMLElement, text: string): void {
function renderPlainTextPreserveLineBreaks(
root: HTMLElement,
text: string,
): void {
const lines = text.split("\n");
const fragment = document.createDocumentFragment();
@@ -255,7 +264,10 @@ export function createSubtitleRenderer(ctx: RendererContext) {
1,
normalizedInvisible.split("\n").length,
);
renderPlainTextPreserveLineBreaks(ctx.dom.subtitleRoot, normalizedInvisible);
renderPlainTextPreserveLineBreaks(
ctx.dom.subtitleRoot,
normalizedInvisible,
);
return;
}
@@ -331,10 +343,13 @@ export function createSubtitleRenderer(ctx: RendererContext) {
function applySubtitleStyle(style: SubtitleStyleConfig | null): void {
if (!style) return;
if (style.fontFamily) ctx.dom.subtitleRoot.style.fontFamily = style.fontFamily;
if (style.fontSize) ctx.dom.subtitleRoot.style.fontSize = `${style.fontSize}px`;
if (style.fontFamily)
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.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.backgroundColor) {
ctx.dom.subtitleContainer.style.background = style.backgroundColor;
@@ -352,12 +367,12 @@ export function createSubtitleRenderer(ctx: RendererContext) {
N5: ctx.state.jlptN5Color ?? "#8aadf4",
...(style.jlptColors
? {
N1: sanitizeHexColor(style.jlptColors?.N1, ctx.state.jlptN1Color),
N2: sanitizeHexColor(style.jlptColors?.N2, ctx.state.jlptN2Color),
N3: sanitizeHexColor(style.jlptColors?.N3, ctx.state.jlptN3Color),
N4: sanitizeHexColor(style.jlptColors?.N4, ctx.state.jlptN4Color),
N5: sanitizeHexColor(style.jlptColors?.N5, ctx.state.jlptN5Color),
}
N1: sanitizeHexColor(style.jlptColors?.N1, ctx.state.jlptN1Color),
N2: sanitizeHexColor(style.jlptColors?.N2, ctx.state.jlptN2Color),
N3: sanitizeHexColor(style.jlptColors?.N3, ctx.state.jlptN3Color),
N4: sanitizeHexColor(style.jlptColors?.N4, ctx.state.jlptN4Color),
N5: sanitizeHexColor(style.jlptColors?.N5, ctx.state.jlptN5Color),
}
: {}),
};
@@ -367,20 +382,39 @@ export function createSubtitleRenderer(ctx: RendererContext) {
"--subtitle-known-word-color",
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.jlptN2Color = jlptColors.N2;
ctx.state.jlptN3Color = jlptColors.N3;
ctx.state.jlptN4Color = jlptColors.N4;
ctx.state.jlptN5Color = jlptColors.N5;
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n1-color", jlptColors.N1);
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);
ctx.dom.subtitleRoot.style.setProperty(
"--subtitle-jlpt-n1-color",
jlptColors.N1,
);
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 frequencyEnabled =
frequencyDictionarySettings.enabled ?? ctx.state.frequencyDictionaryEnabled;
frequencyDictionarySettings.enabled ??
ctx.state.frequencyDictionaryEnabled;
const frequencyTopX = sanitizeFrequencyTopX(
frequencyDictionarySettings.topX,
ctx.state.frequencyDictionaryTopX,
@@ -458,7 +492,8 @@ export function createSubtitleRenderer(ctx: RendererContext) {
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
}
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"),
subtitleContainer: getRequiredElement<HTMLElement>("subtitleContainer"),
overlay: getRequiredElement<HTMLElement>("overlay"),
secondarySubContainer:
getRequiredElement<HTMLElement>("secondarySubContainer"),
secondarySubContainer: getRequiredElement<HTMLElement>(
"secondarySubContainer",
),
secondarySubRoot: getRequiredElement<HTMLElement>("secondarySubRoot"),
jimakuModal: getRequiredElement<HTMLDivElement>("jimakuModal"),
@@ -88,60 +89,89 @@ export function resolveRendererDom(): RendererDom {
jimakuSearchButton: getRequiredElement<HTMLButtonElement>("jimakuSearch"),
jimakuCloseButton: getRequiredElement<HTMLButtonElement>("jimakuClose"),
jimakuStatus: getRequiredElement<HTMLDivElement>("jimakuStatus"),
jimakuEntriesSection: getRequiredElement<HTMLDivElement>("jimakuEntriesSection"),
jimakuEntriesSection: getRequiredElement<HTMLDivElement>(
"jimakuEntriesSection",
),
jimakuEntriesList: getRequiredElement<HTMLUListElement>("jimakuEntries"),
jimakuFilesSection: getRequiredElement<HTMLDivElement>("jimakuFilesSection"),
jimakuFilesSection:
getRequiredElement<HTMLDivElement>("jimakuFilesSection"),
jimakuFilesList: getRequiredElement<HTMLUListElement>("jimakuFiles"),
jimakuBroadenButton: getRequiredElement<HTMLButtonElement>("jimakuBroaden"),
kikuModal: getRequiredElement<HTMLDivElement>("kikuFieldGroupingModal"),
kikuCard1: getRequiredElement<HTMLDivElement>("kikuCard1"),
kikuCard2: getRequiredElement<HTMLDivElement>("kikuCard2"),
kikuCard1Expression: getRequiredElement<HTMLDivElement>("kikuCard1Expression"),
kikuCard2Expression: getRequiredElement<HTMLDivElement>("kikuCard2Expression"),
kikuCard1Expression: getRequiredElement<HTMLDivElement>(
"kikuCard1Expression",
),
kikuCard2Expression: getRequiredElement<HTMLDivElement>(
"kikuCard2Expression",
),
kikuCard1Sentence: getRequiredElement<HTMLDivElement>("kikuCard1Sentence"),
kikuCard2Sentence: getRequiredElement<HTMLDivElement>("kikuCard2Sentence"),
kikuCard1Meta: getRequiredElement<HTMLDivElement>("kikuCard1Meta"),
kikuCard2Meta: getRequiredElement<HTMLDivElement>("kikuCard2Meta"),
kikuConfirmButton: getRequiredElement<HTMLButtonElement>("kikuConfirmButton"),
kikuConfirmButton:
getRequiredElement<HTMLButtonElement>("kikuConfirmButton"),
kikuCancelButton: getRequiredElement<HTMLButtonElement>("kikuCancelButton"),
kikuDeleteDuplicateCheckbox:
getRequiredElement<HTMLInputElement>("kikuDeleteDuplicate"),
kikuDeleteDuplicateCheckbox: getRequiredElement<HTMLInputElement>(
"kikuDeleteDuplicate",
),
kikuSelectionStep: getRequiredElement<HTMLDivElement>("kikuSelectionStep"),
kikuPreviewStep: getRequiredElement<HTMLDivElement>("kikuPreviewStep"),
kikuPreviewJson: getRequiredElement<HTMLPreElement>("kikuPreviewJson"),
kikuPreviewCompactButton:
getRequiredElement<HTMLButtonElement>("kikuPreviewCompact"),
kikuPreviewFullButton: getRequiredElement<HTMLButtonElement>("kikuPreviewFull"),
kikuPreviewFullButton:
getRequiredElement<HTMLButtonElement>("kikuPreviewFull"),
kikuPreviewError: getRequiredElement<HTMLDivElement>("kikuPreviewError"),
kikuBackButton: getRequiredElement<HTMLButtonElement>("kikuBackButton"),
kikuFinalConfirmButton:
getRequiredElement<HTMLButtonElement>("kikuFinalConfirmButton"),
kikuFinalCancelButton:
getRequiredElement<HTMLButtonElement>("kikuFinalCancelButton"),
kikuFinalConfirmButton: getRequiredElement<HTMLButtonElement>(
"kikuFinalConfirmButton",
),
kikuFinalCancelButton: getRequiredElement<HTMLButtonElement>(
"kikuFinalCancelButton",
),
kikuHint: getRequiredElement<HTMLDivElement>("kikuHint"),
runtimeOptionsModal: getRequiredElement<HTMLDivElement>("runtimeOptionsModal"),
runtimeOptionsClose: getRequiredElement<HTMLButtonElement>("runtimeOptionsClose"),
runtimeOptionsList: getRequiredElement<HTMLUListElement>("runtimeOptionsList"),
runtimeOptionsStatus: getRequiredElement<HTMLDivElement>("runtimeOptionsStatus"),
runtimeOptionsModal: getRequiredElement<HTMLDivElement>(
"runtimeOptionsModal",
),
runtimeOptionsClose: getRequiredElement<HTMLButtonElement>(
"runtimeOptionsClose",
),
runtimeOptionsList:
getRequiredElement<HTMLUListElement>("runtimeOptionsList"),
runtimeOptionsStatus: getRequiredElement<HTMLDivElement>(
"runtimeOptionsStatus",
),
subsyncModal: getRequiredElement<HTMLDivElement>("subsyncModal"),
subsyncCloseButton: getRequiredElement<HTMLButtonElement>("subsyncClose"),
subsyncEngineAlass: getRequiredElement<HTMLInputElement>("subsyncEngineAlass"),
subsyncEngineFfsubsync:
getRequiredElement<HTMLInputElement>("subsyncEngineFfsubsync"),
subsyncSourceLabel: getRequiredElement<HTMLLabelElement>("subsyncSourceLabel"),
subsyncSourceSelect: getRequiredElement<HTMLSelectElement>("subsyncSourceSelect"),
subsyncEngineAlass:
getRequiredElement<HTMLInputElement>("subsyncEngineAlass"),
subsyncEngineFfsubsync: getRequiredElement<HTMLInputElement>(
"subsyncEngineFfsubsync",
),
subsyncSourceLabel:
getRequiredElement<HTMLLabelElement>("subsyncSourceLabel"),
subsyncSourceSelect: getRequiredElement<HTMLSelectElement>(
"subsyncSourceSelect",
),
subsyncRunButton: getRequiredElement<HTMLButtonElement>("subsyncRun"),
subsyncStatus: getRequiredElement<HTMLDivElement>("subsyncStatus"),
sessionHelpModal: getRequiredElement<HTMLDivElement>("sessionHelpModal"),
sessionHelpClose: getRequiredElement<HTMLButtonElement>("sessionHelpClose"),
sessionHelpShortcut: getRequiredElement<HTMLDivElement>("sessionHelpShortcut"),
sessionHelpWarning: getRequiredElement<HTMLDivElement>("sessionHelpWarning"),
sessionHelpShortcut: getRequiredElement<HTMLDivElement>(
"sessionHelpShortcut",
),
sessionHelpWarning:
getRequiredElement<HTMLDivElement>("sessionHelpWarning"),
sessionHelpStatus: getRequiredElement<HTMLDivElement>("sessionHelpStatus"),
sessionHelpFilter: getRequiredElement<HTMLInputElement>("sessionHelpFilter"),
sessionHelpContent: getRequiredElement<HTMLDivElement>("sessionHelpContent"),
sessionHelpFilter:
getRequiredElement<HTMLInputElement>("sessionHelpFilter"),
sessionHelpContent:
getRequiredElement<HTMLDivElement>("sessionHelpContent"),
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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