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 {