migrate subtitle style config to CSS declaration shape

- Flat style keys (fontFamily, fontSize, hoverTokenColor, etc.) consolidated into subtitleStyle.css, secondary.css, and subtitleSidebar.css objects
- Hover token colors migrated to --subtitle-hover-token-color CSS custom properties
- Plugin app-ping now checks result.status (0=running, 1=stopped) to avoid treating transient failures as stopped
- Note-fields note type picker defaults to configured deck's note type before falling back to Kiku/Lapis
- New migration for legacy ankiConnect N+1 config paths
This commit is contained in:
2026-05-18 03:01:31 -07:00
parent c7fc328194
commit ff4d38e5be
33 changed files with 990 additions and 339 deletions
+57 -5
View File
@@ -11,7 +11,8 @@ type WindowTrackerStub = {
isTargetWindowMinimized?: () => boolean;
};
function createMainWindowRecorder() {
function createMainWindowRecorder(options: { emitShowImmediately?: boolean } = {}) {
const emitShowImmediately = options.emitShowImmediately ?? true;
const calls: string[] = [];
const listeners = new Map<string, Array<() => void>>();
let visible = false;
@@ -25,6 +26,10 @@ function createMainWindowRecorder() {
handler();
}
};
const emitShow = (): void => {
visible = true;
emit('show');
};
const window = {
webContents: {},
isDestroyed: () => false,
@@ -39,14 +44,16 @@ function createMainWindowRecorder() {
calls.push('hide');
},
show: () => {
visible = true;
calls.push('show');
emit('show');
if (emitShowImmediately) {
emitShow();
}
},
showInactive: () => {
visible = true;
calls.push('show-inactive');
emit('show');
if (emitShowImmediately) {
emitShow();
}
},
focus: () => {
focused = true;
@@ -81,6 +88,7 @@ function createMainWindowRecorder() {
window,
calls,
getOpacity: () => opacity,
emitShow,
setContentReady: (nextContentReady: boolean) => {
contentReady = nextContentReady;
(
@@ -267,6 +275,50 @@ test('tracked non-macOS overlay reapplies bounds after first show', () => {
);
});
test('tracked non-macOS overlay queues only one first-show bounds refresh', () => {
const { window, calls, emitShow } = createMainWindowRecorder({ emitShowImmediately: false });
let width = 1280;
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width, height: 720 }),
};
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: (geometry: { width: number }) => {
calls.push(`update-bounds:${geometry.width}`);
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
run();
width = 1440;
run();
emitShow();
assert.deepEqual(
calls.filter((call) => call.startsWith('update-bounds:')),
['update-bounds:1280', 'update-bounds:1440', 'update-bounds:1440'],
);
});
test('Windows visible overlay stays click-through and binds to mpv while tracked', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
+11 -1
View File
@@ -8,6 +8,7 @@ const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
BrowserWindow,
ReturnType<typeof setTimeout>
>();
const pendingFirstShowBoundsRefreshGeometry = new WeakMap<BrowserWindow, WindowGeometry>();
function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
const opacityCapableWindow = window as BrowserWindow & {
setOpacity?: (opacity: number) => void;
@@ -279,11 +280,20 @@ export function updateVisibleOverlayVisibility(args: {
) {
return;
}
if (pendingFirstShowBoundsRefreshGeometry.has(mainWindow)) {
pendingFirstShowBoundsRefreshGeometry.set(mainWindow, geometry);
return;
}
pendingFirstShowBoundsRefreshGeometry.set(mainWindow, geometry);
mainWindow.once('show', () => {
const pendingGeometry = pendingFirstShowBoundsRefreshGeometry.get(mainWindow);
pendingFirstShowBoundsRefreshGeometry.delete(mainWindow);
if (mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return;
}
args.updateVisibleOverlayBounds(geometry);
if (pendingGeometry) {
args.updateVisibleOverlayBounds(pendingGeometry);
}
});
};
+22 -4
View File
@@ -43,6 +43,7 @@ export interface TokenizerServiceDeps {
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null;
getNPlusOneEnabled?: () => boolean;
getJlptEnabled?: () => boolean;
@@ -74,6 +75,7 @@ export interface TokenizerDepsRuntimeOptions {
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null;
getNPlusOneEnabled?: () => boolean;
getJlptEnabled?: () => boolean;
@@ -88,6 +90,7 @@ export interface TokenizerDepsRuntimeOptions {
}
interface TokenizerAnnotationOptions {
knownWordsEnabled: boolean;
nPlusOneEnabled: boolean;
jlptEnabled: boolean;
nameMatchEnabled: boolean;
@@ -119,18 +122,28 @@ function getKnownWordLookup(
deps: TokenizerServiceDeps,
options: TokenizerAnnotationOptions,
): (text: string) => boolean {
if (!options.nPlusOneEnabled) {
if (!options.knownWordsEnabled && !options.nPlusOneEnabled) {
return () => false;
}
return deps.isKnownWord;
}
function needsMecabPosEnrichment(options: TokenizerAnnotationOptions): boolean {
return options.nPlusOneEnabled || options.jlptEnabled || options.frequencyEnabled;
return (
options.knownWordsEnabled ||
options.nPlusOneEnabled ||
options.jlptEnabled ||
options.frequencyEnabled
);
}
function hasAnyAnnotationEnabled(options: TokenizerAnnotationOptions): boolean {
return options.nPlusOneEnabled || options.jlptEnabled || options.frequencyEnabled;
return (
options.knownWordsEnabled ||
options.nPlusOneEnabled ||
options.jlptEnabled ||
options.frequencyEnabled
);
}
async function enrichTokensWithMecabAsync(
@@ -211,6 +224,7 @@ export function createTokenizerDepsRuntime(
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
isKnownWord: options.isKnownWord,
getKnownWordMatchMode: options.getKnownWordMatchMode,
getKnownWordsEnabled: options.getKnownWordsEnabled,
getJlptLevel: options.getJlptLevel,
getNPlusOneEnabled: options.getNPlusOneEnabled,
getJlptEnabled: options.getJlptEnabled,
@@ -662,8 +676,12 @@ function applyFrequencyRanks(
}
function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOptions {
const nPlusOneEnabled = deps.getNPlusOneEnabled?.() !== false;
return {
nPlusOneEnabled: deps.getNPlusOneEnabled?.() !== false,
knownWordsEnabled: deps.getKnownWordsEnabled
? deps.getKnownWordsEnabled() !== false
: nPlusOneEnabled,
nPlusOneEnabled,
jlptEnabled: deps.getJlptEnabled?.() !== false,
nameMatchEnabled: deps.getNameMatchEnabled?.() !== false,
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
@@ -56,6 +56,50 @@ test('annotateTokens known-word match mode uses headword vs surface', () => {
assert.equal(surfaceResult[0]?.isKnown, false);
});
test('annotateTokens marks known words when N+1 is disabled', () => {
const tokens = [
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
makeToken({ surface: '猫', headword: '猫', startPos: 1, endPos: 2 }),
makeToken({ surface: '犬', headword: '犬', startPos: 2, endPos: 3 }),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '私' || text === '猫',
}),
{ nPlusOneEnabled: false, knownWordsEnabled: true },
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[1]?.isKnown, true);
assert.equal(result[1]?.isNPlusOneTarget, false);
assert.equal(result[2]?.isKnown, false);
assert.equal(result[2]?.isNPlusOneTarget, false);
});
test('annotateTokens hides known-word marks while still using known words for N+1', () => {
const tokens = [
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
makeToken({ surface: '猫', headword: '猫', startPos: 1, endPos: 2 }),
makeToken({ surface: '犬', headword: '犬', startPos: 2, endPos: 3 }),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '私' || text === '猫',
}),
{ nPlusOneEnabled: true, knownWordsEnabled: false, minSentenceWordsForNPlusOne: 3 },
);
assert.equal(result[0]?.isKnown, false);
assert.equal(result[1]?.isKnown, false);
assert.equal(result[2]?.isKnown, false);
assert.equal(result[2]?.isNPlusOneTarget, true);
});
test('annotateTokens falls back to reading for known-word matches when headword lookup misses', () => {
const tokens = [
makeToken({
+24 -10
View File
@@ -31,6 +31,7 @@ export interface AnnotationStageDeps {
}
export interface AnnotationStageOptions {
knownWordsEnabled?: boolean;
nPlusOneEnabled?: boolean;
nameMatchEnabled?: boolean;
jlptEnabled?: boolean;
@@ -669,13 +670,16 @@ export function annotateTokens(
): MergedToken[] {
const pos1Exclusions = resolvePos1Exclusions(options);
const pos2Exclusions = resolvePos2Exclusions(options);
const knownWordsEnabled = options.knownWordsEnabled !== false;
const nPlusOneEnabled = options.nPlusOneEnabled !== false;
const nameMatchEnabled = options.nameMatchEnabled !== false;
const frequencyEnabled = options.frequencyEnabled !== false;
const jlptEnabled = options.jlptEnabled !== false;
const shouldComputeKnownStatus = knownWordsEnabled || nPlusOneEnabled;
const nPlusOneKnownStatuses: boolean[] = [];
// Single pass: compute known word status, frequency filtering, and JLPT level together
const annotated = tokens.map((token) => {
const annotated = tokens.map((token, index) => {
if (
sharedShouldExcludeTokenFromSubtitleAnnotations(token, {
pos1Exclusions,
@@ -686,6 +690,7 @@ export function annotateTokens(
pos1Exclusions,
pos2Exclusions,
});
nPlusOneKnownStatuses[index] = false;
return {
...strippedToken,
isKnown: false,
@@ -693,9 +698,10 @@ export function annotateTokens(
}
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
const isKnown = nPlusOneEnabled
const isKnownForMatching = shouldComputeKnownStatus
? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode)
: false;
nPlusOneKnownStatuses[index] = isKnownForMatching;
const frequencyRank =
frequencyEnabled && !prioritizedNameMatch
@@ -709,7 +715,7 @@ export function annotateTokens(
return {
...token,
isKnown,
isKnown: knownWordsEnabled ? isKnownForMatching : false,
isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false,
frequencyRank,
jlptLevel,
@@ -728,13 +734,21 @@ export function annotateTokens(
? minSentenceWordsForNPlusOne
: 3;
const nPlusOneMarked = markNPlusOneTargets(
annotated,
sanitizedMinSentenceWordsForNPlusOne,
pos1Exclusions,
pos2Exclusions,
options.sourceText,
);
const nPlusOneMarked = nPlusOneEnabled
? markNPlusOneTargets(
annotated.map((token, index) => ({
...token,
isKnown: nPlusOneKnownStatuses[index] ?? false,
})),
sanitizedMinSentenceWordsForNPlusOne,
pos1Exclusions,
pos2Exclusions,
options.sourceText,
).map((token, index) => ({
...annotated[index]!,
isNPlusOneTarget: token.isNPlusOneTarget,
}))
: annotated;
if (!nameMatchEnabled) {
return nPlusOneMarked;