mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
fix: align texthooker and stats formatting with CI expectations
This commit is contained in:
@@ -319,7 +319,7 @@
|
||||
"SubMiner"
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"fields": {
|
||||
"word": "Expression", // Word setting.
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Audio setting.
|
||||
"image": "Picture", // Image setting.
|
||||
"sentence": "Sentence", // Sentence setting.
|
||||
|
||||
@@ -319,7 +319,7 @@
|
||||
"SubMiner"
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"fields": {
|
||||
"word": "Expression", // Word setting.
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Audio setting.
|
||||
"image": "Picture", // Image setting.
|
||||
"sentence": "Sentence", // Sentence setting.
|
||||
|
||||
@@ -1,23 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { injectTexthookerBootstrapHtml } from './texthooker';
|
||||
import { injectTexthookerBootstrapHtml, type TexthookerBootstrapSettings } from './texthooker';
|
||||
|
||||
test('injectTexthookerBootstrapHtml injects websocket bootstrap before head close', () => {
|
||||
const html = '<html><head><title>Texthooker</title></head><body></body></html>';
|
||||
|
||||
const actual = injectTexthookerBootstrapHtml(html, 'ws://127.0.0.1:6678');
|
||||
const settings: TexthookerBootstrapSettings = {
|
||||
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(
|
||||
actual,
|
||||
/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.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', () => {
|
||||
|
||||
@@ -5,23 +5,92 @@ import { createLogger } from '../../logger';
|
||||
|
||||
const logger = createLogger('main:texthooker');
|
||||
|
||||
export function injectTexthookerBootstrapHtml(html: string, websocketUrl?: string): string {
|
||||
if (!websocketUrl) {
|
||||
export type TexthookerBootstrapSettings = {
|
||||
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;
|
||||
}
|
||||
|
||||
const bootstrapScript = `<script>window.localStorage.setItem('bannou-texthooker-websocketUrl', ${JSON.stringify(
|
||||
websocketUrl,
|
||||
)});</script>`;
|
||||
|
||||
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 {
|
||||
constructor(
|
||||
private readonly getBootstrapSettings?: () => TexthookerBootstrapSettings | undefined,
|
||||
) {}
|
||||
|
||||
private server: http.Server | null = null;
|
||||
|
||||
public isRunning(): boolean {
|
||||
@@ -62,9 +131,16 @@ export class Texthooker {
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
const bootstrapSettings = this.getBootstrapSettings?.();
|
||||
const responseData =
|
||||
urlPath === '/' || urlPath === '/index.html'
|
||||
? Buffer.from(injectTexthookerBootstrapHtml(data.toString('utf-8'), websocketUrl))
|
||||
? Buffer.from(
|
||||
injectTexthookerBootstrapHtml(
|
||||
data.toString('utf-8'),
|
||||
websocketUrl,
|
||||
bootstrapSettings,
|
||||
),
|
||||
)
|
||||
: data;
|
||||
res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'text/plain' });
|
||||
res.end(responseData);
|
||||
|
||||
35
src/main.ts
35
src/main.ts
@@ -568,7 +568,40 @@ const anilistUpdateQueue = createAnilistUpdateQueue(
|
||||
},
|
||||
);
|
||||
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 annotationSubtitleWsService = new SubtitleWebSocket();
|
||||
const logger = createLogger('main');
|
||||
|
||||
@@ -55,7 +55,9 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
|
||||
<div className="text-xs text-ctp-overlay2">total watch time</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>
|
||||
<div>
|
||||
|
||||
@@ -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 {
|
||||
marker: SessionChartMarker;
|
||||
@@ -83,7 +87,8 @@ export function SessionEventPopover({
|
||||
{marker.kind === 'seek' && (
|
||||
<div className="space-y-1 text-xs text-ctp-subtext0">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
@@ -120,7 +125,9 @@ export function SessionEventPopover({
|
||||
) : null}
|
||||
</div>
|
||||
{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}
|
||||
{info?.context ? (
|
||||
<div className="mb-1 text-xs text-ctp-subtext0">{info.context}</div>
|
||||
|
||||
@@ -177,7 +177,12 @@ export function TrendsTab() {
|
||||
color="#8aadf4"
|
||||
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="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
|
||||
|
||||
@@ -196,7 +201,12 @@ export function TrendsTab() {
|
||||
color="#c6a0f6"
|
||||
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
|
||||
title="Episodes Watched"
|
||||
data={data.progress.episodes}
|
||||
|
||||
@@ -146,7 +146,9 @@ test('extractSessionEventNoteInfo ignores malformed notes without a numeric note
|
||||
});
|
||||
|
||||
test('mergeSessionEventNoteInfos keys previews by both requested and returned note ids', () => {
|
||||
const noteInfos = mergeSessionEventNoteInfos([111], [
|
||||
const noteInfos = mergeSessionEventNoteInfos(
|
||||
[111],
|
||||
[
|
||||
{
|
||||
noteId: 222,
|
||||
fields: {
|
||||
@@ -154,7 +156,8 @@ test('mergeSessionEventNoteInfos keys previews by both requested and returned no
|
||||
Sentence: { value: 'この剣は呪いだ' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(noteInfos.get(111), {
|
||||
noteId: 222,
|
||||
|
||||
@@ -237,17 +237,16 @@ export function collectPendingSessionEventNoteIds(
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getSessionEventCardRequest(
|
||||
marker: SessionChartMarker | null,
|
||||
): { noteIds: number[]; requestKey: string | null } {
|
||||
export function getSessionEventCardRequest(marker: SessionChartMarker | null): {
|
||||
noteIds: number[];
|
||||
requestKey: string | null;
|
||||
} {
|
||||
if (!marker || marker.kind !== 'card' || marker.noteIds.length === 0) {
|
||||
return { noteIds: [], requestKey: null };
|
||||
}
|
||||
|
||||
const noteIds = Array.from(
|
||||
new Set(
|
||||
marker.noteIds.filter((noteId) => Number.isInteger(noteId) && noteId > 0),
|
||||
),
|
||||
new Set(marker.noteIds.filter((noteId) => Number.isInteger(noteId) && noteId > 0)),
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user