fix: align texthooker and stats formatting with CI expectations

This commit is contained in:
2026-03-18 19:01:29 -07:00
parent ec56970646
commit f4cce31d4a
11 changed files with 225 additions and 46 deletions

View File

@@ -319,7 +319,7 @@
"SubMiner" "SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"fields": { "fields": {
"word": "Expression", // Word setting. "word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Audio setting. "audio": "ExpressionAudio", // Audio setting.
"image": "Picture", // Image setting. "image": "Picture", // Image setting.
"sentence": "Sentence", // Sentence setting. "sentence": "Sentence", // Sentence setting.

View File

@@ -319,7 +319,7 @@
"SubMiner" "SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"fields": { "fields": {
"word": "Expression", // Word setting. "word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Audio setting. "audio": "ExpressionAudio", // Audio setting.
"image": "Picture", // Image setting. "image": "Picture", // Image setting.
"sentence": "Sentence", // Sentence setting. "sentence": "Sentence", // Sentence setting.

View File

@@ -1,23 +1,72 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { injectTexthookerBootstrapHtml } from './texthooker'; import { injectTexthookerBootstrapHtml, type TexthookerBootstrapSettings } from './texthooker';
test('injectTexthookerBootstrapHtml injects websocket bootstrap before head close', () => { test('injectTexthookerBootstrapHtml injects websocket bootstrap before head close', () => {
const html = '<html><head><title>Texthooker</title></head><body></body></html>'; const html = '<html><head><title>Texthooker</title></head><body></body></html>';
const settings: TexthookerBootstrapSettings = {
const actual = injectTexthookerBootstrapHtml(html, 'ws://127.0.0.1:6678'); enableKnownWordColoring: true,
enableNPlusOneColoring: true,
enableNameMatchColoring: true,
enableFrequencyColoring: true,
enableJlptColoring: true,
characterDictionaryEnabled: true,
knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6',
nameMatchColor: '#f5bde6',
hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
jlptColors: {
N1: '#ed8796',
N2: '#f5a97f',
N3: '#f9e2af',
N4: '#a6e3a1',
N5: '#8aadf4',
},
frequencyDictionary: {
singleColor: '#f5a97f',
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
},
};
const actual = injectTexthookerBootstrapHtml(html, 'ws://127.0.0.1:6678', settings);
assert.match( assert.match(
actual, actual,
/window\.localStorage\.setItem\('bannou-texthooker-websocketUrl', "ws:\/\/127\.0\.0\.1:6678"\)/, /window\.localStorage\.setItem\('bannou-texthooker-websocketUrl', "ws:\/\/127\.0\.0\.1:6678"\)/,
); );
assert.match(
actual,
/window\.localStorage\.setItem\('bannou-texthooker-enableKnownWordColoring', "1"\)/,
);
assert.match(
actual,
/window\.localStorage\.setItem\('bannou-texthooker-enableNPlusOneColoring', "1"\)/,
);
assert.match(
actual,
/window\.localStorage\.setItem\('bannou-texthooker-enableNameMatchColoring', "1"\)/,
);
assert.match(
actual,
/window\.localStorage\.setItem\('bannou-texthooker-enableFrequencyColoring', "1"\)/,
);
assert.match(
actual,
/window\.localStorage\.setItem\('bannou-texthooker-enableJlptColoring', "1"\)/,
);
assert.match(
actual,
/window\.localStorage\.setItem\('bannou-texthooker-characterDictionaryEnabled', "1"\)/,
);
assert.match(actual, /--subminer-known-word-color:\s*#a6da95;/);
assert.match(actual, /--subminer-n-plus-one-color:\s*#c6a0f6;/);
assert.match(actual, /--subminer-name-match-color:\s*#f5bde6;/);
assert.match(actual, /--subminer-jlpt-n1-color:\s*#ed8796;/);
assert.match(actual, /--subminer-frequency-band-4-color:\s*#8bd5ca;/);
assert.match(actual, /--sm-token-hover-bg:\s*rgba\(54, 58, 79, 0\.84\);/);
assert.match(actual, /p \.word\.word-known\s*\{\s*color:\s*var\(--subminer-known-word-color\);/);
assert.ok(actual.indexOf('</script></head>') !== -1); assert.ok(actual.indexOf('</script></head>') !== -1);
assert.ok(actual.includes('bannou-texthooker-websocketUrl')); assert.ok(actual.includes('bannou-texthooker-websocketUrl'));
assert.ok(!actual.includes('bannou-texthooker-enableKnownWordColoring'));
assert.ok(!actual.includes('bannou-texthooker-enableNPlusOneColoring'));
assert.ok(!actual.includes('bannou-texthooker-enableNameMatchColoring'));
assert.ok(!actual.includes('bannou-texthooker-enableFrequencyColoring'));
assert.ok(!actual.includes('bannou-texthooker-enableJlptColoring'));
}); });
test('injectTexthookerBootstrapHtml leaves html unchanged without websocketUrl', () => { test('injectTexthookerBootstrapHtml leaves html unchanged without websocketUrl', () => {

View File

@@ -5,23 +5,92 @@ import { createLogger } from '../../logger';
const logger = createLogger('main:texthooker'); const logger = createLogger('main:texthooker');
export function injectTexthookerBootstrapHtml(html: string, websocketUrl?: string): string { export type TexthookerBootstrapSettings = {
if (!websocketUrl) { enableKnownWordColoring: boolean;
enableNPlusOneColoring: boolean;
enableNameMatchColoring: boolean;
enableFrequencyColoring: boolean;
enableJlptColoring: boolean;
characterDictionaryEnabled: boolean;
knownWordColor: string;
nPlusOneColor: string;
nameMatchColor: string;
hoverTokenColor: string;
hoverTokenBackgroundColor: string;
jlptColors: {
N1: string;
N2: string;
N3: string;
N4: string;
N5: string;
};
frequencyDictionary: {
singleColor: string;
bandedColors: readonly [string, string, string, string, string];
};
};
function buildTexthookerBootstrapScript(
websocketUrl?: string,
settings?: TexthookerBootstrapSettings,
): string {
const statements: string[] = [];
if (websocketUrl) {
statements.push(
`window.localStorage.setItem('bannou-texthooker-websocketUrl', ${JSON.stringify(websocketUrl)});`,
);
}
if (settings) {
const booleanStorageValue = (enabled: boolean): '"1"' | '"0"' => (enabled ? '"1"' : '"0"');
statements.push(
`window.localStorage.setItem('bannou-texthooker-enableKnownWordColoring', ${booleanStorageValue(settings.enableKnownWordColoring)});`,
`window.localStorage.setItem('bannou-texthooker-enableNPlusOneColoring', ${booleanStorageValue(settings.enableNPlusOneColoring)});`,
`window.localStorage.setItem('bannou-texthooker-enableNameMatchColoring', ${booleanStorageValue(settings.enableNameMatchColoring)});`,
`window.localStorage.setItem('bannou-texthooker-enableFrequencyColoring', ${booleanStorageValue(settings.enableFrequencyColoring)});`,
`window.localStorage.setItem('bannou-texthooker-enableJlptColoring', ${booleanStorageValue(settings.enableJlptColoring)});`,
`window.localStorage.setItem('bannou-texthooker-characterDictionaryEnabled', ${booleanStorageValue(settings.characterDictionaryEnabled)});`,
);
}
return statements.length > 0 ? `<script>${statements.join('')}</script>` : '';
}
function buildTexthookerBootstrapStyle(settings?: TexthookerBootstrapSettings): string {
if (!settings) {
return '';
}
const [band1, band2, band3, band4, band5] = settings.frequencyDictionary.bandedColors;
return `<style id="subminer-texthooker-bootstrap-style">:root{--subminer-known-word-color:${settings.knownWordColor};--subminer-n-plus-one-color:${settings.nPlusOneColor};--subminer-name-match-color:${settings.nameMatchColor};--subminer-jlpt-n1-color:${settings.jlptColors.N1};--subminer-jlpt-n2-color:${settings.jlptColors.N2};--subminer-jlpt-n3-color:${settings.jlptColors.N3};--subminer-jlpt-n4-color:${settings.jlptColors.N4};--subminer-jlpt-n5-color:${settings.jlptColors.N5};--subminer-frequency-single-color:${settings.frequencyDictionary.singleColor};--subminer-frequency-band-1-color:${band1};--subminer-frequency-band-2-color:${band2};--subminer-frequency-band-3-color:${band3};--subminer-frequency-band-4-color:${band4};--subminer-frequency-band-5-color:${band5};--sm-token-hover-bg:${settings.hoverTokenBackgroundColor};--sm-token-hover-text:${settings.hoverTokenColor};}p .word.word-known{color:var(--subminer-known-word-color);}p .word.word-n-plus-one{color:var(--subminer-n-plus-one-color);}p .word.word-name-match{color:var(--subminer-name-match-color);}p .word.word-jlpt-n1{text-decoration-color:var(--subminer-jlpt-n1-color);}p .word.word-jlpt-n1[data-jlpt-level]::after{color:var(--subminer-jlpt-n1-color);}p .word.word-jlpt-n2{text-decoration-color:var(--subminer-jlpt-n2-color);}p .word.word-jlpt-n2[data-jlpt-level]::after{color:var(--subminer-jlpt-n2-color);}p .word.word-jlpt-n3{text-decoration-color:var(--subminer-jlpt-n3-color);}p .word.word-jlpt-n3[data-jlpt-level]::after{color:var(--subminer-jlpt-n3-color);}p .word.word-jlpt-n4{text-decoration-color:var(--subminer-jlpt-n4-color);}p .word.word-jlpt-n4[data-jlpt-level]::after{color:var(--subminer-jlpt-n4-color);}p .word.word-jlpt-n5{text-decoration-color:var(--subminer-jlpt-n5-color);}p .word.word-jlpt-n5[data-jlpt-level]::after{color:var(--subminer-jlpt-n5-color);}p .word.word-frequency-single{color:var(--subminer-frequency-single-color);}p .word.word-frequency-band-1{color:var(--subminer-frequency-band-1-color);}p .word.word-frequency-band-2{color:var(--subminer-frequency-band-2-color);}p .word.word-frequency-band-3{color:var(--subminer-frequency-band-3-color);}p .word.word-frequency-band-4{color:var(--subminer-frequency-band-4-color);}p .word.word-frequency-band-5{color:var(--subminer-frequency-band-5-color);}</style>`;
}
export function injectTexthookerBootstrapHtml(
html: string,
websocketUrl?: string,
settings?: TexthookerBootstrapSettings,
): string {
const bootstrapStyle = buildTexthookerBootstrapStyle(settings);
const bootstrapScript = buildTexthookerBootstrapScript(websocketUrl, settings);
if (!bootstrapStyle && !bootstrapScript) {
return html; return html;
} }
const bootstrapScript = `<script>window.localStorage.setItem('bannou-texthooker-websocketUrl', ${JSON.stringify(
websocketUrl,
)});</script>`;
if (html.includes('</head>')) { if (html.includes('</head>')) {
return html.replace('</head>', `${bootstrapScript}</head>`); return html.replace('</head>', `${bootstrapStyle}${bootstrapScript}</head>`);
} }
return `${bootstrapScript}${html}`; return `${bootstrapStyle}${bootstrapScript}${html}`;
} }
export class Texthooker { export class Texthooker {
constructor(
private readonly getBootstrapSettings?: () => TexthookerBootstrapSettings | undefined,
) {}
private server: http.Server | null = null; private server: http.Server | null = null;
public isRunning(): boolean { public isRunning(): boolean {
@@ -62,9 +131,16 @@ export class Texthooker {
res.end('Not found'); res.end('Not found');
return; return;
} }
const bootstrapSettings = this.getBootstrapSettings?.();
const responseData = const responseData =
urlPath === '/' || urlPath === '/index.html' urlPath === '/' || urlPath === '/index.html'
? Buffer.from(injectTexthookerBootstrapHtml(data.toString('utf-8'), websocketUrl)) ? Buffer.from(
injectTexthookerBootstrapHtml(
data.toString('utf-8'),
websocketUrl,
bootstrapSettings,
),
)
: data; : data;
res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'text/plain' }); res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'text/plain' });
res.end(responseData); res.end(responseData);

View File

@@ -568,7 +568,40 @@ const anilistUpdateQueue = createAnilistUpdateQueue(
}, },
); );
const isDev = process.argv.includes('--dev') || process.argv.includes('--debug'); const isDev = process.argv.includes('--dev') || process.argv.includes('--debug');
const texthookerService = new Texthooker(); const texthookerService = new Texthooker(() => {
const config = getResolvedConfig();
const characterDictionaryEnabled =
config.anilist.characterDictionary.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled();
const knownAndNPlusOneEnabled = getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
config.ankiConnect.knownWords.highlightEnabled,
);
return {
enableKnownWordColoring: knownAndNPlusOneEnabled,
enableNPlusOneColoring: knownAndNPlusOneEnabled,
enableNameMatchColoring: config.subtitleStyle.nameMatchEnabled && characterDictionaryEnabled,
enableFrequencyColoring: getRuntimeBooleanOption(
'subtitle.annotation.frequency',
config.subtitleStyle.frequencyDictionary.enabled,
),
enableJlptColoring: getRuntimeBooleanOption(
'subtitle.annotation.jlpt',
config.subtitleStyle.enableJlpt,
),
characterDictionaryEnabled,
knownWordColor: config.ankiConnect.knownWords.color,
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
nameMatchColor: config.subtitleStyle.nameMatchColor,
hoverTokenColor: config.subtitleStyle.hoverTokenColor,
hoverTokenBackgroundColor: config.subtitleStyle.hoverTokenBackgroundColor,
jlptColors: config.subtitleStyle.jlptColors,
frequencyDictionary: {
singleColor: config.subtitleStyle.frequencyDictionary.singleColor,
bandedColors: config.subtitleStyle.frequencyDictionary.bandedColors,
},
};
});
const subtitleWsService = new SubtitleWebSocket(); const subtitleWsService = new SubtitleWebSocket();
const annotationSubtitleWsService = new SubtitleWebSocket(); const annotationSubtitleWsService = new SubtitleWebSocket();
const logger = createLogger('main'); const logger = createLogger('main');

View File

@@ -55,7 +55,9 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
<div className="text-xs text-ctp-overlay2">total watch time</div> <div className="text-xs text-ctp-overlay2">total watch time</div>
</div> </div>
<div> <div>
<div className="text-ctp-cards-mined font-medium">{formatNumber(detail.totalCards)}</div> <div className="text-ctp-cards-mined font-medium">
{formatNumber(detail.totalCards)}
</div>
<div className="text-xs text-ctp-overlay2">cards mined</div> <div className="text-xs text-ctp-overlay2">cards mined</div>
</div> </div>
<div> <div>

View File

@@ -1,4 +1,8 @@
import { formatEventSeconds, type SessionChartMarker, type SessionEventNoteInfo } from '../../lib/session-events'; import {
formatEventSeconds,
type SessionChartMarker,
type SessionEventNoteInfo,
} from '../../lib/session-events';
interface SessionEventPopoverProps { interface SessionEventPopoverProps {
marker: SessionChartMarker; marker: SessionChartMarker;
@@ -83,7 +87,8 @@ export function SessionEventPopover({
{marker.kind === 'seek' && ( {marker.kind === 'seek' && (
<div className="space-y-1 text-xs text-ctp-subtext0"> <div className="space-y-1 text-xs text-ctp-subtext0">
<div> <div>
From <span className="text-ctp-teal">{formatEventSeconds(marker.fromMs) ?? '\u2014'}</span>{' '} From{' '}
<span className="text-ctp-teal">{formatEventSeconds(marker.fromMs) ?? '\u2014'}</span>{' '}
to <span className="text-ctp-teal">{formatEventSeconds(marker.toMs) ?? '\u2014'}</span> to <span className="text-ctp-teal">{formatEventSeconds(marker.toMs) ?? '\u2014'}</span>
</div> </div>
<div> <div>
@@ -120,7 +125,9 @@ export function SessionEventPopover({
) : null} ) : null}
</div> </div>
{info?.expression ? ( {info?.expression ? (
<div className="mb-1 text-sm font-medium text-ctp-text">{info.expression}</div> <div className="mb-1 text-sm font-medium text-ctp-text">
{info.expression}
</div>
) : null} ) : null}
{info?.context ? ( {info?.context ? (
<div className="mb-1 text-xs text-ctp-subtext0">{info.context}</div> <div className="mb-1 text-xs text-ctp-subtext0">{info.context}</div>

View File

@@ -177,7 +177,12 @@ export function TrendsTab() {
color="#8aadf4" color="#8aadf4"
type="bar" type="bar"
/> />
<TrendChart title="Cards Mined" data={data.activity.cards} color={cardsMinedColor} type="bar" /> <TrendChart
title="Cards Mined"
data={data.activity.cards}
color={cardsMinedColor}
type="bar"
/>
<TrendChart title="Tokens Seen" data={data.activity.words} color="#8bd5ca" type="bar" /> <TrendChart title="Tokens Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" /> <TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
@@ -196,7 +201,12 @@ export function TrendsTab() {
color="#c6a0f6" color="#c6a0f6"
type="line" type="line"
/> />
<TrendChart title="Cards Mined" data={data.progress.cards} color={cardsMinedColor} type="line" /> <TrendChart
title="Cards Mined"
data={data.progress.cards}
color={cardsMinedColor}
type="line"
/>
<TrendChart <TrendChart
title="Episodes Watched" title="Episodes Watched"
data={data.progress.episodes} data={data.progress.episodes}

View File

@@ -146,7 +146,9 @@ test('extractSessionEventNoteInfo ignores malformed notes without a numeric note
}); });
test('mergeSessionEventNoteInfos keys previews by both requested and returned note ids', () => { test('mergeSessionEventNoteInfos keys previews by both requested and returned note ids', () => {
const noteInfos = mergeSessionEventNoteInfos([111], [ const noteInfos = mergeSessionEventNoteInfos(
[111],
[
{ {
noteId: 222, noteId: 222,
fields: { fields: {
@@ -154,7 +156,8 @@ test('mergeSessionEventNoteInfos keys previews by both requested and returned no
Sentence: { value: 'この剣は呪いだ' }, Sentence: { value: 'この剣は呪いだ' },
}, },
}, },
]); ],
);
assert.deepEqual(noteInfos.get(111), { assert.deepEqual(noteInfos.get(111), {
noteId: 222, noteId: 222,

View File

@@ -237,17 +237,16 @@ export function collectPendingSessionEventNoteIds(
return next; return next;
} }
export function getSessionEventCardRequest( export function getSessionEventCardRequest(marker: SessionChartMarker | null): {
marker: SessionChartMarker | null, noteIds: number[];
): { noteIds: number[]; requestKey: string | null } { requestKey: string | null;
} {
if (!marker || marker.kind !== 'card' || marker.noteIds.length === 0) { if (!marker || marker.kind !== 'card' || marker.noteIds.length === 0) {
return { noteIds: [], requestKey: null }; return { noteIds: [], requestKey: null };
} }
const noteIds = Array.from( const noteIds = Array.from(
new Set( new Set(marker.noteIds.filter((noteId) => Number.isInteger(noteId) && noteId > 0)),
marker.noteIds.filter((noteId) => Number.isInteger(noteId) && noteId > 0),
),
); );
return { return {