diff --git a/config.example.jsonc b/config.example.jsonc index 52ae35f..cf9f1ae 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -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. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 52ae35f..cf9f1ae 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -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. diff --git a/src/core/services/texthooker.test.ts b/src/core/services/texthooker.test.ts index 8021c36..e4f85ce 100644 --- a/src/core/services/texthooker.test.ts +++ b/src/core/services/texthooker.test.ts @@ -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 = 'Texthooker'; - - 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('') !== -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', () => { diff --git a/src/core/services/texthooker.ts b/src/core/services/texthooker.ts index a4446d6..9e76533 100644 --- a/src/core/services/texthooker.ts +++ b/src/core/services/texthooker.ts @@ -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 ? `` : ''; +} + +function buildTexthookerBootstrapStyle(settings?: TexthookerBootstrapSettings): string { + if (!settings) { + return ''; + } + + const [band1, band2, band3, band4, band5] = settings.frequencyDictionary.bandedColors; + + return ``; +} + +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 = ``; - if (html.includes('')) { - return html.replace('', `${bootstrapScript}`); + return html.replace('', `${bootstrapStyle}${bootstrapScript}`); } - 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); diff --git a/src/main.ts b/src/main.ts index 08a4695..d080992 100644 --- a/src/main.ts +++ b/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'); diff --git a/stats/src/components/library/MediaHeader.tsx b/stats/src/components/library/MediaHeader.tsx index ef96081..5ce1319 100644 --- a/stats/src/components/library/MediaHeader.tsx +++ b/stats/src/components/library/MediaHeader.tsx @@ -55,7 +55,9 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
total watch time
-
{formatNumber(detail.totalCards)}
+
+ {formatNumber(detail.totalCards)} +
cards mined
diff --git a/stats/src/components/overview/RecentSessions.tsx b/stats/src/components/overview/RecentSessions.tsx index f50617c..64b2d08 100644 --- a/stats/src/components/overview/RecentSessions.tsx +++ b/stats/src/components/overview/RecentSessions.tsx @@ -245,13 +245,13 @@ function AnimeGroupRow({ {group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
-
-
-
- {formatNumber(group.totalCards)} +
+
+
+ {formatNumber(group.totalCards)} +
+
cards
-
cards
-
{formatNumber(group.totalWords)} diff --git a/stats/src/components/sessions/SessionEventPopover.tsx b/stats/src/components/sessions/SessionEventPopover.tsx index 7d013ee..b9e3090 100644 --- a/stats/src/components/sessions/SessionEventPopover.tsx +++ b/stats/src/components/sessions/SessionEventPopover.tsx @@ -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' && (
- From {formatEventSeconds(marker.fromMs) ?? '\u2014'}{' '} + From{' '} + {formatEventSeconds(marker.fromMs) ?? '\u2014'}{' '} to {formatEventSeconds(marker.toMs) ?? '\u2014'}
@@ -120,7 +125,9 @@ export function SessionEventPopover({ ) : null}
{info?.expression ? ( -
{info.expression}
+
+ {info.expression} +
) : null} {info?.context ? (
{info.context}
diff --git a/stats/src/components/trends/TrendsTab.tsx b/stats/src/components/trends/TrendsTab.tsx index 350a5dc..f12dc19 100644 --- a/stats/src/components/trends/TrendsTab.tsx +++ b/stats/src/components/trends/TrendsTab.tsx @@ -177,7 +177,12 @@ export function TrendsTab() { color="#8aadf4" type="bar" /> - + @@ -196,7 +201,12 @@ export function TrendsTab() { color="#c6a0f6" type="line" /> - + { - const noteInfos = mergeSessionEventNoteInfos([111], [ - { - noteId: 222, - fields: { - Expression: { value: '呪い' }, - Sentence: { value: 'この剣は呪いだ' }, + const noteInfos = mergeSessionEventNoteInfos( + [111], + [ + { + noteId: 222, + fields: { + Expression: { value: '呪い' }, + Sentence: { value: 'この剣は呪いだ' }, + }, }, - }, - ]); + ], + ); assert.deepEqual(noteInfos.get(111), { noteId: 222, diff --git a/stats/src/lib/session-events.ts b/stats/src/lib/session-events.ts index 4a5badf..ddacfcd 100644 --- a/stats/src/lib/session-events.ts +++ b/stats/src/lib/session-events.ts @@ -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 {