mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
Add vendor dict fallback logic
This commit is contained in:
@@ -555,6 +555,12 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgba(54, 58, 79, 0.5)"`) |
|
||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||
| `frequencyDictionary.sourcePath` | string | Optional absolute path used for dictionary discovery (defaults to built-in paths) |
|
||||
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
|
||||
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
||||
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
||||
| `nPlusOneColor` | string | Existing n+1 highlight color (default: `#c6a0f6`) |
|
||||
| `knownWordColor` | string | Existing known-word highlight color (default: `#a6da95`) |
|
||||
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
||||
@@ -562,6 +568,8 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
|
||||
JLPT underlining is powered by offline term-meta bank files at runtime. See [`docs/jlpt-vocab-bundle.md`](jlpt-vocab-bundle.md) for required files, source/version refresh steps, and deterministic fallback behavior.
|
||||
|
||||
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`. In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window.
|
||||
|
||||
Secondary subtitle defaults: `fontSize: 24`, `fontColor: "#ffffff"`, `backgroundColor: "transparent"`. Any property not set in `secondary` falls back to the CSS defaults.
|
||||
|
||||
**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
|
||||
|
||||
@@ -151,6 +151,20 @@
|
||||
// ==========================================
|
||||
"subtitleStyle": {
|
||||
"enableJlpt": false,
|
||||
"frequencyDictionary": {
|
||||
"enabled": false,
|
||||
"sourcePath": "",
|
||||
"topX": 1000,
|
||||
"mode": "single",
|
||||
"singleColor": "#f5a97f",
|
||||
"bandedColors": [
|
||||
"#ed8796",
|
||||
"#f5a97f",
|
||||
"#f9e2af",
|
||||
"#a6e3a1",
|
||||
"#8aadf4"
|
||||
]
|
||||
},
|
||||
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
|
||||
"fontSize": 35,
|
||||
"fontColor": "#cad3f5",
|
||||
|
||||
@@ -103,6 +103,10 @@
|
||||
"from": "vendor/yomitan-jlpt-vocab",
|
||||
"to": "yomitan-jlpt-vocab"
|
||||
},
|
||||
{
|
||||
"from": "vendor/jiten_freq_global",
|
||||
"to": "jiten_freq_global"
|
||||
},
|
||||
{
|
||||
"from": "assets",
|
||||
"to": "assets"
|
||||
|
||||
@@ -28,6 +28,31 @@ local function is_linux()
|
||||
return not is_windows() and not is_macos()
|
||||
end
|
||||
|
||||
local function normalize_binary_path_candidate(candidate)
|
||||
if type(candidate) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local trimmed = candidate:match("^%s*(.-)%s*$") or ""
|
||||
if trimmed == "" then
|
||||
return nil
|
||||
end
|
||||
if #trimmed >= 2 then
|
||||
local first = trimmed:sub(1, 1)
|
||||
local last = trimmed:sub(-1)
|
||||
if (first == '"' and last == '"') or (first == "'" and last == "'") then
|
||||
trimmed = trimmed:sub(2, -2)
|
||||
end
|
||||
end
|
||||
return trimmed ~= "" and trimmed or nil
|
||||
end
|
||||
|
||||
local function binary_candidates_from_app_path(app_path)
|
||||
return {
|
||||
utils.join_path(app_path, "Contents", "MacOS", "SubMiner"),
|
||||
utils.join_path(app_path, "Contents", "MacOS", "subminer"),
|
||||
}
|
||||
end
|
||||
|
||||
local opts = {
|
||||
binary_path = "",
|
||||
socket_path = default_socket_path(),
|
||||
@@ -131,12 +156,68 @@ end
|
||||
|
||||
local function file_exists(path)
|
||||
local info = utils.file_info(path)
|
||||
return info ~= nil
|
||||
if not info then return false end
|
||||
if info.is_dir ~= nil then
|
||||
return not info.is_dir
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function resolve_binary_candidate(candidate)
|
||||
local normalized = normalize_binary_path_candidate(candidate)
|
||||
if not normalized then
|
||||
return nil
|
||||
end
|
||||
|
||||
if file_exists(normalized) then
|
||||
return normalized
|
||||
end
|
||||
|
||||
if not normalized:lower():find("%.app") then
|
||||
return nil
|
||||
end
|
||||
|
||||
local app_root = normalized
|
||||
if not app_root:lower():match("%.app$") then
|
||||
app_root = normalized:match("(.+%.app)")
|
||||
end
|
||||
if not app_root then
|
||||
return nil
|
||||
end
|
||||
|
||||
for _, path in ipairs(binary_candidates_from_app_path(app_root)) do
|
||||
if file_exists(path) then
|
||||
return path
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
local function find_binary_override()
|
||||
local candidates = {
|
||||
resolve_binary_candidate(os.getenv("SUBMINER_APPIMAGE_PATH")),
|
||||
resolve_binary_candidate(os.getenv("SUBMINER_BINARY_PATH")),
|
||||
}
|
||||
|
||||
for _, path in ipairs(candidates) do
|
||||
if path and path ~= "" then
|
||||
return path
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
local function find_binary()
|
||||
if opts.binary_path ~= "" and file_exists(opts.binary_path) then
|
||||
return opts.binary_path
|
||||
local override = find_binary_override()
|
||||
if override then
|
||||
return override
|
||||
end
|
||||
|
||||
local configured = resolve_binary_candidate(opts.binary_path)
|
||||
if configured then
|
||||
return configured
|
||||
end
|
||||
|
||||
local search_paths = {
|
||||
|
||||
@@ -195,6 +195,20 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
N4: "#a6e3a1",
|
||||
N5: "#8aadf4",
|
||||
},
|
||||
frequencyDictionary: {
|
||||
enabled: false,
|
||||
sourcePath: "",
|
||||
topX: 1000,
|
||||
mode: "single",
|
||||
singleColor: "#f5a97f",
|
||||
bandedColors: [
|
||||
"#ed8796",
|
||||
"#f5a97f",
|
||||
"#f9e2af",
|
||||
"#a6e3a1",
|
||||
"#8aadf4",
|
||||
],
|
||||
},
|
||||
secondary: {
|
||||
fontSize: 24,
|
||||
fontColor: "#ffffff",
|
||||
@@ -306,6 +320,48 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
|
||||
description: "Enable JLPT vocabulary level underlines. "
|
||||
+ "When disabled, JLPT tagging lookup and underlines are skipped.",
|
||||
},
|
||||
{
|
||||
path: "subtitleStyle.frequencyDictionary.enabled",
|
||||
kind: "boolean",
|
||||
defaultValue: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.enabled,
|
||||
description:
|
||||
"Enable frequency-dictionary-based highlighting based on token rank.",
|
||||
},
|
||||
{
|
||||
path: "subtitleStyle.frequencyDictionary.sourcePath",
|
||||
kind: "string",
|
||||
defaultValue: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.sourcePath,
|
||||
description:
|
||||
"Optional absolute path to a frequency dictionary directory."
|
||||
+ " If empty, built-in discovery search paths are used.",
|
||||
},
|
||||
{
|
||||
path: "subtitleStyle.frequencyDictionary.topX",
|
||||
kind: "number",
|
||||
defaultValue: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.topX,
|
||||
description: "Only color tokens with frequency rank <= topX (default: 1000).",
|
||||
},
|
||||
{
|
||||
path: "subtitleStyle.frequencyDictionary.mode",
|
||||
kind: "enum",
|
||||
enumValues: ["single", "banded"],
|
||||
defaultValue: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.mode,
|
||||
description:
|
||||
"single: use one color for all matching tokens. banded: use color ramp by frequency band.",
|
||||
},
|
||||
{
|
||||
path: "subtitleStyle.frequencyDictionary.singleColor",
|
||||
kind: "string",
|
||||
defaultValue: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.singleColor,
|
||||
description: "Color used when frequencyDictionary.mode is `single`.",
|
||||
},
|
||||
{
|
||||
path: "subtitleStyle.frequencyDictionary.bandedColors",
|
||||
kind: "array",
|
||||
defaultValue: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.bandedColors,
|
||||
description:
|
||||
"Five colors used for rank bands when mode is `banded` (from most common to least within topX).",
|
||||
},
|
||||
{
|
||||
path: "ankiConnect.enabled",
|
||||
kind: "boolean",
|
||||
|
||||
@@ -45,6 +45,21 @@ function asColor(value: unknown): string | undefined {
|
||||
return hexColorPattern.test(text) ? text : undefined;
|
||||
}
|
||||
|
||||
function asFrequencyBandedColors(
|
||||
value: unknown,
|
||||
): [string, string, string, string, string] | undefined {
|
||||
if (!Array.isArray(value) || value.length !== 5) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const colors = value.map((item) => asColor(item));
|
||||
if (colors.some((color) => color === undefined)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return colors as [string, string, string, string, string];
|
||||
}
|
||||
|
||||
export class ConfigService {
|
||||
private readonly configDir: string;
|
||||
private readonly configFileJsonc: string;
|
||||
@@ -468,6 +483,108 @@ export class ConfigService {
|
||||
"Expected boolean.",
|
||||
);
|
||||
}
|
||||
|
||||
const frequencyDictionary = isObject(
|
||||
(src.subtitleStyle as { frequencyDictionary?: unknown })
|
||||
.frequencyDictionary,
|
||||
)
|
||||
? ((src.subtitleStyle as { frequencyDictionary?: unknown })
|
||||
.frequencyDictionary as Record<string, unknown>)
|
||||
: {};
|
||||
const frequencyEnabled = asBoolean(
|
||||
(frequencyDictionary as { enabled?: unknown }).enabled,
|
||||
);
|
||||
if (frequencyEnabled !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.enabled = frequencyEnabled;
|
||||
} else if (
|
||||
(frequencyDictionary as { enabled?: unknown }).enabled !== undefined
|
||||
) {
|
||||
warn(
|
||||
"subtitleStyle.frequencyDictionary.enabled",
|
||||
(frequencyDictionary as { enabled?: unknown }).enabled,
|
||||
resolved.subtitleStyle.frequencyDictionary.enabled,
|
||||
"Expected boolean.",
|
||||
);
|
||||
}
|
||||
|
||||
const sourcePath = asString(
|
||||
(frequencyDictionary as { sourcePath?: unknown }).sourcePath,
|
||||
);
|
||||
if (sourcePath !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.sourcePath = sourcePath;
|
||||
} else if (
|
||||
(frequencyDictionary as { sourcePath?: unknown }).sourcePath !== undefined
|
||||
) {
|
||||
warn(
|
||||
"subtitleStyle.frequencyDictionary.sourcePath",
|
||||
(frequencyDictionary as { sourcePath?: unknown }).sourcePath,
|
||||
resolved.subtitleStyle.frequencyDictionary.sourcePath,
|
||||
"Expected string.",
|
||||
);
|
||||
}
|
||||
|
||||
const topX = asNumber((frequencyDictionary as { topX?: unknown }).topX);
|
||||
if (
|
||||
topX !== undefined &&
|
||||
Number.isInteger(topX) &&
|
||||
topX > 0
|
||||
) {
|
||||
resolved.subtitleStyle.frequencyDictionary.topX = Math.floor(topX);
|
||||
} else if ((frequencyDictionary as { topX?: unknown }).topX !== undefined) {
|
||||
warn(
|
||||
"subtitleStyle.frequencyDictionary.topX",
|
||||
(frequencyDictionary as { topX?: unknown }).topX,
|
||||
resolved.subtitleStyle.frequencyDictionary.topX,
|
||||
"Expected a positive integer.",
|
||||
);
|
||||
}
|
||||
|
||||
const frequencyMode = frequencyDictionary.mode;
|
||||
if (
|
||||
frequencyMode === "single" ||
|
||||
frequencyMode === "banded"
|
||||
) {
|
||||
resolved.subtitleStyle.frequencyDictionary.mode = frequencyMode;
|
||||
} else if (frequencyMode !== undefined) {
|
||||
warn(
|
||||
"subtitleStyle.frequencyDictionary.mode",
|
||||
frequencyDictionary.mode,
|
||||
resolved.subtitleStyle.frequencyDictionary.mode,
|
||||
"Expected 'single' or 'banded'.",
|
||||
);
|
||||
}
|
||||
|
||||
const singleColor = asColor(
|
||||
(frequencyDictionary as { singleColor?: unknown }).singleColor,
|
||||
);
|
||||
if (singleColor !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.singleColor = singleColor;
|
||||
} else if (
|
||||
(frequencyDictionary as { singleColor?: unknown }).singleColor !== undefined
|
||||
) {
|
||||
warn(
|
||||
"subtitleStyle.frequencyDictionary.singleColor",
|
||||
(frequencyDictionary as { singleColor?: unknown }).singleColor,
|
||||
resolved.subtitleStyle.frequencyDictionary.singleColor,
|
||||
"Expected hex color.",
|
||||
);
|
||||
}
|
||||
|
||||
const bandedColors = asFrequencyBandedColors(
|
||||
(frequencyDictionary as { bandedColors?: unknown }).bandedColors,
|
||||
);
|
||||
if (bandedColors !== undefined) {
|
||||
resolved.subtitleStyle.frequencyDictionary.bandedColors = bandedColors;
|
||||
} else if (
|
||||
(frequencyDictionary as { bandedColors?: unknown }).bandedColors !== undefined
|
||||
) {
|
||||
warn(
|
||||
"subtitleStyle.frequencyDictionary.bandedColors",
|
||||
(frequencyDictionary as { bandedColors?: unknown }).bandedColors,
|
||||
resolved.subtitleStyle.frequencyDictionary.bandedColors,
|
||||
"Expected an array of five hex colors.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.ankiConnect)) {
|
||||
|
||||
189
src/core/services/frequency-dictionary-service.ts
Normal file
189
src/core/services/frequency-dictionary-service.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface FrequencyDictionaryLookupOptions {
|
||||
searchPaths: string[];
|
||||
log: (message: string) => void;
|
||||
}
|
||||
|
||||
interface FrequencyDictionaryEntry {
|
||||
rank: number;
|
||||
term: string;
|
||||
}
|
||||
|
||||
const FREQUENCY_BANK_FILE_GLOB = /^term_meta_bank_.*\.json$/;
|
||||
const NOOP_LOOKUP = (): null => null;
|
||||
|
||||
function normalizeFrequencyTerm(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function extractFrequencyDisplayValue(meta: unknown): number | null {
|
||||
if (!meta || typeof meta !== "object") return null;
|
||||
const frequency = (meta as { frequency?: unknown }).frequency;
|
||||
if (!frequency || typeof frequency !== "object") return null;
|
||||
const displayValue = (frequency as { displayValue?: unknown }).displayValue;
|
||||
if (typeof displayValue === "number") {
|
||||
if (!Number.isFinite(displayValue) || displayValue <= 0) return null;
|
||||
return Math.floor(displayValue);
|
||||
}
|
||||
if (typeof displayValue === "string") {
|
||||
const normalized = displayValue.trim().replace(/,/g, "");
|
||||
const parsed = Number.parseInt(normalized, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function asFrequencyDictionaryEntry(
|
||||
entry: unknown,
|
||||
): FrequencyDictionaryEntry | null {
|
||||
if (!Array.isArray(entry) || entry.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [term, _id, meta] = entry as [
|
||||
unknown,
|
||||
unknown,
|
||||
unknown,
|
||||
];
|
||||
if (typeof term !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frequency = extractFrequencyDisplayValue(meta);
|
||||
if (frequency === null) return null;
|
||||
|
||||
const normalizedTerm = normalizeFrequencyTerm(term);
|
||||
if (!normalizedTerm) return null;
|
||||
|
||||
return {
|
||||
term: normalizedTerm,
|
||||
rank: frequency,
|
||||
};
|
||||
}
|
||||
|
||||
function addEntriesToMap(
|
||||
rawEntries: unknown,
|
||||
terms: Map<string, number>,
|
||||
log: (message: string) => void,
|
||||
): void {
|
||||
if (!Array.isArray(rawEntries)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const rawEntry of rawEntries) {
|
||||
const entry = asFrequencyDictionaryEntry(rawEntry);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const currentRank = terms.get(entry.term);
|
||||
if (currentRank === undefined || entry.rank < currentRank) {
|
||||
terms.set(entry.term, entry.rank);
|
||||
continue;
|
||||
}
|
||||
|
||||
log(
|
||||
`Frequency dictionary duplicate term ${entry.term} with weaker rank ${entry.rank}; keeping ${currentRank}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function collectDictionaryFromPath(
|
||||
dictionaryPath: string,
|
||||
log: (message: string) => void,
|
||||
): Map<string, number> {
|
||||
const terms = new Map<string, number>();
|
||||
|
||||
let fileNames: string[];
|
||||
try {
|
||||
fileNames = fs.readdirSync(dictionaryPath);
|
||||
} catch {
|
||||
return terms;
|
||||
}
|
||||
|
||||
const bankFiles = fileNames
|
||||
.filter((name) => FREQUENCY_BANK_FILE_GLOB.test(name))
|
||||
.sort();
|
||||
|
||||
if (bankFiles.length === 0) {
|
||||
return terms;
|
||||
}
|
||||
|
||||
for (const bankFile of bankFiles) {
|
||||
const bankPath = path.join(dictionaryPath, bankFile);
|
||||
let rawText: string;
|
||||
try {
|
||||
rawText = fs.readFileSync(bankPath, "utf-8");
|
||||
} catch {
|
||||
log(`Failed to read frequency dictionary file ${bankPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let rawEntries: unknown;
|
||||
try {
|
||||
rawEntries = JSON.parse(rawText) as unknown;
|
||||
} catch {
|
||||
log(`Failed to parse frequency dictionary file as JSON: ${bankPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const beforeSize = terms.size;
|
||||
addEntriesToMap(rawEntries, terms, log);
|
||||
if (terms.size === beforeSize) {
|
||||
log(
|
||||
`Frequency dictionary file contained no extractable entries: ${bankPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return terms;
|
||||
}
|
||||
|
||||
export async function createFrequencyDictionaryLookupService(
|
||||
options: FrequencyDictionaryLookupOptions,
|
||||
): Promise<(term: string) => number | null> {
|
||||
const attemptedPaths: string[] = [];
|
||||
let foundDictionaryPathCount = 0;
|
||||
|
||||
for (const dictionaryPath of options.searchPaths) {
|
||||
attemptedPaths.push(dictionaryPath);
|
||||
if (!fs.existsSync(dictionaryPath)) {
|
||||
continue;
|
||||
}
|
||||
if (!fs.statSync(dictionaryPath).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foundDictionaryPathCount += 1;
|
||||
const terms = collectDictionaryFromPath(dictionaryPath, options.log);
|
||||
if (terms.size > 0) {
|
||||
options.log(
|
||||
`Frequency dictionary loaded from ${dictionaryPath} (${terms.size} entries)`,
|
||||
);
|
||||
return (term: string): number | null => {
|
||||
const normalized = normalizeFrequencyTerm(term);
|
||||
if (!normalized) return null;
|
||||
return terms.get(normalized) ?? null;
|
||||
};
|
||||
}
|
||||
|
||||
options.log(
|
||||
`Frequency dictionary directory exists but contains no readable term_meta_bank_*.json files: ${dictionaryPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
options.log(
|
||||
`Frequency dictionary not found. Searched ${attemptedPaths.length} candidate path(s): ${attemptedPaths.join(", ")}`,
|
||||
);
|
||||
if (foundDictionaryPathCount > 0) {
|
||||
options.log(
|
||||
"Frequency dictionary directories found, but no usable term_meta_bank_*.json files were loaded.",
|
||||
);
|
||||
}
|
||||
|
||||
return NOOP_LOOKUP;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export {
|
||||
} from "./startup-service";
|
||||
export { openYomitanSettingsWindow } from "./yomitan-settings-service";
|
||||
export { createTokenizerDepsRuntimeService, tokenizeSubtitleService } from "./tokenizer-service";
|
||||
export { createFrequencyDictionaryLookupService } from "./frequency-dictionary-service";
|
||||
export { createJlptVocabularyLookupService } from "./jlpt-vocab-service";
|
||||
export {
|
||||
getIgnoredPos1Entries,
|
||||
|
||||
@@ -190,6 +190,75 @@ test("tokenizeSubtitleService skips JLPT lookups when disabled", async () => {
|
||||
assert.equal(lookupCalls, 0);
|
||||
});
|
||||
|
||||
test("tokenizeSubtitleService applies frequency dictionary ranks", async () => {
|
||||
const result = await tokenizeSubtitleService(
|
||||
"猫です",
|
||||
makeDeps({
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
tokenizeWithMecab: async () => [
|
||||
{
|
||||
headword: "猫",
|
||||
surface: "猫",
|
||||
reading: "ネコ",
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: "です",
|
||||
surface: "です",
|
||||
reading: "デス",
|
||||
startPos: 1,
|
||||
endPos: 2,
|
||||
partOfSpeech: PartOfSpeech.bound_auxiliary,
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
],
|
||||
getFrequencyRank: (text) => (text === "猫" ? 23 : 1200),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.length, 2);
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 23);
|
||||
assert.equal(result.tokens?.[1]?.frequencyRank, 1200);
|
||||
});
|
||||
|
||||
test("tokenizeSubtitleService skips frequency lookups when disabled", async () => {
|
||||
let frequencyCalls = 0;
|
||||
const result = await tokenizeSubtitleService(
|
||||
"猫",
|
||||
makeDeps({
|
||||
getFrequencyDictionaryEnabled: () => false,
|
||||
tokenizeWithMecab: async () => [
|
||||
{
|
||||
headword: "猫",
|
||||
surface: "猫",
|
||||
reading: "ネコ",
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
],
|
||||
getFrequencyRank: () => {
|
||||
frequencyCalls += 1;
|
||||
return 10;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
|
||||
assert.equal(frequencyCalls, 0);
|
||||
});
|
||||
|
||||
test("tokenizeSubtitleService skips JLPT level for excluded demonstratives", async () => {
|
||||
const result = await tokenizeSubtitleService(
|
||||
"この",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PartOfSpeech,
|
||||
SubtitleData,
|
||||
Token,
|
||||
FrequencyDictionaryLookup,
|
||||
} from "../../types";
|
||||
import {
|
||||
shouldIgnoreJlptForMecabPos1,
|
||||
@@ -35,11 +36,16 @@ const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
|
||||
const KATAKANA_CODEPOINT_START = 0x30a1;
|
||||
const KATAKANA_CODEPOINT_END = 0x30f6;
|
||||
const JLPT_LEVEL_LOOKUP_CACHE_LIMIT = 2048;
|
||||
const FREQUENCY_RANK_LOOKUP_CACHE_LIMIT = 2048;
|
||||
|
||||
const jlptLevelLookupCaches = new WeakMap<
|
||||
(text: string) => JlptLevel | null,
|
||||
Map<string, JlptLevel | null>
|
||||
>();
|
||||
const frequencyRankLookupCaches = new WeakMap<
|
||||
FrequencyDictionaryLookup,
|
||||
Map<string, number | null>
|
||||
>();
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object");
|
||||
@@ -61,6 +67,8 @@ export interface TokenizerServiceDeps {
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
getJlptEnabled?: () => boolean;
|
||||
getFrequencyDictionaryEnabled?: () => boolean;
|
||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||
getMinSentenceWordsForNPlusOne?: () => number;
|
||||
tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>;
|
||||
}
|
||||
@@ -81,6 +89,8 @@ export interface TokenizerDepsRuntimeOptions {
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
getJlptEnabled?: () => boolean;
|
||||
getFrequencyDictionaryEnabled?: () => boolean;
|
||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||
getMinSentenceWordsForNPlusOne?: () => number;
|
||||
getMecabTokenizer: () => MecabTokenizerLike | null;
|
||||
}
|
||||
@@ -122,6 +132,47 @@ function getCachedJlptLevel(
|
||||
return level;
|
||||
}
|
||||
|
||||
function normalizeFrequencyLookupText(rawText: string): string {
|
||||
return rawText.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getCachedFrequencyRank(
|
||||
lookupText: string,
|
||||
getFrequencyRank: FrequencyDictionaryLookup,
|
||||
): number | null {
|
||||
const normalizedText = normalizeFrequencyLookupText(lookupText);
|
||||
if (!normalizedText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cache = frequencyRankLookupCaches.get(getFrequencyRank);
|
||||
if (!cache) {
|
||||
cache = new Map<string, number | null>();
|
||||
frequencyRankLookupCaches.set(getFrequencyRank, cache);
|
||||
}
|
||||
|
||||
if (cache.has(normalizedText)) {
|
||||
return cache.get(normalizedText) ?? null;
|
||||
}
|
||||
|
||||
let rank: number | null;
|
||||
try {
|
||||
rank = getFrequencyRank(normalizedText);
|
||||
} catch {
|
||||
rank = null;
|
||||
}
|
||||
|
||||
cache.set(normalizedText, rank);
|
||||
while (cache.size > FREQUENCY_RANK_LOOKUP_CACHE_LIMIT) {
|
||||
const firstKey = cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
return rank;
|
||||
}
|
||||
|
||||
export function createTokenizerDepsRuntimeService(
|
||||
options: TokenizerDepsRuntimeOptions,
|
||||
): TokenizerServiceDeps {
|
||||
@@ -137,6 +188,8 @@ export function createTokenizerDepsRuntimeService(
|
||||
getKnownWordMatchMode: options.getKnownWordMatchMode,
|
||||
getJlptLevel: options.getJlptLevel,
|
||||
getJlptEnabled: options.getJlptEnabled,
|
||||
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
|
||||
getFrequencyRank: options.getFrequencyRank,
|
||||
getMinSentenceWordsForNPlusOne:
|
||||
options.getMinSentenceWordsForNPlusOne ?? (() => 3),
|
||||
tokenizeWithMecab: async (text) => {
|
||||
@@ -184,6 +237,34 @@ function applyKnownWordMarking(
|
||||
});
|
||||
}
|
||||
|
||||
function resolveFrequencyLookupText(token: MergedToken): string {
|
||||
if (token.headword && token.headword.length > 0) {
|
||||
return token.headword;
|
||||
}
|
||||
if (token.reading && token.reading.length > 0) {
|
||||
return token.reading;
|
||||
}
|
||||
return token.surface;
|
||||
}
|
||||
|
||||
function applyFrequencyMarking(
|
||||
tokens: MergedToken[],
|
||||
getFrequencyRank: FrequencyDictionaryLookup,
|
||||
): MergedToken[] {
|
||||
return tokens.map((token) => {
|
||||
const lookupText = resolveFrequencyLookupText(token);
|
||||
if (!lookupText) {
|
||||
return { ...token, frequencyRank: undefined };
|
||||
}
|
||||
|
||||
const rank = getCachedFrequencyRank(lookupText, getFrequencyRank);
|
||||
return {
|
||||
...token,
|
||||
frequencyRank: rank ?? undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveJlptLookupText(token: MergedToken): string {
|
||||
if (token.headword && token.headword.length > 0) {
|
||||
return token.headword;
|
||||
@@ -753,6 +834,8 @@ export async function tokenizeSubtitleService(
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const jlptEnabled = deps.getJlptEnabled?.() !== false;
|
||||
const frequencyEnabled = deps.getFrequencyDictionaryEnabled?.() !== false;
|
||||
const frequencyLookup = deps.getFrequencyRank;
|
||||
|
||||
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps);
|
||||
if (yomitanTokens && yomitanTokens.length > 0) {
|
||||
@@ -761,9 +844,16 @@ export async function tokenizeSubtitleService(
|
||||
deps.isKnownWord,
|
||||
deps.getKnownWordMatchMode(),
|
||||
);
|
||||
const jlptMarkedTokens = jlptEnabled
|
||||
? applyJlptMarking(knownMarkedTokens, deps.getJlptLevel)
|
||||
: knownMarkedTokens.map((token) => ({ ...token, jlptLevel: undefined }));
|
||||
const frequencyMarkedTokens =
|
||||
frequencyEnabled && frequencyLookup
|
||||
? applyFrequencyMarking(knownMarkedTokens, frequencyLookup)
|
||||
: knownMarkedTokens.map((token) => ({
|
||||
...token,
|
||||
frequencyRank: undefined,
|
||||
}));
|
||||
const jlptMarkedTokens = jlptEnabled
|
||||
? applyJlptMarking(frequencyMarkedTokens, deps.getJlptLevel)
|
||||
: frequencyMarkedTokens.map((token) => ({ ...token, jlptLevel: undefined }));
|
||||
return {
|
||||
text: displayText,
|
||||
tokens: markNPlusOneTargets(
|
||||
@@ -781,9 +871,16 @@ export async function tokenizeSubtitleService(
|
||||
deps.isKnownWord,
|
||||
deps.getKnownWordMatchMode(),
|
||||
);
|
||||
const frequencyMarkedTokens =
|
||||
frequencyEnabled && frequencyLookup
|
||||
? applyFrequencyMarking(knownMarkedTokens, frequencyLookup)
|
||||
: knownMarkedTokens.map((token) => ({
|
||||
...token,
|
||||
frequencyRank: undefined,
|
||||
}));
|
||||
const jlptMarkedTokens = jlptEnabled
|
||||
? applyJlptMarking(knownMarkedTokens, deps.getJlptLevel)
|
||||
: knownMarkedTokens.map((token) => ({ ...token, jlptLevel: undefined }));
|
||||
? applyJlptMarking(frequencyMarkedTokens, deps.getJlptLevel)
|
||||
: frequencyMarkedTokens.map((token) => ({ ...token, jlptLevel: undefined }));
|
||||
return {
|
||||
text: displayText,
|
||||
tokens: markNPlusOneTargets(
|
||||
|
||||
43
src/main.ts
43
src/main.ts
@@ -162,6 +162,10 @@ import {
|
||||
createJlptDictionaryRuntimeService,
|
||||
getJlptDictionarySearchPaths,
|
||||
} from "./main/jlpt-runtime";
|
||||
import {
|
||||
createFrequencyDictionaryRuntimeService,
|
||||
getFrequencyDictionarySearchPaths,
|
||||
} from "./main/frequency-dictionary-runtime";
|
||||
import { createMediaRuntimeService } from "./main/media-runtime";
|
||||
import { createOverlayVisibilityRuntimeService } from "./main/overlay-visibility-runtime";
|
||||
import {
|
||||
@@ -353,6 +357,39 @@ const jlptDictionaryRuntime = createJlptDictionaryRuntimeService({
|
||||
},
|
||||
});
|
||||
|
||||
const frequencyDictionaryRuntime = createFrequencyDictionaryRuntimeService({
|
||||
isFrequencyDictionaryEnabled: () =>
|
||||
getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||
getSearchPaths: () =>
|
||||
getFrequencyDictionarySearchPaths({
|
||||
getDictionaryRoots: () => [
|
||||
path.join(__dirname, "..", "..", "vendor", "jiten_freq_global"),
|
||||
path.join(__dirname, "..", "..", "vendor", "frequency-dictionary"),
|
||||
path.join(app.getAppPath(), "vendor", "jiten_freq_global"),
|
||||
path.join(app.getAppPath(), "vendor", "frequency-dictionary"),
|
||||
path.join(process.resourcesPath, "jiten_freq_global"),
|
||||
path.join(process.resourcesPath, "frequency-dictionary"),
|
||||
path.join(process.resourcesPath, "app.asar", "vendor", "jiten_freq_global"),
|
||||
path.join(process.resourcesPath, "app.asar", "vendor", "frequency-dictionary"),
|
||||
USER_DATA_PATH,
|
||||
app.getPath("userData"),
|
||||
path.join(os.homedir(), ".config", "SubMiner"),
|
||||
path.join(os.homedir(), ".config", "subminer"),
|
||||
path.join(os.homedir(), "Library", "Application Support", "SubMiner"),
|
||||
path.join(os.homedir(), "Library", "Application Support", "subminer"),
|
||||
process.cwd(),
|
||||
].filter((dictionaryRoot) => dictionaryRoot),
|
||||
getSourcePath: () =>
|
||||
getResolvedConfig().subtitleStyle.frequencyDictionary.sourcePath,
|
||||
}),
|
||||
setFrequencyRankLookup: (lookup) => {
|
||||
appState.frequencyRankLookup = lookup;
|
||||
},
|
||||
log: (message) => {
|
||||
logger.info(`[Frequency] ${message}`);
|
||||
},
|
||||
});
|
||||
|
||||
function getFieldGroupingResolver(): ((choice: KikuFieldGroupingChoice) => void) | null {
|
||||
return appState.fieldGroupingResolver;
|
||||
}
|
||||
@@ -844,6 +881,7 @@ function updateMpvSubtitleRenderMetrics(
|
||||
|
||||
async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
|
||||
await jlptDictionaryRuntime.ensureJlptDictionaryLookup();
|
||||
await frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup();
|
||||
return tokenizeSubtitleService(
|
||||
text,
|
||||
createTokenizerDepsRuntimeService({
|
||||
@@ -870,6 +908,9 @@ async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
|
||||
getJlptLevel: (text) => appState.jlptLevelLookup(text),
|
||||
getJlptEnabled: () =>
|
||||
getResolvedConfig().subtitleStyle.enableJlpt,
|
||||
getFrequencyDictionaryEnabled: () =>
|
||||
getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||
getFrequencyRank: (text) => appState.frequencyRankLookup(text),
|
||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||
}),
|
||||
);
|
||||
@@ -1345,6 +1386,8 @@ registerIpcRuntimeServices({
|
||||
nPlusOneColor: resolvedConfig.ankiConnect.nPlusOne.nPlusOne,
|
||||
knownWordColor: resolvedConfig.ankiConnect.nPlusOne.knownWord,
|
||||
enableJlpt: resolvedConfig.subtitleStyle.enableJlpt,
|
||||
frequencyDictionary:
|
||||
resolvedConfig.subtitleStyle.frequencyDictionary,
|
||||
};
|
||||
},
|
||||
saveSubtitlePosition: (position: unknown) =>
|
||||
|
||||
80
src/main/frequency-dictionary-runtime.ts
Normal file
80
src/main/frequency-dictionary-runtime.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import * as path from "path";
|
||||
import type { FrequencyDictionaryLookup } from "../types";
|
||||
import { createFrequencyDictionaryLookupService } from "../core/services";
|
||||
|
||||
export interface FrequencyDictionarySearchPathDeps {
|
||||
getDictionaryRoots: () => string[];
|
||||
getSourcePath?: () => string | undefined;
|
||||
}
|
||||
|
||||
export interface FrequencyDictionaryRuntimeDeps {
|
||||
isFrequencyDictionaryEnabled: () => boolean;
|
||||
getSearchPaths: () => string[];
|
||||
setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
|
||||
let frequencyDictionaryLookupInitialized = false;
|
||||
let frequencyDictionaryLookupInitialization: Promise<void> | null = null;
|
||||
|
||||
export function getFrequencyDictionarySearchPaths(
|
||||
deps: FrequencyDictionarySearchPathDeps,
|
||||
): string[] {
|
||||
const dictionaryRoots = deps.getDictionaryRoots();
|
||||
const sourcePath = deps.getSourcePath?.();
|
||||
|
||||
const rawSearchPaths: string[] = [];
|
||||
if (sourcePath && sourcePath.trim()) {
|
||||
rawSearchPaths.push(sourcePath.trim());
|
||||
rawSearchPaths.push(path.join(sourcePath.trim(), "frequency-dictionary"));
|
||||
rawSearchPaths.push(path.join(sourcePath.trim(), "vendor", "frequency-dictionary"));
|
||||
}
|
||||
|
||||
for (const dictionaryRoot of dictionaryRoots) {
|
||||
rawSearchPaths.push(dictionaryRoot);
|
||||
rawSearchPaths.push(path.join(dictionaryRoot, "frequency-dictionary"));
|
||||
rawSearchPaths.push(path.join(dictionaryRoot, "vendor", "frequency-dictionary"));
|
||||
}
|
||||
|
||||
return [...new Set(rawSearchPaths)];
|
||||
}
|
||||
|
||||
export async function initializeFrequencyDictionaryLookup(
|
||||
deps: FrequencyDictionaryRuntimeDeps,
|
||||
): Promise<void> {
|
||||
const lookup = await createFrequencyDictionaryLookupService({
|
||||
searchPaths: deps.getSearchPaths(),
|
||||
log: deps.log,
|
||||
});
|
||||
deps.setFrequencyRankLookup(lookup);
|
||||
}
|
||||
|
||||
export async function ensureFrequencyDictionaryLookup(
|
||||
deps: FrequencyDictionaryRuntimeDeps,
|
||||
): Promise<void> {
|
||||
if (!deps.isFrequencyDictionaryEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (frequencyDictionaryLookupInitialized) {
|
||||
return;
|
||||
}
|
||||
if (!frequencyDictionaryLookupInitialization) {
|
||||
frequencyDictionaryLookupInitialization = initializeFrequencyDictionaryLookup(deps)
|
||||
.then(() => {
|
||||
frequencyDictionaryLookupInitialized = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
frequencyDictionaryLookupInitialization = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
await frequencyDictionaryLookupInitialization;
|
||||
}
|
||||
|
||||
export function createFrequencyDictionaryRuntimeService(
|
||||
deps: FrequencyDictionaryRuntimeDeps,
|
||||
): { ensureFrequencyDictionaryLookup: () => Promise<void> } {
|
||||
return {
|
||||
ensureFrequencyDictionaryLookup: () => ensureFrequencyDictionaryLookup(deps),
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
SubtitlePosition,
|
||||
KikuFieldGroupingChoice,
|
||||
JlptLevel,
|
||||
FrequencyDictionaryLookup,
|
||||
} from "../types";
|
||||
import type { CliArgs } from "../cli/args";
|
||||
import type { SubtitleTimingTracker } from "../subtitle-timing-tracker";
|
||||
@@ -55,6 +56,7 @@ export interface AppState {
|
||||
autoStartOverlay: boolean;
|
||||
texthookerOnlyMode: boolean;
|
||||
jlptLevelLookup: (term: string) => JlptLevel | null;
|
||||
frequencyRankLookup: FrequencyDictionaryLookup;
|
||||
}
|
||||
|
||||
export interface AppStateInitialValues {
|
||||
@@ -115,6 +117,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
autoStartOverlay: values.autoStartOverlay ?? false,
|
||||
texthookerOnlyMode: values.texthookerOnlyMode ?? false,
|
||||
jlptLevelLookup: () => null,
|
||||
frequencyRankLookup: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,15 @@ export type RendererState = {
|
||||
jlptN3Color: string;
|
||||
jlptN4Color: string;
|
||||
jlptN5Color: string;
|
||||
frequencyDictionaryEnabled: boolean;
|
||||
frequencyDictionaryTopX: number;
|
||||
frequencyDictionaryMode: "single" | "banded";
|
||||
frequencyDictionarySingleColor: string;
|
||||
frequencyDictionaryBand1Color: string;
|
||||
frequencyDictionaryBand2Color: string;
|
||||
frequencyDictionaryBand3Color: string;
|
||||
frequencyDictionaryBand4Color: string;
|
||||
frequencyDictionaryBand5Color: string;
|
||||
|
||||
keybindingsMap: Map<string, (string | number)[]>;
|
||||
chordPending: boolean;
|
||||
@@ -140,6 +149,15 @@ export function createRendererState(): RendererState {
|
||||
jlptN3Color: "#f9e2af",
|
||||
jlptN4Color: "#a6e3a1",
|
||||
jlptN5Color: "#8aadf4",
|
||||
frequencyDictionaryEnabled: false,
|
||||
frequencyDictionaryTopX: 1000,
|
||||
frequencyDictionaryMode: "single",
|
||||
frequencyDictionarySingleColor: "#f5a97f",
|
||||
frequencyDictionaryBand1Color: "#ed8796",
|
||||
frequencyDictionaryBand2Color: "#f5a97f",
|
||||
frequencyDictionaryBand3Color: "#f9e2af",
|
||||
frequencyDictionaryBand4Color: "#a6e3a1",
|
||||
frequencyDictionaryBand5Color: "#8aadf4",
|
||||
|
||||
keybindingsMap: new Map(),
|
||||
chordPending: false,
|
||||
|
||||
@@ -255,6 +255,12 @@ body {
|
||||
--subtitle-jlpt-n3-color: #f9e2af;
|
||||
--subtitle-jlpt-n4-color: #a6e3a1;
|
||||
--subtitle-jlpt-n5-color: #8aadf4;
|
||||
--subtitle-frequency-single-color: #f5a97f;
|
||||
--subtitle-frequency-band-1-color: #ed8796;
|
||||
--subtitle-frequency-band-2-color: #f5a97f;
|
||||
--subtitle-frequency-band-3-color: #f9e2af;
|
||||
--subtitle-frequency-band-4-color: #a6e3a1;
|
||||
--subtitle-frequency-band-5-color: #8aadf4;
|
||||
text-shadow:
|
||||
2px 2px 4px rgba(0, 0, 0, 0.8),
|
||||
-1px -1px 2px rgba(0, 0, 0, 0.5);
|
||||
@@ -346,6 +352,39 @@ body.settings-modal-open #subtitleContainer {
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-single,
|
||||
#subtitleRoot .word.word-frequency-band-1,
|
||||
#subtitleRoot .word.word-frequency-band-2,
|
||||
#subtitleRoot .word.word-frequency-band-3,
|
||||
#subtitleRoot .word.word-frequency-band-4,
|
||||
#subtitleRoot .word.word-frequency-band-5 {
|
||||
text-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-single {
|
||||
color: var(--subtitle-frequency-single-color, #f5a97f);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-1 {
|
||||
color: var(--subtitle-frequency-band-1-color, #ed8796);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-2 {
|
||||
color: var(--subtitle-frequency-band-2-color, #f5a97f);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-3 {
|
||||
color: var(--subtitle-frequency-band-3-color, #f9e2af);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-4 {
|
||||
color: var(--subtitle-frequency-band-4-color, #a6e3a1);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-5 {
|
||||
color: var(--subtitle-frequency-band-5-color, #8aadf4);
|
||||
}
|
||||
|
||||
#subtitleRoot .word:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
|
||||
@@ -22,8 +22,7 @@ function createToken(overrides: Partial<MergedToken>): MergedToken {
|
||||
};
|
||||
}
|
||||
|
||||
function extractClassBlock(cssText: string, level: number): string {
|
||||
const selector = `#subtitleRoot .word.word-jlpt-n${level}`;
|
||||
function extractClassBlock(cssText: string, selector: string): string {
|
||||
const start = cssText.indexOf(selector);
|
||||
if (start < 0) return "";
|
||||
|
||||
@@ -54,6 +53,87 @@ test("computeWordClass preserves known and n+1 classes while adding JLPT classes
|
||||
);
|
||||
});
|
||||
|
||||
test("computeWordClass adds frequency class for single mode when rank is within topX", () => {
|
||||
const token = createToken({
|
||||
surface: "猫",
|
||||
frequencyRank: 50,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(
|
||||
token,
|
||||
{
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: "single",
|
||||
singleColor: "#000000",
|
||||
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(actual, "word word-frequency-single");
|
||||
});
|
||||
|
||||
test("computeWordClass adds frequency class when rank equals topX", () => {
|
||||
const token = createToken({
|
||||
surface: "水",
|
||||
frequencyRank: 100,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(
|
||||
token,
|
||||
{
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: "single",
|
||||
singleColor: "#000000",
|
||||
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(actual, "word word-frequency-single");
|
||||
});
|
||||
|
||||
test("computeWordClass adds frequency class for banded mode", () => {
|
||||
const token = createToken({
|
||||
surface: "犬",
|
||||
frequencyRank: 250,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(
|
||||
token,
|
||||
{
|
||||
enabled: true,
|
||||
topX: 1000,
|
||||
mode: "banded",
|
||||
singleColor: "#000000",
|
||||
bandedColors:
|
||||
["#111111", "#222222", "#333333", "#444444", "#555555"] as const,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(actual, "word word-frequency-band-2");
|
||||
});
|
||||
|
||||
test("computeWordClass skips frequency class when rank is out of topX", () => {
|
||||
const token = createToken({
|
||||
surface: "犬",
|
||||
frequencyRank: 1200,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(
|
||||
token,
|
||||
{
|
||||
enabled: true,
|
||||
topX: 1000,
|
||||
mode: "single",
|
||||
singleColor: "#000000",
|
||||
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(actual, "word");
|
||||
});
|
||||
|
||||
test("JLPT CSS rules use underline-only styling in renderer stylesheet", () => {
|
||||
const distCssPath = path.join(process.cwd(), "dist", "renderer", "style.css");
|
||||
const srcCssPath = path.join(process.cwd(), "src", "renderer", "style.css");
|
||||
@@ -70,11 +150,25 @@ test("JLPT CSS rules use underline-only styling in renderer stylesheet", () => {
|
||||
const cssText = fs.readFileSync(cssPath, "utf-8");
|
||||
|
||||
for (let level = 1; level <= 5; level += 1) {
|
||||
const block = extractClassBlock(cssText, level);
|
||||
const block = extractClassBlock(
|
||||
cssText,
|
||||
`#subtitleRoot .word.word-jlpt-n${level}`,
|
||||
);
|
||||
assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`);
|
||||
assert.match(block, /text-decoration-line:\s*underline;/);
|
||||
assert.match(block, /text-decoration-thickness:\s*2px;/);
|
||||
assert.match(block, /text-underline-offset:\s*4px;/);
|
||||
assert.match(block, /color:\s*inherit;/);
|
||||
}
|
||||
|
||||
for (let band = 1; band <= 5; band += 1) {
|
||||
const block = extractClassBlock(
|
||||
cssText,
|
||||
band === 1
|
||||
? "#subtitleRoot .word.word-frequency-single"
|
||||
: `#subtitleRoot .word.word-frequency-band-${band}`,
|
||||
);
|
||||
assert.ok(block.length > 0, `frequency class word-frequency-${band === 1 ? "single" : `band-${band}`} should exist`);
|
||||
assert.match(block, /color:\s*var\(/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,14 @@ import type {
|
||||
} from "../types";
|
||||
import type { RendererContext } from "./context";
|
||||
|
||||
type FrequencyRenderSettings = {
|
||||
enabled: boolean;
|
||||
topX: number;
|
||||
mode: "single" | "banded";
|
||||
singleColor: string;
|
||||
bandedColors: [string, string, string, string, string];
|
||||
};
|
||||
|
||||
function normalizeSubtitle(text: string, trim = true): string {
|
||||
if (!text) return "";
|
||||
|
||||
@@ -24,7 +32,87 @@ function sanitizeHexColor(value: unknown, fallback: string): string {
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
|
||||
const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
|
||||
enabled: false,
|
||||
topX: 1000,
|
||||
mode: "single",
|
||||
singleColor: "#f5a97f",
|
||||
bandedColors: ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"],
|
||||
};
|
||||
|
||||
function sanitizeFrequencyTopX(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
|
||||
function sanitizeFrequencyBandedColors(
|
||||
value: unknown,
|
||||
fallback: FrequencyRenderSettings["bandedColors"],
|
||||
): FrequencyRenderSettings["bandedColors"] {
|
||||
if (!Array.isArray(value) || value.length !== 5) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return [
|
||||
sanitizeHexColor(value[0], fallback[0]),
|
||||
sanitizeHexColor(value[1], fallback[1]),
|
||||
sanitizeHexColor(value[2], fallback[2]),
|
||||
sanitizeHexColor(value[3], fallback[3]),
|
||||
sanitizeHexColor(value[4], fallback[4]),
|
||||
];
|
||||
}
|
||||
|
||||
function getFrequencyDictionaryClass(
|
||||
token: MergedToken,
|
||||
settings: FrequencyRenderSettings,
|
||||
): string {
|
||||
if (!settings.enabled) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof token.frequencyRank !== "number" || !Number.isFinite(token.frequencyRank)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const rank = Math.max(1, Math.floor(token.frequencyRank));
|
||||
const topX = sanitizeFrequencyTopX(settings.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX);
|
||||
if (rank > topX) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (settings.mode === "banded") {
|
||||
const normalizedBand = Math.ceil((rank / topX) * 5);
|
||||
const band = Math.min(5, Math.max(1, normalizedBand));
|
||||
return `word-frequency-band-${band}`;
|
||||
}
|
||||
|
||||
return "word-frequency-single";
|
||||
}
|
||||
|
||||
function renderWithTokens(
|
||||
root: HTMLElement,
|
||||
tokens: MergedToken[],
|
||||
frequencyRenderSettings?: Partial<FrequencyRenderSettings>,
|
||||
): void {
|
||||
const resolvedFrequencyRenderSettings = {
|
||||
...DEFAULT_FREQUENCY_RENDER_SETTINGS,
|
||||
...frequencyRenderSettings,
|
||||
bandedColors: sanitizeFrequencyBandedColors(
|
||||
frequencyRenderSettings?.bandedColors,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors,
|
||||
),
|
||||
topX: sanitizeFrequencyTopX(
|
||||
frequencyRenderSettings?.topX,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.topX,
|
||||
),
|
||||
singleColor: sanitizeHexColor(
|
||||
frequencyRenderSettings?.singleColor,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor,
|
||||
),
|
||||
};
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (const token of tokens) {
|
||||
@@ -35,7 +123,10 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
if (parts[i]) {
|
||||
const span = document.createElement("span");
|
||||
span.className = computeWordClass(token);
|
||||
span.className = computeWordClass(
|
||||
token,
|
||||
resolvedFrequencyRenderSettings,
|
||||
);
|
||||
span.textContent = parts[i];
|
||||
if (token.reading) span.dataset.reading = token.reading;
|
||||
if (token.headword) span.dataset.headword = token.headword;
|
||||
@@ -49,7 +140,7 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
|
||||
}
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.className = computeWordClass(token);
|
||||
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
|
||||
span.textContent = surface;
|
||||
if (token.reading) span.dataset.reading = token.reading;
|
||||
if (token.headword) span.dataset.headword = token.headword;
|
||||
@@ -59,7 +150,27 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
|
||||
root.appendChild(fragment);
|
||||
}
|
||||
|
||||
export function computeWordClass(token: MergedToken): string {
|
||||
export function computeWordClass(
|
||||
token: MergedToken,
|
||||
frequencySettings?: Partial<FrequencyRenderSettings>,
|
||||
): string {
|
||||
const resolvedFrequencySettings = {
|
||||
...DEFAULT_FREQUENCY_RENDER_SETTINGS,
|
||||
...frequencySettings,
|
||||
bandedColors: sanitizeFrequencyBandedColors(
|
||||
frequencySettings?.bandedColors,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors,
|
||||
),
|
||||
topX: sanitizeFrequencyTopX(
|
||||
frequencySettings?.topX,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.topX,
|
||||
),
|
||||
singleColor: sanitizeHexColor(
|
||||
frequencySettings?.singleColor,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor,
|
||||
),
|
||||
};
|
||||
|
||||
const classes = ["word"];
|
||||
|
||||
if (token.isNPlusOneTarget) {
|
||||
@@ -72,6 +183,14 @@ export function computeWordClass(token: MergedToken): string {
|
||||
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
|
||||
}
|
||||
|
||||
const frequencyClass = getFrequencyDictionaryClass(
|
||||
token,
|
||||
resolvedFrequencySettings,
|
||||
);
|
||||
if (frequencyClass) {
|
||||
classes.push(frequencyClass);
|
||||
}
|
||||
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
@@ -139,12 +258,32 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
|
||||
const normalized = normalizeSubtitle(text);
|
||||
if (tokens && tokens.length > 0) {
|
||||
renderWithTokens(ctx.dom.subtitleRoot, tokens);
|
||||
renderWithTokens(
|
||||
ctx.dom.subtitleRoot,
|
||||
tokens,
|
||||
getFrequencyRenderSettings(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
renderCharacterLevel(ctx.dom.subtitleRoot, normalized);
|
||||
}
|
||||
|
||||
function getFrequencyRenderSettings(): Partial<FrequencyRenderSettings> {
|
||||
return {
|
||||
enabled: ctx.state.frequencyDictionaryEnabled,
|
||||
topX: ctx.state.frequencyDictionaryTopX,
|
||||
mode: ctx.state.frequencyDictionaryMode,
|
||||
singleColor: ctx.state.frequencyDictionarySingleColor,
|
||||
bandedColors: [
|
||||
ctx.state.frequencyDictionaryBand1Color,
|
||||
ctx.state.frequencyDictionaryBand2Color,
|
||||
ctx.state.frequencyDictionaryBand3Color,
|
||||
ctx.state.frequencyDictionaryBand4Color,
|
||||
ctx.state.frequencyDictionaryBand5Color,
|
||||
] as [string, string, string, string, string],
|
||||
};
|
||||
}
|
||||
|
||||
function renderSecondarySub(text: string): void {
|
||||
ctx.dom.secondarySubRoot.innerHTML = "";
|
||||
if (!text) return;
|
||||
@@ -236,6 +375,66 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n3-color", jlptColors.N3);
|
||||
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n4-color", jlptColors.N4);
|
||||
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n5-color", jlptColors.N5);
|
||||
const frequencyDictionarySettings = style.frequencyDictionary ?? {};
|
||||
const frequencyEnabled =
|
||||
frequencyDictionarySettings.enabled ?? ctx.state.frequencyDictionaryEnabled;
|
||||
const frequencyTopX = sanitizeFrequencyTopX(
|
||||
frequencyDictionarySettings.topX,
|
||||
ctx.state.frequencyDictionaryTopX,
|
||||
);
|
||||
const frequencyMode = frequencyDictionarySettings.mode
|
||||
? frequencyDictionarySettings.mode
|
||||
: ctx.state.frequencyDictionaryMode;
|
||||
const frequencySingleColor = sanitizeHexColor(
|
||||
frequencyDictionarySettings.singleColor,
|
||||
ctx.state.frequencyDictionarySingleColor,
|
||||
);
|
||||
const frequencyBandedColors = sanitizeFrequencyBandedColors(
|
||||
frequencyDictionarySettings.bandedColors,
|
||||
[
|
||||
ctx.state.frequencyDictionaryBand1Color,
|
||||
ctx.state.frequencyDictionaryBand2Color,
|
||||
ctx.state.frequencyDictionaryBand3Color,
|
||||
ctx.state.frequencyDictionaryBand4Color,
|
||||
ctx.state.frequencyDictionaryBand5Color,
|
||||
] as [string, string, string, string, string],
|
||||
);
|
||||
|
||||
ctx.state.frequencyDictionaryEnabled = frequencyEnabled;
|
||||
ctx.state.frequencyDictionaryTopX = frequencyTopX;
|
||||
ctx.state.frequencyDictionaryMode = frequencyMode;
|
||||
ctx.state.frequencyDictionarySingleColor = frequencySingleColor;
|
||||
[
|
||||
ctx.state.frequencyDictionaryBand1Color,
|
||||
ctx.state.frequencyDictionaryBand2Color,
|
||||
ctx.state.frequencyDictionaryBand3Color,
|
||||
ctx.state.frequencyDictionaryBand4Color,
|
||||
ctx.state.frequencyDictionaryBand5Color,
|
||||
] = frequencyBandedColors;
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
"--subtitle-frequency-single-color",
|
||||
frequencySingleColor,
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
"--subtitle-frequency-band-1-color",
|
||||
frequencyBandedColors[0],
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
"--subtitle-frequency-band-2-color",
|
||||
frequencyBandedColors[1],
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
"--subtitle-frequency-band-3-color",
|
||||
frequencyBandedColors[2],
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
"--subtitle-frequency-band-4-color",
|
||||
frequencyBandedColors[3],
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
"--subtitle-frequency-band-5-color",
|
||||
frequencyBandedColors[4],
|
||||
);
|
||||
|
||||
const secondaryStyle = style.secondary;
|
||||
if (!secondaryStyle) return;
|
||||
|
||||
25
src/types.ts
25
src/types.ts
@@ -55,8 +55,11 @@ export interface MergedToken {
|
||||
isKnown: boolean;
|
||||
isNPlusOneTarget: boolean;
|
||||
jlptLevel?: JlptLevel;
|
||||
frequencyRank?: number;
|
||||
}
|
||||
|
||||
export type FrequencyDictionaryLookup = (term: string) => number | null;
|
||||
|
||||
export type JlptLevel = "N1" | "N2" | "N3" | "N4" | "N5";
|
||||
|
||||
export interface WindowGeometry {
|
||||
@@ -283,6 +286,14 @@ export interface SubtitleStyleConfig {
|
||||
N4: string;
|
||||
N5: string;
|
||||
};
|
||||
frequencyDictionary?: {
|
||||
enabled?: boolean;
|
||||
sourcePath?: string;
|
||||
topX?: number;
|
||||
mode?: FrequencyDictionaryMode;
|
||||
singleColor?: string;
|
||||
bandedColors?: [string, string, string, string, string];
|
||||
};
|
||||
secondary?: {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
@@ -293,6 +304,8 @@ export interface SubtitleStyleConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export type FrequencyDictionaryMode = "single" | "banded";
|
||||
|
||||
export interface ShortcutsConfig {
|
||||
toggleVisibleOverlayGlobal?: string | null;
|
||||
toggleInvisibleOverlayGlobal?: string | null;
|
||||
@@ -431,8 +444,18 @@ export interface ResolvedConfig {
|
||||
shortcuts: Required<ShortcutsConfig>;
|
||||
secondarySub: Required<SecondarySubConfig>;
|
||||
subsync: Required<SubsyncConfig>;
|
||||
subtitleStyle: Required<Omit<SubtitleStyleConfig, "secondary">> & {
|
||||
subtitleStyle: Required<
|
||||
Omit<SubtitleStyleConfig, "secondary" | "frequencyDictionary">
|
||||
> & {
|
||||
secondary: Required<NonNullable<SubtitleStyleConfig["secondary"]>>;
|
||||
frequencyDictionary: {
|
||||
enabled: boolean;
|
||||
sourcePath: string;
|
||||
topX: number;
|
||||
mode: FrequencyDictionaryMode;
|
||||
singleColor: string;
|
||||
bandedColors: [string, string, string, string, string];
|
||||
};
|
||||
};
|
||||
auto_start_overlay: boolean;
|
||||
bind_visible_overlay_to_mpv_sub_visibility: boolean;
|
||||
|
||||
42
subminer
42
subminer
@@ -705,6 +705,39 @@ function isExecutable(filePath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMacAppBinaryCandidate(candidate: string): string {
|
||||
if (process.platform !== "darwin") return "";
|
||||
|
||||
const direct = resolveBinaryPathCandidate(candidate);
|
||||
if (!direct) return "";
|
||||
|
||||
if (isExecutable(direct)) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const appIndex = direct.indexOf(".app/");
|
||||
const appPath =
|
||||
direct.endsWith(".app") && direct.includes(".app")
|
||||
? direct
|
||||
: appIndex >= 0
|
||||
? direct.slice(0, appIndex + ".app".length)
|
||||
: "";
|
||||
if (!appPath) return "";
|
||||
|
||||
const candidates = [
|
||||
path.join(appPath, "Contents", "MacOS", "SubMiner"),
|
||||
path.join(appPath, "Contents", "MacOS", "subminer"),
|
||||
];
|
||||
|
||||
for (const candidateBinary of candidates) {
|
||||
if (isExecutable(candidateBinary)) {
|
||||
return candidateBinary;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function commandExists(command: string): boolean {
|
||||
const pathEnv = process.env.PATH ?? "";
|
||||
for (const dir of pathEnv.split(path.delimiter)) {
|
||||
@@ -1666,8 +1699,8 @@ function findAppBinary(selfPath: string): string | null {
|
||||
].filter((candidate): candidate is string => Boolean(candidate));
|
||||
|
||||
for (const envPath of envPaths) {
|
||||
const resolved = resolveBinaryPathCandidate(envPath);
|
||||
if (resolved && isExecutable(resolved)) {
|
||||
const resolved = resolveMacAppBinaryCandidate(envPath);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
@@ -2636,6 +2669,7 @@ function startMpv(
|
||||
targetKind: "file" | "url",
|
||||
args: Args,
|
||||
socketPath: string,
|
||||
appPath: string,
|
||||
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
||||
): void {
|
||||
if (
|
||||
@@ -2692,6 +2726,9 @@ function startMpv(
|
||||
if (preloadedSubtitles?.secondaryPath) {
|
||||
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
|
||||
}
|
||||
mpvArgs.push(
|
||||
`--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`,
|
||||
);
|
||||
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
||||
|
||||
try {
|
||||
@@ -2833,6 +2870,7 @@ async function main(): Promise<void> {
|
||||
selectedTarget.kind,
|
||||
args,
|
||||
mpvSocketPath,
|
||||
appPath,
|
||||
preloadedSubtitles,
|
||||
);
|
||||
|
||||
|
||||
1
vendor/jiten_freq_global/index.json
vendored
Normal file
1
vendor/jiten_freq_global/index.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"title":"Jiten","format":3,"revision":"Jiten 26-02-16","isUpdatable":true,"indexUrl":"https://api.jiten.moe/api/frequency-list/index","downloadUrl":"https://api.jiten.moe/api/frequency-list/download","sequenced":false,"frequencyMode":"rank-based","author":"Jiten","url":"https://jiten.moe","description":"Dictionary based on frequency data of all media from jiten.moe"}
|
||||
1
vendor/jiten_freq_global/term_meta_bank_1.json
vendored
Normal file
1
vendor/jiten_freq_global/term_meta_bank_1.json
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user