Fix Windows Anki startup and overlay regressions (#128)

This commit is contained in:
2026-06-14 20:51:56 -07:00
committed by GitHub
parent aa8eb753f6
commit 70da3ee8bd
28 changed files with 1322 additions and 47 deletions
+114 -1
View File
@@ -95,7 +95,7 @@ function createIntegrationTestContext(
knownWordsScope: string;
knownWordsLastRefreshedAtMs: number;
};
privateState.knownWordsScope = 'is:note';
privateState.knownWordsScope = 'all';
privateState.knownWordsLastRefreshedAtMs = Date.now();
return {
@@ -324,6 +324,119 @@ test('AnkiIntegration resolves merged-away note ids to the kept note id', () =>
}
});
function processSentenceWithConfig(
config: Partial<AnkiConnectConfig>,
mpvSentence: string,
noteFields: Record<string, string>,
): string {
const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never);
return (
integration as unknown as {
processSentence: (sentence: string, fields: Record<string, string>) => string;
}
).processSentence(mpvSentence, noteFields);
}
function processSentenceFuriganaWithConfig(
config: Partial<AnkiConnectConfig>,
sentenceFurigana: string,
noteFields: Record<string, string>,
): string {
const integration = new AnkiIntegration(config as AnkiConnectConfig, {} as never, {} as never);
return (
integration as unknown as {
processSentenceFurigana: (sentence: string, fields: Record<string, string>) => string;
}
).processSentenceFurigana(sentenceFurigana, noteFields);
}
test('AnkiIntegration highlights mined word from expression field when sentence has no bold marker', () => {
const processed = processSentenceWithConfig(
{
fields: {
word: 'Expression',
sentence: 'Sentence',
},
behavior: {
highlightWord: true,
},
},
'先日 貴様らが潜入した キールダンジョンから―',
{
expression: '潜入',
sentence: '先日 貴様らが潜入した キールダンジョンから―',
},
);
assert.equal(processed, '先日 貴様らが<b>潜入</b>した キールダンジョンから―');
});
test('AnkiIntegration keeps existing Yomitan bold target when present', () => {
const processed = processSentenceWithConfig(
{
fields: {
word: 'Expression',
sentence: 'Sentence',
},
behavior: {
highlightWord: true,
},
},
'先日 貴様らが潜入した キールダンジョンから―',
{
expression: '潜入',
sentence: '<b>潜入した</b>',
},
);
assert.equal(processed, '先日 貴様らが<b>潜入した</b> キールダンジョンから―');
});
test('AnkiIntegration leaves sentence plain when word highlighting is disabled', () => {
const processed = processSentenceWithConfig(
{
fields: {
word: 'Expression',
sentence: 'Sentence',
},
behavior: {
highlightWord: false,
},
},
'先日 貴様らが潜入した キールダンジョンから―',
{
expression: '潜入',
sentence: '<b>潜入</b>',
},
);
assert.equal(processed, '先日 貴様らが潜入した キールダンジョンから―');
});
test('AnkiIntegration highlights mined word in sentence furigana field', () => {
const processed = processSentenceFuriganaWithConfig(
{
fields: {
word: 'Expression',
sentence: 'Sentence',
},
behavior: {
highlightWord: true,
},
},
'<span class="term"><ruby>不思議<rt>ふしぎ</rt></ruby></span><span class="term">な</span><span class="term"><ruby>特技<rt>とくぎ</rt></ruby></span><span class="term">を</span>',
{
expression: '特技',
sentence: '不思議な特技を',
},
);
assert.equal(
processed,
'<span class="term"><ruby>不思議<rt>ふしぎ</rt></ruby></span><span class="term">な</span><b><span class="term"><ruby>特技<rt>とくぎ</rt></ruby></span></b><span class="term">を</span>',
);
});
test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => {
const integration = new AnkiIntegration(
{
+114 -7
View File
@@ -70,7 +70,7 @@ interface NoteInfo {
fields: Record<string, { value: string }>;
}
type CardKind = 'sentence' | 'audio';
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') return null;
@@ -78,6 +78,66 @@ function trimToNonEmptyString(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : null;
}
function stripRubyReadingText(value: string): string {
return value
.replace(/<rt\b[^>]*>[\s\S]*?<\/rt>/gi, '')
.replace(/<rp\b[^>]*>[\s\S]*?<\/rp>/gi, '');
}
function stripHtmlTags(value: string): string {
return value.replace(/<[^>]+>/g, '');
}
function getVisibleFuriganaText(value: string): string {
return stripHtmlTags(stripRubyReadingText(value));
}
function boldMatchingFuriganaTerms(sentenceFurigana: string, highlightedText: string): string {
if (!sentenceFurigana || !highlightedText || /<b\b/i.test(sentenceFurigana)) {
return sentenceFurigana;
}
const spanRegex = /<span\b[^>]*>[\s\S]*?<\/span>/gi;
const spans: Array<{ start: number; end: number; visibleStart: number; visibleEnd: number }> = [];
let visibleSentence = '';
let match: RegExpExecArray | null;
while ((match = spanRegex.exec(sentenceFurigana)) !== null) {
const visibleStart = visibleSentence.length;
visibleSentence += getVisibleFuriganaText(match[0] || '');
spans.push({
start: match.index,
end: match.index + match[0].length,
visibleStart,
visibleEnd: visibleSentence.length,
});
}
if (spans.length === 0) {
return sentenceFurigana.replace(highlightedText, `<b>${highlightedText}</b>`);
}
const highlightStart = visibleSentence.indexOf(highlightedText);
if (highlightStart === -1) {
return sentenceFurigana;
}
const highlightEnd = highlightStart + highlightedText.length;
const matchingSpans = spans.filter(
(span) => span.visibleEnd > highlightStart && span.visibleStart < highlightEnd,
);
if (matchingSpans.length === 0) {
return sentenceFurigana;
}
let result = sentenceFurigana;
for (const span of [...matchingSpans].reverse()) {
result = `${result.slice(0, span.start)}<b>${result.slice(
span.start,
span.end,
)}</b>${result.slice(span.end)}`;
}
return result;
}
function decodeURIComponentSafe(value: string): string {
try {
return decodeURIComponent(value);
@@ -461,6 +521,10 @@ export class AnkiIntegration {
handleFieldGroupingManual: (originalNoteId, newNoteId, newNoteInfo, expression) =>
this.handleFieldGroupingManual(originalNoteId, newNoteId, newNoteInfo, expression),
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
processSentenceFurigana: (sentenceFurigana, noteFields) =>
this.processSentenceFurigana(sentenceFurigana, noteFields),
setCardTypeFields: (updatedFields, availableFieldNames, cardKind) =>
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
getResolvedSentenceAudioFieldName: (noteInfo) =>
@@ -677,20 +741,25 @@ export class AnkiIntegration {
return result;
}
private getSentenceHighlightText(noteFields: Record<string, string>): string {
const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence';
const existingSentence = noteFields[sentenceFieldName] || '';
return (
existingSentence.match(/<b>(.*?)<\/b>/)?.[1] ||
getPreferredWordValueFromExtractedFields(noteFields, this.config).trim()
);
}
private processSentence(mpvSentence: string, noteFields: Record<string, string>): string {
if (this.config.behavior?.highlightWord === false) {
return mpvSentence;
}
const sentenceFieldName = this.config.fields?.sentence?.toLowerCase() || 'sentence';
const existingSentence = noteFields[sentenceFieldName] || '';
const highlightMatch = existingSentence.match(/<b>(.*?)<\/b>/);
if (!highlightMatch || !highlightMatch[1]) {
const highlightedText = this.getSentenceHighlightText(noteFields);
if (!highlightedText) {
return mpvSentence;
}
const highlightedText = highlightMatch[1];
const index = mpvSentence.indexOf(highlightedText);
if (index === -1) {
@@ -702,6 +771,20 @@ export class AnkiIntegration {
return `${prefix}<b>${highlightedText}</b>${suffix}`;
}
private processSentenceFurigana(
sentenceFurigana: string,
noteFields: Record<string, string>,
): string {
if (this.config.behavior?.highlightWord === false) {
return sentenceFurigana;
}
const highlightedText = this.getSentenceHighlightText(noteFields);
return highlightedText
? boldMatchingFuriganaTerms(sentenceFurigana, highlightedText)
: sentenceFurigana;
}
private consumeSubtitleMiningContext(): SubtitleMiningContext | null {
if (!this.consumeSubtitleMiningContextCallback) {
return null;
@@ -1030,6 +1113,30 @@ export class AnkiIntegration {
): void {
const audioFlagNames = ['IsAudioCard'];
if (cardKind === 'word-and-sentence') {
const wordAndSentenceFlag = this.resolveFieldName(
availableFieldNames,
'IsWordAndSentenceCard',
);
if (!wordAndSentenceFlag) {
return;
}
updatedFields[wordAndSentenceFlag] = 'x';
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
if (sentenceFlag && sentenceFlag !== wordAndSentenceFlag) {
updatedFields[sentenceFlag] = '';
}
for (const audioFlagName of audioFlagNames) {
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
if (resolved && resolved !== wordAndSentenceFlag) {
updatedFields[resolved] = '';
}
}
return;
}
if (cardKind === 'sentence') {
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
if (sentenceFlag) {
@@ -6,6 +6,27 @@ import type { AnkiConnectConfig } from '../types/anki';
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
function setWordAndSentenceCardTypeFields(
updatedFields: Record<string, string>,
availableFieldNames: string[],
cardKind: 'sentence' | 'audio' | 'word-and-sentence',
): void {
if (cardKind !== 'word-and-sentence') return;
const resolveFieldName = (preferredName: string): string | null =>
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
if (!wordAndSentenceFlag) return;
updatedFields[wordAndSentenceFlag] = 'x';
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
const resolved = resolveFieldName(flagName);
if (resolved && resolved !== wordAndSentenceFlag) {
updatedFields[resolved] = '';
}
}
}
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
service: CardCreationService;
updatedFields: Record<string, string>[];
@@ -142,6 +163,72 @@ test('manual clipboard subtitle update replaces sentence audio without touching
);
});
test('manual clipboard subtitle update marks Kiku word cards as word-and-sentence cards when enabled', async () => {
const { service, updatedFields } = createManualUpdateService({
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Sentence',
audio: 'ExpressionAudio',
},
media: {
generateAudio: false,
generateImage: false,
maxMediaDuration: 30,
},
behavior: {
overwriteAudio: false,
overwriteImage: false,
},
ai: false,
}) as AnkiConnectConfig,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: {
Expression: { value: '単語' },
Sentence: { value: '' },
IsWordAndSentenceCard: { value: '' },
IsSentenceCard: { value: '' },
IsAudioCard: { value: '' },
},
},
],
updateNoteFields: async (_noteId, fields) => {
updatedFields.push(fields);
},
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
},
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
setCardTypeFields: setWordAndSentenceCardTypeFields,
});
await service.updateLastAddedFromClipboard('字幕');
assert.equal(updatedFields.length, 1);
assert.deepEqual(updatedFields[0], {
Sentence: '字幕',
IsWordAndSentenceCard: 'x',
IsSentenceCard: '',
IsAudioCard: '',
});
});
test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => {
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
client: {
+11 -2
View File
@@ -10,6 +10,7 @@ import { AiConfig } from '../types/integrations';
import { MpvClient } from '../types/runtime';
import { resolveSentenceBackText } from './ai';
import { resolveMediaGenerationInputPath } from './media-source';
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
const log = createLogger('anki').child('integration.card-creation');
@@ -18,7 +19,7 @@ export interface CardCreationNoteInfo {
fields: Record<string, { value: string }>;
}
type CardKind = 'sentence' | 'audio';
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
interface CardCreationClient {
addNote(
@@ -219,7 +220,8 @@ export class CardCreationService {
this.deps.getConfig(),
);
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceField = sentenceCardConfig.sentenceField;
const sentence = blocks.join(' ');
const updatedFields: Record<string, string> = {};
@@ -230,6 +232,13 @@ export class CardCreationService {
if (sentenceField) {
const processedSentence = this.deps.processSentence(sentence, fields);
updatedFields[sentenceField] = processedSentence;
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
this.deps.setCardTypeFields(
updatedFields,
Object.keys(noteInfo.fields),
'word-and-sentence',
);
}
updatePerformed = true;
}
+35 -5
View File
@@ -94,7 +94,7 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
JSON.stringify({
version: 2,
refreshedAtMs: 120_000,
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}',
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
words: ['猫'],
notes: {
'1': ['猫'],
@@ -143,7 +143,7 @@ test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted
JSON.stringify({
version: 2,
refreshedAtMs: 59_000,
scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
scope: '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}',
words: ['猫'],
notes: {
'1': ['猫'],
@@ -229,7 +229,7 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
JSON.stringify({
version: 2,
refreshedAtMs: 1,
scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":"Word"}',
scope: '{"refreshMinutes":1440,"scope":"all","fieldsWord":"Word"}',
words: ['猫', '犬'],
notes: {
'1': ['猫'],
@@ -276,6 +276,36 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
}
});
test('KnownWordCacheManager uses empty query when no known-word deck is configured', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesByQuery.set('', [1]);
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '猫' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('猫'), true);
} finally {
cleanup();
}
});
test('KnownWordCacheManager skips malformed note info without fields', async () => {
const config: AnkiConnectConfig = {
fields: {
@@ -364,7 +394,7 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo
scope: string;
words: string[];
};
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}');
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}');
assert.deepEqual(persisted.words, ['猫']);
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
@@ -568,7 +598,7 @@ test('KnownWordCacheManager reports immediate append cache clears as mutations',
JSON.stringify({
version: 2,
refreshedAtMs: Date.now(),
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":"Expression"}',
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":"Expression"}',
words: ['猫'],
notes: {
'1': ['猫'],
+2 -2
View File
@@ -48,7 +48,7 @@ export function getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): stri
}
const configuredDeck = trimToNonEmptyString(config.deck);
return configuredDeck ? `deck:${configuredDeck}` : 'is:note';
return configuredDeck ? `deck:${configuredDeck}` : 'all';
}
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
@@ -396,7 +396,7 @@ export class KnownWordCacheManager {
private buildKnownWordsQuery(): string {
const decks = this.getKnownWordDecks();
if (decks.length === 0) {
return 'is:note';
return '';
}
if (decks.length === 1) {
+34
View File
@@ -0,0 +1,34 @@
export interface NoteFieldValueInfo {
fields: Record<string, { value: string }>;
}
export function getNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: string): string | null {
const resolvedFieldName = Object.keys(noteInfo.fields).find(
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
);
return resolvedFieldName ? (noteInfo.fields[resolvedFieldName]?.value ?? '') : null;
}
export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: string): boolean {
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
}
export function shouldMarkWordAndSentenceCard(
noteInfo: NoteFieldValueInfo,
sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean },
): boolean {
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
return false;
}
const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard');
if (wordAndSentenceValue === null) {
return false;
}
if (wordAndSentenceValue.trim().length > 0) {
return true;
}
return (
!hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard')
);
}
@@ -7,6 +7,27 @@ import {
} from './note-update-workflow';
import type { SubtitleMiningContext } from '../types/subtitle';
function setWordAndSentenceCardTypeFields(
updatedFields: Record<string, string>,
availableFieldNames: string[],
cardKind: 'word-and-sentence',
): void {
assert.equal(cardKind, 'word-and-sentence');
const resolveFieldName = (preferredName: string): string | null =>
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
if (!wordAndSentenceFlag) return;
updatedFields[wordAndSentenceFlag] = 'x';
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
const resolved = resolveFieldName(flagName);
if (resolved && resolved !== wordAndSentenceFlag) {
updatedFields[resolved] = '';
}
}
}
function createWorkflowHarness() {
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
const notifications: Array<{ noteId: number; label: string | number }> = [];
@@ -40,6 +61,7 @@ function createWorkflowHarness() {
getCurrentSubtitleStart: () => 12.3,
getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled' as const,
}),
@@ -57,6 +79,7 @@ function createWorkflowHarness() {
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
false,
processSentence: (text: string, _noteFields: Record<string, string>) => text,
setCardTypeFields: setWordAndSentenceCardTypeFields,
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
if (!preferred) return null;
const names = Object.keys(noteInfo.fields);
@@ -102,6 +125,118 @@ test('NoteUpdateWorkflow updates sentence field and emits notification', async (
assert.equal(harness.notifications.length, 1);
});
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'tokugi' },
Sentence: { value: '' },
SentenceFurigana: { value: '<span class="term">tokugi</span>' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
harness.deps.processSentenceFurigana = (sentenceFurigana) =>
sentenceFurigana.replace('tokugi', '<b>tokugi</b>');
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text',
SentenceFurigana: '<span class="term"><b>tokugi</b></span>',
});
});
test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence cards', async () => {
const harness = createWorkflowHarness();
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
});
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
IsWordAndSentenceCard: { value: '' },
IsSentenceCard: { value: '' },
IsAudioCard: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text',
IsWordAndSentenceCard: 'x',
IsSentenceCard: '',
IsAudioCard: '',
});
});
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
Sentence: { value: '' },
IsWordAndSentenceCard: { value: '' },
IsSentenceCard: { value: '' },
IsAudioCard: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text',
});
});
test('NoteUpdateWorkflow preserves explicit sentence card type during sentence enrichment', async () => {
const harness = createWorkflowHarness();
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
lapisEnabled: true,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
});
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'sentence expression' },
Sentence: { value: '' },
IsWordAndSentenceCard: { value: '' },
IsSentenceCard: { value: 'x' },
IsAudioCard: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
await harness.workflow.execute(42);
assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text',
});
});
test('NoteUpdateWorkflow no-ops when note info is missing', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () => [];
@@ -119,6 +254,7 @@ test('NoteUpdateWorkflow updates note before auto field grouping merge', async (
let notesInfoCallCount = 0;
harness.deps.getEffectiveSentenceCardConfig = () => ({
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'auto',
});
@@ -1,6 +1,7 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
import type { SubtitleMiningContext } from '../types/subtitle';
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
export interface NoteUpdateWorkflowNoteInfo {
noteId: number;
@@ -35,6 +36,7 @@ export interface NoteUpdateWorkflowDeps {
getCurrentSubtitleStart: () => number | undefined;
getEffectiveSentenceCardConfig: () => {
sentenceField: string;
lapisEnabled: boolean;
kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
};
@@ -58,6 +60,15 @@ export interface NoteUpdateWorkflowDeps {
expression: string,
) => Promise<boolean>;
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
processSentenceFurigana?: (
sentenceFurigana: string,
noteFields: Record<string, string>,
) => string;
setCardTypeFields: (
updatedFields: Record<string, string>,
availableFieldNames: string[],
cardKind: 'word-and-sentence',
) => void;
resolveConfiguredFieldName: (
noteInfo: NoteUpdateWorkflowNoteInfo,
...preferredNames: (string | undefined)[]
@@ -189,8 +200,32 @@ export class NoteUpdateWorkflow {
if (sentenceField && currentSubtitleText) {
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
updatedFields[sentenceField] = processedSentence;
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
this.deps.setCardTypeFields(
updatedFields,
Object.keys(noteInfo.fields),
'word-and-sentence',
);
}
updatePerformed = true;
}
const sentenceFuriganaField = this.deps.resolveConfiguredFieldName(
noteInfo,
'SentenceFurigana',
);
const existingSentenceFurigana = sentenceFuriganaField
? noteInfo.fields[sentenceFuriganaField]?.value || ''
: '';
if (sentenceFuriganaField && existingSentenceFurigana && this.deps.processSentenceFurigana) {
const processedSentenceFurigana = this.deps.processSentenceFurigana(
existingSentenceFurigana,
fields,
);
if (processedSentenceFurigana !== existingSentenceFurigana) {
updatedFields[sentenceFuriganaField] = processedSentenceFurigana;
updatePerformed = true;
}
}
if (config.media?.generateAudio) {
try {
@@ -2035,6 +2035,76 @@ Aligned English subtitle
});
});
it('POST /api/stats/mine-card marks Kiku word mining notes as word-and-sentence cards when enabled', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
await withFakeAnkiConnect(
async (requests, url) => {
const app = createStatsApp(createMockTracker(), {
addYomitanNote: async () => 777,
createMediaGenerator: () => ({
generateAudio: async () => null,
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
}),
ankiConnectConfig: {
url,
deck: 'Mining',
fields: {
image: 'Picture',
sentence: 'Sentence',
},
media: {
generateAudio: false,
generateImage: false,
},
isKiku: {
enabled: true,
fieldGrouping: 'disabled',
deleteDuplicateInAuto: true,
},
},
});
const res = await app.request('/api/stats/mine-card?mode=word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1_000,
endMs: 2_000,
sentence: '猫を見た',
word: '猫',
videoTitle: 'Episode 1',
}),
});
const body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
const fields = updateRequest?.params?.note?.fields ?? {};
assert.equal(fields.Sentence, '<b>猫</b>を見た');
assert.equal(fields.IsWordAndSentenceCard, 'x');
assert.equal(fields.IsSentenceCard, '');
assert.equal(fields.IsAudioCard, '');
},
{
notesInfoFields: {
Expression: { value: '猫' },
Sentence: { value: '' },
Picture: { value: '' },
IsWordAndSentenceCard: { value: '' },
IsSentenceCard: { value: '' },
IsAudioCard: { value: '' },
},
},
);
});
});
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
+29 -1
View File
@@ -204,6 +204,29 @@ function getStatsWordMiningAudioFieldName(
);
}
function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig): boolean {
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
}
function applyStatsWordAndSentenceCardFields(
fields: Record<string, string>,
noteInfo: StatsServerNoteInfo | null,
ankiConfig: AnkiConnectConfig,
): void {
if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return;
const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard');
if (!wordAndSentenceFlag) return;
fields[wordAndSentenceFlag] = 'x';
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
const resolved = resolveStatsNoteFieldName(noteInfo, flagName);
if (resolved && resolved !== wordAndSentenceFlag) {
fields[resolved] = '';
}
}
}
function getStatsDirectMiningAudioFieldNames(
ankiConfig: AnkiConnectConfig,
noteInfo: StatsServerNoteInfo | null,
@@ -1299,7 +1322,11 @@ export function createStatsApp(
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
let noteInfo: StatsServerNoteInfo | null = null;
if (audioBuffer || (syncAnimatedImageToWordAudio && generateImage)) {
if (
audioBuffer ||
(syncAnimatedImageToWordAudio && generateImage) ||
shouldUseStatsLapisKikuCardFields(ankiConfig)
) {
try {
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
noteInfo = noteInfoResult[0] ?? null;
@@ -1339,6 +1366,7 @@ export function createStatsApp(
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
mediaFields[sentenceFieldName] = highlightedSentence;
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
if (audioBuffer) {
const audioFilename = `subminer_audio_${timestamp}.mp3`;
+10 -2
View File
@@ -2244,6 +2244,7 @@ const mediaRuntime = createMediaRuntimeService(
const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
userDataPath: USER_DATA_PATH,
getCurrentMediaPath: () => appState.currentMediaPath,
getCurrentVideoPath: () => appState.mpvClient?.currentVideoPath,
getCurrentMediaTitle: () => appState.currentMediaTitle,
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
@@ -2561,6 +2562,10 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void {
visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop();
}
function tickWindowsOverlayPointerInteractionNow(): void {
visibleOverlayInteractionRuntime.tickWindowsOverlayPointerInteractionNow();
}
function scheduleVisibleOverlayBlurRefresh(): void {
visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh();
}
@@ -5408,13 +5413,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
if (!mainWindow || senderWindow !== mainWindow) {
return;
}
if (visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() === active) {
const previousActive =
visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive();
visibleOverlayInteractionRuntime.setVisibleOverlayInteractionActive(active);
if (previousActive === active) {
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}
return;
}
visibleOverlayInteractionRuntime.setVisibleOverlayInteractionActive(active);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
},
onOverlayInteractiveHint: (interactive, senderWindow) => {
@@ -5614,6 +5621,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
reportOverlayContentBounds: (payload: unknown) => {
if (overlayContentMeasurementStore.report(payload)) {
tickLinuxOverlayPointerInteractionNow();
tickWindowsOverlayPointerInteractionNow();
primeLinuxOverlayPointerInteractionAfterFirstMeasurement();
autoplayReadyGate.flushPendingAutoplayReadySignal();
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
@@ -2386,6 +2386,36 @@ test('buildMergedDictionary rebuilds snapshots written with an older format vers
}
});
test('getManualSelectionSnapshot falls back to mpv current video path when app media path is not ready', async () => {
const userDataPath = makeTempDir();
const mpvPath =
'C:\\Videos\\KonoSuba - Gods blessing on this wonderful world!! (2016) - S02E05.mkv';
const calls: Array<{ mediaPath: string | null; mediaTitle: string | null }> = [];
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => null,
getCurrentVideoPath: () => mpvPath,
getCurrentMediaTitle: () => null,
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async (mediaPath, mediaTitle) => {
calls.push({ mediaPath, mediaTitle });
return {
title: 'KonoSuba - Gods blessing on this wonderful world!!',
season: 2,
episode: 5,
source: 'fallback',
};
},
now: () => 1_700_000_000_000,
});
const snapshot = await runtime.getManualSelectionSnapshot(undefined, '');
assert.deepEqual(calls, [{ mediaPath: mpvPath, mediaTitle: null }]);
assert.equal(snapshot.guessTitle, 'KonoSuba - Gods blessing on this wonderful world!!');
assert.equal(snapshot.candidates.length, 0);
});
test('buildMergedDictionary reapplies collapsible open states from current config', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
+8 -2
View File
@@ -76,6 +76,11 @@ function expandUserPath(input: string): string {
return input;
}
function trimToNull(input: string | null | undefined): string | null {
const trimmed = typeof input === 'string' ? input.trim() : '';
return trimmed.length > 0 ? trimmed : null;
}
function isVideoFile(filePath: string): boolean {
return hasVideoExtension(path.extname(filePath));
}
@@ -195,8 +200,9 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
return dictionaryTarget.length > 0
? resolveDictionaryGuessInputs(dictionaryTarget)
: {
mediaPath: deps.getCurrentMediaPath(),
mediaTitle: deps.getCurrentMediaTitle(),
mediaPath:
trimToNull(deps.getCurrentMediaPath()) ?? trimToNull(deps.getCurrentVideoPath?.()),
mediaTitle: trimToNull(deps.getCurrentMediaTitle()),
};
};
@@ -137,6 +137,7 @@ export type CharacterDictionaryManualSelectionResult = {
export interface CharacterDictionaryRuntimeDeps {
userDataPath: string;
getCurrentMediaPath: () => string | null;
getCurrentVideoPath?: () => string | null | undefined;
getCurrentMediaTitle: () => string | null;
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
guessAnilistMediaInfo: (
+6 -1
View File
@@ -308,7 +308,7 @@ test('visible overlay content-ready does not tokenize before first measurement',
);
});
test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => {
test('accepted visible overlay measurement immediately refreshes pointer interaction', () => {
const source = readMainSource();
const measurementBlock = source.match(
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
@@ -317,6 +317,7 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
assert.ok(measurementBlock);
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
assert.match(measurementBlock, /tickWindowsOverlayPointerInteractionNow\(\)/);
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
assert.ok(
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
@@ -324,6 +325,10 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
);
assert.ok(
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();'),
);
assert.ok(
measurementBlock.indexOf('tickWindowsOverlayPointerInteractionNow();') <
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
);
});
@@ -32,6 +32,7 @@ import { createLinuxX11CursorPointReader } from './linux-x11-cursor-point';
import type { LinuxVisibleOverlayWindowMode } from './linux-visible-overlay-window-mode';
import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility';
import { hasLiveSeparateWindow } from './settings-window-z-order';
import { tickWindowsOverlayPointerInteraction } from './windows-overlay-pointer-interaction';
export interface VisibleOverlayInteractionRuntimeDeps {
overlayManager: {
@@ -89,6 +90,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
let windowsVisibleOverlayZOrderSyncInFlight = false;
let windowsVisibleOverlayZOrderSyncQueued = false;
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
let windowsOverlayPointerInteractionActive = false;
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0;
let lastLinuxVisibleOverlayFollowedMpvAtMs = 0;
@@ -122,6 +124,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
function resetVisibleOverlayInputState(): void {
visibleOverlayInteractionActive = false;
windowsOverlayPointerInteractionActive = false;
linuxOverlayInputShapeActive = false;
linuxOverlayPointerInteractionStateApplied = false;
resetLinuxVisibleOverlayStartupInputPrimer();
@@ -538,6 +541,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
maybePollWindowsVisibleOverlayForegroundProcess();
tickWindowsOverlayPointerInteractionNow();
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
}
@@ -571,6 +575,56 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
}
}
function shouldSuspendWindowsOverlayPointerInteraction(): boolean {
return (
deps.getModalInputExclusive() ||
deps.getStatsOverlayVisible() ||
hasLiveSeparateWindow(deps.getOverlayForegroundSeparateWindows())
);
}
function updateWindowsOverlayPointerInteractionActive(active: boolean): void {
windowsOverlayPointerInteractionActive = active;
visibleOverlayInteractionActive = active;
const mainWindow = overlayManager.getMainWindow();
if (
process.platform !== 'win32' ||
!mainWindow ||
mainWindow.isDestroyed() ||
!mainWindow.isVisible()
) {
deps.updateVisibleOverlayVisibility();
return;
}
if (active) {
mainWindow.setIgnoreMouseEvents(false);
} else {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
}
}
const windowsOverlayPointerInteractionDeps = {
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getMainWindow: () => overlayManager.getMainWindow(),
getCursorScreenPoint: () => screen.getCursorScreenPoint(),
getSubtitleMeasurement: () => overlayContentMeasurementStore.getLatestByLayer('visible'),
shouldSuspend: shouldSuspendWindowsOverlayPointerInteraction,
getInteractionActive: () => windowsOverlayPointerInteractionActive,
setInteractionActive: updateWindowsOverlayPointerInteractionActive,
};
function tickWindowsOverlayPointerInteractionNow(): void {
if (process.platform !== 'win32') {
return;
}
if (!windowsOverlayPointerInteractionActive && visibleOverlayInteractionActive) {
return;
}
tickWindowsOverlayPointerInteraction(windowsOverlayPointerInteractionDeps);
}
ensureWindowsVisibleOverlayForegroundPollLoop();
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
@@ -811,10 +865,12 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
updateLinuxOverlayPointerInteractionActive,
primeLinuxOverlayPointerInteractionAfterFirstMeasurement,
requestLinuxOverlayZOrderFollow,
tickWindowsOverlayPointerInteractionNow,
tickLinuxOverlayPointerInteractionNow,
getVisibleOverlayInteractionActive: () => visibleOverlayInteractionActive,
setVisibleOverlayInteractionActive: (active: boolean) => {
visibleOverlayInteractionActive = active;
windowsOverlayPointerInteractionActive = false;
},
getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive,
getLastWindowsVisibleOverlayForegroundProcessName: () =>
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isCursorOverWindowsOverlayInteractiveRect,
resolveDesiredWindowsOverlayInteractive,
tickWindowsOverlayPointerInteraction,
type WindowsOverlayPointerInteractionDeps,
} from './windows-overlay-pointer-interaction';
import type { OverlayContentMeasurement } from '../../types';
const BOUNDS = { x: 100, y: 100, width: 1920, height: 1080 };
const MEASUREMENT: OverlayContentMeasurement = {
layer: 'visible',
measuredAtMs: 1,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 800, y: 900, width: 320, height: 80 },
};
function makeDeps(overrides: Partial<WindowsOverlayPointerInteractionDeps>): {
deps: WindowsOverlayPointerInteractionDeps;
state: { active: boolean };
} {
const state = { active: false };
const deps: WindowsOverlayPointerInteractionDeps = {
getVisibleOverlayVisible: () => true,
getMainWindow: () => ({
isDestroyed: () => false,
isVisible: () => true,
getBounds: () => BOUNDS,
}),
getCursorScreenPoint: () => ({ x: 1000, y: 1040 }),
getSubtitleMeasurement: () => MEASUREMENT,
shouldSuspend: () => false,
getInteractionActive: () => state.active,
setInteractionActive: (active) => {
state.active = active;
},
...overrides,
};
return { deps, state };
}
test('isCursorOverWindowsOverlayInteractiveRect hit-tests measured overlay rects', () => {
assert.equal(
isCursorOverWindowsOverlayInteractiveRect({ x: 1000, y: 1040 }, BOUNDS, MEASUREMENT),
true,
);
assert.equal(
isCursorOverWindowsOverlayInteractiveRect({ x: 500, y: 1040 }, BOUNDS, MEASUREMENT),
false,
);
});
test('isCursorOverWindowsOverlayInteractiveRect scales viewport px to window px', () => {
const scaled = { ...BOUNDS, width: 3840, height: 2160 };
assert.equal(
isCursorOverWindowsOverlayInteractiveRect({ x: 1700, y: 1900 }, scaled, MEASUREMENT),
true,
);
});
test('isCursorOverWindowsOverlayInteractiveRect uses separate interactive rects', () => {
const measurement: OverlayContentMeasurement = {
layer: 'visible',
measuredAtMs: 1,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 700, y: 40, width: 520, height: 940 },
interactiveRects: [
{ x: 700, y: 40, width: 520, height: 80 },
{ x: 760, y: 900, width: 400, height: 80 },
],
};
assert.equal(
isCursorOverWindowsOverlayInteractiveRect({ x: 900, y: 300 }, BOUNDS, measurement),
false,
);
assert.equal(
isCursorOverWindowsOverlayInteractiveRect({ x: 900, y: 180 }, BOUNDS, measurement),
true,
);
assert.equal(
isCursorOverWindowsOverlayInteractiveRect({ x: 900, y: 1060 }, BOUNDS, measurement),
true,
);
});
test('resolveDesiredWindowsOverlayInteractive: interactive over subtitle, passthrough off it', () => {
assert.equal(resolveDesiredWindowsOverlayInteractive(makeDeps({}).deps), true);
assert.equal(
resolveDesiredWindowsOverlayInteractive(
makeDeps({ getCursorScreenPoint: () => ({ x: 200, y: 200 }) }).deps,
),
false,
);
});
test('resolveDesiredWindowsOverlayInteractive returns null while another surface owns input', () => {
assert.equal(
resolveDesiredWindowsOverlayInteractive(makeDeps({ shouldSuspend: () => true }).deps),
null,
);
assert.equal(
resolveDesiredWindowsOverlayInteractive(makeDeps({ getMainWindow: () => null }).deps),
null,
);
});
test('tickWindowsOverlayPointerInteraction toggles only the fallback-owned state', () => {
const calls: boolean[] = [];
const { deps, state } = makeDeps({
setInteractionActive: (active) => {
calls.push(active);
state.active = active;
},
});
tickWindowsOverlayPointerInteraction(deps);
tickWindowsOverlayPointerInteraction(deps);
assert.deepEqual(calls, [true]);
deps.getCursorScreenPoint = () => ({ x: 200, y: 200 });
tickWindowsOverlayPointerInteraction(deps);
assert.deepEqual(calls, [true, false]);
});
test('tickWindowsOverlayPointerInteraction leaves renderer-owned state alone while suspended', () => {
const calls: boolean[] = [];
const { deps } = makeDeps({
getInteractionActive: () => true,
shouldSuspend: () => true,
setInteractionActive: (active) => calls.push(active),
});
tickWindowsOverlayPointerInteraction(deps);
assert.deepEqual(calls, []);
});
@@ -0,0 +1,99 @@
import type { OverlayContentMeasurement, OverlayContentRect } from '../../types';
type PointerPoint = { x: number; y: number };
type PointerRect = { x: number; y: number; width: number; height: number };
type PointerInteractionWindow = {
isDestroyed: () => boolean;
isVisible: () => boolean;
getBounds: () => PointerRect;
};
export type WindowsOverlayPointerInteractionDeps = {
getVisibleOverlayVisible: () => boolean;
getMainWindow: () => PointerInteractionWindow | null;
getCursorScreenPoint: () => PointerPoint;
getSubtitleMeasurement: () => OverlayContentMeasurement | null;
getRendererInteractiveHint?: () => boolean;
/** True when a modal/stats/separate window owns input. */
shouldSuspend: () => boolean;
getInteractionActive: () => boolean;
setInteractionActive: (active: boolean) => void;
};
// Match Linux fallback padding so hover survives tiny measurement/cursor gaps.
const SUBTITLE_HIT_PADDING_PX = 6;
function measuredRectsForInput(
measurement: OverlayContentMeasurement | null,
): OverlayContentRect[] {
if (!measurement) return [];
return Array.isArray(measurement.interactiveRects) && measurement.interactiveRects.length > 0
? measurement.interactiveRects
: measurement.contentRect
? [measurement.contentRect]
: [];
}
function isCursorOverRect(
cursor: PointerPoint,
bounds: PointerRect,
viewport: { width: number; height: number },
rect: OverlayContentRect,
): boolean {
if (!(bounds.width > 0) || !(bounds.height > 0)) return false;
if (!(viewport.width > 0) || !(viewport.height > 0)) return false;
if (!(rect.width > 0) || !(rect.height > 0)) return false;
const scaleX = bounds.width / viewport.width;
const scaleY = bounds.height / viewport.height;
const left = bounds.x + rect.x * scaleX - SUBTITLE_HIT_PADDING_PX;
const top = bounds.y + rect.y * scaleY - SUBTITLE_HIT_PADDING_PX;
const right = left + rect.width * scaleX + SUBTITLE_HIT_PADDING_PX * 2;
const bottom = top + rect.height * scaleY + SUBTITLE_HIT_PADDING_PX * 2;
return cursor.x >= left && cursor.x <= right && cursor.y >= top && cursor.y <= bottom;
}
export function isCursorOverWindowsOverlayInteractiveRect(
cursor: PointerPoint,
bounds: PointerRect,
measurement: OverlayContentMeasurement | null,
): boolean {
if (!measurement) return false;
return measuredRectsForInput(measurement).some((rect) =>
isCursorOverRect(cursor, bounds, measurement.viewport, rect),
);
}
/**
* Returns the desired Windows overlay mouse-input state, or null when another surface
* currently owns interaction and the fallback should not touch BrowserWindow passthrough.
*/
export function resolveDesiredWindowsOverlayInteractive(
deps: WindowsOverlayPointerInteractionDeps,
): boolean | null {
if (!deps.getVisibleOverlayVisible()) return false;
if (deps.shouldSuspend()) return null;
const mainWindow = deps.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return null;
}
if (deps.getRendererInteractiveHint?.()) return true;
return isCursorOverWindowsOverlayInteractiveRect(
deps.getCursorScreenPoint(),
mainWindow.getBounds(),
deps.getSubtitleMeasurement(),
);
}
export function tickWindowsOverlayPointerInteraction(
deps: WindowsOverlayPointerInteractionDeps,
): void {
const desired = resolveDesiredWindowsOverlayInteractive(deps);
if (desired === null) return;
if (deps.getInteractionActive() === desired) return;
deps.setInteractionActive(desired);
}
+34 -12
View File
@@ -14,23 +14,31 @@ async function withStubbedFfmpeg(
const tempDir = path.join(root, 'media');
const argsPath = path.join(root, 'ffmpeg-args.txt');
fs.mkdirSync(binDir, { recursive: true });
const ffmpegPath = path.join(binDir, 'ffmpeg');
const ffmpegStubPath = path.join(binDir, 'ffmpeg-stub.cjs');
const ffmpegPath = path.join(binDir, process.platform === 'win32' ? 'ffmpeg.cmd' : 'ffmpeg');
fs.writeFileSync(
ffmpegPath,
ffmpegStubPath,
[
'#!/bin/sh',
'if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then',
' echo " V..... libaom-av1"',
' exit 0',
'fi',
'printf "%s\\n" "$@" > "$SUBMINER_TEST_FFMPEG_ARGS"',
'out=""',
'for arg in "$@"; do out="$arg"; done',
'printf avif > "$out"',
"const fs = require('node:fs');",
'const args = process.argv.slice(2);',
"if (args[0] === '-hide_banner' && args[1] === '-encoders') {",
" console.log(' V..... libaom-av1');",
' process.exit(0);',
'}',
"fs.writeFileSync(process.env.SUBMINER_TEST_FFMPEG_ARGS, `${args.join('\\n')}\\n`, 'utf8');",
'const outputPath = args.at(-1);',
"fs.writeFileSync(outputPath, 'avif', 'utf8');",
].join('\n'),
'utf8',
);
fs.chmodSync(ffmpegPath, 0o755);
const ffmpegStub =
process.platform === 'win32'
? ['@echo off', `"${process.execPath}" "${ffmpegStubPath}" %*`].join('\r\n')
: ['#!/bin/sh', `exec "${process.execPath}" "${ffmpegStubPath}" "$@"`].join('\n');
fs.writeFileSync(ffmpegPath, ffmpegStub, 'utf8');
if (process.platform !== 'win32') {
fs.chmodSync(ffmpegPath, 0o755);
}
const originalPath = process.env.PATH;
const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS;
@@ -160,3 +168,17 @@ test('generateAudio clips leading padding without adding it to trailing duration
assert.equal(args[args.indexOf('-t') + 1], '1.7');
});
});
test('generateAudio recreates missing temp directory before invoking ffmpeg', async () => {
await withStubbedFfmpeg(async (generator, argsPath) => {
const tempDir = (generator as unknown as { tempDir: string }).tempDir;
fs.rmSync(tempDir, { recursive: true, force: true });
await generator.generateAudio('/video.mp4', 10, 12);
const args = readFfmpegArgs(argsPath);
const outputPath = args.at(-1);
assert.equal(typeof outputPath, 'string');
assert.equal(fs.existsSync(path.dirname(outputPath!)), true);
});
});
+18 -10
View File
@@ -77,16 +77,23 @@ export class MediaGenerator {
constructor(tempDir?: string) {
this.tempDir = tempDir || path.join(os.tmpdir(), 'subminer-media');
this.notifyIconDir = path.join(os.tmpdir(), 'subminer-notify');
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true });
}
if (!fs.existsSync(this.notifyIconDir)) {
fs.mkdirSync(this.notifyIconDir, { recursive: true });
}
this.ensureDirectory(this.tempDir);
this.ensureDirectory(this.notifyIconDir);
// Clean up old notification icons on startup (older than 1 hour)
this.cleanupOldNotificationIcons();
}
private ensureDirectory(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
private createTempOutputPath(prefix: string, extension: string): string {
this.ensureDirectory(this.tempDir);
return path.join(this.tempDir, `${prefix}_${Date.now()}.${extension}`);
}
/**
* Clean up notification icons older than 1 hour.
* Called on startup to prevent accumulation of temp files.
@@ -121,6 +128,7 @@ export class MediaGenerator {
* compatibility with Linux/Wayland notification daemons.
*/
writeNotificationIconToFile(iconBuffer: Buffer, noteId: number): string {
this.ensureDirectory(this.notifyIconDir);
const filename = `icon_${noteId}_${Date.now()}.png`;
const filePath = path.join(this.notifyIconDir, filename);
fs.writeFileSync(filePath, iconBuffer);
@@ -184,7 +192,7 @@ export class MediaGenerator {
const duration = endTime - start + safePadding;
return new Promise((resolve, reject) => {
const outputPath = path.join(this.tempDir, `audio_${Date.now()}.mp3`);
const outputPath = this.createTempOutputPath('audio', 'mp3');
const args: string[] = ['-ss', start.toString(), '-t', duration.toString(), '-i', videoPath];
if (
@@ -261,7 +269,7 @@ export class MediaGenerator {
args.push('-y');
return new Promise((resolve, reject) => {
const outputPath = path.join(this.tempDir, `screenshot_${Date.now()}.${ext}`);
const outputPath = this.createTempOutputPath('screenshot', ext);
args.push(outputPath);
execFile('ffmpeg', args, { timeout: 30000 }, (error) => {
@@ -288,7 +296,7 @@ export class MediaGenerator {
*/
async generateNotificationIcon(videoPath: string, timestamp: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
const outputPath = path.join(this.tempDir, `notify_icon_${Date.now()}.png`);
const outputPath = this.createTempOutputPath('notify_icon', 'png');
execFile(
'ffmpeg',
@@ -355,7 +363,7 @@ export class MediaGenerator {
}
return new Promise((resolve, reject) => {
const outputPath = path.join(this.tempDir, `animation_${Date.now()}.avif`);
const outputPath = this.createTempOutputPath('animation', 'avif');
const encoderArgs: string[] = ['-c:v', av1Encoder];
if (av1Encoder === 'libaom-av1') {