mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
refactor(core): normalize service naming across app runtime
This commit is contained in:
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]>(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: "接頭詞",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,8 @@ export function createOverlayShortcutsRuntimeService(
|
||||
};
|
||||
};
|
||||
|
||||
const shouldOverlayShortcutsBeActive = () => input.isOverlayRuntimeInitialized();
|
||||
const shouldOverlayShortcutsBeActive = () =>
|
||||
input.isOverlayRuntimeInitialized();
|
||||
|
||||
return {
|
||||
tryHandleOverlayShortcutLocalFallback: (inputEvent) =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ export function createPositioningController(
|
||||
{
|
||||
applyInvisibleSubtitleOffsetPosition:
|
||||
invisibleOffset.applyInvisibleSubtitleOffsetPosition,
|
||||
updateInvisiblePositionEditHud: invisibleOffset.updateInvisiblePositionEditHud,
|
||||
updateInvisiblePositionEditHud:
|
||||
invisibleOffset.updateInvisiblePositionEditHud,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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\(/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user