diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index b32f517..8295423 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -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"); diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 45d751e..c7fd5fe 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -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: (initialMessage: string, action: () => Promise) => - this.withUpdateProgress(initialMessage, action), + withUpdateProgress: ( + initialMessage: string, + action: () => Promise, + ) => 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: (initialMessage: string, action: () => Promise) => - this.withUpdateProgress(initialMessage, action), + withUpdateProgress: ( + initialMessage: string, + action: () => Promise, + ) => 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, ``, @@ -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( @@ -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 { 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 = ``; 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 { 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(); diff --git a/src/anki-integration/ai.ts b/src/anki-integration/ai.ts index 3af1ab8..63b2293 100644 --- a/src/anki-integration/ai.ts +++ b/src/anki-integration/ai.ts @@ -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( diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index a496f62..8db0c4f 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -22,9 +22,15 @@ interface CardCreationClient { fields: Record, ): Promise; notesInfo(noteIds: number[]): Promise; - updateNoteFields(noteId: number, fields: Record): Promise; + updateNoteFields( + noteId: number, + fields: Record, + ): Promise; storeMediaFile(filename: string, data: Buffer): Promise; - findNotes(query: string, options?: { maxRetries?: number }): Promise; + findNotes( + query: string, + options?: { maxRetries?: number }, + ): Promise; } 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; + showNotification: ( + noteId: number, + label: string | number, + errorSuffix?: string, + ) => Promise; beginUpdateProgress: (initialMessage: string) => void; endUpdateProgress: () => void; - withUpdateProgress: (initialMessage: string, action: () => Promise) => Promise; + withUpdateProgress: ( + initialMessage: string, + action: () => Promise, + ) => Promise; resolveConfiguredFieldName: ( noteInfo: CardCreationNoteInfo, ...preferredNames: (string | undefined)[] @@ -80,15 +93,27 @@ interface CardCreationDeps { noteInfo: CardCreationNoteInfo, preferredName?: string, ) => string | null; - extractFields: (fields: Record) => Record; - processSentence: (mpvSentence: string, noteFields: Record) => string; + extractFields: ( + fields: Record, + ) => Record; + processSentence: ( + mpvSentence: string, + noteFields: Record, + ) => string; setCardTypeFields: ( updatedFields: Record, 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 = {}; @@ -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, ``, @@ -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] = - ``; + updatedFields[imageField] = ``; 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 = {}; - const errors: string[] = []; - let miscInfoFilename: string | null = null; + return await this.deps.withUpdateProgress( + "Creating sentence card", + async () => { + const videoPath = mpvClient.currentVideoPath; + const fields: Record = {}; + 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 = {}; - 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 = {}; - - 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] = ``; - 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 = {}; + 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 = {}; - 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] = ``; + 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}`; } } diff --git a/src/anki-integration/duplicate.ts b/src/anki-integration/duplicate.ts index 025e766..52d4f32 100644 --- a/src/anki-integration/duplicate.ts +++ b/src/anki-integration/duplicate.ts @@ -14,7 +14,10 @@ export interface DuplicateDetectionDeps { ) => Promise; notesInfo: (noteIds: number[]) => Promise; 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, diff --git a/src/anki-integration/field-grouping.ts b/src/anki-integration/field-grouping.ts index ead6062..83bb779 100644 --- a/src/anki-integration/field-grouping.ts +++ b/src/anki-integration/field-grouping.ts @@ -20,7 +20,10 @@ interface FieldGroupingDeps { }; isUpdateInProgress: () => boolean; getDeck?: () => string | undefined; - withUpdateProgress: (initialMessage: string, action: () => Promise) => Promise; + withUpdateProgress: ( + initialMessage: string, + action: () => Promise, + ) => Promise; showOsdNotification: (text: string) => void; findNotes: ( query: string, @@ -29,7 +32,9 @@ interface FieldGroupingDeps { }, ) => Promise; notesInfo: (noteIds: number[]) => Promise; - extractFields: (fields: Record) => Record; + extractFields: ( + fields: Record, + ) => Record; 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}`, ); diff --git a/src/anki-integration/known-word-cache.ts b/src/anki-integration/known-word-cache.ts index 6fd8bb4..8240ff0 100644 --- a/src/anki-integration/known-word-cache.ts +++ b/src/anki-integration/known-word-cache.ts @@ -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"); } diff --git a/src/anki-integration/polling.ts b/src/anki-integration/polling.ts index 345bec4..e67ade8 100644 --- a/src/anki-integration/polling.ts +++ b/src/anki-integration/polling.ts @@ -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, }); diff --git a/src/anki-integration/ui-feedback.ts b/src/anki-integration/ui-feedback.ts index 94cb6e4..69c6cb3 100644 --- a/src/anki-integration/ui-feedback.ts +++ b/src/anki-integration/ui-feedback.ts @@ -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); } diff --git a/src/core/services/anilist/anilist-token-store.test.ts b/src/core/services/anilist/anilist-token-store.test.ts index d6022ca..6a7a902 100644 --- a/src/core/services/anilist/anilist-token-store.test.ts +++ b/src/core/services/anilist/anilist-token-store.test.ts @@ -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(); + } + }, +); diff --git a/src/core/services/anilist/anilist-update-queue.test.ts b/src/core/services/anilist/anilist-update-queue.test.ts index a10da10..cf3f1fd 100644 --- a/src/core/services/anilist/anilist-update-queue.test.ts +++ b/src/core/services/anilist/anilist-update-queue.test.ts @@ -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", + ); }); diff --git a/src/core/services/anilist/anilist-update-queue.ts b/src/core/services/anilist/anilist-update-queue.ts index 4ee3c13..ac9a8b5 100644 --- a/src/core/services/anilist/anilist-update-queue.ts +++ b/src/core/services/anilist/anilist-update-queue.ts @@ -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, diff --git a/src/core/services/anilist/anilist-updater.test.ts b/src/core/services/anilist/anilist-updater.test.ts index b1971d7..4a8cd46 100644 --- a/src/core/services/anilist/anilist-updater.test.ts +++ b/src/core/services/anilist/anilist-updater.test.ts @@ -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 { diff --git a/src/core/services/anilist/anilist-updater.ts b/src/core/services/anilist/anilist-updater.ts index cc72b10..cf5219a 100644 --- a/src/core/services/anilist/anilist-updater.ts +++ b/src/core/services/anilist/anilist-updater.ts @@ -128,15 +128,16 @@ async function anilistGraphQl( return { errors: [ { - message: - error instanceof Error ? error.message : String(error), + message: error instanceof Error ? error.message : String(error), }, ], }; } } -function firstErrorMessage(response: AnilistGraphQlResponse): string | null { +function firstErrorMessage( + response: AnilistGraphQlResponse, +): 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", diff --git a/src/core/services/anki-jimaku-ipc.ts b/src/core/services/anki-jimaku-ipc.ts index 610d508..080e5bc 100644 --- a/src/core/services/anki-jimaku-ipc.ts +++ b/src/core/services/anki-jimaku-ipc.ts @@ -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 => { + async ( + _event, + query: JimakuDownloadQuery, + ): Promise => { const apiKey = await deps.resolveJimakuApiKey(); if (!apiKey) { return { diff --git a/src/core/services/anki-jimaku.test.ts b/src/core/services/anki-jimaku.test.ts index eaf63fd..0131e4e 100644 --- a/src/core/services/anki-jimaku.test.ts +++ b/src/core/services/anki-jimaku.test.ts @@ -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 }>, + fetchCalls: [] as Array<{ + endpoint: string; + query?: Record; + }>, 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 }); + state.fetchCalls.push({ + endpoint, + query: query as Record, + }); return { ok: true, data: [ @@ -92,12 +97,12 @@ function createHarness(): RuntimeHarness { }; let registered: Record unknown> = {}; - registerAnkiJimakuIpcRuntime( - options, - (deps) => { - registered = deps as unknown as Record 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, diff --git a/src/core/services/anki-jimaku.ts b/src/core/services/anki-jimaku.ts index d4a5f7e..f0c96fe 100644 --- a/src/core/services/anki-jimaku.ts +++ b/src/core/services/anki-jimaku.ts @@ -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; 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: ( @@ -60,7 +69,13 @@ export interface AnkiJimakuIpcRuntimeOptions { url: string, destPath: string, headers: Record, - ) => 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( diff --git a/src/core/services/app-lifecycle.ts b/src/core/services/app-lifecycle.ts index 2e69871..7cf156b 100644 --- a/src/core/services/app-lifecycle.ts +++ b/src/core/services/app-lifecycle.ts @@ -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); diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index 3fee8e1..6df253a 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -9,22 +9,31 @@ function makeDeps(overrides: Partial = {}) { 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( diff --git a/src/core/services/field-grouping-overlay.test.ts b/src/core/services/field-grouping-overlay.test.ts index 0349215..9e8b2f9 100644 --- a/src/core/services/field-grouping-overlay.test.ts +++ b/src/core/services/field-grouping-overlay.test.ts @@ -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({ diff --git a/src/core/services/field-grouping.ts b/src/core/services/field-grouping.ts index f88bc3f..9c97caf 100644 --- a/src/core/services/field-grouping.ts +++ b/src/core/services/field-grouping.ts @@ -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 { return async ( diff --git a/src/core/services/frequency-dictionary.test.ts b/src/core/services/frequency-dictionary.test.ts index 8f789d4..08a7227 100644 --- a/src/core/services/frequency-dictionary.test.ts +++ b/src/core/services/frequency-dictionary.test.ts @@ -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) => { diff --git a/src/core/services/frequency-dictionary.ts b/src/core/services/frequency-dictionary.ts index acedfac..eed0702 100644 --- a/src/core/services/frequency-dictionary.ts +++ b/src/core/services/frequency-dictionary.ts @@ -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; } diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index 37c7062..d2430bb 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -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 { + 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) => { + privateApi.db.prepare = (...args: Parameters) => { 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(); diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 3d7c74d..4ad0506 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -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 | 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 { + async getSessionSummaries(limit = 50): Promise { 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 { + async getDailyRollups(limit = 60): Promise { 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 { + async getMonthlyRollups(limit = 24): Promise { 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 { + private async getLocalVideoMetadata( + mediaPath: string, + ): Promise { 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 { 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; } diff --git a/src/core/services/ipc-command.ts b/src/core/services/ipc-command.ts index 1c7b637..757a3c4 100644 --- a/src/core/services/ipc-command.ts +++ b/src/core/services/ipc-command.ts @@ -72,8 +72,12 @@ export async function runSubsyncManualFromIpc( isSubsyncInProgress: () => boolean; setSubsyncInProgress: (inProgress: boolean) => void; showMpvOsd: (text: string) => void; - runWithSpinner: (task: () => Promise) => Promise; - runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; + runWithSpinner: ( + task: () => Promise, + ) => Promise; + runSubsyncManual: ( + request: SubsyncManualRunRequest, + ) => Promise; }, ): Promise { if (options.isSubsyncInProgress()) { diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 5d4cef1..ec72f9a 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -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", + ]); }); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index f0fcb34..3333c0c 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -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) => 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(); diff --git a/src/core/services/jlpt-token-filter.ts b/src/core/services/jlpt-token-filter.ts index f340421..a38b63f 100644 --- a/src/core/services/jlpt-token-filter.ts +++ b/src/core/services/jlpt-token-filter.ts @@ -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: "接頭詞", diff --git a/src/core/services/jlpt-vocab.ts b/src/core/services/jlpt-vocab.ts index 52626c7..5013dbc 100644 --- a/src/core/services/jlpt-vocab.ts +++ b/src/core/services/jlpt-vocab.ts @@ -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; } diff --git a/src/core/services/mining.test.ts b/src/core/services/mining.test.ts index fb416a4..3dfda4f 100644 --- a/src/core/services/mining.test.ts +++ b/src/core/services/mining.test.ts @@ -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); }); diff --git a/src/core/services/mining.ts b/src/core/services/mining.ts index 137e698..c7e4490 100644 --- a/src/core/services/mining.ts +++ b/src/core/services/mining.ts @@ -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`); } diff --git a/src/core/services/mpv-render-metrics.ts b/src/core/services/mpv-render-metrics.ts index 784880a..fea9e87 100644 --- a/src/core/services/mpv-render-metrics.ts +++ b/src/core/services/mpv-render-metrics.ts @@ -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 }; } diff --git a/src/core/services/numeric-shortcut.ts b/src/core/services/numeric-shortcut.ts index 2607f64..dee4f9b 100644 --- a/src/core/services/numeric-shortcut.ts +++ b/src/core/services/numeric-shortcut.ts @@ -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; + setTimer: ( + handler: () => void, + timeoutMs: number, + ) => ReturnType; clearTimer: (timer: ReturnType) => 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 | null = null; let digitShortcuts: string[] = []; diff --git a/src/core/services/overlay-bridge.ts b/src/core/services/overlay-bridge.ts index c6be82c..57b9480 100644 --- a/src/core/services/overlay-bridge.ts +++ b/src/core/services/overlay-bridge.ts @@ -45,23 +45,21 @@ export function sendToVisibleOverlayRuntime(options: { return true; } -export function createFieldGroupingCallbackRuntime( - 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 { +export function createFieldGroupingCallbackRuntime(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 { return createFieldGroupingCallback({ getVisibleOverlayVisible: options.getVisibleOverlayVisible, getInvisibleOverlayVisible: options.getInvisibleOverlayVisible, diff --git a/src/core/services/overlay-content-measurement.ts b/src/core/services/overlay-content-measurement.ts index 265a047..2a4a78c 100644 --- a/src/core/services/overlay-content-measurement.ts +++ b/src/core/services/overlay-content-measurement.ts @@ -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; +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]; } diff --git a/src/core/services/overlay-manager.test.ts b/src/core/services/overlay-manager.test.ts index 10fe978..dc4b946 100644 --- a/src/core/services/overlay-manager.test.ts +++ b/src/core/services/overlay-manager.test.ts @@ -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); }); diff --git a/src/core/services/overlay-manager.ts b/src/core/services/overlay-manager.ts index d17c090..135bdce 100644 --- a/src/core/services/overlay-manager.ts +++ b/src/core/services/overlay-manager.ts @@ -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( diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts index a6085b4..a929011 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -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; @@ -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( diff --git a/src/core/services/overlay-shortcut-handler.test.ts b/src/core/services/overlay-shortcut-handler.test.ts index ea28fc9..b636f12 100644 --- a/src/core/services/overlay-shortcut-handler.test.ts +++ b/src/core/services/overlay-shortcut-handler.test.ts @@ -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", () => { diff --git a/src/core/services/overlay-shortcut-handler.ts b/src/core/services/overlay-shortcut-handler.ts index 98106fa..a80c204 100644 --- a/src/core/services/overlay-shortcut-handler.ts +++ b/src/core/services/overlay-shortcut-handler.ts @@ -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; diff --git a/src/core/services/overlay-shortcut.ts b/src/core/services/overlay-shortcut.ts index 85338c6..31deef8 100644 --- a/src/core/services/overlay-shortcut.ts +++ b/src/core/services/overlay-shortcut.ts @@ -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); } diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 2bb8470..ab3813d 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -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; diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index bc1916f..198a9f5 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -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; createSubtitleTimingTracker: () => void; createImmersionTracker?: () => void; + startJellyfinRemoteSession?: () => Promise; loadYomitanExtension: () => Promise; 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."); diff --git a/src/core/services/subsync-runner.ts b/src/core/services/subsync-runner.ts index af2b2c3..19f08d4 100644 --- a/src/core/services/subsync-runner.ts +++ b/src/core/services/subsync-runner.ts @@ -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), }); diff --git a/src/core/services/subsync.test.ts b/src/core/services/subsync.test.ts index 43f070a..59277f9 100644 --- a/src/core/services/subsync.test.ts +++ b/src/core/services/subsync.test.ts @@ -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`, diff --git a/src/core/services/subsync.ts b/src/core/services/subsync.ts index d4d3056..0b3a1de 100644 --- a/src/core/services/subsync.ts +++ b/src/core/services/subsync.ts @@ -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", diff --git a/src/core/services/subtitle-position.ts b/src/core/services/subtitle-position.ts index 3725c69..91dbfe8 100644 --- a/src/core/services/subtitle-position.ts +++ b/src/core/services/subtitle-position.ts @@ -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, diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts index 81f5ee3..e162ba1 100644 --- a/src/core/services/tokenizer.test.ts +++ b/src/core/services/tokenizer.test.ts @@ -53,32 +53,33 @@ test("tokenizeSubtitle assigns JLPT level to parsed Yomitan tokens", async () => const result = await tokenizeSubtitle( "猫です", makeDeps({ - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [ - { - text: "猫", - reading: "ねこ", - headwords: [[{ term: "猫" }]], - }, - { - text: "です", - reading: "です", - headwords: [[{ term: "です" }]], - }, + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "猫", + reading: "ねこ", + headwords: [[{ term: "猫" }]], + }, + { + text: "です", + reading: "です", + headwords: [[{ term: "です" }]], + }, + ], ], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), + }, + ], + }, + }) as unknown as Electron.BrowserWindow, tokenizeWithMecab: async () => null, getJlptLevel: (text) => (text === "猫" ? "N5" : null), }), @@ -92,39 +93,42 @@ test("tokenizeSubtitle caches JLPT lookups across repeated tokens", async () => let lookupCalls = 0; const result = await tokenizeSubtitle( "猫猫", - makeDepsFromMecabTokenizer(async () => [ + makeDepsFromMecabTokenizer( + async () => [ + { + word: "猫", + partOfSpeech: PartOfSpeech.noun, + pos1: "", + pos2: "", + pos3: "", + pos4: "", + inflectionType: "", + inflectionForm: "", + headword: "猫", + katakanaReading: "ネコ", + pronunciation: "ネコ", + }, + { + word: "猫", + partOfSpeech: PartOfSpeech.noun, + pos1: "", + pos2: "", + pos3: "", + pos4: "", + inflectionType: "", + inflectionForm: "", + headword: "猫", + katakanaReading: "ネコ", + pronunciation: "ネコ", + }, + ], { - word: "猫", - partOfSpeech: PartOfSpeech.noun, - pos1: "", - pos2: "", - pos3: "", - pos4: "", - inflectionType: "", - inflectionForm: "", - headword: "猫", - katakanaReading: "ネコ", - pronunciation: "ネコ", + getJlptLevel: (text) => { + lookupCalls += 1; + return text === "猫" ? "N5" : null; + }, }, - { - word: "猫", - partOfSpeech: PartOfSpeech.noun, - pos1: "", - pos2: "", - pos3: "", - pos4: "", - inflectionType: "", - inflectionForm: "", - headword: "猫", - katakanaReading: "ネコ", - pronunciation: "ネコ", - }, - ], { - getJlptLevel: (text) => { - lookupCalls += 1; - return text === "猫" ? "N5" : null; - }, - }), + ), ); assert.equal(result.tokens?.length, 2); @@ -136,23 +140,26 @@ test("tokenizeSubtitle caches JLPT lookups across repeated tokens", async () => test("tokenizeSubtitle leaves JLPT unset for non-matching tokens", async () => { const result = await tokenizeSubtitle( "猫", - makeDepsFromMecabTokenizer(async () => [ + makeDepsFromMecabTokenizer( + async () => [ + { + word: "猫", + partOfSpeech: PartOfSpeech.noun, + pos1: "", + pos2: "", + pos3: "", + pos4: "", + inflectionType: "", + inflectionForm: "", + headword: "猫", + katakanaReading: "ネコ", + pronunciation: "ネコ", + }, + ], { - word: "猫", - partOfSpeech: PartOfSpeech.noun, - pos1: "", - pos2: "", - pos3: "", - pos4: "", - inflectionType: "", - inflectionForm: "", - headword: "猫", - katakanaReading: "ネコ", - pronunciation: "ネコ", + getJlptLevel: () => null, }, - ], { - getJlptLevel: () => null, - }), + ), ); assert.equal(result.tokens?.length, 1); @@ -162,8 +169,8 @@ test("tokenizeSubtitle leaves JLPT unset for non-matching tokens", async () => { test("tokenizeSubtitle skips JLPT lookups when disabled", async () => { let lookupCalls = 0; const result = await tokenizeSubtitle( - "猫です", - makeDeps({ + "猫です", + makeDeps({ tokenizeWithMecab: async () => [ { headword: "猫", @@ -233,31 +240,30 @@ test("tokenizeSubtitle uses only selected Yomitan headword for frequency lookup" "猫です", makeDeps({ getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [ - { - text: "猫です", - reading: "ねこです", - headwords: [ - [{ term: "猫です" }], - [{ term: "猫" }], - ], - }, + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "猫です", + reading: "ねこです", + headwords: [[{ term: "猫です" }], [{ term: "猫" }]], + }, + ], ], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), - getFrequencyRank: (text) => (text === "猫" ? 40 : text === "猫です" ? 1200 : null), + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyRank: (text) => + text === "猫" ? 40 : text === "猫です" ? 1200 : null, }), ); @@ -270,46 +276,48 @@ test("tokenizeSubtitle keeps furigana-split Yomitan segments as one token", asyn "友達と話した", makeDeps({ getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [ - { - text: "友", - reading: "とも", - headwords: [[{ term: "友達" }]], - }, - { - text: "達", - reading: "だち", - }, + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "友", + reading: "とも", + headwords: [[{ term: "友達" }]], + }, + { + text: "達", + reading: "だち", + }, + ], + [ + { + text: "と", + reading: "と", + headwords: [[{ term: "と" }]], + }, + ], + [ + { + text: "話した", + reading: "はなした", + headwords: [[{ term: "話す" }]], + }, + ], ], - [ - { - text: "と", - reading: "と", - headwords: [[{ term: "と" }]], - }, - ], - [ - { - text: "話した", - reading: "はなした", - headwords: [[{ term: "話す" }]], - }, - ], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), - getFrequencyRank: (text) => (text === "友達" ? 22 : text === "話す" ? 90 : null), + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyRank: (text) => + text === "友達" ? 22 : text === "話す" ? 90 : null, }), ); @@ -329,28 +337,30 @@ test("tokenizeSubtitle prefers exact headword frequency over surface/reading whe "猫です", makeDeps({ getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [ - { - text: "猫", - reading: "ねこ", - headwords: [[{ term: "ネコ" }]], - }, + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "猫", + reading: "ねこ", + headwords: [[{ term: "ネコ" }]], + }, + ], ], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), - getFrequencyRank: (text) => (text === "猫" ? 1200 : text === "ネコ" ? 8 : null), + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyRank: (text) => + text === "猫" ? 1200 : text === "ネコ" ? 8 : null, }), ); @@ -363,27 +373,28 @@ test("tokenizeSubtitle keeps no frequency when only reading matches and headword "猫です", makeDeps({ getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [ - { - text: "猫", - reading: "ねこ", - headwords: [[{ term: "猫です" }]], - }, + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "猫", + reading: "ねこ", + headwords: [[{ term: "猫です" }]], + }, + ], ], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), + }, + ], + }, + }) as unknown as Electron.BrowserWindow, getFrequencyRank: (text) => (text === "ねこ" ? 77 : null), }), ); @@ -397,31 +408,30 @@ test("tokenizeSubtitle ignores invalid frequency rank on selected headword", asy "猫です", makeDeps({ getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [ - { - text: "猫です", - reading: "ねこです", - headwords: [ - [{ term: "猫" }], - [{ term: "猫です" }], - ], - }, + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "猫です", + reading: "ねこです", + headwords: [[{ term: "猫" }], [{ term: "猫です" }]], + }, + ], ], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), - getFrequencyRank: (text) => (text === "猫" ? Number.NaN : text === "猫です" ? 500 : null), + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyRank: (text) => + text === "猫" ? Number.NaN : text === "猫です" ? 500 : null, }), ); @@ -434,31 +444,30 @@ test("tokenizeSubtitle handles real-word frequency candidates and prefers most f "昨日", makeDeps({ getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [ - { - text: "昨日", - reading: "きのう", - headwords: [ - [{ term: "昨日" }], - [{ term: "きのう" }], - ], - }, + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "昨日", + reading: "きのう", + headwords: [[{ term: "昨日" }], [{ term: "きのう" }]], + }, + ], ], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), - getFrequencyRank: (text) => (text === "きのう" ? 120 : text === "昨日" ? 40 : null), + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyRank: (text) => + text === "きのう" ? 120 : text === "昨日" ? 40 : null, }), ); @@ -471,32 +480,40 @@ test("tokenizeSubtitle ignores candidates with no dictionary rank when higher-fr "猫です", makeDeps({ getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [ - { - text: "猫", - reading: "ねこ", - headwords: [ - [{ term: "猫" }], - [{ term: "猫です" }], - [{ term: "unknown-term" }], - ], - }, + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "猫", + reading: "ねこ", + headwords: [ + [{ term: "猫" }], + [{ term: "猫です" }], + [{ term: "unknown-term" }], + ], + }, + ], ], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), - getFrequencyRank: (text) => (text === "unknown-term" ? -1 : text === "猫" ? 88 : text === "猫です" ? 9000 : null), + }, + ], + }, + }) as unknown as Electron.BrowserWindow, + getFrequencyRank: (text) => + text === "unknown-term" + ? -1 + : text === "猫" + ? 88 + : text === "猫です" + ? 9000 + : null, }), ); @@ -536,27 +553,28 @@ test("tokenizeSubtitle skips frequency rank when Yomitan token is enriched as pa "は", makeDeps({ getFrequencyDictionaryEnabled: () => true, - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [ - { - text: "は", - reading: "は", - headwords: [[{ term: "は" }]], - }, + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "は", + reading: "は", + headwords: [[{ term: "は" }]], + }, + ], ], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), + }, + ], + }, + }) as unknown as Electron.BrowserWindow, tokenizeWithMecab: async () => [ { headword: "は", @@ -657,27 +675,28 @@ test("tokenizeSubtitle skips JLPT level for excluded demonstratives", async () = const result = await tokenizeSubtitle( "この", makeDeps({ - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [ - { - text: "この", - reading: "この", - headwords: [[{ term: "この" }]], - }, + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "この", + reading: "この", + headwords: [[{ term: "この" }]], + }, + ], ], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), + }, + ], + }, + }) as unknown as Electron.BrowserWindow, tokenizeWithMecab: async () => null, getJlptLevel: (text) => (text === "この" ? "N5" : null), }), @@ -691,27 +710,28 @@ test("tokenizeSubtitle skips JLPT level for repeated kana SFX", async () => { const result = await tokenizeSubtitle( "ああ", makeDeps({ - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [ - { - text: "ああ", - reading: "ああ", - headwords: [[{ term: "ああ" }]], - }, + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "ああ", + reading: "ああ", + headwords: [[{ term: "ああ" }]], + }, + ], ], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), + }, + ], + }, + }) as unknown as Electron.BrowserWindow, tokenizeWithMecab: async () => null, getJlptLevel: (text) => (text === "ああ" ? "N5" : null), }), @@ -724,23 +744,26 @@ test("tokenizeSubtitle skips JLPT level for repeated kana SFX", async () => { test("tokenizeSubtitle assigns JLPT level to mecab tokens", async () => { const result = await tokenizeSubtitle( "猫です", - makeDepsFromMecabTokenizer(async () => [ + makeDepsFromMecabTokenizer( + async () => [ + { + word: "猫", + partOfSpeech: PartOfSpeech.noun, + pos1: "", + pos2: "", + pos3: "", + pos4: "", + inflectionType: "", + inflectionForm: "", + headword: "猫", + katakanaReading: "ネコ", + pronunciation: "ネコ", + }, + ], { - word: "猫", - partOfSpeech: PartOfSpeech.noun, - pos1: "", - pos2: "", - pos3: "", - pos4: "", - inflectionType: "", - inflectionForm: "", - headword: "猫", - katakanaReading: "ネコ", - pronunciation: "ネコ", + getJlptLevel: (text) => (text === "猫" ? "N4" : null), }, - ], { - getJlptLevel: (text) => (text === "猫" ? "N4" : null), - }), + ), ); assert.equal(result.tokens?.length, 1); @@ -750,23 +773,26 @@ test("tokenizeSubtitle assigns JLPT level to mecab tokens", async () => { test("tokenizeSubtitle skips JLPT level for mecab tokens marked as ineligible", async () => { const result = await tokenizeSubtitle( "は", - makeDepsFromMecabTokenizer(async () => [ + makeDepsFromMecabTokenizer( + async () => [ + { + word: "は", + partOfSpeech: PartOfSpeech.particle, + pos1: "助詞", + pos2: "", + pos3: "", + pos4: "", + inflectionType: "", + inflectionForm: "", + headword: "は", + katakanaReading: "ハ", + pronunciation: "ハ", + }, + ], { - word: "は", - partOfSpeech: PartOfSpeech.particle, - pos1: "助詞", - pos2: "", - pos3: "", - pos4: "", - inflectionType: "", - inflectionForm: "", - headword: "は", - katakanaReading: "ハ", - pronunciation: "ハ", + getJlptLevel: (text) => (text === "は" ? "N5" : null), }, - ], { - getJlptLevel: (text) => (text === "は" ? "N5" : null), - }), + ), ); assert.equal(result.tokens?.length, 1); @@ -787,7 +813,7 @@ test("tokenizeSubtitle normalizes newlines before mecab fallback", async () => { tokenizeWithMecab: async (text) => { tokenizeInput = text; return [ - { + { surface: "猫ですね", reading: "ネコデスネ", headword: "猫ですね", @@ -877,7 +903,7 @@ test("tokenizeSubtitle uses Yomitan parser result when available", async () => { const result = await tokenizeSubtitle( "猫です", makeDeps({ - getYomitanExt: () => ({ id: "dummy-ext" } as any), + getYomitanExt: () => ({ id: "dummy-ext" }) as any, getYomitanParserWindow: () => parserWindow, tokenizeWithMecab: async () => null, }), @@ -904,38 +930,39 @@ test("tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled await tokenizeSubtitle( "友達と話した", makeDeps({ - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [ - { - text: "友", - reading: "とも", - headwords: [[{ term: "友達" }]], - }, - { - text: "達", - reading: "だち", - }, + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "友", + reading: "とも", + headwords: [[{ term: "友達" }]], + }, + { + text: "達", + reading: "だち", + }, + ], + [ + { + text: "と", + reading: "と", + headwords: [[{ term: "と" }]], + }, + ], ], - [ - { - text: "と", - reading: "と", - headwords: [[{ term: "と" }]], - }, - ], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), + }, + ], + }, + }) as unknown as Electron.BrowserWindow, tokenizeWithMecab: async () => null, getYomitanGroupDebugEnabled: () => true, }), @@ -960,31 +987,32 @@ test("tokenizeSubtitle does not log Yomitan groups when debug toggle is disabled await tokenizeSubtitle( "友達と話した", makeDeps({ - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [ - { - text: "友", - reading: "とも", - headwords: [[{ term: "友達" }]], - }, - { - text: "達", - reading: "だち", - }, + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "友", + reading: "とも", + headwords: [[{ term: "友達" }]], + }, + { + text: "達", + reading: "だち", + }, + ], ], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), + }, + ], + }, + }) as unknown as Electron.BrowserWindow, tokenizeWithMecab: async () => null, getYomitanGroupDebugEnabled: () => false, }), @@ -1028,7 +1056,7 @@ test("tokenizeSubtitle preserves segmented Yomitan line as one token", async () const result = await tokenizeSubtitle( "猫です", makeDeps({ - getYomitanExt: () => ({ id: "dummy-ext" } as any), + getYomitanExt: () => ({ id: "dummy-ext" }) as any, getYomitanParserWindow: () => parserWindow, tokenizeWithMecab: async () => null, }), @@ -1046,38 +1074,69 @@ test("tokenizeSubtitle prefers mecab parser tokens when scanning parser returns const result = await tokenizeSubtitle( "俺は小園にいきたい", makeDeps({ - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [ - { - text: "俺は小園にいきたい", - reading: "おれは小園にいきたい", - headwords: [[{ term: "俺は小園にいきたい" }]], - }, + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "俺は小園にいきたい", + reading: "おれは小園にいきたい", + headwords: [[{ term: "俺は小園にいきたい" }]], + }, + ], ], - ], - }, - { - source: "mecab", - index: 0, - content: [ - [{ text: "俺", reading: "おれ", headwords: [[{ term: "俺" }]] }], - [{ text: "は", reading: "は", headwords: [[{ term: "は" }]] }], - [{ text: "小園", reading: "おうえん", headwords: [[{ term: "小園" }]] }], - [{ text: "に", reading: "に", headwords: [[{ term: "に" }]] }], - [{ text: "いきたい", reading: "いきたい", headwords: [[{ term: "いきたい" }]] }], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), + }, + { + source: "mecab", + index: 0, + content: [ + [ + { + text: "俺", + reading: "おれ", + headwords: [[{ term: "俺" }]], + }, + ], + [ + { + text: "は", + reading: "は", + headwords: [[{ term: "は" }]], + }, + ], + [ + { + text: "小園", + reading: "おうえん", + headwords: [[{ term: "小園" }]], + }, + ], + [ + { + text: "に", + reading: "に", + headwords: [[{ term: "に" }]], + }, + ], + [ + { + text: "いきたい", + reading: "いきたい", + headwords: [[{ term: "いきたい" }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, getFrequencyDictionaryEnabled: () => true, tokenizeWithMecab: async () => null, getFrequencyRank: (text) => @@ -1086,7 +1145,10 @@ test("tokenizeSubtitle prefers mecab parser tokens when scanning parser returns ); assert.equal(result.tokens?.length, 5); - assert.equal(result.tokens?.map((token) => token.surface).join(","), "俺,は,小園,に,いきたい"); + assert.equal( + result.tokens?.map((token) => token.surface).join(","), + "俺,は,小園,に,いきたい", + ); assert.equal(result.tokens?.[2]?.surface, "小園"); assert.equal(result.tokens?.[2]?.frequencyRank, 25); }); @@ -1095,34 +1157,83 @@ test("tokenizeSubtitle keeps scanning parser tokens when they are already split" const result = await tokenizeSubtitle( "小園に行きたい", makeDeps({ - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [{ text: "小園", reading: "おうえん", headwords: [[{ term: "小園" }]] }], - [{ text: "に", reading: "に", headwords: [[{ term: "に" }]] }], - [{ text: "行きたい", reading: "いきたい", headwords: [[{ term: "行きたい" }]] }], - ], - }, - { - source: "mecab", - index: 0, - content: [ - [{ text: "小", reading: "お", headwords: [[{ term: "小" }]] }], - [{ text: "園", reading: "えん", headwords: [[{ term: "園" }]] }], - [{ text: "に", reading: "に", headwords: [[{ term: "に" }]] }], - [{ text: "行き", reading: "いき", headwords: [[{ term: "行き" }]] }], - [{ text: "たい", reading: "たい", headwords: [[{ term: "たい" }]] }], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "小園", + reading: "おうえん", + headwords: [[{ term: "小園" }]], + }, + ], + [ + { + text: "に", + reading: "に", + headwords: [[{ term: "に" }]], + }, + ], + [ + { + text: "行きたい", + reading: "いきたい", + headwords: [[{ term: "行きたい" }]], + }, + ], + ], + }, + { + source: "mecab", + index: 0, + content: [ + [ + { + text: "小", + reading: "お", + headwords: [[{ term: "小" }]], + }, + ], + [ + { + text: "園", + reading: "えん", + headwords: [[{ term: "園" }]], + }, + ], + [ + { + text: "に", + reading: "に", + headwords: [[{ term: "に" }]], + }, + ], + [ + { + text: "行き", + reading: "いき", + headwords: [[{ term: "行き" }]], + }, + ], + [ + { + text: "たい", + reading: "たい", + headwords: [[{ term: "たい" }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, getFrequencyDictionaryEnabled: () => true, getFrequencyRank: (text) => (text === "小園" ? 20 : null), tokenizeWithMecab: async () => null, @@ -1143,50 +1254,108 @@ test("tokenizeSubtitle prefers parse candidates with fewer fragment-only kana to const result = await tokenizeSubtitle( "俺は公園にいきたい", makeDeps({ - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "mecab-fragmented", - index: 0, - content: [ - [{ text: "俺", reading: "おれ", headwords: [[{ term: "俺" }]] }], - [{ text: "は", reading: "", headwords: [[{ term: "は" }]] }], - [{ text: "公園", reading: "こうえん", headwords: [[{ term: "公園" }]] }], - [{ text: "にい", reading: "", headwords: [[{ term: "兄" }], [{ term: "二位" }]] }], - [{ text: "きたい", reading: "", headwords: [[{ term: "期待" }], [{ term: "来る" }]] }], - ], - }, - { - source: "mecab", - index: 0, - content: [ - [{ text: "俺", reading: "おれ", headwords: [[{ term: "俺" }]] }], - [{ text: "は", reading: "は", headwords: [[{ term: "は" }]] }], - [{ text: "公園", reading: "こうえん", headwords: [[{ term: "公園" }]] }], - [{ text: "に", reading: "に", headwords: [[{ term: "に" }]] }], - [{ text: "行きたい", reading: "いきたい", headwords: [[{ term: "行きたい" }]] }], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "mecab-fragmented", + index: 0, + content: [ + [ + { + text: "俺", + reading: "おれ", + headwords: [[{ term: "俺" }]], + }, + ], + [{ text: "は", reading: "", headwords: [[{ term: "は" }]] }], + [ + { + text: "公園", + reading: "こうえん", + headwords: [[{ term: "公園" }]], + }, + ], + [ + { + text: "にい", + reading: "", + headwords: [[{ term: "兄" }], [{ term: "二位" }]], + }, + ], + [ + { + text: "きたい", + reading: "", + headwords: [[{ term: "期待" }], [{ term: "来る" }]], + }, + ], + ], + }, + { + source: "mecab", + index: 0, + content: [ + [ + { + text: "俺", + reading: "おれ", + headwords: [[{ term: "俺" }]], + }, + ], + [ + { + text: "は", + reading: "は", + headwords: [[{ term: "は" }]], + }, + ], + [ + { + text: "公園", + reading: "こうえん", + headwords: [[{ term: "公園" }]], + }, + ], + [ + { + text: "に", + reading: "に", + headwords: [[{ term: "に" }]], + }, + ], + [ + { + text: "行きたい", + reading: "いきたい", + headwords: [[{ term: "行きたい" }]], + }, + ], + ], + }, + ], + }, + }) as unknown as Electron.BrowserWindow, getFrequencyDictionaryEnabled: () => true, getFrequencyRank: (text) => text === "俺" ? 51 : text === "公園" - ? 2304 - : text === "行きたい" - ? 1500 - : null, + ? 2304 + : text === "行きたい" + ? 1500 + : null, tokenizeWithMecab: async () => null, }), ); - assert.equal(result.tokens?.map((token) => token.surface).join(","), "俺,は,公園,に,行きたい"); + assert.equal( + result.tokens?.map((token) => token.surface).join(","), + "俺,は,公園,に,行きたい", + ); assert.equal(result.tokens?.[1]?.frequencyRank, undefined); assert.equal(result.tokens?.[3]?.frequencyRank, undefined); assert.equal(result.tokens?.[4]?.frequencyRank, 1500); @@ -1196,28 +1365,38 @@ test("tokenizeSubtitle still assigns frequency to non-known Yomitan tokens", asy const result = await tokenizeSubtitle( "小園に", makeDeps({ - getYomitanExt: () => ({ id: "dummy-ext" } as any), - getYomitanParserWindow: () => ({ - isDestroyed: () => false, - webContents: { - executeJavaScript: async () => [ - { - source: "scanning-parser", - index: 0, - content: [ - [ - { text: "小園", reading: "おうえん", headwords: [[{ term: "小園" }]] }, + getYomitanExt: () => ({ id: "dummy-ext" }) as any, + getYomitanParserWindow: () => + ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async () => [ + { + source: "scanning-parser", + index: 0, + content: [ + [ + { + text: "小園", + reading: "おうえん", + headwords: [[{ term: "小園" }]], + }, + ], + [ + { + text: "に", + reading: "に", + headwords: [[{ term: "に" }]], + }, + ], ], - [ - { text: "に", reading: "に", headwords: [[{ term: "に" }]] }, - ], - ], - }, - ], - }, - } as unknown as Electron.BrowserWindow), + }, + ], + }, + }) as unknown as Electron.BrowserWindow, getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === "小園" ? 75 : text === "に" ? 3000 : null), + getFrequencyRank: (text) => + text === "小園" ? 75 : text === "に" ? 3000 : null, isKnownWord: (text) => text === "小園", }), ); @@ -1232,23 +1411,26 @@ test("tokenizeSubtitle still assigns frequency to non-known Yomitan tokens", asy test("tokenizeSubtitle marks tokens as known using callback", async () => { const result = await tokenizeSubtitle( "猫です", - makeDepsFromMecabTokenizer(async () => [ + makeDepsFromMecabTokenizer( + async () => [ + { + word: "猫", + partOfSpeech: PartOfSpeech.noun, + pos1: "", + pos2: "", + pos3: "", + pos4: "", + inflectionType: "", + inflectionForm: "", + headword: "猫", + katakanaReading: "ネコ", + pronunciation: "ネコ", + }, + ], { - word: "猫", - partOfSpeech: PartOfSpeech.noun, - pos1: "", - pos2: "", - pos3: "", - pos4: "", - inflectionType: "", - inflectionForm: "", - headword: "猫", - katakanaReading: "ネコ", - pronunciation: "ネコ", + isKnownWord: (text) => text === "猫", }, - ], { - isKnownWord: (text) => text === "猫", - }), + ), ); assert.equal(result.text, "猫です"); @@ -1300,7 +1482,8 @@ test("tokenizeSubtitle still assigns frequency rank to non-known tokens", async }, ], getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === "既知" ? 20 : text === "未知" ? 30 : null), + getFrequencyRank: (text) => + text === "既知" ? 20 : text === "未知" ? 30 : null, isKnownWord: (text) => text === "既知", }), ); @@ -1336,7 +1519,7 @@ test("tokenizeSubtitle selects one N+1 target token", async () => { endPos: 2, partOfSpeech: PartOfSpeech.noun, isMerged: false, - isKnown: false, + isKnown: false, isNPlusOneTarget: false, }, ], @@ -1344,7 +1527,8 @@ test("tokenizeSubtitle selects one N+1 target token", async () => { }), ); - const targets = result.tokens?.filter((token) => token.isNPlusOneTarget) ?? []; + const targets = + result.tokens?.filter((token) => token.isNPlusOneTarget) ?? []; assert.equal(targets.length, 1); assert.equal(targets[0]?.surface, "犬"); }); @@ -1394,23 +1578,23 @@ test("tokenizeSubtitle applies N+1 target marking to Yomitan results", async () { source: "scanning-parser", index: 0, - content: [ - [ - { - text: "猫", - reading: "ねこ", - headwords: [[{ term: "猫" }]], - }, - ], - [ - { - text: "です", - reading: "です", - headwords: [[{ term: "です" }]], - }, - ], - ], - }, + content: [ + [ + { + text: "猫", + reading: "ねこ", + headwords: [[{ term: "猫" }]], + }, + ], + [ + { + text: "です", + reading: "です", + headwords: [[{ term: "です" }]], + }, + ], + ], + }, ], }, } as unknown as Electron.BrowserWindow; @@ -1418,7 +1602,7 @@ test("tokenizeSubtitle applies N+1 target marking to Yomitan results", async () const result = await tokenizeSubtitle( "猫です", makeDeps({ - getYomitanExt: () => ({ id: "dummy-ext" } as any), + getYomitanExt: () => ({ id: "dummy-ext" }) as any, getYomitanParserWindow: () => parserWindow, tokenizeWithMecab: async () => null, isKnownWord: (text) => text === "です", @@ -1473,23 +1657,26 @@ test("tokenizeSubtitle does not color 1-2 word sentences by default", async () = test("tokenizeSubtitle checks known words by headword, not surface", async () => { const result = await tokenizeSubtitle( "猫です", - makeDepsFromMecabTokenizer(async () => [ + makeDepsFromMecabTokenizer( + async () => [ + { + word: "猫", + partOfSpeech: PartOfSpeech.noun, + pos1: "", + pos2: "", + pos3: "", + pos4: "", + inflectionType: "", + inflectionForm: "", + headword: "猫です", + katakanaReading: "ネコ", + pronunciation: "ネコ", + }, + ], { - word: "猫", - partOfSpeech: PartOfSpeech.noun, - pos1: "", - pos2: "", - pos3: "", - pos4: "", - inflectionType: "", - inflectionForm: "", - headword: "猫です", - katakanaReading: "ネコ", - pronunciation: "ネコ", + isKnownWord: (text) => text === "猫です", }, - ], { - isKnownWord: (text) => text === "猫です", - }), + ), ); assert.equal(result.text, "猫です"); @@ -1499,24 +1686,27 @@ test("tokenizeSubtitle checks known words by headword, not surface", async () => test("tokenizeSubtitle checks known words by surface when configured", async () => { const result = await tokenizeSubtitle( "猫です", - makeDepsFromMecabTokenizer(async () => [ + makeDepsFromMecabTokenizer( + async () => [ + { + word: "猫", + partOfSpeech: PartOfSpeech.noun, + pos1: "", + pos2: "", + pos3: "", + pos4: "", + inflectionType: "", + inflectionForm: "", + headword: "猫です", + katakanaReading: "ネコ", + pronunciation: "ネコ", + }, + ], { - word: "猫", - partOfSpeech: PartOfSpeech.noun, - pos1: "", - pos2: "", - pos3: "", - pos4: "", - inflectionType: "", - inflectionForm: "", - headword: "猫です", - katakanaReading: "ネコ", - pronunciation: "ネコ", + getKnownWordMatchMode: () => "surface", + isKnownWord: (text) => text === "猫", }, - ], { - getKnownWordMatchMode: () => "surface", - isKnownWord: (text) => text === "猫", - }), + ), ); assert.equal(result.text, "猫です"); diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts index 325c729..e820dbe 100644 --- a/src/core/services/tokenizer.ts +++ b/src/core/services/tokenizer.ts @@ -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( diff --git a/src/core/services/yomitan-settings.ts b/src/core/services/yomitan-settings.ts index 698db4c..5d9c955 100644 --- a/src/core/services/yomitan-settings.ts +++ b/src/core/services/yomitan-settings.ts @@ -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, diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 5196718..f3038e5 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -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"; diff --git a/src/core/utils/shortcut-config.ts b/src/core/utils/shortcut-config.ts index ff6c918..c36d903 100644 --- a/src/core/utils/shortcut-config.ts +++ b/src/core/utils/shortcut-config.ts @@ -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, diff --git a/src/jimaku/utils.ts b/src/jimaku/utils.ts index 9c96b5d..c50c872 100644 --- a/src/jimaku/utils.ts +++ b/src/jimaku/utils.ts @@ -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; } diff --git a/src/main/anilist-url-guard.test.ts b/src/main/anilist-url-guard.test.ts index 1a217f5..8e6df65 100644 --- a/src/main/anilist-url-guard.test.ts +++ b/src/main/anilist-url-guard.test.ts @@ -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, + ); }); diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index 47ca562..286063b 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -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: diff --git a/src/main/frequency-dictionary-runtime.ts b/src/main/frequency-dictionary-runtime.ts index 4ea4585..6a7ba38 100644 --- a/src/main/frequency-dictionary-runtime.ts +++ b/src/main/frequency-dictionary-runtime.ts @@ -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 } { return { - ensureFrequencyDictionaryLookup: () => ensureFrequencyDictionaryLookup(deps), + ensureFrequencyDictionaryLookup: () => + ensureFrequencyDictionaryLookup(deps), }; } diff --git a/src/main/media-runtime.ts b/src/main/media-runtime.ts index d7585ef..c79b27b 100644 --- a/src/main/media-runtime.ts +++ b/src/main/media-runtime.ts @@ -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; }, diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 3de4cde..ab47a9e 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -23,7 +23,10 @@ export function createOverlayModalRuntimeService( deps: OverlayWindowResolver, ): OverlayModalRuntime { const restoreVisibleOverlayOnModalClose = new Set(); - const overlayModalAutoShownLayer = new Map(); + 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, }; } diff --git a/src/main/overlay-shortcuts-runtime.ts b/src/main/overlay-shortcuts-runtime.ts index 284ceb2..02fa110 100644 --- a/src/main/overlay-shortcuts-runtime.ts +++ b/src/main/overlay-shortcuts-runtime.ts @@ -90,7 +90,8 @@ export function createOverlayShortcutsRuntimeService( }; }; - const shouldOverlayShortcutsBeActive = () => input.isOverlayRuntimeInitialized(); + const shouldOverlayShortcutsBeActive = () => + input.isOverlayRuntimeInitialized(); return { tryHandleOverlayShortcutLocalFallback: (inputEvent) => diff --git a/src/main/subsync-runtime.ts b/src/main/subsync-runtime.ts index 92128bb..4b7c526 100644 --- a/src/main/subsync-runtime.ts +++ b/src/main/subsync-runtime.ts @@ -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 { - return triggerSubsyncFromConfigRuntimeCore(createSubsyncRuntimeServiceDeps(params)); + return triggerSubsyncFromConfigRuntimeCore( + createSubsyncRuntimeServiceDeps(params), + ); } export async function runSubsyncManualFromIpcRuntime( diff --git a/src/media-generator.ts b/src/media-generator.ts index 7647f40..2d6407b 100644 --- a/src/media-generator.ts +++ b/src/media-generator.ts @@ -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"); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 8a07807..ca32b24 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -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 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; diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index 0b33cce..c1f02c5 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -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, + }); } } } diff --git a/src/renderer/modals/jimaku.ts b/src/renderer/modals/jimaku.ts index 9eb4002..f95bcc6 100644 --- a/src/renderer/modals/jimaku.ts +++ b/src/renderer/modals/jimaku.ts @@ -158,7 +158,10 @@ export function createJimakuModal( } } - async function loadFiles(entryId: number, episode: number | null): Promise { + async function loadFiles( + entryId: number, + episode: number | null, + ): Promise { 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; diff --git a/src/renderer/modals/kiku.ts b/src/renderer/modals/kiku.ts index f99fd13..8c6473f 100644 --- a/src/renderer/modals/kiku.ts +++ b/src/renderer/modals/kiku.ts @@ -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"); } } diff --git a/src/renderer/modals/subsync.ts b/src/renderer/modals/subsync.ts index 744be9e..1ac7eb5 100644 --- a/src/renderer/modals/subsync.ts +++ b/src/renderer/modals/subsync.ts @@ -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"); } } diff --git a/src/renderer/overlay-content-measurement.ts b/src/renderer/overlay-content-measurement.ts index b9fd04a..d2755c9 100644 --- a/src/renderer/overlay-content-measurement.ts +++ b/src/renderer/overlay-content-measurement.ts @@ -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; } diff --git a/src/renderer/positioning/controller.ts b/src/renderer/positioning/controller.ts index c67c2d1..daff957 100644 --- a/src/renderer/positioning/controller.ts +++ b/src/renderer/positioning/controller.ts @@ -32,7 +32,8 @@ export function createPositioningController( { applyInvisibleSubtitleOffsetPosition: invisibleOffset.applyInvisibleSubtitleOffsetPosition, - updateInvisiblePositionEditHud: invisibleOffset.updateInvisiblePositionEditHud, + updateInvisiblePositionEditHud: + invisibleOffset.updateInvisiblePositionEditHud, }, ); diff --git a/src/renderer/positioning/invisible-layout-helpers.ts b/src/renderer/positioning/invisible-layout-helpers.ts index e6882ab..043eddb 100644 --- a/src/renderer/positioning/invisible-layout-helpers.ts +++ b/src/renderer/positioning/invisible-layout-helpers.ts @@ -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 = ""; diff --git a/src/renderer/positioning/invisible-layout-metrics.ts b/src/renderer/positioning/invisible-layout-metrics.ts index 3648fe2..0720067 100644 --- a/src/renderer/positioning/invisible-layout-metrics.ts +++ b/src/renderer/positioning/invisible-layout-metrics.ts @@ -74,7 +74,10 @@ export function applyPlatformFontCompensation( function calculateGeometry( metrics: MpvSubtitleRenderMetrics, osdToCssScale: number, -): Omit { +): 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, diff --git a/src/renderer/positioning/invisible-layout.ts b/src/renderer/positioning/invisible-layout.ts index df7dd38..f60f865 100644 --- a/src/renderer/positioning/invisible-layout.ts +++ b/src/renderer/positioning/invisible-layout.ts @@ -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 { diff --git a/src/renderer/positioning/invisible-offset.ts b/src/renderer/positioning/invisible-offset.ts index af8c318..8f41382 100644 --- a/src/renderer/positioning/invisible-offset.ts +++ b/src/renderer/positioning/invisible-offset.ts @@ -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); } diff --git a/src/renderer/positioning/position-state.ts b/src/renderer/positioning/position-state.ts index a8d94aa..2517c6d 100644 --- a/src/renderer/positioning/position-state.ts +++ b/src/renderer/positioning/position-state.ts @@ -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) => 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): void { + function persistSubtitlePositionPatch( + patch: Partial, + ): 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); diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 1d194bd..91d3ab1 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -132,7 +132,10 @@ async function init(): Promise { 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 { }); 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 { 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 { : 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 { 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 { 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 { await keyboardHandlers.setupMpvInputForwarding(); - subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle()); + subtitleRenderer.applySubtitleStyle( + await window.electronAPI.getSubtitleStyle(), + ); if (ctx.platform.isInvisibleLayer) { positioning.applyInvisibleStoredSubtitlePosition( diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index f70430c..47137e9 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -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\(/); } }); diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index c701b7a..21f1cf8 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -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; } } diff --git a/src/renderer/utils/dom.ts b/src/renderer/utils/dom.ts index 8cd998e..341abfb 100644 --- a/src/renderer/utils/dom.ts +++ b/src/renderer/utils/dom.ts @@ -77,8 +77,9 @@ export function resolveRendererDom(): RendererDom { subtitleRoot: getRequiredElement("subtitleRoot"), subtitleContainer: getRequiredElement("subtitleContainer"), overlay: getRequiredElement("overlay"), - secondarySubContainer: - getRequiredElement("secondarySubContainer"), + secondarySubContainer: getRequiredElement( + "secondarySubContainer", + ), secondarySubRoot: getRequiredElement("secondarySubRoot"), jimakuModal: getRequiredElement("jimakuModal"), @@ -88,60 +89,89 @@ export function resolveRendererDom(): RendererDom { jimakuSearchButton: getRequiredElement("jimakuSearch"), jimakuCloseButton: getRequiredElement("jimakuClose"), jimakuStatus: getRequiredElement("jimakuStatus"), - jimakuEntriesSection: getRequiredElement("jimakuEntriesSection"), + jimakuEntriesSection: getRequiredElement( + "jimakuEntriesSection", + ), jimakuEntriesList: getRequiredElement("jimakuEntries"), - jimakuFilesSection: getRequiredElement("jimakuFilesSection"), + jimakuFilesSection: + getRequiredElement("jimakuFilesSection"), jimakuFilesList: getRequiredElement("jimakuFiles"), jimakuBroadenButton: getRequiredElement("jimakuBroaden"), kikuModal: getRequiredElement("kikuFieldGroupingModal"), kikuCard1: getRequiredElement("kikuCard1"), kikuCard2: getRequiredElement("kikuCard2"), - kikuCard1Expression: getRequiredElement("kikuCard1Expression"), - kikuCard2Expression: getRequiredElement("kikuCard2Expression"), + kikuCard1Expression: getRequiredElement( + "kikuCard1Expression", + ), + kikuCard2Expression: getRequiredElement( + "kikuCard2Expression", + ), kikuCard1Sentence: getRequiredElement("kikuCard1Sentence"), kikuCard2Sentence: getRequiredElement("kikuCard2Sentence"), kikuCard1Meta: getRequiredElement("kikuCard1Meta"), kikuCard2Meta: getRequiredElement("kikuCard2Meta"), - kikuConfirmButton: getRequiredElement("kikuConfirmButton"), + kikuConfirmButton: + getRequiredElement("kikuConfirmButton"), kikuCancelButton: getRequiredElement("kikuCancelButton"), - kikuDeleteDuplicateCheckbox: - getRequiredElement("kikuDeleteDuplicate"), + kikuDeleteDuplicateCheckbox: getRequiredElement( + "kikuDeleteDuplicate", + ), kikuSelectionStep: getRequiredElement("kikuSelectionStep"), kikuPreviewStep: getRequiredElement("kikuPreviewStep"), kikuPreviewJson: getRequiredElement("kikuPreviewJson"), kikuPreviewCompactButton: getRequiredElement("kikuPreviewCompact"), - kikuPreviewFullButton: getRequiredElement("kikuPreviewFull"), + kikuPreviewFullButton: + getRequiredElement("kikuPreviewFull"), kikuPreviewError: getRequiredElement("kikuPreviewError"), kikuBackButton: getRequiredElement("kikuBackButton"), - kikuFinalConfirmButton: - getRequiredElement("kikuFinalConfirmButton"), - kikuFinalCancelButton: - getRequiredElement("kikuFinalCancelButton"), + kikuFinalConfirmButton: getRequiredElement( + "kikuFinalConfirmButton", + ), + kikuFinalCancelButton: getRequiredElement( + "kikuFinalCancelButton", + ), kikuHint: getRequiredElement("kikuHint"), - runtimeOptionsModal: getRequiredElement("runtimeOptionsModal"), - runtimeOptionsClose: getRequiredElement("runtimeOptionsClose"), - runtimeOptionsList: getRequiredElement("runtimeOptionsList"), - runtimeOptionsStatus: getRequiredElement("runtimeOptionsStatus"), + runtimeOptionsModal: getRequiredElement( + "runtimeOptionsModal", + ), + runtimeOptionsClose: getRequiredElement( + "runtimeOptionsClose", + ), + runtimeOptionsList: + getRequiredElement("runtimeOptionsList"), + runtimeOptionsStatus: getRequiredElement( + "runtimeOptionsStatus", + ), subsyncModal: getRequiredElement("subsyncModal"), subsyncCloseButton: getRequiredElement("subsyncClose"), - subsyncEngineAlass: getRequiredElement("subsyncEngineAlass"), - subsyncEngineFfsubsync: - getRequiredElement("subsyncEngineFfsubsync"), - subsyncSourceLabel: getRequiredElement("subsyncSourceLabel"), - subsyncSourceSelect: getRequiredElement("subsyncSourceSelect"), + subsyncEngineAlass: + getRequiredElement("subsyncEngineAlass"), + subsyncEngineFfsubsync: getRequiredElement( + "subsyncEngineFfsubsync", + ), + subsyncSourceLabel: + getRequiredElement("subsyncSourceLabel"), + subsyncSourceSelect: getRequiredElement( + "subsyncSourceSelect", + ), subsyncRunButton: getRequiredElement("subsyncRun"), subsyncStatus: getRequiredElement("subsyncStatus"), sessionHelpModal: getRequiredElement("sessionHelpModal"), sessionHelpClose: getRequiredElement("sessionHelpClose"), - sessionHelpShortcut: getRequiredElement("sessionHelpShortcut"), - sessionHelpWarning: getRequiredElement("sessionHelpWarning"), + sessionHelpShortcut: getRequiredElement( + "sessionHelpShortcut", + ), + sessionHelpWarning: + getRequiredElement("sessionHelpWarning"), sessionHelpStatus: getRequiredElement("sessionHelpStatus"), - sessionHelpFilter: getRequiredElement("sessionHelpFilter"), - sessionHelpContent: getRequiredElement("sessionHelpContent"), + sessionHelpFilter: + getRequiredElement("sessionHelpFilter"), + sessionHelpContent: + getRequiredElement("sessionHelpContent"), }; } diff --git a/src/subsync/engines.ts b/src/subsync/engines.ts index 338ede0..a8afb01 100644 --- a/src/subsync/engines.ts +++ b/src/subsync/engines.ts @@ -22,7 +22,10 @@ export interface SubsyncEngineExecutionContext { alassPath: string; ffsubsyncPath: string; }; - runCommand: (command: string, args: string[]) => Promise; + runCommand: ( + command: string, + args: string[], + ) => Promise; } export interface SubsyncEngineProvider { @@ -34,7 +37,10 @@ export interface SubsyncEngineProvider { type SubsyncEngineProviderFactory = () => SubsyncEngineProvider; -const subsyncEngineProviderFactories = new Map(); +const subsyncEngineProviderFactories = new Map< + SubsyncEngine, + SubsyncEngineProviderFactory +>(); export function registerSubsyncEngineProvider( engine: SubsyncEngine, diff --git a/src/subtitle/pipeline.ts b/src/subtitle/pipeline.ts index 43df702..026e884 100644 --- a/src/subtitle/pipeline.ts +++ b/src/subtitle/pipeline.ts @@ -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 }; diff --git a/src/subtitle/stages/normalize.ts b/src/subtitle/stages/normalize.ts index 4c49d73..d9ed3ec 100644 --- a/src/subtitle/stages/normalize.ts +++ b/src/subtitle/stages/normalize.ts @@ -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(); } diff --git a/src/token-merger.ts b/src/token-merger.ts index 192f015..9f12d34 100644 --- a/src/token-merger.ts +++ b/src/token-merger.ts @@ -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) { diff --git a/src/token-mergers/index.ts b/src/token-mergers/index.ts index 260b843..e7e46fb 100644 --- a/src/token-mergers/index.ts +++ b/src/token-mergers/index.ts @@ -8,7 +8,10 @@ export interface TokenMergerProvider { type TokenMergerProviderFactory = () => TokenMergerProvider; -const tokenMergerProviderFactories = new Map(); +const tokenMergerProviderFactories = new Map< + string, + TokenMergerProviderFactory +>(); export function registerTokenMergerProvider( id: string, diff --git a/src/translators/index.ts b/src/translators/index.ts index 64bcb1a..2bf7fbd 100644 --- a/src/translators/index.ts +++ b/src/translators/index.ts @@ -17,7 +17,10 @@ export interface TranslationProvider { type TranslationProviderFactory = () => TranslationProvider; -const translationProviderFactories = new Map(); +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; }, diff --git a/src/window-trackers/hyprland-tracker.ts b/src/window-trackers/hyprland-tracker.ts index d0fc208..4d03100 100644 --- a/src/window-trackers/hyprland-tracker.ts +++ b/src/window-trackers/hyprland-tracker.ts @@ -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; diff --git a/src/window-trackers/index.ts b/src/window-trackers/index.ts index a89cc60..397fcae 100644 --- a/src/window-trackers/index.ts +++ b/src/window-trackers/index.ts @@ -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; diff --git a/src/window-trackers/sway-tracker.ts b/src/window-trackers/sway-tracker.ts index eecc560..af4790e 100644 --- a/src/window-trackers/sway-tracker.ts +++ b/src/window-trackers/sway-tracker.ts @@ -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 {