mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-08 07:21:31 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
da2c50b89f
|
|||
| dbdf578c68 | |||
| 441ecf3c04 |
@@ -0,0 +1,7 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added an in-app changelog modal, opened from the tray ("View Changelog") or the "What's New" button on the update-available notification, which now stays on screen so "Update" is still reachable after reading the notes. It renders inside the player bounds when a video is playing and in its own window otherwise, the same as the help modal.
|
||||
- The changelog is fetched from the newest published release, so release notes for versions newer than the installed build are visible; a failed download falls back to the changelog bundled with the install and says so in the modal.
|
||||
- Versions are foldable: the current `0.x` line is expanded and older lines are folded, matching the docs-site changelog. A badge marks the installed version and newer versions are tagged "New".
|
||||
- Keyboard: `J`/`K` or arrows move between versions, `Enter` folds/unfolds, `R` refetches, `Esc` closes.
|
||||
@@ -0,0 +1,7 @@
|
||||
type: fixed
|
||||
area: character dictionary
|
||||
|
||||
- A large character dictionary no longer fails to install. The Yomitan import (and the delete that precedes it) used a flat 7 second budget, so a series like One Piece failed with `importYomitanDictionary(merged.zip) timed out after 7000ms` while the import was still healthy. The budget is now sized from the merged ZIP (2 minutes plus 6 seconds per MB, capped at 30 minutes); the quick queries keep the 7 second budget.
|
||||
- The "Generating character dictionary" notification now reports what it is doing instead of one static message for the whole run: the AniList page and character count while characters download (`page 12, 587 characters`), then `image 240/1220, ~4m left` per downloaded character/voice actor image, then `name 800/1220` for MeCab name splits and `saving snapshot` at the end. Updates are throttled to one per second, and every stage change and final item always reports.
|
||||
- Every long phase also carries an elapsed clock (`· 1m 35s`), refreshed every 5 seconds even when nothing else moves, so a stalled step is visibly different from a frozen app. Long imports tick the same way.
|
||||
- Image downloads are also logged every 100 files, and the import logs the timeout it computed.
|
||||
@@ -0,0 +1,20 @@
|
||||
type: changed
|
||||
area: subtitles
|
||||
|
||||
- Subtitle tokenization no longer runs a duplicate full `parseText` pass per line: the termsFind scanner walk is now the only tokenizer and emits its own hoverable filler runs for unmatched text (parseText is kept only as an error fallback). This roughly halves the dictionary work per line.
|
||||
- The Yomitan scanning helpers are now installed once per parser window (`__subminerYomitanScan`) instead of re-shipping and re-parsing a ~500-line script for every subtitle line; each line only evaluates a tiny call.
|
||||
- termsFind lookups are cached across subtitle lines in a window-persistent LRU keyed by substring, so repeated particles and verb forms stop costing backend round trips. The cache invalidates on dictionary/settings changes and window reloads.
|
||||
- The scanner walk now skips lookups at punctuation and whitespace positions (latin letters and digits still look up, e.g. Tシャツ). The shrinking-window retry ladder keeps following the consumed lengths the backend reports, and only blind guesses (windows the backend consumed whole, which tell it nothing) are capped at four per position. A line that hits that cap escalates to a single `parseText` for the whole line, so a hard line still resolves to dictionary tokens instead of an unparsed run, without letting the ladder run to one lookup per window length.
|
||||
- Tokenizer runtime dependencies are built once instead of per line, fixing a JLPT lookup cache that never hit (it was keyed on a per-call closure identity and leaked a Map per line) and a `which mecab` availability check that re-ran synchronously on every line when MeCab is absent.
|
||||
- Subtitle changes no longer restart the prefetch run per line (which discarded in-flight tokenization work); prefetch now only pauses for the live line and restarts on real seeks, cache invalidation, or option changes. Prefetch also stays paused for the whole time the subtitle processing controller is working on the line, including the provisional raw emit that precedes tokenization, so it never competes with the on-screen line for the parser window. The pause is released when the controller reports it has settled, which also covers the lines that finish without an emit (a suppressed duplicate or a failed tokenization) and used to leave prefetching paused indefinitely.
|
||||
- Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
|
||||
- Fixed a reading that stopped covering its surface when an unmatched kana run extended the preceding token (for example a trailing る on 待ち合わせ), which silently disabled the known-word reading fallback for those tokens.
|
||||
- Subtitle prefetching no longer stays paused for the rest of a cue when the same subtitle text is reported twice and there is nothing to tokenize. This covers the startup and overlay priming paths as well as ordinary subtitle changes.
|
||||
- Character name and image lookups are now refreshed centrally whenever a character dictionary sync changes its content, so a newly added name can no longer be skipped by a stale candidate list.
|
||||
- A subtitle that was on screen when its annotations were invalidated (by mining a card, for example) is now re-annotated instead of staying plain for the rest of the line.
|
||||
- Character name annotations no longer cost a dictionary lookup at every position in a line. The scanner now knows which name forms the current title's character dictionary actually contains and only checks where one can start, which removes the whole overhead of having the character dictionary enabled (measured: 21 lookups per line down to 10, the same as with it disabled). Titles with no cached character data keep the previous exhaustive scan, so a missing snapshot costs speed rather than a missing name.
|
||||
- The cross-line termsFind cache is now bounded by the number of retained dictionary entries as well as by key count, so a run of lookups that each carry hundreds of entries with full glossaries cannot grow the parser window's memory without limit. The budget is re-checked when a lookup resolves, so a single oversized response is dropped rather than parked in the cache and reused.
|
||||
- The unnamed-mob disambiguator filter (Girl A / Girl B) now only drops a single letter or digit split off a name, instead of every one-character term: a name that is genuinely one character keeps its terms whatever the script (𠮷, あ, 별 김, ア・ベ). The character dictionary and the scanner's name pre-pass also share one Han code-point table now, so a name the dictionary accepts is a name the scanner will look for.
|
||||
- A character name written in halfwidth katakana takes part in the greedy name pre-pass again, so a longer generic word can no longer swallow the start of it, and it now carries a reading (it used to come out blank, which disables known-word matching and frequency lookups for the token). Voiced halfwidth kana compose properly, so ガク reads ガク rather than ガク, and kana normalization folds halfwidth throughout so those tokens compare equal to the same word written fullwidth. Because the fold makes halfwidth text indexable, the character-name prefilter now judges halfwidth spellings like any other, and a position only bypasses it when an unfoldable voiced mark sits inside the lookup window. That covers a name that starts on a kanji and turns halfwidth later (山ガク), and one stretched out with emphatic characters in between (山ーーーーーーガク).
|
||||
- Dictionary-entry classification (source dictionaries, character-dictionary media ids) is memoized per entry object for as long as the entry is cached, instead of being recomputed for every headword comparison and every retry window.
|
||||
- Autoplay priming no longer broadcasts the plain subtitle twice: it tells the processing controller the line has already been painted, so the controller goes straight to the annotated payload.
|
||||
@@ -75,8 +75,8 @@ src/
|
||||
renderer/ # Overlay renderer (modularized UI/runtime)
|
||||
handlers/ # Keyboard/mouse/gamepad interaction modules
|
||||
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
|
||||
# character dictionary, playlist browser, subtitle sidebar,
|
||||
# YouTube track picker, controller config/debug/select)
|
||||
# changelog, character dictionary, playlist browser, subtitle
|
||||
# sidebar, YouTube track picker, controller config/debug/select)
|
||||
positioning/ # Subtitle position controller (drag-to-reposition)
|
||||
settings/ # Settings window UI (model, controls, markup)
|
||||
types/ # Domain type modules (anki, config, integrations, ...)
|
||||
|
||||
@@ -145,6 +145,8 @@ The tray menu includes `Export Logs`, which creates the same sanitized local-dat
|
||||
|
||||
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
|
||||
|
||||
The tray menu also includes `View Changelog`, which opens the in-app changelog modal. It fetches the changelog from the newest published release, so you see release notes for versions newer than the one you run; if the download fails it falls back to the changelog bundled with your install and says so. Versions in the current `0.x` line are expanded by default and older lines are folded, matching this site's [Changelog](/changelog). A badge marks the version you have installed, and newer versions are tagged `New`. The same modal opens from the `What's New` button on the update-available overlay notification.
|
||||
|
||||
### Logging and App Mode
|
||||
|
||||
- `--log-level` controls logger verbosity.
|
||||
@@ -368,6 +370,8 @@ Press `V` to cycle the primary SubMiner subtitle bar through hidden → visible
|
||||
|
||||
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv.
|
||||
|
||||
The changelog modal (tray > `View Changelog`) works the same way: it renders over mpv when a video is playing and in its own window otherwise. Use `J`/`K` or the arrow keys to move between versions, `Enter` to fold or unfold one, `R` to refetch, and `Esc` to close.
|
||||
|
||||
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
||||
|
||||
### Drag-and-Drop
|
||||
|
||||
@@ -50,10 +50,22 @@ subtitles do not draw.
|
||||
7. Cache miss: call `refreshCurrentSubtitle(text)`. Normal processing emits a plain payload
|
||||
synchronously, then replaces it with the tokenized payload when ready.
|
||||
|
||||
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
|
||||
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
|
||||
`subtitleProcessingController` method. This gives the visible overlay priority over background
|
||||
prefetch work and re-centers prefetch around the live playback time.
|
||||
Both `onSubtitleChange` and `refreshCurrentSubtitle` pause `subtitlePrefetchService` and then call
|
||||
the matching `subtitleProcessingController` method, giving the visible overlay priority over
|
||||
background prefetch work. Prefetch is not re-centered here: restarting the run per line
|
||||
(`onSeek`) discarded the in-flight tokenization every time the subtitle changed, so only real
|
||||
seeks restart it (see `onTimePosUpdate` in `src/main.ts`).
|
||||
|
||||
On an uncached autoplay prime the raw payload is emitted here and reported to the controller with
|
||||
`notePlainSubtitleEmitted`, so the controller skips its own plain emit for that line and the
|
||||
overlay receives one plain payload followed by the annotated one.
|
||||
|
||||
The pause is released by the controller's `onProcessingSettled` callback, which fires once it has
|
||||
no work left. Emits do not release it: the first emit for an uncached line is the plain payload
|
||||
that precedes tokenization, and a run can finish without emitting at all (a suppressed duplicate,
|
||||
a failed tokenization). Both controller methods return whether processing is now pending, and the
|
||||
caller resumes immediately when it is not — a repeated subtitle schedules no work, so no settle is
|
||||
coming and prefetching would otherwise idle for the rest of the cue.
|
||||
|
||||
## Live Cue Delivery
|
||||
|
||||
|
||||
@@ -260,6 +260,10 @@
|
||||
{
|
||||
"from": "dist/launcher/subminer",
|
||||
"to": "launcher/subminer"
|
||||
},
|
||||
{
|
||||
"from": "CHANGELOG.md",
|
||||
"to": "CHANGELOG.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
## Highlights
|
||||
### Changed
|
||||
|
||||
- **Subsync Reference & Target Picker**
|
||||
- You can now choose both sides of a sync run: which subtitle is the timing reference and which one gets retimed.
|
||||
- The video file itself can be used as the reference for local files (audio-based sync), though a subtitle track stays the default.
|
||||
- Works for both alass and ffsubsync, and retiming the secondary subtitle track no longer overwrites your primary one.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Startup Logging**
|
||||
- Background startup now respects your configured log level even when no `--log-level` flag is passed.
|
||||
- **Streaming Subtitle Tokenization**
|
||||
- Jellyfin playback now seeds tokenization straight from the downloaded subtitle file, so episodes no longer fall back to slow, line-by-line tokenizing while waiting on playback events.
|
||||
- Subtitle cues are no longer dropped when switching to a subtitle track embedded in the stream.
|
||||
- Prefetching now runs through the whole episode instead of stopping once the cache filled, and the cache clears between episodes so slowdowns don't carry over to later titles.
|
||||
- The tokenization cache was expanded from 256 to 2,500 lines, leaving more room for repeated lines (like openings and endings) to stay cached across episodes.
|
||||
- **Subtitle Line Display**
|
||||
- Subtitle lines now appear immediately at their cue time even if tokenization hasn't finished, upgrading in place with annotations once ready.
|
||||
- A failed tokenization attempt is no longer cached as plain text, so the line gets another chance at full annotations later.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(subsync): add reference and target subtitle track picker by @ksyasuda in #181
|
||||
- fix(logging): surface subtitle processing debug/warn logs by @ksyasuda in #182
|
||||
- fix(streaming): keep subtitle tokenization prefetch warm for full episodes by @ksyasuda in #183
|
||||
- fix(overlay): show plain subtitle line immediately on tokenization cache miss by @ksyasuda in #184
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -1,6 +1,7 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron';
|
||||
import type {
|
||||
ChangelogSnapshot,
|
||||
CompiledSessionBinding,
|
||||
ControllerConfigUpdate,
|
||||
PlaylistBrowserMutationResult,
|
||||
@@ -122,6 +123,7 @@ export interface IpcServiceDeps {
|
||||
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
||||
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||
@@ -297,6 +299,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
||||
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
|
||||
@@ -418,6 +421,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
entries: [],
|
||||
})),
|
||||
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
||||
getChangelogSnapshot: options.getChangelogSnapshot,
|
||||
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
|
||||
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
|
||||
playPlaylistBrowserIndex: options.playPlaylistBrowserIndex,
|
||||
@@ -820,6 +824,17 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.appendClipboardVideoToQueue();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getChangelogSnapshot, async (_event, payload: unknown) => {
|
||||
const refresh =
|
||||
typeof payload === 'object' && payload !== null && 'refresh' in payload
|
||||
? (payload as { refresh?: unknown }).refresh === true
|
||||
: false;
|
||||
if (!deps.getChangelogSnapshot) {
|
||||
throw new Error('Changelog service is unavailable.');
|
||||
}
|
||||
return await deps.getChangelogSnapshot({ refresh });
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getPlaylistBrowserSnapshot, async () => {
|
||||
return await deps.getPlaylistBrowserSnapshot();
|
||||
});
|
||||
|
||||
@@ -539,3 +539,125 @@ test('default cache limit covers a full-length title without evicting', () => {
|
||||
assert.equal(controller.hasCachedSubtitle('line-0'), true);
|
||||
assert.equal(controller.hasCachedSubtitle('line-1999'), true);
|
||||
});
|
||||
|
||||
test('onSubtitleChange reports whether processing was scheduled', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
// New text schedules work, so an emit (and anything gated on it) will follow.
|
||||
assert.equal(controller.onSubtitleChange('字幕'), true);
|
||||
await flushMicrotasks();
|
||||
|
||||
// A repeat emits nothing, so callers must not wait on an emit that is never
|
||||
// coming (subtitle prefetching would stay paused for the rest of the cue).
|
||||
const emittedCount = emitted.length;
|
||||
assert.equal(controller.onSubtitleChange('字幕'), false);
|
||||
await flushMicrotasks();
|
||||
assert.equal(emitted.length, emittedCount);
|
||||
});
|
||||
|
||||
test('refreshCurrentSubtitle reports the empty-text emit that an in-flight run will deliver', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
let resolveFirst: ((value: SubtitleData | null) => void) | undefined;
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => {
|
||||
if (text === '字幕') {
|
||||
return await new Promise<SubtitleData | null>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
}
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('字幕');
|
||||
await flushMicrotasks();
|
||||
|
||||
// Clearing the subtitle while tokenization is in flight: the running loop
|
||||
// picks the empty text up and emits it, so callers gated on that emit (the
|
||||
// prefetch pause) must be told one is coming.
|
||||
assert.equal(controller.refreshCurrentSubtitle(''), true);
|
||||
|
||||
resolveFirst?.({ text: '字幕', tokens: [] });
|
||||
await flushMicrotasks();
|
||||
await flushMicrotasks();
|
||||
// '字幕' is the provisional plain emit the in-flight run already made before
|
||||
// the refresh; '' is the emit the refresh promised.
|
||||
assert.deepEqual(
|
||||
emitted.map((payload) => payload.text),
|
||||
['字幕', ''],
|
||||
);
|
||||
});
|
||||
|
||||
test('onProcessingSettled fires once after the queue drains, including runs that emit nothing', async () => {
|
||||
const events: string[] = [];
|
||||
let resolveFirst: ((value: SubtitleData | null) => void) | undefined;
|
||||
let tokenizationFails = false;
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => {
|
||||
if (tokenizationFails) {
|
||||
return null;
|
||||
}
|
||||
if (text === '一行目') {
|
||||
return await new Promise<SubtitleData | null>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
}
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
emitSubtitle: (payload) => events.push(`emit:${payload.text}`),
|
||||
onProcessingSettled: () => events.push('settled'),
|
||||
});
|
||||
|
||||
controller.onSubtitleChange('一行目');
|
||||
await flushMicrotasks();
|
||||
// A second line arrives before the first finishes: the controller still has
|
||||
// work, so it must not report itself settled between the two.
|
||||
controller.onSubtitleChange('二行目');
|
||||
resolveFirst?.({ text: '一行目', tokens: [] });
|
||||
await flushMicrotasks();
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(events, ['emit:一行目', 'emit:二行目', 'emit:二行目', 'settled']);
|
||||
|
||||
// Tokenization failure on a line already shown plain: nothing is emitted, and
|
||||
// the settle signal is the only way a caller learns the work is over.
|
||||
events.length = 0;
|
||||
tokenizationFails = true;
|
||||
controller.invalidateTokenizationCache();
|
||||
assert.equal(controller.refreshCurrentSubtitle('二行目'), true);
|
||||
await flushMicrotasks();
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(events, ['settled']);
|
||||
});
|
||||
|
||||
test('notePlainSubtitleEmitted suppresses the controller repeat of a payload already shown', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
// Autoplay priming paints the plain line itself, then asks for tokenization.
|
||||
controller.notePlainSubtitleEmitted('字幕');
|
||||
controller.refreshCurrentSubtitle('字幕');
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]);
|
||||
});
|
||||
|
||||
test('refreshCurrentSubtitle reports no emit for empty text when nothing is running', async () => {
|
||||
const emitted: SubtitleData[] = [];
|
||||
const controller = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||
emitSubtitle: (payload) => emitted.push(payload),
|
||||
});
|
||||
|
||||
assert.equal(controller.refreshCurrentSubtitle(''), false);
|
||||
await flushMicrotasks();
|
||||
assert.deepEqual(emitted, []);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,14 @@ import type { SubtitleData } from '../../types';
|
||||
export interface SubtitleProcessingControllerDeps {
|
||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||
emitSubtitle: (payload: SubtitleData) => void;
|
||||
/**
|
||||
* Fires when the controller runs out of work: every scheduled line has been
|
||||
* processed, whether it ended in an emit, a suppressed duplicate, or a
|
||||
* tokenizer failure. Callers that hold a resource for the duration of
|
||||
* processing (prefetch pausing) release it here rather than on an emit,
|
||||
* which is not guaranteed to happen.
|
||||
*/
|
||||
onProcessingSettled?: () => void;
|
||||
logDebug?: (message: string) => void;
|
||||
now?: () => number;
|
||||
cacheLimit?: number;
|
||||
@@ -17,8 +25,22 @@ export interface SubtitleProcessingControllerDeps {
|
||||
export const DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT = 2500;
|
||||
|
||||
export interface SubtitleProcessingController {
|
||||
onSubtitleChange: (text: string) => void;
|
||||
refreshCurrentSubtitle: (textOverride?: string) => void;
|
||||
/**
|
||||
* Returns whether processing is now scheduled or already in flight for this
|
||||
* event. A false return means the controller is idle and will do nothing, so
|
||||
* onProcessingSettled will not fire; callers that pause work for the duration
|
||||
* of processing (such as subtitle prefetching) must release it themselves.
|
||||
*/
|
||||
onSubtitleChange: (text: string) => boolean;
|
||||
/** Same contract as onSubtitleChange: whether processing is pending. */
|
||||
refreshCurrentSubtitle: (textOverride?: string) => boolean;
|
||||
/**
|
||||
* Records that this exact text has already been shown plain by someone else
|
||||
* (autoplay priming paints its first frame before scheduling tokenization),
|
||||
* so the controller does not repeat that payload on its way to the tokenized
|
||||
* one.
|
||||
*/
|
||||
notePlainSubtitleEmitted: (text: string) => void;
|
||||
invalidateTokenizationCache: () => void;
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||
@@ -164,14 +186,20 @@ export function createSubtitleProcessingController(
|
||||
(latestText.trim() && cacheGeneration !== lastEmittedGeneration)
|
||||
) {
|
||||
processLatest();
|
||||
return;
|
||||
}
|
||||
// Nothing left to do: signal completion even when this run emitted
|
||||
// nothing (suppressed duplicate, tokenizer failure), or callers waiting
|
||||
// on the controller would wait forever.
|
||||
deps.onProcessingSettled?.();
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
onSubtitleChange: (text: string) => {
|
||||
if (text === latestText) {
|
||||
return;
|
||||
// A run already in flight for this text will still emit for it.
|
||||
return processing;
|
||||
}
|
||||
latestText = text;
|
||||
if (
|
||||
@@ -183,21 +211,28 @@ export function createSubtitleProcessingController(
|
||||
lastPlainEmittedText = text;
|
||||
}
|
||||
processLatest();
|
||||
return true;
|
||||
},
|
||||
refreshCurrentSubtitle: (textOverride?: string) => {
|
||||
if (typeof textOverride === 'string') {
|
||||
latestText = textOverride;
|
||||
}
|
||||
if (!latestText.trim()) {
|
||||
return;
|
||||
// A run in flight will pick this up and emit the empty subtitle, so
|
||||
// the caller is still waiting on an emit.
|
||||
return processing;
|
||||
}
|
||||
if (
|
||||
processing ||
|
||||
(latestText === lastEmittedText && cacheGeneration === lastEmittedGeneration)
|
||||
) {
|
||||
return;
|
||||
if (processing) {
|
||||
return true;
|
||||
}
|
||||
if (latestText === lastEmittedText && cacheGeneration === lastEmittedGeneration) {
|
||||
return false;
|
||||
}
|
||||
processLatest();
|
||||
return true;
|
||||
},
|
||||
notePlainSubtitleEmitted: (text: string) => {
|
||||
lastPlainEmittedText = text;
|
||||
},
|
||||
invalidateTokenizationCache: () => {
|
||||
tokenizationCache.clear();
|
||||
|
||||
@@ -2934,44 +2934,12 @@ test('tokenizeSubtitle preserves Yomitan compound token when MeCab components ar
|
||||
return [];
|
||||
}
|
||||
|
||||
if (script.includes('parseText')) {
|
||||
return [
|
||||
{
|
||||
source: 'scanning-parser',
|
||||
index: 0,
|
||||
content: [
|
||||
[
|
||||
{
|
||||
text: '取り組んで',
|
||||
reading: 'とりくんで',
|
||||
headwords: [[{ term: '取り組む' }]],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: 'もらいます',
|
||||
reading: 'もらいます',
|
||||
headwords: [[{ term: 'もらう' }]],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
surface: '取り',
|
||||
reading: 'とり',
|
||||
headword: '取る',
|
||||
surface: '取り組んで',
|
||||
reading: 'とりくんで',
|
||||
headword: '取り組む',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
},
|
||||
{
|
||||
surface: '組んで',
|
||||
reading: 'くんで',
|
||||
headword: '組む',
|
||||
startPos: 2,
|
||||
endPos: 5,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface TokenizerServiceDeps {
|
||||
getNameMatchImagesEnabled?: () => boolean;
|
||||
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
|
||||
getCurrentCharacterDictionaryMediaId?: () => number | null;
|
||||
getCharacterNameCandidates?: () => { key: string; forms: string[] } | null;
|
||||
getFrequencyDictionaryEnabled?: () => boolean;
|
||||
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||
@@ -106,6 +107,7 @@ export interface TokenizerDepsRuntimeOptions {
|
||||
getNameMatchImagesEnabled?: () => boolean;
|
||||
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
|
||||
getCurrentCharacterDictionaryMediaId?: () => number | null;
|
||||
getCharacterNameCandidates?: () => { key: string; forms: string[] } | null;
|
||||
getFrequencyDictionaryEnabled?: () => boolean;
|
||||
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||
@@ -266,6 +268,7 @@ export function createTokenizerDepsRuntime(
|
||||
getNameMatchImagesEnabled: options.getNameMatchImagesEnabled,
|
||||
getCharacterNameImage: options.getCharacterNameImage,
|
||||
getCurrentCharacterDictionaryMediaId: options.getCurrentCharacterDictionaryMediaId,
|
||||
getCharacterNameCandidates: options.getCharacterNameCandidates,
|
||||
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
|
||||
getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
|
||||
getFrequencyRank: options.getFrequencyRank,
|
||||
@@ -716,15 +719,30 @@ function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOp
|
||||
};
|
||||
}
|
||||
|
||||
// Per-line stage durations for the pipeline debug log; every field is filled in
|
||||
// by the stage that awaits the corresponding work.
|
||||
interface TokenizationStageTimings {
|
||||
scanMs?: number;
|
||||
mecabMs?: number;
|
||||
frequencyMs?: number;
|
||||
annotateMs?: number;
|
||||
}
|
||||
|
||||
async function parseWithYomitanInternalParser(
|
||||
text: string,
|
||||
deps: TokenizerServiceDeps,
|
||||
options: TokenizerAnnotationOptions,
|
||||
stageTimings?: TokenizationStageTimings,
|
||||
): Promise<MergedToken[] | null> {
|
||||
const scanStartedAtMs = Date.now();
|
||||
const selectedTokens = await requestYomitanScanTokens(text, deps, logger, {
|
||||
includeNameMatchMetadata: options.nameMatchEnabled,
|
||||
currentCharacterDictionaryMediaId: deps.getCurrentCharacterDictionaryMediaId?.() ?? null,
|
||||
nameCandidates: deps.getCharacterNameCandidates?.() ?? null,
|
||||
});
|
||||
if (stageTimings) {
|
||||
stageTimings.scanMs = Date.now() - scanStartedAtMs;
|
||||
}
|
||||
if (!selectedTokens || selectedTokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -757,6 +775,7 @@ async function parseWithYomitanInternalParser(
|
||||
|
||||
const frequencyRankPromise: Promise<YomitanFrequencyIndex> = options.frequencyEnabled
|
||||
? (async () => {
|
||||
const frequencyStartedAtMs = Date.now();
|
||||
const frequencyMatchMode = options.frequencyMatchMode;
|
||||
const termReadingList = buildYomitanFrequencyTermReadingList(
|
||||
normalizedSelectedTokens,
|
||||
@@ -767,12 +786,17 @@ async function parseWithYomitanInternalParser(
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
return buildYomitanFrequencyIndex(yomitanFrequencies);
|
||||
const frequencyIndex = buildYomitanFrequencyIndex(yomitanFrequencies);
|
||||
if (stageTimings) {
|
||||
stageTimings.frequencyMs = Date.now() - frequencyStartedAtMs;
|
||||
}
|
||||
return frequencyIndex;
|
||||
})()
|
||||
: Promise.resolve({ byPair: new Map(), byTerm: new Map() });
|
||||
|
||||
const mecabEnrichmentPromise: Promise<MergedToken[]> = needsMecabPosEnrichment(options)
|
||||
? (async () => {
|
||||
const mecabStartedAtMs = Date.now();
|
||||
try {
|
||||
const mecabTokens = await deps.tokenizeWithMecab(text);
|
||||
const enrichTokensWithMecab = deps.enrichTokensWithMecab ?? enrichTokensWithMecabAsync;
|
||||
@@ -786,6 +810,10 @@ async function parseWithYomitanInternalParser(
|
||||
`textLength=${text.length}`,
|
||||
);
|
||||
return normalizedSelectedTokens;
|
||||
} finally {
|
||||
if (stageTimings) {
|
||||
stageTimings.mecabMs = Date.now() - mecabStartedAtMs;
|
||||
}
|
||||
}
|
||||
})()
|
||||
: Promise.resolve(normalizedSelectedTokens);
|
||||
@@ -876,15 +904,35 @@ export async function tokenizeSubtitle(
|
||||
const annotationOptions = getAnnotationOptions(deps);
|
||||
annotationOptions.sourceText = tokenizeText;
|
||||
|
||||
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps, annotationOptions);
|
||||
const stageTimings: TokenizationStageTimings = {};
|
||||
const startedAtMs = Date.now();
|
||||
const logStageTimings = (tokenCount: number): void => {
|
||||
logger.debug(
|
||||
`Subtitle tokenization stages; textLength=${tokenizeText.length}, tokenCount=${tokenCount}, ` +
|
||||
`scanMs=${stageTimings.scanMs ?? '-'}, mecabMs=${stageTimings.mecabMs ?? '-'}, ` +
|
||||
`frequencyMs=${stageTimings.frequencyMs ?? '-'}, annotateMs=${stageTimings.annotateMs ?? '-'}, ` +
|
||||
`totalMs=${Date.now() - startedAtMs}`,
|
||||
);
|
||||
};
|
||||
|
||||
const yomitanTokens = await parseWithYomitanInternalParser(
|
||||
tokenizeText,
|
||||
deps,
|
||||
annotationOptions,
|
||||
stageTimings,
|
||||
);
|
||||
if (yomitanTokens && yomitanTokens.length > 0) {
|
||||
const annotateStartedAtMs = Date.now();
|
||||
const annotatedTokens = await applyAnnotationStage(yomitanTokens, deps, annotationOptions);
|
||||
stageTimings.annotateMs = Date.now() - annotateStartedAtMs;
|
||||
const renderedTokens = applyCharacterNameImages(annotatedTokens, deps, annotationOptions);
|
||||
logStageTimings(renderedTokens.length);
|
||||
return {
|
||||
text: displayText,
|
||||
tokens: renderedTokens.length > 0 ? renderedTokens : null,
|
||||
};
|
||||
}
|
||||
|
||||
logStageTimings(0);
|
||||
return { text: displayText, tokens: null };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Title prefix of the dictionaries SubMiner generates per media. Lives on its
|
||||
// own because both the main process and the injected scan runtime match on it,
|
||||
// and the injected fragments interpolate it into their own source.
|
||||
export const CHARACTER_DICTIONARY_TITLE_PREFIX = 'SubMiner Character Dictionary';
|
||||
@@ -366,8 +366,11 @@ export function createReplayMessageStore(messages: GoldenRecordedMessage[]): Rep
|
||||
};
|
||||
}
|
||||
|
||||
async function runInjectedScriptInVm(script: string, store: ReplayMessageStore): Promise<unknown> {
|
||||
return await vm.runInNewContext(script, {
|
||||
// One persistent context per fixture, matching the real parser window: the
|
||||
// scan runtime installs itself once into globalThis and later per-line call
|
||||
// scripts reuse it.
|
||||
function createInjectedScriptVm(store: ReplayMessageStore): (script: string) => Promise<unknown> {
|
||||
const context = vm.createContext({
|
||||
chrome: {
|
||||
runtime: {
|
||||
lastError: null,
|
||||
@@ -393,6 +396,7 @@ async function runInjectedScriptInVm(script: string, store: ReplayMessageStore):
|
||||
Set,
|
||||
String,
|
||||
});
|
||||
return async (script: string) => await vm.runInContext(script, context);
|
||||
}
|
||||
|
||||
export function createReplayTokenizerDeps(fixture: GoldenFixture): TokenizerServiceDeps {
|
||||
@@ -400,13 +404,14 @@ export function createReplayTokenizerDeps(fixture: GoldenFixture): TokenizerServ
|
||||
const scriptResults = new Map(
|
||||
fixture.recording.scripts.map((entry) => [entry.sha256, entry] as const),
|
||||
);
|
||||
const runInjectedScriptInVm = createInjectedScriptVm(store);
|
||||
|
||||
const parserWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => {
|
||||
try {
|
||||
return await runInjectedScriptInVm(script, store);
|
||||
return await runInjectedScriptInVm(script);
|
||||
} catch (vmError) {
|
||||
const recorded = scriptResults.get(hashInjectedScript(script));
|
||||
if (recorded) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isKanaChar,
|
||||
isKanaOnlyText,
|
||||
isTokenPos2Excluded,
|
||||
normalizeKana,
|
||||
} from './token-classification';
|
||||
|
||||
const POS1_EXCLUSIONS = new Set(['助詞']);
|
||||
@@ -29,6 +30,26 @@ function makeNoun(surface: string): MergedToken {
|
||||
};
|
||||
}
|
||||
|
||||
test('kana normalization folds halfwidth kana, composing the voiced pairs', () => {
|
||||
// カ + ゙ is two code points for one character: without composing them, a
|
||||
// halfwidth word counts as longer than the reading that spells it, which
|
||||
// disqualifies the reading from known-word matching.
|
||||
assert.equal(normalizeKana('ガク'), normalizeKana('ガク'));
|
||||
assert.equal(normalizeKana('パン'), normalizeKana('パン'));
|
||||
assert.equal(normalizeKana('ミナト'), 'みなと');
|
||||
assert.ok(isKanaOnlyText('ガク'));
|
||||
});
|
||||
|
||||
test('kana normalization leaves characters other than halfwidth kana alone', () => {
|
||||
// The composition is scoped to the halfwidth runs: applied to the whole
|
||||
// string, NFKC would also rewrite these into something the dictionary, the
|
||||
// known-word list, and the frequency data were never keyed on.
|
||||
assert.equal(normalizeKana('①ガ'), '①が');
|
||||
assert.equal(normalizeKana('Aガ'), 'Aが');
|
||||
assert.equal(normalizeKana('㍑ガ'), '㍑が');
|
||||
assert.equal(normalizeKana('fiガ'), 'fiが');
|
||||
});
|
||||
|
||||
test('kana classification excludes the katakana-hiragana double hyphen', () => {
|
||||
assert.equal(isKanaChar('゠'), false);
|
||||
assert.equal(isKanaOnlyText('゠'), false);
|
||||
|
||||
@@ -4,8 +4,20 @@ const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
|
||||
const KATAKANA_CODEPOINT_START = 0x30a1;
|
||||
const KATAKANA_CODEPOINT_END = 0x30f6;
|
||||
|
||||
// No `u` flag: the range is entirely BMP so it changes nothing here, and
|
||||
// Bun's unicode-mode matcher mis-handles this class next to certain ligatures.
|
||||
const HALFWIDTH_KANA_RUN = /[\uff66-\uff9f]+/g;
|
||||
|
||||
// NFKC over the halfwidth kana only, never the whole string: it composes the
|
||||
// voiced pairs (カ + ゙) into single characters so ガク compares equal to ガク
|
||||
// instead of counting one character longer than the word it spells, but run
|
||||
// over everything it would also rewrite unrelated text (① → 1, ㍑ → リットル).
|
||||
function composeHalfwidthKana(text: string): string {
|
||||
return text.replace(HALFWIDTH_KANA_RUN, (run) => run.normalize('NFKC'));
|
||||
}
|
||||
|
||||
export function normalizeKana(text: string): string {
|
||||
const raw = text.trim();
|
||||
const raw = composeHalfwidthKana(text).trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Dictionary classification for the injected scan runtime: which dictionaries
|
||||
// an entry came from, and whether it is a SubMiner character entry for the
|
||||
// media being watched. Both walk nested entry data, so both are memoized on the
|
||||
// entry object by the runtime that hosts them.
|
||||
|
||||
import { CHARACTER_DICTIONARY_TITLE_PREFIX } from './character-dictionary-title';
|
||||
|
||||
// The prefix is interpolated into generated regex source, so metacharacters in
|
||||
// it would change what the pattern matches (or fail to compile).
|
||||
const ESCAPED_TITLE_PREFIX = CHARACTER_DICTIONARY_TITLE_PREFIX.replace(
|
||||
/[.*+?^${}()|[\]\\]/g,
|
||||
'\\$&',
|
||||
);
|
||||
const TITLE_MEDIA_ID_PATTERN = ESCAPED_TITLE_PREFIX + String.raw`[^\d]*(?:AniList\s*)?(\d+)`;
|
||||
|
||||
export const YOMITAN_DICTIONARY_CLASSIFICATION_HELPERS = String.raw`
|
||||
function normalizeWordClasses(headword) {
|
||||
if (!Array.isArray(headword?.wordClasses)) { return undefined; }
|
||||
const classes = headword.wordClasses.filter((wordClass) => typeof wordClass === "string" && wordClass.trim().length > 0);
|
||||
return classes.length > 0 ? classes : undefined;
|
||||
}
|
||||
function appendDictionaryNames(target, value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
const candidates = [
|
||||
value.dictionary,
|
||||
value.dictionaryName,
|
||||
value.name,
|
||||
value.title,
|
||||
value.dictionaryTitle,
|
||||
value.dictionaryAlias
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
target.push(candidate.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Memoized on the entry object: termsFind results are cached across
|
||||
// lines, so the same entries come back for every repeated lookup, and
|
||||
// each one is classified several times per scan (name pre-pass,
|
||||
// headword preference, every retry window).
|
||||
function getDictionaryEntryNames(entry) {
|
||||
if (!entry || typeof entry !== 'object') { return []; }
|
||||
const cached = dictionaryEntryNamesCache.get(entry);
|
||||
if (cached !== undefined) { return cached; }
|
||||
const names = [];
|
||||
appendDictionaryNames(names, entry);
|
||||
for (const definition of entry?.definitions || []) {
|
||||
appendDictionaryNames(names, definition);
|
||||
}
|
||||
for (const frequency of entry?.frequencies || []) {
|
||||
appendDictionaryNames(names, frequency);
|
||||
}
|
||||
for (const pronunciation of entry?.pronunciations || []) {
|
||||
appendDictionaryNames(names, pronunciation);
|
||||
}
|
||||
dictionaryEntryNamesCache.set(entry, names);
|
||||
return names;
|
||||
}
|
||||
// Cached per scan rather than per runtime: the answer depends on
|
||||
// includeNameMatchMetadata, which is a per-call parameter.
|
||||
const nameDictionaryEntryCache = new WeakMap();
|
||||
function isNameDictionaryEntry(entry) {
|
||||
if (!includeNameMatchMetadata || !entry || typeof entry !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const cached = nameDictionaryEntryCache.get(entry);
|
||||
if (cached !== undefined) { return cached; }
|
||||
const isName = getDictionaryEntryNames(entry).some((name) => name.startsWith(${JSON.stringify(CHARACTER_DICTIONARY_TITLE_PREFIX)}));
|
||||
nameDictionaryEntryCache.set(entry, isName);
|
||||
return isName;
|
||||
}
|
||||
const TITLE_MEDIA_ID_REGEX = new RegExp(${JSON.stringify(TITLE_MEDIA_ID_PATTERN)}, 'i');
|
||||
function parseSubMinerMediaIdFromString(value) {
|
||||
const imageMatch = value.match(/\bimg\/m(\d+)-/i);
|
||||
if (imageMatch) {
|
||||
const parsed = Number.parseInt(imageMatch[1], 10);
|
||||
if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; }
|
||||
}
|
||||
const titleMatch = value.match(TITLE_MEDIA_ID_REGEX);
|
||||
if (titleMatch) {
|
||||
const parsed = Number.parseInt(titleMatch[1], 10);
|
||||
if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function parseSubMinerMediaIdCandidate(value) {
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function collectSubMinerMediaIds(value, target) {
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseSubMinerMediaIdFromString(value);
|
||||
if (parsed !== null) { target.add(parsed); }
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) { collectSubMinerMediaIds(item, target); }
|
||||
return;
|
||||
}
|
||||
const mediaIdCandidates = [
|
||||
value.subminerMediaId,
|
||||
value.subMinerMediaId,
|
||||
value.characterDictionaryMediaId,
|
||||
value.data?.subminerMediaId,
|
||||
value.data?.subMinerMediaId,
|
||||
value.data?.characterDictionaryMediaId
|
||||
];
|
||||
for (const candidate of mediaIdCandidates) {
|
||||
const parsed = parseSubMinerMediaIdCandidate(candidate);
|
||||
if (parsed !== null) { target.add(parsed); }
|
||||
}
|
||||
for (const child of Object.values(value)) {
|
||||
collectSubMinerMediaIds(child, target);
|
||||
}
|
||||
}
|
||||
// Walking an entry collects media ids from every nested value, so this
|
||||
// is the most expensive classification step; memoized on the entry for
|
||||
// the same reason as the dictionary names above.
|
||||
function getSubMinerMediaIds(entry) {
|
||||
if (!entry || typeof entry !== 'object') { return EMPTY_MEDIA_ID_SET; }
|
||||
const cached = subMinerMediaIdsCache.get(entry);
|
||||
if (cached !== undefined) { return cached; }
|
||||
const mediaIds = new Set();
|
||||
collectSubMinerMediaIds(entry, mediaIds);
|
||||
subMinerMediaIdsCache.set(entry, mediaIds);
|
||||
return mediaIds;
|
||||
}
|
||||
function isCurrentMediaNameDictionaryEntry(entry) {
|
||||
if (!isNameDictionaryEntry(entry)) {
|
||||
return false;
|
||||
}
|
||||
if (currentCharacterDictionaryMediaId === null) {
|
||||
return true;
|
||||
}
|
||||
const mediaIds = getSubMinerMediaIds(entry);
|
||||
return mediaIds.size === 0 || mediaIds.has(currentCharacterDictionaryMediaId);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,135 @@
|
||||
// Frequency-rank resolution for the injected scan runtime: reads the many
|
||||
// shapes a Yomitan frequency entry can take and picks the best rank for a
|
||||
// headword, honouring per-dictionary priority and occurrence-vs-rank mode.
|
||||
|
||||
export const YOMITAN_FREQUENCY_HELPERS = String.raw`
|
||||
function parsePositiveFrequencyNumber(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const numericMatch = value.trim().match(/[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/)?.[0];
|
||||
if (!numericMatch) { return null; }
|
||||
const parsed = Number.parseFloat(numericMatch);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) { return null; }
|
||||
return Math.max(1, Math.floor(parsed));
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const parsed = parsePositiveFrequencyNumber(item);
|
||||
if (parsed !== null) { return parsed; }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function parseDisplayFrequencyNumber(value) {
|
||||
if (typeof value === 'string') {
|
||||
const leadingDigits = value.trim().match(/^\d+/)?.[0];
|
||||
if (!leadingDigits) { return null; }
|
||||
const parsed = Number.parseInt(leadingDigits, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
return parsePositiveFrequencyNumber(value);
|
||||
}
|
||||
function getFrequencyDictionaryName(frequency) {
|
||||
const candidates = [
|
||||
frequency?.dictionary,
|
||||
frequency?.dictionaryName,
|
||||
frequency?.name,
|
||||
frequency?.title,
|
||||
frequency?.dictionaryTitle,
|
||||
frequency?.dictionaryAlias
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function getBestFrequencyRank(dictionaryEntry, headwordIndex, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
|
||||
let best = null;
|
||||
const headwordCount = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords.length : 0;
|
||||
for (const frequency of dictionaryEntry?.frequencies || []) {
|
||||
if (!frequency || typeof frequency !== 'object') { continue; }
|
||||
const frequencyHeadwordIndex = frequency.headwordIndex;
|
||||
if (typeof frequencyHeadwordIndex === 'number') {
|
||||
if (frequencyHeadwordIndex !== headwordIndex) { continue; }
|
||||
} else if (headwordCount > 1) {
|
||||
continue;
|
||||
}
|
||||
const dictionary = getFrequencyDictionaryName(frequency);
|
||||
if (!dictionary) { continue; }
|
||||
if (dictionaryFrequencyModeByName[dictionary] === 'occurrence-based') { continue; }
|
||||
const rank =
|
||||
parseDisplayFrequencyNumber(frequency.displayValue) ??
|
||||
parsePositiveFrequencyNumber(frequency.frequency);
|
||||
if (rank === null) { continue; }
|
||||
const priorityRaw = dictionaryPriorityByName[dictionary];
|
||||
const fallbackPriority =
|
||||
typeof frequency.dictionaryIndex === 'number' && Number.isFinite(frequency.dictionaryIndex)
|
||||
? Math.max(0, Math.floor(frequency.dictionaryIndex))
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const priority =
|
||||
typeof priorityRaw === 'number' && Number.isFinite(priorityRaw)
|
||||
? Math.max(0, Math.floor(priorityRaw))
|
||||
: fallbackPriority;
|
||||
if (best === null || priority < best.priority || (priority === best.priority && rank < best.rank)) {
|
||||
best = { priority, rank };
|
||||
}
|
||||
}
|
||||
return best?.rank ?? null;
|
||||
}
|
||||
function hasExactSource(headword, token, requirePrimary) {
|
||||
for (const src of headword?.sources || []) {
|
||||
if (src.originalText !== token) { continue; }
|
||||
if (requirePrimary && !src.isPrimary) { continue; }
|
||||
if (src.matchType !== 'exact') { continue; }
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function collectExactHeadwordMatches(dictionaryEntries, token, requirePrimary) {
|
||||
const matches = [];
|
||||
for (const dictionaryEntry of dictionaryEntries || []) {
|
||||
const headwords = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords : [];
|
||||
for (let headwordIndex = 0; headwordIndex < headwords.length; headwordIndex += 1) {
|
||||
const headword = headwords[headwordIndex];
|
||||
if (!hasExactSource(headword, token, requirePrimary)) { continue; }
|
||||
matches.push({ dictionaryEntry, headword, headwordIndex });
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
function sameHeadword(match, preferredMatch) {
|
||||
if (!match || !preferredMatch) {
|
||||
return false;
|
||||
}
|
||||
if (match.headword?.term !== preferredMatch.headword?.term) {
|
||||
return false;
|
||||
}
|
||||
const matchReading = typeof match.headword?.reading === 'string' ? match.headword.reading : '';
|
||||
const preferredReading =
|
||||
typeof preferredMatch.headword?.reading === 'string' ? preferredMatch.headword.reading : '';
|
||||
if (!matchReading || !preferredReading) {
|
||||
return true;
|
||||
}
|
||||
return matchReading === preferredReading;
|
||||
}
|
||||
function getBestFrequencyRankForMatches(matches, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
|
||||
let best = null;
|
||||
for (const match of matches) {
|
||||
const rank = getBestFrequencyRank(
|
||||
match.dictionaryEntry,
|
||||
match.headwordIndex,
|
||||
dictionaryPriorityByName,
|
||||
dictionaryFrequencyModeByName
|
||||
);
|
||||
if (rank === null) { continue; }
|
||||
if (best === null || rank < best) {
|
||||
best = rank;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,170 @@
|
||||
// Furigana distribution for the injected scan runtime: splits a headword and
|
||||
// its reading into the segments a token carries, including the inflected case
|
||||
// where the matched source text differs from the dictionary form.
|
||||
|
||||
export const YOMITAN_FURIGANA_HELPERS = String.raw`
|
||||
function createFuriganaSegment(text, reading) { return {text, reading}; }
|
||||
function getSegmentReadingContribution(segment) {
|
||||
if (typeof segment.reading === "string" && segment.reading.length > 0) { return segment.reading; }
|
||||
const segmentText = typeof segment.text === "string" ? segment.text : "";
|
||||
const isKanaOnly = segmentText.length > 0 && [...segmentText].every((char) => isCodePointKana(char.codePointAt(0)));
|
||||
return isKanaOnly ? convertHalfwidthKanaToKatakana(segmentText) : "";
|
||||
}
|
||||
function getProlongedHiragana(previousCharacter) {
|
||||
switch (previousCharacter) {
|
||||
case "あ": case "か": case "が": case "さ": case "ざ": case "た": case "だ": case "な": case "は": case "ば": case "ぱ": case "ま": case "や": case "ら": case "わ": case "ぁ": case "ゃ": case "ゎ": return "あ";
|
||||
case "い": case "き": case "ぎ": case "し": case "じ": case "ち": case "ぢ": case "に": case "ひ": case "び": case "ぴ": case "み": case "り": case "ぃ": return "い";
|
||||
case "う": case "く": case "ぐ": case "す": case "ず": case "つ": case "づ": case "ぬ": case "ふ": case "ぶ": case "ぷ": case "む": case "ゆ": case "る": case "ぅ": case "ゅ": return "う";
|
||||
case "え": case "け": case "げ": case "せ": case "ぜ": case "て": case "で": case "ね": case "へ": case "べ": case "ぺ": case "め": case "れ": case "ぇ": return "え";
|
||||
case "お": case "こ": case "ご": case "そ": case "ぞ": case "と": case "ど": case "の": case "ほ": case "ぼ": case "ぽ": case "も": case "よ": case "ろ": case "を": case "ぉ": case "ょ": return "う";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
function getFuriganaKanaSegments(text, reading) {
|
||||
const newSegments = [];
|
||||
let start = 0;
|
||||
let state = (reading[0] === text[0]);
|
||||
for (let i = 1; i < text.length; ++i) {
|
||||
const newState = (reading[i] === text[i]);
|
||||
if (state === newState) { continue; }
|
||||
newSegments.push(createFuriganaSegment(text.substring(start, i), state ? '' : reading.substring(start, i)));
|
||||
state = newState;
|
||||
start = i;
|
||||
}
|
||||
newSegments.push(createFuriganaSegment(text.substring(start), state ? '' : reading.substring(start)));
|
||||
return newSegments;
|
||||
}
|
||||
function convertKatakanaToHiragana(text, keepProlongedSoundMarks = false) {
|
||||
let result = '';
|
||||
const offset = (HIRAGANA_CONVERSION_RANGE[0] - KATAKANA_CONVERSION_RANGE[0]);
|
||||
for (let char of text) {
|
||||
const codePoint = char.codePointAt(0);
|
||||
switch (codePoint) {
|
||||
case KATAKANA_SMALL_KA_CODE_POINT:
|
||||
case KATAKANA_SMALL_KE_CODE_POINT:
|
||||
break;
|
||||
case KANA_PROLONGED_SOUND_MARK_CODE_POINT:
|
||||
case HALFWIDTH_KANA_PROLONGED_SOUND_MARK_CODE_POINT:
|
||||
char = "ー";
|
||||
if (!keepProlongedSoundMarks && result.length > 0) {
|
||||
const char2 = getProlongedHiragana(result[result.length - 1]);
|
||||
if (char2 !== null) { char = char2; }
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (isCodePointInRange(codePoint, KATAKANA_CONVERSION_RANGE)) {
|
||||
char = String.fromCodePoint(codePoint + offset);
|
||||
break;
|
||||
}
|
||||
// Halfwidth katakana folds too, or a name written that way would
|
||||
// match neither a candidate form nor its own reading.
|
||||
const halfwidthHiragana = convertHalfwidthKanaCodePointToHiragana(codePoint);
|
||||
if (halfwidthHiragana !== null) { char = halfwidthHiragana; }
|
||||
break;
|
||||
}
|
||||
result += char;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function segmentizeFurigana(reading, readingNormalized, groups, groupsStart) {
|
||||
const groupCount = groups.length - groupsStart;
|
||||
if (groupCount <= 0) { return reading.length === 0 ? [] : null; }
|
||||
const group = groups[groupsStart];
|
||||
const {isKana, text} = group;
|
||||
if (isKana) {
|
||||
if (group.textNormalized !== null && readingNormalized.startsWith(group.textNormalized)) {
|
||||
const segments = segmentizeFurigana(reading.substring(text.length), readingNormalized.substring(text.length), groups, groupsStart + 1);
|
||||
if (segments !== null) {
|
||||
if (reading.startsWith(text)) { segments.unshift(createFuriganaSegment(text, '')); }
|
||||
else { segments.unshift(...getFuriganaKanaSegments(text, reading)); }
|
||||
return segments;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
let result = null;
|
||||
for (let i = reading.length; i >= text.length; --i) {
|
||||
const segments = segmentizeFurigana(reading.substring(i), readingNormalized.substring(i), groups, groupsStart + 1);
|
||||
if (segments !== null) {
|
||||
if (result !== null) { return null; }
|
||||
segments.unshift(createFuriganaSegment(text, reading.substring(0, i)));
|
||||
result = segments;
|
||||
}
|
||||
if (groupCount === 1) { break; }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function distributeFurigana(term, reading) {
|
||||
if (reading === term) { return [createFuriganaSegment(term, '')]; }
|
||||
const groups = [];
|
||||
let groupPre = null;
|
||||
let isKanaPre = null;
|
||||
for (const c of term) {
|
||||
const isKana = isCodePointKana(c.codePointAt(0));
|
||||
if (isKana === isKanaPre) { groupPre.text += c; }
|
||||
else {
|
||||
groupPre = {isKana, text: c, textNormalized: null};
|
||||
groups.push(groupPre);
|
||||
isKanaPre = isKana;
|
||||
}
|
||||
}
|
||||
for (const group of groups) {
|
||||
if (group.isKana) { group.textNormalized = convertKatakanaToHiragana(group.text); }
|
||||
}
|
||||
const segments = segmentizeFurigana(reading, convertKatakanaToHiragana(reading), groups, 0);
|
||||
return segments !== null ? segments : [createFuriganaSegment(term, reading)];
|
||||
}
|
||||
function getStemLength(text1, text2) {
|
||||
const minLength = Math.min(text1.length, text2.length);
|
||||
if (minLength === 0) { return 0; }
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const char1 = text1.codePointAt(i);
|
||||
const char2 = text2.codePointAt(i);
|
||||
if (char1 !== char2) { break; }
|
||||
const charLength = String.fromCodePoint(char1).length;
|
||||
i += charLength;
|
||||
if (i >= minLength) {
|
||||
if (i > minLength) { i -= charLength; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
return i;
|
||||
}
|
||||
function distributeFuriganaInflected(term, reading, source) {
|
||||
const termNormalized = convertKatakanaToHiragana(term);
|
||||
const readingNormalized = convertKatakanaToHiragana(reading);
|
||||
const sourceNormalized = convertKatakanaToHiragana(source);
|
||||
let mainText = term;
|
||||
let stemLength = getStemLength(termNormalized, sourceNormalized);
|
||||
const readingStemLength = getStemLength(readingNormalized, sourceNormalized);
|
||||
if (readingStemLength > 0 && readingStemLength >= stemLength) {
|
||||
mainText = reading;
|
||||
stemLength = readingStemLength;
|
||||
reading = source.substring(0, stemLength) + reading.substring(stemLength);
|
||||
}
|
||||
const segments = [];
|
||||
if (stemLength > 0) {
|
||||
mainText = source.substring(0, stemLength) + mainText.substring(stemLength);
|
||||
const segments2 = distributeFurigana(mainText, reading);
|
||||
let consumed = 0;
|
||||
for (const segment of segments2) {
|
||||
const start = consumed;
|
||||
consumed += segment.text.length;
|
||||
if (consumed < stemLength) { segments.push(segment); }
|
||||
else if (consumed === stemLength) { segments.push(segment); break; }
|
||||
else {
|
||||
if (start < stemLength) { segments.push(createFuriganaSegment(mainText.substring(start, stemLength), '')); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stemLength < source.length) {
|
||||
const remainder = source.substring(stemLength);
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last.reading.length === 0) { last.text += remainder; }
|
||||
else { segments.push(createFuriganaSegment(remainder, '')); }
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,45 @@
|
||||
// Kana classification and normalization for the injected scan runtime: the
|
||||
// code-point ranges the walk tests every character against, and the folds that
|
||||
// let halfwidth and katakana spellings compare equal to their dictionary form.
|
||||
import { HAN_CODE_POINT_RANGES } from '../../text/han-code-points';
|
||||
|
||||
export const YOMITAN_KANA_HELPERS = String.raw`
|
||||
const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096];
|
||||
const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6];
|
||||
const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc;
|
||||
const KATAKANA_SMALL_KA_CODE_POINT = 0x30f5;
|
||||
const KATAKANA_SMALL_KE_CODE_POINT = 0x30f6;
|
||||
const KANA_RANGES = [[0x3040, 0x309f], [0x30a0, 0x30ff], [0xff66, 0xff9f]];
|
||||
const HALFWIDTH_KATAKANA_RANGE = [0xff66, 0xff9d];
|
||||
const HALFWIDTH_KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0xff70;
|
||||
// Folded one code point to one, so every index into a normalized string
|
||||
// still lines up with the original text — the name-candidate prefilter
|
||||
// and the furigana stem matching both index back into it. The standalone
|
||||
// voiced marks (゙ ゚) have no one-character equivalent and stay as they are.
|
||||
const HALFWIDTH_KATAKANA_TO_HIRAGANA = "をぁぃぅぇぉゃゅょっーあいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわん";
|
||||
function convertHalfwidthKanaCodePointToHiragana(codePoint) {
|
||||
if (codePoint < HALFWIDTH_KATAKANA_RANGE[0] || codePoint > HALFWIDTH_KATAKANA_RANGE[1]) { return null; }
|
||||
return HALFWIDTH_KATAKANA_TO_HIRAGANA[codePoint - HALFWIDTH_KATAKANA_RANGE[0]] || null;
|
||||
}
|
||||
// Halfwidth katakana is kana here but not to the rest of the pipeline
|
||||
// (known-word matching and frequency lookups only fold fullwidth), so a
|
||||
// reading taken from halfwidth text is written the way the fullwidth
|
||||
// katakana path already writes it. NFKC rather than the per-code-point
|
||||
// table: this is the one place where nothing indexes back into the
|
||||
// result, so a voiced pair (カ + ゙) can compose into the single ガ it
|
||||
// means instead of leaving a stray combining mark in the reading. Scoped
|
||||
// to the halfwidth runs, because NFKC over everything else rewrites
|
||||
// characters that have nothing to do with kana (① → 1, ㍑ → リットル).
|
||||
function convertHalfwidthKanaToKatakana(text) {
|
||||
return text.replace(/[ヲ-゚]+/g, (run) => run.normalize("NFKC"));
|
||||
}
|
||||
// Han ranges come from the shared table so the scan walk and the character
|
||||
// dictionary agree on what a kanji is (supplementary planes included).
|
||||
// Halfwidth katakana counts as Japanese text: a name written that way has
|
||||
// to reach the greedy pre-pass, which has its own handling for it.
|
||||
const JAPANESE_RANGES = [[0x3040, 0x30ff], [0xff66, 0xff9f], ...${JSON.stringify(HAN_CODE_POINT_RANGES)}];
|
||||
function isCodePointInRange(codePoint, range) { return codePoint >= range[0] && codePoint <= range[1]; }
|
||||
function isCodePointInRanges(codePoint, ranges) { return ranges.some((range) => isCodePointInRange(codePoint, range)); }
|
||||
function isCodePointKana(codePoint) { return isCodePointInRanges(codePoint, KANA_RANGES); }
|
||||
function isCodePointJapanese(codePoint) { return isCodePointInRanges(codePoint, JAPANESE_RANGES); }
|
||||
`;
|
||||
@@ -0,0 +1,79 @@
|
||||
// Match selection for the injected scan runtime: picks the headword a position
|
||||
// tokenizes to, and the longest name or generic match in a window, which is how
|
||||
// the greedy name pre-pass decides what to reserve.
|
||||
|
||||
export const YOMITAN_MATCH_SELECTION_HELPERS = String.raw`
|
||||
function findLongestNameMatch(dictionaryEntries, textWindow) {
|
||||
let best = null;
|
||||
for (const dictionaryEntry of dictionaryEntries || []) {
|
||||
if (!isCurrentMediaNameDictionaryEntry(dictionaryEntry)) { continue; }
|
||||
const headwords = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords : [];
|
||||
for (let headwordIndex = 0; headwordIndex < headwords.length; headwordIndex += 1) {
|
||||
const headword = headwords[headwordIndex];
|
||||
for (const src of headword?.sources || []) {
|
||||
if (src.matchType !== 'exact' || src.isPrimary !== true) { continue; }
|
||||
const originalText = typeof src.originalText === 'string' ? src.originalText : '';
|
||||
if (!originalText || !textWindow.startsWith(originalText)) { continue; }
|
||||
if (best === null || originalText.length > best.sourceLength) {
|
||||
best = { dictionaryEntry, headword, headwordIndex, sourceLength: originalText.length };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function findLongestGenericMatchLength(dictionaryEntries, textWindow) {
|
||||
let best = 0;
|
||||
for (const dictionaryEntry of dictionaryEntries || []) {
|
||||
if (isNameDictionaryEntry(dictionaryEntry)) { continue; }
|
||||
const headwords = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords : [];
|
||||
for (const headword of headwords) {
|
||||
for (const src of headword?.sources || []) {
|
||||
if (src.matchType !== 'exact' || src.isPrimary !== true) { continue; }
|
||||
const originalText = typeof src.originalText === 'string' ? src.originalText : '';
|
||||
if (!originalText || !textWindow.startsWith(originalText)) { continue; }
|
||||
if (originalText.length > best) { best = originalText.length; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function getPreferredHeadword(dictionaryEntries, token, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
|
||||
const currentMediaDictionaryEntries =
|
||||
currentCharacterDictionaryMediaId === null
|
||||
? (dictionaryEntries || [])
|
||||
: (dictionaryEntries || []).filter((entry) => {
|
||||
if (!isNameDictionaryEntry(entry)) { return true; }
|
||||
return isCurrentMediaNameDictionaryEntry(entry);
|
||||
});
|
||||
const exactPrimaryMatches = collectExactHeadwordMatches(currentMediaDictionaryEntries, token, true);
|
||||
let matchedNameDictionary = false;
|
||||
if (includeNameMatchMetadata) {
|
||||
// Every match already comes from currentMediaDictionaryEntries, so
|
||||
// classifying its own entry is enough.
|
||||
for (const match of exactPrimaryMatches) {
|
||||
if (!isCurrentMediaNameDictionaryEntry(match.dictionaryEntry)) { continue; }
|
||||
matchedNameDictionary = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const preferredMatch = exactPrimaryMatches[0];
|
||||
if (preferredMatch) {
|
||||
const exactFrequencyMatches = collectExactHeadwordMatches(currentMediaDictionaryEntries, token, false)
|
||||
.filter((match) => sameHeadword(match, preferredMatch));
|
||||
return {
|
||||
term: preferredMatch.headword.term,
|
||||
reading: preferredMatch.headword.reading,
|
||||
wordClasses: normalizeWordClasses(preferredMatch.headword),
|
||||
isNameMatch:
|
||||
matchedNameDictionary || isCurrentMediaNameDictionaryEntry(preferredMatch.dictionaryEntry),
|
||||
frequencyRank: getBestFrequencyRankForMatches(
|
||||
exactFrequencyMatches.length > 0 ? exactFrequencyMatches : exactPrimaryMatches,
|
||||
dictionaryPriorityByName,
|
||||
dictionaryFrequencyModeByName
|
||||
)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,14 @@ import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as path from 'path';
|
||||
import { selectYomitanParseTokens } from './parser-selection-stage';
|
||||
import {
|
||||
buildYomitanScanCallScript,
|
||||
buildYomitanScanNameCandidatesScript,
|
||||
CHARACTER_DICTIONARY_TITLE_PREFIX,
|
||||
YOMITAN_SCAN_RUNTIME_INSTALL_SCRIPT,
|
||||
YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL,
|
||||
type YomitanFrequencyMode,
|
||||
} from './yomitan-scan-runtime-script';
|
||||
|
||||
interface LoggerLike {
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
@@ -22,8 +30,6 @@ interface YomitanParserRuntimeDeps {
|
||||
createYomitanExtensionWindow?: (pageName: string) => Promise<BrowserWindow | null>;
|
||||
}
|
||||
|
||||
type YomitanFrequencyMode = 'occurrence-based' | 'rank-based';
|
||||
|
||||
export interface YomitanDictionaryInfo {
|
||||
title: string;
|
||||
revision?: string | number;
|
||||
@@ -74,13 +80,19 @@ export interface YomitanAddNoteResult {
|
||||
}
|
||||
|
||||
const DEFAULT_YOMITAN_SCAN_LENGTH = 40;
|
||||
const CHARACTER_DICTIONARY_TITLE_PREFIX = 'SubMiner Character Dictionary';
|
||||
const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>();
|
||||
const yomitanProfileDiagnosticsLoggedByWindow = new WeakSet<BrowserWindow>();
|
||||
const yomitanFrequencyCacheByWindow = new WeakMap<
|
||||
BrowserWindow,
|
||||
Map<string, YomitanTermFrequency[]>
|
||||
>();
|
||||
// Epoch passed with every scan request; the in-window termsFind cache clears
|
||||
// itself when the epoch changes (dictionary imports, settings changes).
|
||||
const yomitanScanCacheEpochByWindow = new WeakMap<BrowserWindow, number>();
|
||||
|
||||
function getYomitanScanCacheEpoch(window: BrowserWindow): number {
|
||||
return yomitanScanCacheEpochByWindow.get(window) ?? 0;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object');
|
||||
@@ -99,6 +111,7 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
|
||||
typeof entry.startPos === 'number' &&
|
||||
typeof entry.endPos === 'number' &&
|
||||
(entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean') &&
|
||||
(entry.isUnparsedRun === undefined || typeof entry.isUnparsedRun === 'boolean') &&
|
||||
(entry.frequencyRank === undefined || typeof entry.frequencyRank === 'number') &&
|
||||
(entry.wordClasses === undefined ||
|
||||
(Array.isArray(entry.wordClasses) &&
|
||||
@@ -107,13 +120,9 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
|
||||
);
|
||||
}
|
||||
|
||||
function scanTokenSpanKey(token: YomitanScanToken): string {
|
||||
return `${token.startPos}:${token.endPos}:${token.surface}`;
|
||||
}
|
||||
|
||||
// Maps a parse-selected token to the scanner-token shape carried out of the
|
||||
// parser runtime. Shared by both selectYomitanParseTokens fallback paths so the
|
||||
// projected fields stay in sync as the shape changes.
|
||||
// parser runtime, used by the parseText fallback path when the in-window
|
||||
// scanner is unavailable.
|
||||
function toYomitanScanToken(token: {
|
||||
surface: string;
|
||||
reading: string;
|
||||
@@ -132,66 +141,6 @@ function toYomitanScanToken(token: {
|
||||
};
|
||||
}
|
||||
|
||||
// parseText segmentation is authoritative (it emits filler chunks for text the
|
||||
// termsFind scanner skips), but only the termsFind scanner carries annotation
|
||||
// metadata (isNameMatch, frequencyRank, headwordReading, wordClasses). Graft
|
||||
// scanner tokens onto the parseText segmentation per matching span so one
|
||||
// unmatched chunk degrades only itself instead of dropping the whole line's
|
||||
// metadata.
|
||||
//
|
||||
// Exception: character-name tokens. The greedy name scan can re-segment text
|
||||
// around a name (e.g. とヨータ → と + ヨータ instead of とヨー + タ), so
|
||||
// parseText segmentation cannot be authoritative there. Each name span is
|
||||
// expanded until it aligns with token boundaries in both segmentations, then
|
||||
// the parse tokens inside are replaced with the scanner tokens.
|
||||
function mergeScannerTokensIntoParseTokens(
|
||||
parseScanTokens: YomitanScanToken[],
|
||||
scannerTokens: YomitanScanToken[],
|
||||
): YomitanScanToken[] {
|
||||
const scannerTokensBySpan = new Map<string, YomitanScanToken>();
|
||||
for (const token of scannerTokens) {
|
||||
scannerTokensBySpan.set(scanTokenSpanKey(token), token);
|
||||
}
|
||||
const graftedTokens = parseScanTokens.map(
|
||||
(token) => scannerTokensBySpan.get(scanTokenSpanKey(token)) ?? token,
|
||||
);
|
||||
|
||||
const nameTokens = scannerTokens.filter((token) => token.isNameMatch === true);
|
||||
if (nameTokens.length === 0) {
|
||||
return graftedTokens;
|
||||
}
|
||||
|
||||
const regions = nameTokens.map((token) => ({ start: token.startPos, end: token.endPos }));
|
||||
const allTokens = [...parseScanTokens, ...scannerTokens];
|
||||
let expanded = true;
|
||||
while (expanded) {
|
||||
expanded = false;
|
||||
for (const region of regions) {
|
||||
for (const token of allTokens) {
|
||||
const overlaps = token.startPos < region.end && token.endPos > region.start;
|
||||
const extendsBeyond = token.startPos < region.start || token.endPos > region.end;
|
||||
if (overlaps && extendsBeyond) {
|
||||
region.start = Math.min(region.start, token.startPos);
|
||||
region.end = Math.max(region.end, token.endPos);
|
||||
expanded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isInsideNameRegion = (token: YomitanScanToken): boolean =>
|
||||
regions.some((region) => token.startPos >= region.start && token.endPos <= region.end);
|
||||
|
||||
const merged = graftedTokens.filter((token) => !isInsideNameRegion(token));
|
||||
for (const token of scannerTokens) {
|
||||
if (isInsideNameRegion(token)) {
|
||||
merged.push(token);
|
||||
}
|
||||
}
|
||||
merged.sort((a, b) => a.startPos - b.startPos || a.endPos - b.endPos);
|
||||
return merged;
|
||||
}
|
||||
|
||||
function makeTermReadingCacheKey(term: string, reading: string | null): string {
|
||||
return `${term}\u0000${reading ?? ''}`;
|
||||
}
|
||||
@@ -208,6 +157,7 @@ function getWindowFrequencyCache(window: BrowserWindow): Map<string, YomitanTerm
|
||||
function clearWindowCaches(window: BrowserWindow): void {
|
||||
yomitanProfileMetadataByWindow.delete(window);
|
||||
yomitanFrequencyCacheByWindow.delete(window);
|
||||
yomitanScanCacheEpochByWindow.set(window, getYomitanScanCacheEpoch(window) + 1);
|
||||
}
|
||||
export function clearYomitanParserCachesForWindow(window: BrowserWindow): void {
|
||||
clearWindowCaches(window);
|
||||
@@ -704,6 +654,10 @@ async function ensureYomitanParserWindow(
|
||||
if (readyPromise) {
|
||||
await readyPromise;
|
||||
}
|
||||
// Eagerly install the scan runtime so the first subtitle line does not
|
||||
// pay the install round trip; failures fall back to the per-request
|
||||
// install-and-retry path.
|
||||
await installYomitanScanRuntime(parserWindow).catch(() => {});
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -877,668 +831,42 @@ async function serveDictionaryZipOnce<T>(
|
||||
}
|
||||
}
|
||||
|
||||
const YOMITAN_SCANNING_HELPERS = String.raw`
|
||||
const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096];
|
||||
const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6];
|
||||
const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc;
|
||||
const KATAKANA_SMALL_KA_CODE_POINT = 0x30f5;
|
||||
const KATAKANA_SMALL_KE_CODE_POINT = 0x30f6;
|
||||
const KANA_RANGES = [[0x3040, 0x309f], [0x30a0, 0x30ff]];
|
||||
const JAPANESE_RANGES = [[0x3040, 0x30ff], [0x3400, 0x9fff]];
|
||||
function isCodePointInRange(codePoint, range) { return codePoint >= range[0] && codePoint <= range[1]; }
|
||||
function isCodePointInRanges(codePoint, ranges) { return ranges.some((range) => isCodePointInRange(codePoint, range)); }
|
||||
function isCodePointKana(codePoint) { return isCodePointInRanges(codePoint, KANA_RANGES); }
|
||||
function isCodePointJapanese(codePoint) { return isCodePointInRanges(codePoint, JAPANESE_RANGES); }
|
||||
function createFuriganaSegment(text, reading) { return {text, reading}; }
|
||||
function getSegmentReadingContribution(segment) {
|
||||
if (typeof segment.reading === "string" && segment.reading.length > 0) { return segment.reading; }
|
||||
const segmentText = typeof segment.text === "string" ? segment.text : "";
|
||||
const isKanaOnly = segmentText.length > 0 && [...segmentText].every((char) => isCodePointKana(char.codePointAt(0)));
|
||||
return isKanaOnly ? segmentText : "";
|
||||
}
|
||||
function getProlongedHiragana(previousCharacter) {
|
||||
switch (previousCharacter) {
|
||||
case "あ": case "か": case "が": case "さ": case "ざ": case "た": case "だ": case "な": case "は": case "ば": case "ぱ": case "ま": case "や": case "ら": case "わ": case "ぁ": case "ゃ": case "ゎ": return "あ";
|
||||
case "い": case "き": case "ぎ": case "し": case "じ": case "ち": case "ぢ": case "に": case "ひ": case "び": case "ぴ": case "み": case "り": case "ぃ": return "い";
|
||||
case "う": case "く": case "ぐ": case "す": case "ず": case "つ": case "づ": case "ぬ": case "ふ": case "ぶ": case "ぷ": case "む": case "ゆ": case "る": case "ぅ": case "ゅ": return "う";
|
||||
case "え": case "け": case "げ": case "せ": case "ぜ": case "て": case "で": case "ね": case "へ": case "べ": case "ぺ": case "め": case "れ": case "ぇ": return "え";
|
||||
case "お": case "こ": case "ご": case "そ": case "ぞ": case "と": case "ど": case "の": case "ほ": case "ぼ": case "ぽ": case "も": case "よ": case "ろ": case "を": case "ぉ": case "ょ": return "う";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
function getFuriganaKanaSegments(text, reading) {
|
||||
const newSegments = [];
|
||||
let start = 0;
|
||||
let state = (reading[0] === text[0]);
|
||||
for (let i = 1; i < text.length; ++i) {
|
||||
const newState = (reading[i] === text[i]);
|
||||
if (state === newState) { continue; }
|
||||
newSegments.push(createFuriganaSegment(text.substring(start, i), state ? '' : reading.substring(start, i)));
|
||||
state = newState;
|
||||
start = i;
|
||||
}
|
||||
newSegments.push(createFuriganaSegment(text.substring(start), state ? '' : reading.substring(start)));
|
||||
return newSegments;
|
||||
}
|
||||
function convertKatakanaToHiragana(text, keepProlongedSoundMarks = false) {
|
||||
let result = '';
|
||||
const offset = (HIRAGANA_CONVERSION_RANGE[0] - KATAKANA_CONVERSION_RANGE[0]);
|
||||
for (let char of text) {
|
||||
const codePoint = char.codePointAt(0);
|
||||
switch (codePoint) {
|
||||
case KATAKANA_SMALL_KA_CODE_POINT:
|
||||
case KATAKANA_SMALL_KE_CODE_POINT:
|
||||
break;
|
||||
case KANA_PROLONGED_SOUND_MARK_CODE_POINT:
|
||||
if (!keepProlongedSoundMarks && result.length > 0) {
|
||||
const char2 = getProlongedHiragana(result[result.length - 1]);
|
||||
if (char2 !== null) { char = char2; }
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (isCodePointInRange(codePoint, KATAKANA_CONVERSION_RANGE)) {
|
||||
char = String.fromCodePoint(codePoint + offset);
|
||||
}
|
||||
break;
|
||||
}
|
||||
result += char;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function segmentizeFurigana(reading, readingNormalized, groups, groupsStart) {
|
||||
const groupCount = groups.length - groupsStart;
|
||||
if (groupCount <= 0) { return reading.length === 0 ? [] : null; }
|
||||
const group = groups[groupsStart];
|
||||
const {isKana, text} = group;
|
||||
if (isKana) {
|
||||
if (group.textNormalized !== null && readingNormalized.startsWith(group.textNormalized)) {
|
||||
const segments = segmentizeFurigana(reading.substring(text.length), readingNormalized.substring(text.length), groups, groupsStart + 1);
|
||||
if (segments !== null) {
|
||||
if (reading.startsWith(text)) { segments.unshift(createFuriganaSegment(text, '')); }
|
||||
else { segments.unshift(...getFuriganaKanaSegments(text, reading)); }
|
||||
return segments;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
let result = null;
|
||||
for (let i = reading.length; i >= text.length; --i) {
|
||||
const segments = segmentizeFurigana(reading.substring(i), readingNormalized.substring(i), groups, groupsStart + 1);
|
||||
if (segments !== null) {
|
||||
if (result !== null) { return null; }
|
||||
segments.unshift(createFuriganaSegment(text, reading.substring(0, i)));
|
||||
result = segments;
|
||||
}
|
||||
if (groupCount === 1) { break; }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function distributeFurigana(term, reading) {
|
||||
if (reading === term) { return [createFuriganaSegment(term, '')]; }
|
||||
const groups = [];
|
||||
let groupPre = null;
|
||||
let isKanaPre = null;
|
||||
for (const c of term) {
|
||||
const isKana = isCodePointKana(c.codePointAt(0));
|
||||
if (isKana === isKanaPre) { groupPre.text += c; }
|
||||
else {
|
||||
groupPre = {isKana, text: c, textNormalized: null};
|
||||
groups.push(groupPre);
|
||||
isKanaPre = isKana;
|
||||
}
|
||||
}
|
||||
for (const group of groups) {
|
||||
if (group.isKana) { group.textNormalized = convertKatakanaToHiragana(group.text); }
|
||||
}
|
||||
const segments = segmentizeFurigana(reading, convertKatakanaToHiragana(reading), groups, 0);
|
||||
return segments !== null ? segments : [createFuriganaSegment(term, reading)];
|
||||
}
|
||||
function getStemLength(text1, text2) {
|
||||
const minLength = Math.min(text1.length, text2.length);
|
||||
if (minLength === 0) { return 0; }
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const char1 = text1.codePointAt(i);
|
||||
const char2 = text2.codePointAt(i);
|
||||
if (char1 !== char2) { break; }
|
||||
const charLength = String.fromCodePoint(char1).length;
|
||||
i += charLength;
|
||||
if (i >= minLength) {
|
||||
if (i > minLength) { i -= charLength; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
return i;
|
||||
}
|
||||
function distributeFuriganaInflected(term, reading, source) {
|
||||
const termNormalized = convertKatakanaToHiragana(term);
|
||||
const readingNormalized = convertKatakanaToHiragana(reading);
|
||||
const sourceNormalized = convertKatakanaToHiragana(source);
|
||||
let mainText = term;
|
||||
let stemLength = getStemLength(termNormalized, sourceNormalized);
|
||||
const readingStemLength = getStemLength(readingNormalized, sourceNormalized);
|
||||
if (readingStemLength > 0 && readingStemLength >= stemLength) {
|
||||
mainText = reading;
|
||||
stemLength = readingStemLength;
|
||||
reading = source.substring(0, stemLength) + reading.substring(stemLength);
|
||||
}
|
||||
const segments = [];
|
||||
if (stemLength > 0) {
|
||||
mainText = source.substring(0, stemLength) + mainText.substring(stemLength);
|
||||
const segments2 = distributeFurigana(mainText, reading);
|
||||
let consumed = 0;
|
||||
for (const segment of segments2) {
|
||||
const start = consumed;
|
||||
consumed += segment.text.length;
|
||||
if (consumed < stemLength) { segments.push(segment); }
|
||||
else if (consumed === stemLength) { segments.push(segment); break; }
|
||||
else {
|
||||
if (start < stemLength) { segments.push(createFuriganaSegment(mainText.substring(start, stemLength), '')); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stemLength < source.length) {
|
||||
const remainder = source.substring(stemLength);
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last.reading.length === 0) { last.text += remainder; }
|
||||
else { segments.push(createFuriganaSegment(remainder, '')); }
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
function parsePositiveFrequencyNumber(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const numericMatch = value.trim().match(/[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/)?.[0];
|
||||
if (!numericMatch) { return null; }
|
||||
const parsed = Number.parseFloat(numericMatch);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) { return null; }
|
||||
return Math.max(1, Math.floor(parsed));
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const parsed = parsePositiveFrequencyNumber(item);
|
||||
if (parsed !== null) { return parsed; }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function parseDisplayFrequencyNumber(value) {
|
||||
if (typeof value === 'string') {
|
||||
const leadingDigits = value.trim().match(/^\d+/)?.[0];
|
||||
if (!leadingDigits) { return null; }
|
||||
const parsed = Number.parseInt(leadingDigits, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
return parsePositiveFrequencyNumber(value);
|
||||
}
|
||||
function getFrequencyDictionaryName(frequency) {
|
||||
const candidates = [
|
||||
frequency?.dictionary,
|
||||
frequency?.dictionaryName,
|
||||
frequency?.name,
|
||||
frequency?.title,
|
||||
frequency?.dictionaryTitle,
|
||||
frequency?.dictionaryAlias
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function getBestFrequencyRank(dictionaryEntry, headwordIndex, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
|
||||
let best = null;
|
||||
const headwordCount = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords.length : 0;
|
||||
for (const frequency of dictionaryEntry?.frequencies || []) {
|
||||
if (!frequency || typeof frequency !== 'object') { continue; }
|
||||
const frequencyHeadwordIndex = frequency.headwordIndex;
|
||||
if (typeof frequencyHeadwordIndex === 'number') {
|
||||
if (frequencyHeadwordIndex !== headwordIndex) { continue; }
|
||||
} else if (headwordCount > 1) {
|
||||
continue;
|
||||
}
|
||||
const dictionary = getFrequencyDictionaryName(frequency);
|
||||
if (!dictionary) { continue; }
|
||||
if (dictionaryFrequencyModeByName[dictionary] === 'occurrence-based') { continue; }
|
||||
const rank =
|
||||
parseDisplayFrequencyNumber(frequency.displayValue) ??
|
||||
parsePositiveFrequencyNumber(frequency.frequency);
|
||||
if (rank === null) { continue; }
|
||||
const priorityRaw = dictionaryPriorityByName[dictionary];
|
||||
const fallbackPriority =
|
||||
typeof frequency.dictionaryIndex === 'number' && Number.isFinite(frequency.dictionaryIndex)
|
||||
? Math.max(0, Math.floor(frequency.dictionaryIndex))
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const priority =
|
||||
typeof priorityRaw === 'number' && Number.isFinite(priorityRaw)
|
||||
? Math.max(0, Math.floor(priorityRaw))
|
||||
: fallbackPriority;
|
||||
if (best === null || priority < best.priority || (priority === best.priority && rank < best.rank)) {
|
||||
best = { priority, rank };
|
||||
}
|
||||
}
|
||||
return best?.rank ?? null;
|
||||
}
|
||||
function hasExactSource(headword, token, requirePrimary) {
|
||||
for (const src of headword.sources || []) {
|
||||
if (src.originalText !== token) { continue; }
|
||||
if (requirePrimary && !src.isPrimary) { continue; }
|
||||
if (src.matchType !== 'exact') { continue; }
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function collectExactHeadwordMatches(dictionaryEntries, token, requirePrimary) {
|
||||
const matches = [];
|
||||
for (const dictionaryEntry of dictionaryEntries || []) {
|
||||
const headwords = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords : [];
|
||||
for (let headwordIndex = 0; headwordIndex < headwords.length; headwordIndex += 1) {
|
||||
const headword = headwords[headwordIndex];
|
||||
if (!hasExactSource(headword, token, requirePrimary)) { continue; }
|
||||
matches.push({ dictionaryEntry, headword, headwordIndex });
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
function sameHeadword(match, preferredMatch) {
|
||||
if (!match || !preferredMatch) {
|
||||
return false;
|
||||
}
|
||||
if (match.headword?.term !== preferredMatch.headword?.term) {
|
||||
return false;
|
||||
}
|
||||
const matchReading = typeof match.headword?.reading === 'string' ? match.headword.reading : '';
|
||||
const preferredReading =
|
||||
typeof preferredMatch.headword?.reading === 'string' ? preferredMatch.headword.reading : '';
|
||||
if (!matchReading || !preferredReading) {
|
||||
return true;
|
||||
}
|
||||
return matchReading === preferredReading;
|
||||
}
|
||||
function getBestFrequencyRankForMatches(matches, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
|
||||
let best = null;
|
||||
for (const match of matches) {
|
||||
const rank = getBestFrequencyRank(
|
||||
match.dictionaryEntry,
|
||||
match.headwordIndex,
|
||||
dictionaryPriorityByName,
|
||||
dictionaryFrequencyModeByName
|
||||
);
|
||||
if (rank === null) { continue; }
|
||||
if (best === null || rank < best) {
|
||||
best = rank;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function normalizeWordClasses(headword) {
|
||||
if (!Array.isArray(headword?.wordClasses)) { return undefined; }
|
||||
const classes = headword.wordClasses.filter((wordClass) => typeof wordClass === "string" && wordClass.trim().length > 0);
|
||||
return classes.length > 0 ? classes : undefined;
|
||||
}
|
||||
function appendDictionaryNames(target, value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
const candidates = [
|
||||
value.dictionary,
|
||||
value.dictionaryName,
|
||||
value.name,
|
||||
value.title,
|
||||
value.dictionaryTitle,
|
||||
value.dictionaryAlias
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
target.push(candidate.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
function getDictionaryEntryNames(entry) {
|
||||
const names = [];
|
||||
appendDictionaryNames(names, entry);
|
||||
for (const definition of entry?.definitions || []) {
|
||||
appendDictionaryNames(names, definition);
|
||||
}
|
||||
for (const frequency of entry?.frequencies || []) {
|
||||
appendDictionaryNames(names, frequency);
|
||||
}
|
||||
for (const pronunciation of entry?.pronunciations || []) {
|
||||
appendDictionaryNames(names, pronunciation);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
function isNameDictionaryEntry(entry) {
|
||||
if (!includeNameMatchMetadata || !entry || typeof entry !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return getDictionaryEntryNames(entry).some((name) => name.startsWith(${JSON.stringify(CHARACTER_DICTIONARY_TITLE_PREFIX)}));
|
||||
}
|
||||
function parseSubMinerMediaIdFromString(value) {
|
||||
const imageMatch = value.match(/\bimg\/m(\d+)-/i);
|
||||
if (imageMatch) {
|
||||
const parsed = Number.parseInt(imageMatch[1], 10);
|
||||
if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; }
|
||||
}
|
||||
const titleMatch = value.match(/${CHARACTER_DICTIONARY_TITLE_PREFIX}[^\d]*(?:AniList\s*)?(\d+)/i);
|
||||
if (titleMatch) {
|
||||
const parsed = Number.parseInt(titleMatch[1], 10);
|
||||
if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function parseSubMinerMediaIdCandidate(value) {
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function collectSubMinerMediaIds(value, target) {
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseSubMinerMediaIdFromString(value);
|
||||
if (parsed !== null) { target.add(parsed); }
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) { collectSubMinerMediaIds(item, target); }
|
||||
return;
|
||||
}
|
||||
const mediaIdCandidates = [
|
||||
value.subminerMediaId,
|
||||
value.subMinerMediaId,
|
||||
value.characterDictionaryMediaId,
|
||||
value.data?.subminerMediaId,
|
||||
value.data?.subMinerMediaId,
|
||||
value.data?.characterDictionaryMediaId
|
||||
];
|
||||
for (const candidate of mediaIdCandidates) {
|
||||
const parsed = parseSubMinerMediaIdCandidate(candidate);
|
||||
if (parsed !== null) { target.add(parsed); }
|
||||
}
|
||||
for (const child of Object.values(value)) {
|
||||
collectSubMinerMediaIds(child, target);
|
||||
}
|
||||
}
|
||||
function getSubMinerMediaIds(entry) {
|
||||
const mediaIds = new Set();
|
||||
collectSubMinerMediaIds(entry, mediaIds);
|
||||
return mediaIds;
|
||||
}
|
||||
function isCurrentMediaNameDictionaryEntry(entry) {
|
||||
if (!isNameDictionaryEntry(entry)) {
|
||||
return false;
|
||||
}
|
||||
if (currentCharacterDictionaryMediaId === null) {
|
||||
return true;
|
||||
}
|
||||
const mediaIds = getSubMinerMediaIds(entry);
|
||||
return mediaIds.size === 0 || mediaIds.has(currentCharacterDictionaryMediaId);
|
||||
}
|
||||
function findLongestNameMatch(dictionaryEntries, textWindow) {
|
||||
let best = null;
|
||||
for (const dictionaryEntry of dictionaryEntries || []) {
|
||||
if (!isCurrentMediaNameDictionaryEntry(dictionaryEntry)) { continue; }
|
||||
const headwords = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords : [];
|
||||
for (let headwordIndex = 0; headwordIndex < headwords.length; headwordIndex += 1) {
|
||||
const headword = headwords[headwordIndex];
|
||||
for (const src of headword?.sources || []) {
|
||||
if (src.matchType !== 'exact' || src.isPrimary !== true) { continue; }
|
||||
const originalText = typeof src.originalText === 'string' ? src.originalText : '';
|
||||
if (!originalText || !textWindow.startsWith(originalText)) { continue; }
|
||||
if (best === null || originalText.length > best.sourceLength) {
|
||||
best = { dictionaryEntry, headword, headwordIndex, sourceLength: originalText.length };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function findLongestGenericMatchLength(dictionaryEntries, textWindow) {
|
||||
let best = 0;
|
||||
for (const dictionaryEntry of dictionaryEntries || []) {
|
||||
if (isNameDictionaryEntry(dictionaryEntry)) { continue; }
|
||||
const headwords = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords : [];
|
||||
for (const headword of headwords) {
|
||||
for (const src of headword?.sources || []) {
|
||||
if (src.matchType !== 'exact' || src.isPrimary !== true) { continue; }
|
||||
const originalText = typeof src.originalText === 'string' ? src.originalText : '';
|
||||
if (!originalText || !textWindow.startsWith(originalText)) { continue; }
|
||||
if (originalText.length > best) { best = originalText.length; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function getPreferredHeadword(dictionaryEntries, token, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
|
||||
const currentMediaDictionaryEntries =
|
||||
currentCharacterDictionaryMediaId === null
|
||||
? (dictionaryEntries || [])
|
||||
: (dictionaryEntries || []).filter((entry) => {
|
||||
if (!isNameDictionaryEntry(entry)) { return true; }
|
||||
return isCurrentMediaNameDictionaryEntry(entry);
|
||||
});
|
||||
const exactPrimaryMatches = collectExactHeadwordMatches(currentMediaDictionaryEntries, token, true);
|
||||
let matchedNameDictionary = false;
|
||||
if (includeNameMatchMetadata) {
|
||||
for (const dictionaryEntry of currentMediaDictionaryEntries || []) {
|
||||
if (!isCurrentMediaNameDictionaryEntry(dictionaryEntry)) { continue; }
|
||||
for (const match of exactPrimaryMatches) {
|
||||
if (match.dictionaryEntry !== dictionaryEntry) { continue; }
|
||||
matchedNameDictionary = true;
|
||||
break;
|
||||
}
|
||||
if (matchedNameDictionary) { break; }
|
||||
}
|
||||
}
|
||||
const preferredMatch = exactPrimaryMatches[0];
|
||||
if (preferredMatch) {
|
||||
const exactFrequencyMatches = collectExactHeadwordMatches(currentMediaDictionaryEntries, token, false)
|
||||
.filter((match) => sameHeadword(match, preferredMatch));
|
||||
return {
|
||||
term: preferredMatch.headword.term,
|
||||
reading: preferredMatch.headword.reading,
|
||||
wordClasses: normalizeWordClasses(preferredMatch.headword),
|
||||
isNameMatch:
|
||||
matchedNameDictionary || isCurrentMediaNameDictionaryEntry(preferredMatch.dictionaryEntry),
|
||||
frequencyRank: getBestFrequencyRankForMatches(
|
||||
exactFrequencyMatches.length > 0 ? exactFrequencyMatches : exactPrimaryMatches,
|
||||
dictionaryPriorityByName,
|
||||
dictionaryFrequencyModeByName
|
||||
)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
async function installYomitanScanRuntime(parserWindow: BrowserWindow): Promise<void> {
|
||||
await parserWindow.webContents.executeJavaScript(YOMITAN_SCAN_RUNTIME_INSTALL_SCRIPT, true);
|
||||
// A fresh runtime has no candidate list; force the next scan to reinstall it.
|
||||
yomitanScanNameCandidateKeyByWindow.delete(parserWindow);
|
||||
}
|
||||
|
||||
function buildYomitanScanningScript(
|
||||
text: string,
|
||||
profileIndex: number,
|
||||
scanLength: number,
|
||||
includeNameMatchMetadata: boolean,
|
||||
greedyNameScanEnabled: boolean,
|
||||
currentCharacterDictionaryMediaId: number | null,
|
||||
dictionaryPriorityByName: Record<string, number>,
|
||||
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>,
|
||||
): string {
|
||||
return `
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
if (!response || typeof response !== "object") {
|
||||
reject(new Error("Invalid response from Yomitan backend"));
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
reject(new Error(response.error.message || "Yomitan backend error"));
|
||||
return;
|
||||
}
|
||||
resolve(response.result);
|
||||
});
|
||||
});
|
||||
${YOMITAN_SCANNING_HELPERS}
|
||||
const includeNameMatchMetadata = ${includeNameMatchMetadata ? 'true' : 'false'};
|
||||
const greedyNameScanEnabled = ${greedyNameScanEnabled ? 'true' : 'false'};
|
||||
const currentCharacterDictionaryMediaId = ${
|
||||
currentCharacterDictionaryMediaId !== null
|
||||
? String(currentCharacterDictionaryMediaId)
|
||||
: 'null'
|
||||
};
|
||||
const dictionaryPriorityByName = ${JSON.stringify(dictionaryPriorityByName)};
|
||||
const dictionaryFrequencyModeByName = ${JSON.stringify(dictionaryFrequencyModeByName)};
|
||||
const text = ${JSON.stringify(text)};
|
||||
const details = {matchType: "exact", deinflect: true};
|
||||
const tokens = [];
|
||||
const termsFindCache = new Map();
|
||||
async function termsFindAt(position, windowLength) {
|
||||
const cacheKey = position + ":" + windowLength;
|
||||
const cached = termsFindCache.get(cacheKey);
|
||||
if (cached) { return cached; }
|
||||
const substring = text.substring(position, position + windowLength);
|
||||
const result = await invoke("termsFind", { text: substring, details, optionsContext: { index: ${profileIndex} } });
|
||||
termsFindCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
function buildScanToken(position, source, preferredHeadword) {
|
||||
const reading = typeof preferredHeadword.reading === "string" ? preferredHeadword.reading : "";
|
||||
const segments = distributeFuriganaInflected(preferredHeadword.term, reading, source);
|
||||
const tokenPayload = {
|
||||
surface: segments.map((segment) => segment.text).join("") || source,
|
||||
reading: segments.map(getSegmentReadingContribution).join(""),
|
||||
headword: preferredHeadword.term,
|
||||
headwordReading: reading || undefined,
|
||||
startPos: position,
|
||||
endPos: position + source.length,
|
||||
isNameMatch: includeNameMatchMetadata && preferredHeadword.isNameMatch === true,
|
||||
frequencyRank:
|
||||
typeof preferredHeadword.frequencyRank === "number" && Number.isFinite(preferredHeadword.frequencyRank)
|
||||
? Math.max(1, Math.floor(preferredHeadword.frequencyRank))
|
||||
: undefined,
|
||||
};
|
||||
if (Array.isArray(preferredHeadword.wordClasses) && preferredHeadword.wordClasses.length > 0) {
|
||||
tokenPayload.wordClasses = preferredHeadword.wordClasses;
|
||||
}
|
||||
return tokenPayload;
|
||||
}
|
||||
async function findTokenAt(position, windowLength) {
|
||||
const codePoint = text.codePointAt(position);
|
||||
const character = String.fromCodePoint(codePoint);
|
||||
const result = await termsFindAt(position, windowLength);
|
||||
const dictionaryEntries = Array.isArray(result?.dictionaryEntries) ? result.dictionaryEntries : [];
|
||||
const originalTextLength = typeof result?.originalTextLength === "number" ? result.originalTextLength : 0;
|
||||
if (dictionaryEntries.length === 0 || originalTextLength <= 0 || (originalTextLength === character.length && !isCodePointJapanese(codePoint))) {
|
||||
return { token: null, matchedLength: 0 };
|
||||
}
|
||||
const source = text.substring(position, position + originalTextLength);
|
||||
const preferredHeadword = getPreferredHeadword(
|
||||
dictionaryEntries,
|
||||
source,
|
||||
dictionaryPriorityByName,
|
||||
dictionaryFrequencyModeByName
|
||||
);
|
||||
if (!preferredHeadword || typeof preferredHeadword.term !== "string") {
|
||||
return { token: null, matchedLength: originalTextLength };
|
||||
}
|
||||
return { token: buildScanToken(position, source, preferredHeadword), matchedLength: originalTextLength };
|
||||
}
|
||||
// Greedy name pre-pass: character-name matches claim their spans before
|
||||
// the left-to-right walk, so a longer generic match starting earlier
|
||||
// (e.g. とヨー → 渡洋) cannot swallow the start of a name (ヨータ).
|
||||
const nameTokens = [];
|
||||
if (greedyNameScanEnabled) {
|
||||
let namePos = 0;
|
||||
while (namePos < text.length) {
|
||||
const codePoint = text.codePointAt(namePos);
|
||||
if (!isCodePointJapanese(codePoint)) {
|
||||
namePos += String.fromCodePoint(codePoint).length;
|
||||
continue;
|
||||
}
|
||||
const result = await termsFindAt(namePos, ${scanLength});
|
||||
const dictionaryEntries = Array.isArray(result?.dictionaryEntries) ? result.dictionaryEntries : [];
|
||||
const textWindow = text.substring(namePos, namePos + ${scanLength});
|
||||
const nameMatch = findLongestNameMatch(dictionaryEntries, textWindow);
|
||||
// A name only claims its span when no strictly longer generic word
|
||||
// starts at the same position (a character named 空 must not split
|
||||
// 空気). Ties go to the name. Generic matches that start earlier and
|
||||
// overlap the name are still blocked by the reservation.
|
||||
if (
|
||||
!nameMatch ||
|
||||
findLongestGenericMatchLength(dictionaryEntries, textWindow) > nameMatch.sourceLength
|
||||
) {
|
||||
namePos += String.fromCodePoint(codePoint).length;
|
||||
continue;
|
||||
}
|
||||
const source = text.substring(namePos, namePos + nameMatch.sourceLength);
|
||||
nameTokens.push(buildScanToken(namePos, source, {
|
||||
term: nameMatch.headword.term,
|
||||
reading: nameMatch.headword.reading,
|
||||
wordClasses: normalizeWordClasses(nameMatch.headword),
|
||||
isNameMatch: true,
|
||||
frequencyRank: getBestFrequencyRank(
|
||||
nameMatch.dictionaryEntry,
|
||||
nameMatch.headwordIndex,
|
||||
dictionaryPriorityByName,
|
||||
dictionaryFrequencyModeByName
|
||||
)
|
||||
}));
|
||||
namePos += nameMatch.sourceLength;
|
||||
}
|
||||
}
|
||||
let i = 0;
|
||||
let nameIndex = 0;
|
||||
while (i < text.length) {
|
||||
while (nameIndex < nameTokens.length && nameTokens[nameIndex].startPos < i) { nameIndex += 1; }
|
||||
const nextNameToken = nameIndex < nameTokens.length ? nameTokens[nameIndex] : null;
|
||||
if (nextNameToken && nextNameToken.startPos === i) {
|
||||
tokens.push(nextNameToken);
|
||||
i = nextNameToken.endPos;
|
||||
nameIndex += 1;
|
||||
continue;
|
||||
}
|
||||
// Cap the window at the next reserved name span so a generic match
|
||||
// cannot consume into it.
|
||||
const windowLength = nextNameToken ? Math.min(${scanLength}, nextNameToken.startPos - i) : ${scanLength};
|
||||
let attempt = await findTokenAt(i, windowLength);
|
||||
// Yomitan text normalization can consume characters (whitespace,
|
||||
// punctuation) beyond the matched term, leaving no headword whose
|
||||
// source equals the consumed text. Retry with shorter windows so a
|
||||
// valid prefix term (e.g. a character name before a paren) still
|
||||
// tokenizes instead of the position being skipped.
|
||||
let retryLength = Math.min(attempt.matchedLength, windowLength) - 1;
|
||||
while (!attempt.token && retryLength >= 1) {
|
||||
const retry = await findTokenAt(i, retryLength);
|
||||
if (retry.token) {
|
||||
attempt = retry;
|
||||
break;
|
||||
}
|
||||
retryLength = Math.min(retryLength - 1, retry.matchedLength - 1);
|
||||
}
|
||||
if (attempt.token) {
|
||||
tokens.push(attempt.token);
|
||||
i += attempt.matchedLength;
|
||||
continue;
|
||||
}
|
||||
i += String.fromCodePoint(text.codePointAt(i)).length;
|
||||
}
|
||||
return tokens;
|
||||
})();
|
||||
`;
|
||||
// Key of the character-name candidate list currently installed in each parser
|
||||
// window, so an unchanged list costs nothing per line.
|
||||
const yomitanScanNameCandidateKeyByWindow = new WeakMap<BrowserWindow, string>();
|
||||
|
||||
async function ensureYomitanScanNameCandidates(
|
||||
parserWindow: BrowserWindow,
|
||||
nameCandidates: { key: string; forms: string[] } | null,
|
||||
logger: LoggerLike,
|
||||
): Promise<void> {
|
||||
const installedKey = yomitanScanNameCandidateKeyByWindow.get(parserWindow);
|
||||
const nextKey = nameCandidates?.key ?? '';
|
||||
if (installedKey === nextKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await parserWindow.webContents.executeJavaScript(
|
||||
buildYomitanScanNameCandidatesScript(nameCandidates),
|
||||
true,
|
||||
);
|
||||
yomitanScanNameCandidateKeyByWindow.set(parserWindow, nextKey);
|
||||
} catch (err) {
|
||||
// The scan falls back to checking every position when the list is absent,
|
||||
// so a failed install costs speed, never a missed name.
|
||||
logger.warn?.(
|
||||
'Failed to install Yomitan character-name scan candidates:',
|
||||
(err as Error).message,
|
||||
);
|
||||
yomitanScanNameCandidateKeyByWindow.delete(parserWindow);
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestYomitanParseResults(
|
||||
@@ -1635,6 +963,20 @@ export async function requestYomitanParseResults(
|
||||
}
|
||||
}
|
||||
|
||||
// parseText fallback for when the in-window scanner cannot run (script eval
|
||||
// failure, unexpected payload). The scanner walk is the primary tokenizer and
|
||||
// emits its own filler runs, so this extra full parse only happens on errors.
|
||||
async function requestYomitanParseFallbackTokens(
|
||||
text: string,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
): Promise<YomitanScanToken[] | null> {
|
||||
const parseResults = await requestYomitanParseResults(text, deps, logger);
|
||||
const selectedTokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||
const parseScanTokens = selectedTokens?.map(toYomitanScanToken) ?? null;
|
||||
return parseScanTokens && parseScanTokens.length > 0 ? parseScanTokens : null;
|
||||
}
|
||||
|
||||
export async function requestYomitanScanTokens(
|
||||
text: string,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
@@ -1642,6 +984,7 @@ export async function requestYomitanScanTokens(
|
||||
options?: {
|
||||
includeNameMatchMetadata?: boolean;
|
||||
currentCharacterDictionaryMediaId?: number | null;
|
||||
nameCandidates?: { key: string; forms: string[] } | null;
|
||||
},
|
||||
): Promise<YomitanScanToken[] | null> {
|
||||
const yomitanExt = deps.getYomitanExt();
|
||||
@@ -1655,10 +998,6 @@ export async function requestYomitanScanTokens(
|
||||
return null;
|
||||
}
|
||||
|
||||
const parseResults = await requestYomitanParseResults(text, deps, logger);
|
||||
const selectedParseTokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||
const parseScanTokens = selectedParseTokens?.map(toYomitanScanToken) ?? null;
|
||||
|
||||
const metadata = await requestYomitanProfileMetadata(parserWindow, logger);
|
||||
const profileIndex = metadata?.profileIndex ?? 0;
|
||||
const scanLength = metadata?.scanLength ?? DEFAULT_YOMITAN_SCAN_LENGTH;
|
||||
@@ -1669,44 +1008,63 @@ export async function requestYomitanScanTokens(
|
||||
name.startsWith(CHARACTER_DICTIONARY_TITLE_PREFIX),
|
||||
);
|
||||
|
||||
// Candidate name forms let the in-page pre-pass skip positions where no
|
||||
// character name can start. Installed only when it changes (per media), so
|
||||
// the per-line call stays a single tiny script.
|
||||
const nameCandidates = greedyNameScanEnabled ? (options?.nameCandidates ?? null) : null;
|
||||
await ensureYomitanScanNameCandidates(parserWindow, nameCandidates, logger);
|
||||
|
||||
const callScript = buildYomitanScanCallScript({
|
||||
text,
|
||||
profileIndex,
|
||||
scanLength,
|
||||
includeNameMatchMetadata,
|
||||
greedyNameScanEnabled,
|
||||
currentCharacterDictionaryMediaId:
|
||||
typeof options?.currentCharacterDictionaryMediaId === 'number' &&
|
||||
Number.isFinite(options.currentCharacterDictionaryMediaId) &&
|
||||
options.currentCharacterDictionaryMediaId > 0
|
||||
? Math.floor(options.currentCharacterDictionaryMediaId)
|
||||
: null,
|
||||
dictionaryPriorityByName: metadata?.dictionaryPriorityByName ?? {},
|
||||
dictionaryFrequencyModeByName: metadata?.dictionaryFrequencyModeByName ?? {},
|
||||
cacheEpoch: getYomitanScanCacheEpoch(parserWindow),
|
||||
nameCandidateKey: nameCandidates?.key ?? null,
|
||||
});
|
||||
|
||||
try {
|
||||
const rawResult = await parserWindow.webContents.executeJavaScript(
|
||||
buildYomitanScanningScript(
|
||||
text,
|
||||
profileIndex,
|
||||
scanLength,
|
||||
includeNameMatchMetadata,
|
||||
greedyNameScanEnabled,
|
||||
typeof options?.currentCharacterDictionaryMediaId === 'number' &&
|
||||
Number.isFinite(options.currentCharacterDictionaryMediaId) &&
|
||||
options.currentCharacterDictionaryMediaId > 0
|
||||
? Math.floor(options.currentCharacterDictionaryMediaId)
|
||||
: null,
|
||||
metadata?.dictionaryPriorityByName ?? {},
|
||||
metadata?.dictionaryFrequencyModeByName ?? {},
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (isScanTokenArray(rawResult)) {
|
||||
if (parseScanTokens && parseScanTokens.length > 0) {
|
||||
return mergeScannerTokensIntoParseTokens(parseScanTokens, rawResult);
|
||||
let rawResult = await parserWindow.webContents.executeJavaScript(callScript, true);
|
||||
if (rawResult === YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL) {
|
||||
// First request for this window, or the page reloaded and dropped the
|
||||
// installed runtime: install and retry once. The candidate list lives in
|
||||
// the same page state, so it has to be reinstalled alongside it.
|
||||
await installYomitanScanRuntime(parserWindow);
|
||||
await ensureYomitanScanNameCandidates(parserWindow, nameCandidates, logger);
|
||||
rawResult = await parserWindow.webContents.executeJavaScript(callScript, true);
|
||||
}
|
||||
// The scanner reports a line where a position ran out of shrinking-window
|
||||
// retries: it stopped short of windows an uncapped ladder would have tried,
|
||||
// so a real term may be sitting in an unparsed run. One parseText for the
|
||||
// line is the bounded way to get the exhaustive answer back (this is the
|
||||
// parse the scanner replaced, and it only runs for these rare lines).
|
||||
if (isObject(rawResult) && rawResult.retryBudgetExhausted === true) {
|
||||
logger.info?.('Yomitan scanner exhausted its retry budget; parsing the line as a fallback.');
|
||||
const fallbackTokens = await requestYomitanParseFallbackTokens(text, deps, logger);
|
||||
if (fallbackTokens) {
|
||||
return fallbackTokens;
|
||||
}
|
||||
return rawResult;
|
||||
rawResult = rawResult.tokens;
|
||||
}
|
||||
if (Array.isArray(rawResult)) {
|
||||
const selectedTokens = selectYomitanParseTokens(rawResult, () => false, 'headword');
|
||||
return selectedTokens?.map(toYomitanScanToken) ?? null;
|
||||
if (isScanTokenArray(rawResult)) {
|
||||
// Filler-only results carry no dictionary match; keep the historical
|
||||
// contract of returning null so callers fall back to raw text.
|
||||
return rawResult.some((token) => token.isUnparsedRun !== true) ? rawResult : null;
|
||||
}
|
||||
if (parseScanTokens && parseScanTokens.length > 0) {
|
||||
return parseScanTokens;
|
||||
}
|
||||
return null;
|
||||
logger.error('Yomitan scanner returned an unexpected payload; using parseText fallback.');
|
||||
return await requestYomitanParseFallbackTokens(text, deps, logger);
|
||||
} catch (err) {
|
||||
if (parseScanTokens && parseScanTokens.length > 0) {
|
||||
return parseScanTokens;
|
||||
}
|
||||
logger.error('Yomitan scanner request failed:', (err as Error).message);
|
||||
return null;
|
||||
return await requestYomitanParseFallbackTokens(text, deps, logger);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
// In-page Yomitan scan runtime: the scan walk that gets installed once per
|
||||
// parser window as globalThis.__subminerYomitanScan, plus the tiny per-line
|
||||
// call script. Kept separate from the host runtime module so the injected
|
||||
// script text (which is data, not executed here) does not dominate that file;
|
||||
// the helper bundle it embeds is composed in yomitan-scanning-helpers-script.ts
|
||||
// from the yomitan-*-script.ts fragments.
|
||||
import { YOMITAN_SCANNING_HELPERS } from './yomitan-scanning-helpers-script';
|
||||
|
||||
export { CHARACTER_DICTIONARY_TITLE_PREFIX } from './yomitan-scanning-helpers-script';
|
||||
|
||||
export type YomitanFrequencyMode = 'occurrence-based' | 'rank-based';
|
||||
|
||||
// Bump whenever the install script below changes so already-loaded parser
|
||||
// windows re-install the new scan runtime instead of running the stale one.
|
||||
export const YOMITAN_SCAN_RUNTIME_VERSION = 12;
|
||||
export const YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL = '__subminer-yomitan-scan-runtime-missing__';
|
||||
|
||||
export interface YomitanScanRequestParams {
|
||||
text: string;
|
||||
profileIndex: number;
|
||||
scanLength: number;
|
||||
includeNameMatchMetadata: boolean;
|
||||
greedyNameScanEnabled: boolean;
|
||||
currentCharacterDictionaryMediaId: number | null;
|
||||
dictionaryPriorityByName: Record<string, number>;
|
||||
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>;
|
||||
cacheEpoch: number;
|
||||
/**
|
||||
* Key of the character-name candidate list installed for the current media,
|
||||
* or null to scan every Japanese position (see the pre-pass prefilter).
|
||||
*/
|
||||
nameCandidateKey: string | null;
|
||||
}
|
||||
|
||||
// Installed once per parser window (and re-installed after in-page reloads):
|
||||
// keeps V8 from re-parsing the helper bundle on every subtitle line, and hosts
|
||||
// the cross-line termsFind cache. Each subtitle line then only evaluates a tiny
|
||||
// call into globalThis.__subminerYomitanScan.
|
||||
export const YOMITAN_SCAN_RUNTIME_INSTALL_SCRIPT = String.raw`
|
||||
(() => {
|
||||
if (globalThis.__subminerYomitanScanVersion === ${YOMITAN_SCAN_RUNTIME_VERSION}) {
|
||||
return true;
|
||||
}
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
if (!response || typeof response !== "object") {
|
||||
reject(new Error("Invalid response from Yomitan backend"));
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
reject(new Error(response.error.message || "Yomitan backend error"));
|
||||
return;
|
||||
}
|
||||
resolve(response.result);
|
||||
});
|
||||
});
|
||||
// Cross-line termsFind LRU keyed by profile + substring: subtitle lines
|
||||
// repeat particles and inflections constantly, so most lookups hit here.
|
||||
// Entries hold in-flight promises so concurrent identical lookups dedupe.
|
||||
const termsFindCache = new Map();
|
||||
// Two bounds. The key count keeps the map itself small; the accumulated
|
||||
// dictionary-entry count stands in for retained bytes, because a single
|
||||
// lookup over a common prefix can hold hundreds of entries with their full
|
||||
// glossaries and a key-count cap alone would not bound that.
|
||||
const TERMS_FIND_CACHE_LIMIT = 2000;
|
||||
const TERMS_FIND_CACHE_DICTIONARY_ENTRY_LIMIT = 20000;
|
||||
let termsFindCacheDictionaryEntries = 0;
|
||||
let termsFindCacheEpoch = -1;
|
||||
function dropCachedTermsFind(cacheKey, entry) {
|
||||
if (termsFindCache.get(cacheKey) !== entry) { return; }
|
||||
termsFindCache.delete(cacheKey);
|
||||
termsFindCacheDictionaryEntries -= entry.dictionaryEntryCount;
|
||||
}
|
||||
// Runs on insert and again once a lookup resolves: an entry is only worth
|
||||
// its estimated weight of 1 until then, so a single oversized response
|
||||
// would otherwise sit in the cache forever, over the limit and reused.
|
||||
function evictOverflowingTermsFindEntries() {
|
||||
while (
|
||||
termsFindCache.size > TERMS_FIND_CACHE_LIMIT ||
|
||||
termsFindCacheDictionaryEntries > TERMS_FIND_CACHE_DICTIONARY_ENTRY_LIMIT
|
||||
) {
|
||||
const oldest = termsFindCache.entries().next().value;
|
||||
if (oldest === undefined) { break; }
|
||||
dropCachedTermsFind(oldest[0], oldest[1]);
|
||||
}
|
||||
}
|
||||
// Classification of a dictionary entry (which dictionaries it came from,
|
||||
// which media ids it mentions) depends only on the entry object, so it is
|
||||
// memoized for as long as that object lives. Entries are shared with the
|
||||
// termsFind cache above, which is what makes this worth keeping: the same
|
||||
// objects come back for every repeated lookup, on every line.
|
||||
const dictionaryEntryNamesCache = new WeakMap();
|
||||
const subMinerMediaIdsCache = new WeakMap();
|
||||
const EMPTY_MEDIA_ID_SET = new Set();
|
||||
// Only blind ladder steps are capped (see the retry loop): those are the
|
||||
// ones that would otherwise degrade into O(scanLength) lookups at a single
|
||||
// position. Steps the backend guides by reporting a shorter consumed length
|
||||
// stay uncapped, so a valid prefix term is still found on lines where
|
||||
// normalization eats a long tail.
|
||||
const MAX_BLIND_SHRINKING_WINDOW_RETRIES = 4;
|
||||
// Character-name candidate forms for the current media, installed
|
||||
// separately from the per-line scan call so the per-line script stays tiny.
|
||||
// Stored raw here; the normalized lookup index is built inside the scan,
|
||||
// where the kana-normalization helper is in scope, and reused by key.
|
||||
let rawNameCandidates = null;
|
||||
let nameCandidateIndex = null;
|
||||
globalThis.__subminerYomitanScanSetNameCandidates = (key, forms) => {
|
||||
if (!key || !Array.isArray(forms) || forms.length === 0) {
|
||||
rawNameCandidates = null;
|
||||
nameCandidateIndex = null;
|
||||
return false;
|
||||
}
|
||||
rawNameCandidates = { key, forms };
|
||||
nameCandidateIndex = null;
|
||||
return true;
|
||||
};
|
||||
globalThis.__subminerYomitanScanVersion = ${YOMITAN_SCAN_RUNTIME_VERSION};
|
||||
globalThis.__subminerYomitanScan = async (scanParams) => {
|
||||
const {
|
||||
text,
|
||||
profileIndex,
|
||||
scanLength,
|
||||
includeNameMatchMetadata,
|
||||
greedyNameScanEnabled,
|
||||
currentCharacterDictionaryMediaId,
|
||||
dictionaryPriorityByName,
|
||||
dictionaryFrequencyModeByName,
|
||||
cacheEpoch,
|
||||
nameCandidateKey
|
||||
} = scanParams;
|
||||
if (cacheEpoch !== termsFindCacheEpoch) {
|
||||
termsFindCache.clear();
|
||||
termsFindCacheDictionaryEntries = 0;
|
||||
termsFindCacheEpoch = cacheEpoch;
|
||||
}
|
||||
${YOMITAN_SCANNING_HELPERS}
|
||||
const CAPTION_OPENING_BRACKETS = new Set(["(", "(", "[", "[", "{", "{", "「", "『", "【", "〈", "《", "≪", "<", "<"]);
|
||||
function shouldEmitUnparsedRunAsToken(runText) {
|
||||
if (!/[\p{L}\p{N}]/u.test(runText)) { return false; }
|
||||
const firstChar = Array.from(runText.trim())[0];
|
||||
return firstChar !== undefined && !CAPTION_OPENING_BRACKETS.has(firstChar);
|
||||
}
|
||||
function isLookupWorthyCodePoint(codePoint) {
|
||||
if (isCodePointJapanese(codePoint)) { return true; }
|
||||
return /[\p{L}\p{N}]/u.test(String.fromCodePoint(codePoint));
|
||||
}
|
||||
function isKanaOnlyRunText(runText) {
|
||||
const chars = Array.from(runText);
|
||||
return chars.length > 0 && chars.every((char) => isCodePointKana(char.codePointAt(0)));
|
||||
}
|
||||
const details = {matchType: "exact", deinflect: true};
|
||||
const tokens = [];
|
||||
async function termsFindAt(position, windowLength) {
|
||||
const substring = text.substring(position, position + windowLength);
|
||||
const cacheKey = profileIndex + "\u0000" + substring;
|
||||
const cached = termsFindCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
termsFindCache.delete(cacheKey);
|
||||
termsFindCache.set(cacheKey, cached);
|
||||
return await cached.promise;
|
||||
}
|
||||
// An in-flight lookup counts as one entry until it resolves; the real
|
||||
// weight replaces that estimate once the result is known.
|
||||
const entry = { promise: null, dictionaryEntryCount: 1 };
|
||||
entry.promise = invoke("termsFind", { text: substring, details, optionsContext: { index: profileIndex } })
|
||||
.then((result) => {
|
||||
const resolvedCount =
|
||||
1 + (Array.isArray(result?.dictionaryEntries) ? result.dictionaryEntries.length : 0);
|
||||
const isCached = termsFindCache.get(cacheKey) === entry;
|
||||
if (isCached) {
|
||||
termsFindCacheDictionaryEntries += resolvedCount - entry.dictionaryEntryCount;
|
||||
}
|
||||
entry.dictionaryEntryCount = resolvedCount;
|
||||
// The real weight can push the cache over its budget, and a single
|
||||
// response can exceed it on its own, so re-check here.
|
||||
if (isCached) { evictOverflowingTermsFindEntries(); }
|
||||
return result;
|
||||
});
|
||||
termsFindCache.set(cacheKey, entry);
|
||||
termsFindCacheDictionaryEntries += entry.dictionaryEntryCount;
|
||||
evictOverflowingTermsFindEntries();
|
||||
try {
|
||||
return await entry.promise;
|
||||
} catch (error) {
|
||||
dropCachedTermsFind(cacheKey, entry);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Text the walk skips accumulates into unparsed runs, mirroring the
|
||||
// filler chunks the parseText segmentation used to provide: runs stay
|
||||
// hoverable (flagged isUnparsedRun) unless they are punctuation-only or
|
||||
// caption-style asides, and kana continuations of a longer headword
|
||||
// extend the previous token instead.
|
||||
function flushUnparsedRun(runStart, runEnd) {
|
||||
if (runStart === null || runEnd <= runStart) { return; }
|
||||
const runText = text.substring(runStart, runEnd);
|
||||
const previousToken = tokens[tokens.length - 1];
|
||||
if (
|
||||
previousToken &&
|
||||
previousToken.endPos === runStart &&
|
||||
isKanaOnlyRunText(runText) &&
|
||||
typeof previousToken.headword === "string" &&
|
||||
previousToken.headword.length > previousToken.surface.length &&
|
||||
previousToken.headword.startsWith(previousToken.surface + runText)
|
||||
) {
|
||||
previousToken.surface += runText;
|
||||
// The run is kana-only, so its reading is itself: append it or the
|
||||
// reading stops covering the surface, which disables the known-word
|
||||
// reading fallback (isCompleteReadingForSurface) downstream.
|
||||
previousToken.reading += runText;
|
||||
// The run is kana-only, so its reading is itself: append it or the
|
||||
// reading stops covering the surface, which disables the known-word
|
||||
// reading fallback (isCompleteReadingForSurface) downstream.
|
||||
previousToken.endPos = runEnd;
|
||||
return;
|
||||
}
|
||||
if (!shouldEmitUnparsedRunAsToken(runText)) { return; }
|
||||
tokens.push({
|
||||
surface: runText,
|
||||
reading: "",
|
||||
headword: runText,
|
||||
startPos: runStart,
|
||||
endPos: runEnd,
|
||||
isUnparsedRun: true
|
||||
});
|
||||
}
|
||||
function buildScanToken(position, source, preferredHeadword) {
|
||||
const reading = typeof preferredHeadword.reading === "string" ? preferredHeadword.reading : "";
|
||||
const segments = distributeFuriganaInflected(preferredHeadword.term, reading, source);
|
||||
const tokenPayload = {
|
||||
surface: segments.map((segment) => segment.text).join("") || source,
|
||||
reading: segments.map(getSegmentReadingContribution).join(""),
|
||||
headword: preferredHeadword.term,
|
||||
headwordReading: reading || undefined,
|
||||
startPos: position,
|
||||
endPos: position + source.length,
|
||||
isNameMatch: includeNameMatchMetadata && preferredHeadword.isNameMatch === true,
|
||||
frequencyRank:
|
||||
typeof preferredHeadword.frequencyRank === "number" && Number.isFinite(preferredHeadword.frequencyRank)
|
||||
? Math.max(1, Math.floor(preferredHeadword.frequencyRank))
|
||||
: undefined,
|
||||
};
|
||||
if (Array.isArray(preferredHeadword.wordClasses) && preferredHeadword.wordClasses.length > 0) {
|
||||
tokenPayload.wordClasses = preferredHeadword.wordClasses;
|
||||
}
|
||||
return tokenPayload;
|
||||
}
|
||||
// findTokenAt plus the shrinking-window ladder below it: Yomitan text
|
||||
// normalization can consume characters (whitespace, punctuation) beyond
|
||||
// the matched term, leaving no headword whose source equals the consumed
|
||||
// text. Retry with shorter windows so a valid prefix term (e.g. a
|
||||
// character name before a paren) still tokenizes instead of the position
|
||||
// being skipped.
|
||||
// Every window at or above the consumed length repeats the same result,
|
||||
// so the next informative window sits just below it. A lookup that
|
||||
// consumed its whole window reports nothing to aim at, and the step down
|
||||
// from it is a blind guess: only those are budgeted.
|
||||
// The window can run past the end of the line, so blindness is judged
|
||||
// against the text the lookup actually saw.
|
||||
// Set when a position stopped short of windows an uncapped ladder would
|
||||
// still have tried; the line then escalates to parseText at the end.
|
||||
let blindRetryBudgetExhausted = false;
|
||||
async function resolveTokenAt(position, windowLength) {
|
||||
let attempt = await findTokenAt(position, windowLength);
|
||||
const scannedLength = Math.min(windowLength, text.length - position);
|
||||
let retryLength = Math.min(attempt.matchedLength, scannedLength) - 1;
|
||||
let stepIsBlind = attempt.matchedLength >= scannedLength;
|
||||
let blindRetriesRemaining = MAX_BLIND_SHRINKING_WINDOW_RETRIES;
|
||||
while (!attempt.token && retryLength >= 1) {
|
||||
if (stepIsBlind) {
|
||||
if (blindRetriesRemaining <= 0) {
|
||||
blindRetryBudgetExhausted = true;
|
||||
break;
|
||||
}
|
||||
blindRetriesRemaining -= 1;
|
||||
}
|
||||
const retry = await findTokenAt(position, retryLength);
|
||||
if (retry.token) { return retry; }
|
||||
const guidedLength = retry.matchedLength - 1;
|
||||
stepIsBlind = guidedLength >= retryLength - 1;
|
||||
retryLength = Math.min(retryLength - 1, guidedLength);
|
||||
}
|
||||
return attempt;
|
||||
}
|
||||
async function findTokenAt(position, windowLength) {
|
||||
const codePoint = text.codePointAt(position);
|
||||
const character = String.fromCodePoint(codePoint);
|
||||
const result = await termsFindAt(position, windowLength);
|
||||
const dictionaryEntries = Array.isArray(result?.dictionaryEntries) ? result.dictionaryEntries : [];
|
||||
const originalTextLength = typeof result?.originalTextLength === "number" ? result.originalTextLength : 0;
|
||||
if (dictionaryEntries.length === 0 || originalTextLength <= 0 || (originalTextLength === character.length && !isCodePointJapanese(codePoint))) {
|
||||
return { token: null, matchedLength: 0 };
|
||||
}
|
||||
const source = text.substring(position, position + originalTextLength);
|
||||
const preferredHeadword = getPreferredHeadword(
|
||||
dictionaryEntries,
|
||||
source,
|
||||
dictionaryPriorityByName,
|
||||
dictionaryFrequencyModeByName
|
||||
);
|
||||
if (!preferredHeadword || typeof preferredHeadword.term !== "string") {
|
||||
return { token: null, matchedLength: originalTextLength };
|
||||
}
|
||||
return { token: buildScanToken(position, source, preferredHeadword), matchedLength: originalTextLength };
|
||||
}
|
||||
// Kana normalization folds halfwidth katakana one code point to one, so an
|
||||
// unvoiced halfwidth spelling prefix-matches a candidate form like any
|
||||
// other. What it cannot fold is a voiced pair: カ + ゙ stays two characters
|
||||
// where the candidate form carries the single が, so the comparison fails
|
||||
// at that character. That break can sit anywhere inside the name, not
|
||||
// just at its first character (山ガク starts on a kanji), so the bypass is
|
||||
// keyed on the region a candidate could cover, not on how it starts.
|
||||
function isHalfwidthKanaVoicedMarkCodePoint(codePoint) {
|
||||
return codePoint === 0xff9e || codePoint === 0xff9f;
|
||||
}
|
||||
// Build (once per candidate list) a first-character bucket index of the
|
||||
// normalized name forms, so the pre-pass can reject a position with a
|
||||
// single map hit instead of a backend round trip.
|
||||
if (rawNameCandidates && nameCandidateIndex?.key !== rawNameCandidates.key) {
|
||||
const byFirstChar = new Map();
|
||||
for (const form of rawNameCandidates.forms) {
|
||||
const normalized = typeof form === "string" ? convertKatakanaToHiragana(form.trim()) : "";
|
||||
if (!normalized) { continue; }
|
||||
const bucket = byFirstChar.get(normalized[0]);
|
||||
if (bucket) { bucket.push(normalized); } else { byFirstChar.set(normalized[0], [normalized]); }
|
||||
}
|
||||
nameCandidateIndex = byFirstChar.size > 0 ? { key: rawNameCandidates.key, byFirstChar } : null;
|
||||
} else if (!rawNameCandidates) {
|
||||
nameCandidateIndex = null;
|
||||
}
|
||||
// Only meaningful when the installed list matches the media this scan is
|
||||
// for; otherwise fall back to scanning every position.
|
||||
const activeNameCandidateIndex =
|
||||
nameCandidateKey !== null && nameCandidateIndex?.key === nameCandidateKey
|
||||
? nameCandidateIndex
|
||||
: null;
|
||||
const normalizedText = activeNameCandidateIndex ? convertKatakanaToHiragana(text) : "";
|
||||
// Yomitan collapses emphatic sequences before matching (すっっごーーい →
|
||||
// すごい), so a stretched name still resolves to its entry. Skipping these
|
||||
// characters keeps such spellings candidates; the filter only ever grows
|
||||
// the probe set, so a false positive costs one lookup, never a name.
|
||||
const EMPHATIC_SKIP_CHARS = new Set(["ぁ", "ぃ", "ぅ", "ぇ", "ぉ", "っ", "ゃ", "ゅ", "ょ", "ー"]);
|
||||
function matchesCandidateFormAt(form, position) {
|
||||
let textIndex = position;
|
||||
for (let formIndex = 0; formIndex < form.length; formIndex += 1) {
|
||||
while (
|
||||
textIndex < normalizedText.length &&
|
||||
normalizedText[textIndex] !== form[formIndex] &&
|
||||
EMPHATIC_SKIP_CHARS.has(normalizedText[textIndex])
|
||||
) {
|
||||
textIndex += 1;
|
||||
}
|
||||
if (normalizedText[textIndex] !== form[formIndex]) { return false; }
|
||||
textIndex += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Where the folding gives up, listed once per line. Matching may skip any
|
||||
// number of emphatic characters on its way through a form (山ーーーーーーガク),
|
||||
// so there is no shorter honest bound than the window a name lookup
|
||||
// covers: scanLength. The list is almost always empty, which is what
|
||||
// keeps the check below free on ordinary lines.
|
||||
const halfwidthVoicedMarkPositions = [];
|
||||
if (activeNameCandidateIndex) {
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
if (isHalfwidthKanaVoicedMarkCodePoint(text.charCodeAt(index))) {
|
||||
halfwidthVoicedMarkPositions.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
function hasHalfwidthVoicedMarkInScanWindow(position) {
|
||||
const end = position + scanLength;
|
||||
for (const markPosition of halfwidthVoicedMarkPositions) {
|
||||
if (markPosition >= position && markPosition < end) { return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// A name written ガ... folds to か + ゙, so its first character never leads
|
||||
// to the が bucket the candidate form is filed under. Nothing else can
|
||||
// find it, so such a position is always worth a probe.
|
||||
function startsHalfwidthVoicedPair(position, codePoint) {
|
||||
if (codePoint < 0xff66 || codePoint > 0xff9d) { return false; }
|
||||
return isHalfwidthKanaVoicedMarkCodePoint(text.charCodeAt(position + 1));
|
||||
}
|
||||
function couldNameStartAt(position, codePoint) {
|
||||
// Nothing starts with a combining voiced mark, whether or not the
|
||||
// prefilter is active.
|
||||
if (isHalfwidthKanaVoicedMarkCodePoint(codePoint)) { return false; }
|
||||
if (!activeNameCandidateIndex) { return true; }
|
||||
const bucket = activeNameCandidateIndex.byFirstChar.get(normalizedText[position]);
|
||||
if (!bucket) {
|
||||
// No candidate begins with this character, and the window search
|
||||
// below would only ever say yes to positions like this one, so an
|
||||
// unrelated ガ elsewhere in the line must not drag them in.
|
||||
return startsHalfwidthVoicedPair(position, codePoint);
|
||||
}
|
||||
for (const form of bucket) {
|
||||
if (matchesCandidateFormAt(form, position)) { return true; }
|
||||
}
|
||||
// A candidate does start here but did not match: an unfoldable voiced
|
||||
// pair anywhere in the window is a reason the comparison could not see
|
||||
// it (山ガク, 山ーーーーーーガク), so probe rather than drop the name.
|
||||
return hasHalfwidthVoicedMarkInScanWindow(position);
|
||||
}
|
||||
// Greedy name pre-pass: character-name matches claim their spans before
|
||||
// the left-to-right walk, so a longer generic match starting earlier
|
||||
// (e.g. とヨー → 渡洋) cannot swallow the start of a name (ヨータ).
|
||||
const nameTokens = [];
|
||||
if (greedyNameScanEnabled) {
|
||||
let namePos = 0;
|
||||
while (namePos < text.length) {
|
||||
const codePoint = text.codePointAt(namePos);
|
||||
if (!isCodePointJapanese(codePoint) || !couldNameStartAt(namePos, codePoint)) {
|
||||
namePos += String.fromCodePoint(codePoint).length;
|
||||
continue;
|
||||
}
|
||||
const result = await termsFindAt(namePos, scanLength);
|
||||
const dictionaryEntries = Array.isArray(result?.dictionaryEntries) ? result.dictionaryEntries : [];
|
||||
const textWindow = text.substring(namePos, namePos + scanLength);
|
||||
const nameMatch = findLongestNameMatch(dictionaryEntries, textWindow);
|
||||
// A name only claims its span when no strictly longer generic word
|
||||
// starts at the same position (a character named 空 must not split
|
||||
// 空気). Ties go to the name. Generic matches that start earlier and
|
||||
// overlap the name are still blocked by the reservation.
|
||||
if (
|
||||
!nameMatch ||
|
||||
findLongestGenericMatchLength(dictionaryEntries, textWindow) > nameMatch.sourceLength
|
||||
) {
|
||||
namePos += String.fromCodePoint(codePoint).length;
|
||||
continue;
|
||||
}
|
||||
const source = text.substring(namePos, namePos + nameMatch.sourceLength);
|
||||
nameTokens.push(buildScanToken(namePos, source, {
|
||||
term: nameMatch.headword.term,
|
||||
reading: nameMatch.headword.reading,
|
||||
wordClasses: normalizeWordClasses(nameMatch.headword),
|
||||
isNameMatch: true,
|
||||
frequencyRank: getBestFrequencyRank(
|
||||
nameMatch.dictionaryEntry,
|
||||
nameMatch.headwordIndex,
|
||||
dictionaryPriorityByName,
|
||||
dictionaryFrequencyModeByName
|
||||
)
|
||||
}));
|
||||
namePos += nameMatch.sourceLength;
|
||||
}
|
||||
}
|
||||
// First reserved name span that a match ending at endPos would leave
|
||||
// half-consumed. Spans the match covers entirely are not returned: those
|
||||
// lose to the longer word instead of splitting it.
|
||||
function findSplitNameToken(startIndex, endPos) {
|
||||
for (let index = startIndex; index < nameTokens.length; index += 1) {
|
||||
const nameToken = nameTokens[index];
|
||||
if (nameToken.startPos >= endPos) { return null; }
|
||||
if (nameToken.endPos > endPos) { return nameToken; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
let i = 0;
|
||||
let nameIndex = 0;
|
||||
let unparsedRunStart = null;
|
||||
while (i < text.length) {
|
||||
while (nameIndex < nameTokens.length && nameTokens[nameIndex].startPos < i) { nameIndex += 1; }
|
||||
const nextNameToken = nameIndex < nameTokens.length ? nameTokens[nameIndex] : null;
|
||||
if (nextNameToken && nextNameToken.startPos === i) {
|
||||
flushUnparsedRun(unparsedRunStart, i);
|
||||
unparsedRunStart = null;
|
||||
tokens.push(nextNameToken);
|
||||
i = nextNameToken.endPos;
|
||||
nameIndex += 1;
|
||||
continue;
|
||||
}
|
||||
const codePoint = text.codePointAt(i);
|
||||
// Punctuation and whitespace can never start a token: skip the backend
|
||||
// round trip entirely. Latin letters and digits stay lookup-worthy
|
||||
// (terms like Tシャツ start on an ASCII letter).
|
||||
if (!isLookupWorthyCodePoint(codePoint)) {
|
||||
if (unparsedRunStart === null) { unparsedRunStart = i; }
|
||||
i += String.fromCodePoint(codePoint).length;
|
||||
continue;
|
||||
}
|
||||
// A reservation only outranks generic matches that would cut into it.
|
||||
// Look the position up unrestricted first: a generic word that starts
|
||||
// earlier and covers the whole name span (写真 over a character named
|
||||
// 真) is the better reading, so the reservation yields rather than
|
||||
// splitting the word. Only a match that ends inside a name span gets
|
||||
// re-run against a window capped at that span.
|
||||
let attempt = await resolveTokenAt(i, scanLength);
|
||||
if (attempt.token) {
|
||||
const splitNameToken = findSplitNameToken(nameIndex, attempt.token.endPos);
|
||||
if (splitNameToken) {
|
||||
attempt = await resolveTokenAt(i, splitNameToken.startPos - i);
|
||||
}
|
||||
}
|
||||
if (attempt.token) {
|
||||
flushUnparsedRun(unparsedRunStart, i);
|
||||
unparsedRunStart = null;
|
||||
tokens.push(attempt.token);
|
||||
i += attempt.matchedLength;
|
||||
continue;
|
||||
}
|
||||
if (unparsedRunStart === null) { unparsedRunStart = i; }
|
||||
i += String.fromCodePoint(text.codePointAt(i)).length;
|
||||
}
|
||||
flushUnparsedRun(unparsedRunStart, text.length);
|
||||
if (blindRetryBudgetExhausted) {
|
||||
// A position gave up with shorter windows still worth trying. The walk
|
||||
// is the only tokenizer now, so stopping there would leave a real term
|
||||
// as an unparsed run; report it so the host can spend one parseText on
|
||||
// the line instead of letting the ladder run to O(scanLength) lookups.
|
||||
return { tokens, retryBudgetExhausted: true };
|
||||
}
|
||||
return tokens;
|
||||
};
|
||||
return true;
|
||||
})();
|
||||
`;
|
||||
|
||||
// Installs (or clears) the character-name candidate forms for the current
|
||||
// media. Runs only when the list changes, not per line. Passing null restores
|
||||
// the exhaustive every-position pre-pass.
|
||||
export function buildYomitanScanNameCandidatesScript(
|
||||
nameCandidates: { key: string; forms: string[] } | null,
|
||||
): string {
|
||||
if (!nameCandidates) {
|
||||
return `
|
||||
(() => {
|
||||
if (typeof globalThis.__subminerYomitanScanSetNameCandidates !== "function") {
|
||||
return false;
|
||||
}
|
||||
return globalThis.__subminerYomitanScanSetNameCandidates(null, null);
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
(() => {
|
||||
if (typeof globalThis.__subminerYomitanScanSetNameCandidates !== "function") {
|
||||
return false;
|
||||
}
|
||||
return globalThis.__subminerYomitanScanSetNameCandidates(
|
||||
${JSON.stringify(nameCandidates.key)},
|
||||
${JSON.stringify(nameCandidates.forms)}
|
||||
);
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildYomitanScanCallScript(params: YomitanScanRequestParams): string {
|
||||
return `
|
||||
(async () => {
|
||||
if (typeof globalThis.__subminerYomitanScan !== "function") {
|
||||
return ${JSON.stringify(YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL)};
|
||||
}
|
||||
return await globalThis.__subminerYomitanScan(${JSON.stringify(params)});
|
||||
})();
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { requestYomitanScanTokens } from './yomitan-parser-runtime';
|
||||
import {
|
||||
countTermsFindLookups,
|
||||
createNameScanDeps,
|
||||
NAME_SCAN_WORDS,
|
||||
} from './yomitan-scan-test-harness';
|
||||
|
||||
// Behaviour of the in-page scan runtime around character names and kana:
|
||||
// which positions the greedy pre-pass probes, and what the walk makes of
|
||||
// halfwidth spellings. Driven end to end through requestYomitanScanTokens
|
||||
// because the runtime only exists inside the parser window.
|
||||
|
||||
const NAME_SCAN_LINE = 'ミナトはまだ学校にいない';
|
||||
|
||||
test('requestYomitanScanTokens skips name pre-pass lookups where no candidate name can start', async () => {
|
||||
const exhaustiveLookups: string[] = [];
|
||||
const exhaustive = await requestYomitanScanTokens(
|
||||
NAME_SCAN_LINE,
|
||||
createNameScanDeps(exhaustiveLookups),
|
||||
{ error: () => undefined },
|
||||
{ includeNameMatchMetadata: true },
|
||||
);
|
||||
|
||||
const prefilteredLookups: string[] = [];
|
||||
const prefiltered = await requestYomitanScanTokens(
|
||||
NAME_SCAN_LINE,
|
||||
createNameScanDeps(prefilteredLookups),
|
||||
{ error: () => undefined },
|
||||
{
|
||||
includeNameMatchMetadata: true,
|
||||
currentCharacterDictionaryMediaId: 1,
|
||||
// Terms and readings the generated dictionary exposes for this media.
|
||||
nameCandidates: { key: 'media-1', forms: ['ミナト', 'みなと'] },
|
||||
},
|
||||
);
|
||||
|
||||
// Same tokenization, including the name match, with fewer round trips.
|
||||
assert.deepEqual(prefiltered, exhaustive);
|
||||
assert.equal(prefiltered?.[0]?.surface, 'ミナト');
|
||||
assert.equal(prefiltered?.[0]?.isNameMatch, true);
|
||||
assert.ok(
|
||||
prefilteredLookups.length < exhaustiveLookups.length,
|
||||
`expected fewer lookups with candidates (${prefilteredLookups.length} vs ${exhaustiveLookups.length})`,
|
||||
);
|
||||
// Mid-token positions are exactly what the pre-pass used to probe (a name can
|
||||
// start mid-token); with candidates they cost nothing, while the main walk's
|
||||
// own token-start lookups are unaffected.
|
||||
assert.ok(countTermsFindLookups(exhaustiveLookups, '校に') > 0);
|
||||
assert.equal(countTermsFindLookups(prefilteredLookups, '校に'), 0);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens matches a katakana name from its kana-normalized candidate form', async () => {
|
||||
const lookups: string[] = [];
|
||||
const result = await requestYomitanScanTokens(
|
||||
NAME_SCAN_LINE,
|
||||
createNameScanDeps(lookups),
|
||||
{ error: () => undefined },
|
||||
{
|
||||
includeNameMatchMetadata: true,
|
||||
currentCharacterDictionaryMediaId: 1,
|
||||
// Only the hiragana reading is listed; the katakana surface in the line
|
||||
// must still be found through kana normalization.
|
||||
nameCandidates: { key: 'media-1', forms: ['みなと'] },
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result?.[0]?.surface, 'ミナト');
|
||||
assert.equal(result?.[0]?.isNameMatch, true);
|
||||
});
|
||||
|
||||
// Kana normalization folds halfwidth katakana, so a name written that way does
|
||||
// prefix-match a candidate form — but only if the position counts as Japanese
|
||||
// in the first place. The generic word here reaches into the name, so only a
|
||||
// pre-pass reservation can keep the name whole.
|
||||
const HALFWIDTH_NAME_SCAN_WORDS: Array<[string, string, string, boolean]> = [
|
||||
['ネコ', 'ネコ', 'ねこ', false],
|
||||
['まだミ', 'まだミ', 'まだみ', false],
|
||||
['まだ', 'まだ', 'まだ', false],
|
||||
['ミナト', 'ミナト', 'みなと', true],
|
||||
];
|
||||
|
||||
test('requestYomitanScanTokens probes halfwidth katakana positions during the name pre-pass', async () => {
|
||||
const lookups: string[] = [];
|
||||
const result = await requestYomitanScanTokens(
|
||||
'ネコまだミナト',
|
||||
createNameScanDeps(lookups, HALFWIDTH_NAME_SCAN_WORDS),
|
||||
{ error: () => undefined },
|
||||
{
|
||||
includeNameMatchMetadata: true,
|
||||
currentCharacterDictionaryMediaId: 1,
|
||||
// Fullwidth forms only, as the generated dictionary stores them.
|
||||
nameCandidates: { key: 'media-1', forms: ['ミナト', 'みなと'] },
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(countTermsFindLookups(lookups, 'ミナト'), 1);
|
||||
// コ is mid-token, so only the pre-pass would ever look it up, and it matches
|
||||
// no candidate: folding halfwidth made those positions indexable, so they no
|
||||
// longer cost a round trip apiece.
|
||||
assert.equal(countTermsFindLookups(lookups, 'コ'), 0);
|
||||
assert.deepEqual(
|
||||
result?.map((token) => token.surface),
|
||||
['ネコ', 'まだ', 'ミナト'],
|
||||
);
|
||||
assert.equal(result?.[2]?.isNameMatch, true);
|
||||
// The reading is written the way the fullwidth katakana path writes it
|
||||
// (surface spelling, fullwidth): halfwidth kana is not kana to the known-word
|
||||
// and frequency code downstream, and an empty reading there disables the
|
||||
// reading fallback entirely.
|
||||
assert.equal(result?.[2]?.reading, 'ミナト');
|
||||
assert.equal(result?.[2]?.headwordReading, 'みなと');
|
||||
});
|
||||
|
||||
test('a voiced halfwidth name still bypasses the candidate prefilter', async () => {
|
||||
const lookups: string[] = [];
|
||||
const result = await requestYomitanScanTokens(
|
||||
'まだガク',
|
||||
createNameScanDeps(lookups, [
|
||||
['まだカ', 'まだカ', 'まだか', false],
|
||||
['まだ', 'まだ', 'まだ', false],
|
||||
['ガク', 'ガク', 'がく', true],
|
||||
]),
|
||||
{ error: () => undefined },
|
||||
{
|
||||
includeNameMatchMetadata: true,
|
||||
currentCharacterDictionaryMediaId: 1,
|
||||
nameCandidates: { key: 'media-1', forms: ['ガク', 'がく'] },
|
||||
},
|
||||
);
|
||||
|
||||
// カ + ゙ folds to か + ゙, which cannot prefix-match が, so the prefilter would
|
||||
// drop this position; the voiced-mark bypass is what keeps the name.
|
||||
assert.deepEqual(
|
||||
result?.map((token) => token.surface),
|
||||
['まだ', 'ガク'],
|
||||
);
|
||||
assert.equal(result?.[1]?.isNameMatch, true);
|
||||
});
|
||||
|
||||
test('an unrelated halfwidth voiced word does not restore the exhaustive pre-pass', async () => {
|
||||
const baseline: string[] = [];
|
||||
await requestYomitanScanTokens(
|
||||
NAME_SCAN_LINE,
|
||||
createNameScanDeps(baseline),
|
||||
{ error: () => undefined },
|
||||
{
|
||||
includeNameMatchMetadata: true,
|
||||
currentCharacterDictionaryMediaId: 1,
|
||||
nameCandidates: { key: 'media-1', forms: ['ミナト', 'みなと'] },
|
||||
},
|
||||
);
|
||||
|
||||
const withVoicedTail: string[] = [];
|
||||
await requestYomitanScanTokens(
|
||||
`${NAME_SCAN_LINE}ガ`,
|
||||
createNameScanDeps(withVoicedTail),
|
||||
{ error: () => undefined },
|
||||
{
|
||||
includeNameMatchMetadata: true,
|
||||
currentCharacterDictionaryMediaId: 1,
|
||||
nameCandidates: { key: 'media-1', forms: ['ミナト', 'みなと'] },
|
||||
},
|
||||
);
|
||||
|
||||
// Mid-token positions are the ones only the pre-pass would ever probe. A ガ
|
||||
// anywhere in the line used to drag every position within scanLength of it
|
||||
// back in; now only the voiced pair itself, which the fold cannot index, is
|
||||
// added to what the line already looked up.
|
||||
for (const midTokenPrefix of ['ナト', 'だ学', '校に', 'ない']) {
|
||||
assert.equal(countTermsFindLookups(baseline, midTokenPrefix), 0, midTokenPrefix);
|
||||
assert.equal(countTermsFindLookups(withVoicedTail, midTokenPrefix), 0, midTokenPrefix);
|
||||
}
|
||||
assert.ok(
|
||||
withVoicedTail.length - baseline.length <= 3,
|
||||
`expected the ガ tail to add only its own lookups, saw ${JSON.stringify(withVoicedTail)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a mixed-width voiced name survives the candidate prefilter', async () => {
|
||||
const lookups: string[] = [];
|
||||
const result = await requestYomitanScanTokens(
|
||||
'まだ山ガク',
|
||||
createNameScanDeps(lookups, [
|
||||
['まだ山', 'まだ山', 'まだやま', false],
|
||||
['まだ', 'まだ', 'まだ', false],
|
||||
['山ガク', '山ガク', 'やまがく', true],
|
||||
]),
|
||||
{ error: () => undefined },
|
||||
{
|
||||
includeNameMatchMetadata: true,
|
||||
currentCharacterDictionaryMediaId: 1,
|
||||
nameCandidates: { key: 'media-1', forms: ['山ガク', 'やまがく'] },
|
||||
},
|
||||
);
|
||||
|
||||
// The name starts on a kanji, so the fold only breaks mid-name: 山ガク
|
||||
// normalizes to 山がく, which still cannot match the candidate 山がく. The
|
||||
// bypass is keyed on the scan window rather than the first character, so the
|
||||
// position is still probed and the generic まだ山 cannot swallow the 山.
|
||||
assert.deepEqual(
|
||||
result?.map((token) => token.surface),
|
||||
['まだ', '山ガク'],
|
||||
);
|
||||
assert.equal(result?.[1]?.isNameMatch, true);
|
||||
});
|
||||
|
||||
test('a stretched mixed-width voiced name survives the candidate prefilter', async () => {
|
||||
const lookups: string[] = [];
|
||||
const result = await requestYomitanScanTokens(
|
||||
'まだ山ーーーーーーガク',
|
||||
createNameScanDeps(lookups, [
|
||||
['まだ山', 'まだ山', 'まだやま', false],
|
||||
['まだ', 'まだ', 'まだ', false],
|
||||
['山ーーーーーーガク', '山ガク', 'やまがく', true],
|
||||
]),
|
||||
{ error: () => undefined },
|
||||
{
|
||||
includeNameMatchMetadata: true,
|
||||
currentCharacterDictionaryMediaId: 1,
|
||||
nameCandidates: { key: 'media-1', forms: ['山ガク', 'やまがく'] },
|
||||
},
|
||||
);
|
||||
|
||||
// Matching skips any number of emphatic characters, so the voiced mark that
|
||||
// defeats the fold can sit arbitrarily far into the name: the search for it
|
||||
// has to cover the whole lookup window, not a multiple of the form length.
|
||||
assert.deepEqual(
|
||||
result?.map((token) => token.surface),
|
||||
['まだ', '山ーーーーーーガク'],
|
||||
);
|
||||
assert.equal(result?.[1]?.isNameMatch, true);
|
||||
});
|
||||
|
||||
test('halfwidth voiced kana compose into the reading instead of leaving a stray mark', async () => {
|
||||
const lookups: string[] = [];
|
||||
const result = await requestYomitanScanTokens(
|
||||
'ガク パン',
|
||||
createNameScanDeps(lookups, [
|
||||
['ガク', 'ガク', 'がく', false],
|
||||
['パン', 'パン', 'ぱん', false],
|
||||
]),
|
||||
{ error: () => undefined },
|
||||
{ includeNameMatchMetadata: true },
|
||||
);
|
||||
|
||||
// The name pre-pass runs over every position here (no candidate list), but a
|
||||
// standalone voiced mark can never start a name, so it costs no lookup.
|
||||
assert.equal(countTermsFindLookups(lookups, '゙'), 0);
|
||||
assert.equal(countTermsFindLookups(lookups, '゚'), 0);
|
||||
const readings = (result ?? [])
|
||||
.filter((token) => token.isUnparsedRun !== true)
|
||||
.map((token) => [token.surface, token.reading]);
|
||||
assert.deepEqual(readings, [
|
||||
['ガク', 'ガク'],
|
||||
['パン', 'パン'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens falls back to the exhaustive name scan without candidates', async () => {
|
||||
const withoutLookups: string[] = [];
|
||||
const withoutCandidates = await requestYomitanScanTokens(
|
||||
NAME_SCAN_LINE,
|
||||
createNameScanDeps(withoutLookups),
|
||||
{ error: () => undefined },
|
||||
{ includeNameMatchMetadata: true, currentCharacterDictionaryMediaId: 1, nameCandidates: null },
|
||||
);
|
||||
|
||||
assert.equal(withoutCandidates?.[0]?.isNameMatch, true);
|
||||
// No candidate list means every Japanese position is probed, as before.
|
||||
assert.ok(countTermsFindLookups(withoutLookups, '校に') > 0);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens reinstalls name candidates when the media changes', async () => {
|
||||
const lookups: string[] = [];
|
||||
const deps = createNameScanDeps(lookups);
|
||||
|
||||
// First media's candidates cannot match this line's name.
|
||||
const otherMedia = await requestYomitanScanTokens(
|
||||
NAME_SCAN_LINE,
|
||||
deps,
|
||||
{ error: () => undefined },
|
||||
{
|
||||
includeNameMatchMetadata: true,
|
||||
currentCharacterDictionaryMediaId: 2,
|
||||
nameCandidates: { key: 'media-2', forms: ['カズマ'] },
|
||||
},
|
||||
);
|
||||
assert.equal(otherMedia?.[0]?.isNameMatch, undefined);
|
||||
|
||||
const correctMedia = await requestYomitanScanTokens(
|
||||
NAME_SCAN_LINE,
|
||||
deps,
|
||||
{ error: () => undefined },
|
||||
{
|
||||
includeNameMatchMetadata: true,
|
||||
currentCharacterDictionaryMediaId: 1,
|
||||
nameCandidates: { key: 'media-1', forms: ['ミナト'] },
|
||||
},
|
||||
);
|
||||
assert.equal(correctMedia?.[0]?.surface, 'ミナト');
|
||||
assert.equal(correctMedia?.[0]?.isNameMatch, true);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
// Shared harness for the Yomitan parser-runtime and scan-runtime tests: fake
|
||||
// parser-window deps whose injected scripts run in a vm context, plus the
|
||||
// backend stubs the scanner tests drive them with. Kept out of the test files
|
||||
// so the runtime tests and the in-page scanner tests can share one setup.
|
||||
import * as vm from 'node:vm';
|
||||
|
||||
export function createDeps(
|
||||
executeJavaScript: (script: string) => Promise<unknown>,
|
||||
options?: {
|
||||
createYomitanExtensionWindow?: (pageName: string) => Promise<unknown>;
|
||||
},
|
||||
) {
|
||||
const parserWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
executeJavaScript: async (script: string) => await executeJavaScript(script),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
getYomitanExt: () => ({ id: 'ext-id' }) as never,
|
||||
getYomitanParserWindow: () => parserWindow as never,
|
||||
setYomitanParserWindow: () => undefined,
|
||||
getYomitanParserReadyPromise: () => null,
|
||||
setYomitanParserReadyPromise: () => undefined,
|
||||
getYomitanParserInitPromise: () => null,
|
||||
setYomitanParserInitPromise: () => undefined,
|
||||
createYomitanExtensionWindow: options?.createYomitanExtensionWindow as never,
|
||||
};
|
||||
}
|
||||
|
||||
function createYomitanScriptSandbox(handler: (action: string, params: unknown) => unknown) {
|
||||
return {
|
||||
chrome: {
|
||||
runtime: {
|
||||
lastError: null,
|
||||
sendMessage: (
|
||||
payload: { action?: string; params?: unknown },
|
||||
callback: (response: { result?: unknown; error?: { message?: string } }) => void,
|
||||
) => {
|
||||
try {
|
||||
callback({ result: handler(payload.action ?? '', payload.params) });
|
||||
} catch (error) {
|
||||
callback({ error: { message: (error as Error).message } });
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
Array,
|
||||
Error,
|
||||
JSON,
|
||||
Map,
|
||||
Math,
|
||||
Number,
|
||||
Object,
|
||||
Promise,
|
||||
RegExp,
|
||||
Set,
|
||||
String,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runInjectedYomitanScript(
|
||||
script: string,
|
||||
handler: (action: string, params: unknown) => unknown,
|
||||
): Promise<unknown> {
|
||||
return await vm.runInNewContext(script, createYomitanScriptSandbox(handler));
|
||||
}
|
||||
|
||||
// Persistent page context shared across executeJavaScript calls, matching the
|
||||
// real parser window: the scan runtime is installed once via
|
||||
// globalThis.__subminerYomitanScan and per-line calls reuse it (and its
|
||||
// cross-line termsFind cache).
|
||||
function createPersistentYomitanScriptRunner(
|
||||
handler: (action: string, params: unknown) => unknown,
|
||||
): (script: string) => Promise<unknown> {
|
||||
const context = vm.createContext(createYomitanScriptSandbox(handler));
|
||||
return async (script: string) => await vm.runInContext(script, context);
|
||||
}
|
||||
|
||||
// Deps whose parser window executes every injected script (profile metadata,
|
||||
// scan runtime install, per-line scan calls, parseText fallback) inside one
|
||||
// persistent vm context, dispatching backend actions to `handler`.
|
||||
export function createScanDeps(
|
||||
handler: (action: string, params: unknown) => unknown,
|
||||
options?: { onScript?: (script: string) => void },
|
||||
) {
|
||||
const runScript = createPersistentYomitanScriptRunner(handler);
|
||||
return createDeps(async (script) => {
|
||||
options?.onScript?.(script);
|
||||
return await runScript(script);
|
||||
});
|
||||
}
|
||||
|
||||
export function countTermsFindLookups(lookups: string[], prefix: string): number {
|
||||
return lookups.filter((lookupText) => lookupText.startsWith(prefix)).length;
|
||||
}
|
||||
|
||||
// Backend stub for the greedy name pre-pass: one character name (ミナト) in a
|
||||
// line of ordinary words, with the SubMiner character dictionary enabled.
|
||||
export const NAME_SCAN_WORDS: Array<[string, string, string, boolean]> = [
|
||||
['ミナト', 'ミナト', 'みなと', true],
|
||||
['は', 'は', 'は', false],
|
||||
['まだ', 'まだ', 'まだ', false],
|
||||
['学校', '学校', 'がっこう', false],
|
||||
['に', 'に', 'に', false],
|
||||
['いない', 'いる', 'いる', false],
|
||||
];
|
||||
|
||||
export function createNameScanDeps(
|
||||
lookups: string[],
|
||||
words: Array<[string, string, string, boolean]> = NAME_SCAN_WORDS,
|
||||
) {
|
||||
return createScanDeps((action, params) => {
|
||||
if (action === 'optionsGetFull') {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
dictionaries: [
|
||||
{ name: 'JMdict', enabled: true, id: 0 },
|
||||
{
|
||||
name: 'SubMiner Character Dictionary (AniList 1)',
|
||||
enabled: true,
|
||||
id: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (action === 'getDictionaryInfo') {
|
||||
return [];
|
||||
}
|
||||
if (action !== 'termsFind') {
|
||||
throw new Error(`unexpected action: ${action}`);
|
||||
}
|
||||
const text = (params as { text?: string } | undefined)?.text ?? '';
|
||||
lookups.push(text);
|
||||
for (const [surface, term, reading, isName] of words) {
|
||||
if (text.startsWith(surface)) {
|
||||
return {
|
||||
originalTextLength: surface.length,
|
||||
dictionaryEntries: [
|
||||
{
|
||||
headwords: [
|
||||
{
|
||||
term,
|
||||
reading,
|
||||
sources: [{ originalText: surface, isPrimary: true, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
definitions: [
|
||||
{ dictionary: isName ? 'SubMiner Character Dictionary (AniList 1)' : 'JMdict' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
return { originalTextLength: 0, dictionaryEntries: [] };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Helper bundle for the in-page Yomitan scan runtime, composed from the
|
||||
// fragments below. Injected as text into the parser window by
|
||||
// yomitan-scan-runtime-script.ts, so it is data here, not code this process
|
||||
// runs. The fragments are concatenated into a single function body and share
|
||||
// one lexical scope: every function in them is hoisted, but the constants are
|
||||
// not, so kana stays first — the later fragments read its ranges as they run.
|
||||
import { YOMITAN_DICTIONARY_CLASSIFICATION_HELPERS } from './yomitan-dictionary-classification-script';
|
||||
import { YOMITAN_FREQUENCY_HELPERS } from './yomitan-frequency-script';
|
||||
import { YOMITAN_FURIGANA_HELPERS } from './yomitan-furigana-script';
|
||||
import { YOMITAN_KANA_HELPERS } from './yomitan-kana-script';
|
||||
import { YOMITAN_MATCH_SELECTION_HELPERS } from './yomitan-match-selection-script';
|
||||
|
||||
export { CHARACTER_DICTIONARY_TITLE_PREFIX } from './character-dictionary-title';
|
||||
|
||||
export const YOMITAN_SCANNING_HELPERS = [
|
||||
YOMITAN_KANA_HELPERS,
|
||||
YOMITAN_FURIGANA_HELPERS,
|
||||
YOMITAN_FREQUENCY_HELPERS,
|
||||
YOMITAN_DICTIONARY_CLASSIFICATION_HELPERS,
|
||||
YOMITAN_MATCH_SELECTION_HELPERS,
|
||||
].join('\n');
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { HAN_CODE_POINT_RANGES, HAN_REGEXP_CLASS_BODY, isHanCodePoint } from './han-code-points';
|
||||
|
||||
test('every range boundary is inside the table', () => {
|
||||
for (const [start, end] of HAN_CODE_POINT_RANGES) {
|
||||
for (const codePoint of [start, end]) {
|
||||
assert.ok(isHanCodePoint(codePoint), `expected U+${codePoint.toString(16)} to be Han`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extension J (Unicode 17) and the Compatibility blocks are the ones a
|
||||
// BMP-only table used to miss.
|
||||
assert.ok(isHanCodePoint(0x323b0));
|
||||
assert.ok(isHanCodePoint(0x33479));
|
||||
assert.ok(isHanCodePoint(0xf900));
|
||||
assert.ok(isHanCodePoint(0x2f800));
|
||||
});
|
||||
|
||||
test('no unified ideograph the runtime knows about falls outside the table', () => {
|
||||
// One direction only: a runtime with older Unicode data simply checks fewer
|
||||
// code points, where asserting the reverse would fail on Extension J.
|
||||
const unifiedIdeograph = /\p{Unified_Ideograph}/u;
|
||||
|
||||
for (let codePoint = 0x3000; codePoint <= 0x40000; codePoint += 1) {
|
||||
if (unifiedIdeograph.test(String.fromCodePoint(codePoint))) {
|
||||
assert.ok(
|
||||
isHanCodePoint(codePoint),
|
||||
`expected unified ideograph U+${codePoint.toString(16)} to be in the table`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('code points just outside the table are rejected', () => {
|
||||
for (const codePoint of [0x33ff, 0x4dc0, 0xa000, 0x1f000, 0x3347a]) {
|
||||
assert.equal(
|
||||
isHanCodePoint(codePoint),
|
||||
false,
|
||||
`expected U+${codePoint.toString(16)} not to be Han`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the regexp class body matches the same code points as the predicate', () => {
|
||||
const classRegExp = new RegExp(`^[${HAN_REGEXP_CLASS_BODY}]$`, 'u');
|
||||
|
||||
for (const codePoint of [0x3400, 0x4e00, 0x9fff, 0xf900, 0x20000, 0x323b0, 0x33479]) {
|
||||
assert.match(String.fromCodePoint(codePoint), classRegExp);
|
||||
}
|
||||
for (const codePoint of [0x3040, 0x30ff, 0x33fa, 0x3347a]) {
|
||||
assert.doesNotMatch(String.fromCodePoint(codePoint), classRegExp);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// Single source of truth for "this code point is a Han character", shared by
|
||||
// the main-process character dictionary and the in-page Yomitan scan runtime.
|
||||
// The two used to carry separate range lists, and they drifted: a name written
|
||||
// with a supplementary-plane kanji could enter the generated dictionary while
|
||||
// the scanner's greedy name pre-pass refused to probe the position.
|
||||
//
|
||||
// Ranges rather than \p{Script=Han}: the scan walk tests one code point per
|
||||
// character of every subtitle line, where an integer compare beats building a
|
||||
// string for a regex, and the script is injected as text into a page where a
|
||||
// shared helper cannot be imported.
|
||||
export const HAN_CODE_POINT_RANGES: ReadonlyArray<readonly [number, number]> = [
|
||||
[0x3400, 0x4dbf], // Extension A
|
||||
[0x4e00, 0x9fff], // CJK Unified Ideographs
|
||||
[0xf900, 0xfaff], // Compatibility Ideographs
|
||||
[0x20000, 0x2a6df], // Extension B
|
||||
[0x2a700, 0x2ebef], // Extensions C-F
|
||||
[0x2ebf0, 0x2ee5f], // Extension I
|
||||
[0x2f800, 0x2fa1f], // Compatibility Ideographs Supplement
|
||||
[0x30000, 0x3134f], // Extension G
|
||||
[0x31350, 0x323af], // Extension H
|
||||
[0x323b0, 0x33479], // Extension J (Unicode 17)
|
||||
];
|
||||
|
||||
export function isHanCodePoint(codePoint: number): boolean {
|
||||
return HAN_CODE_POINT_RANGES.some(([start, end]) => codePoint >= start && codePoint <= end);
|
||||
}
|
||||
|
||||
/** The same ranges as a regular expression character class body (needs the `u` flag). */
|
||||
export const HAN_REGEXP_CLASS_BODY = HAN_CODE_POINT_RANGES.map(
|
||||
([start, end]) => `\\u{${start.toString(16)}}-\\u{${end.toString(16)}}`,
|
||||
).join('');
|
||||
@@ -0,0 +1,203 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseChangelog, resolveChangelogGroupKey } from './changelog-parse';
|
||||
|
||||
const SAMPLE = `# Changelog
|
||||
|
||||
## v0.19.2 (2026-08-04)
|
||||
|
||||
### Changed
|
||||
- Subsync: picks both tracks now.
|
||||
|
||||
### Fixed
|
||||
- Overlay: shows the plain line immediately.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- Patched \`undici\`.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.1 (2026-08-01)
|
||||
|
||||
### Added
|
||||
- **Word Card Type:**
|
||||
- Adds a setting.
|
||||
- Flags clear each other.
|
||||
|
||||
## v0.18.0 (2026-07-01)
|
||||
|
||||
### Fixed
|
||||
- Something older.
|
||||
`;
|
||||
|
||||
test('changelog parser reads versions, dates, and sections in file order', () => {
|
||||
const entries = parseChangelog(SAMPLE);
|
||||
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => `${entry.version}@${entry.date}`),
|
||||
['0.19.2@2026-08-04', '0.19.1@2026-08-01', '0.18.0@2026-07-01'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
entries[0]?.sections.map((section) => section.heading),
|
||||
['Changed', 'Fixed', 'Internal'],
|
||||
);
|
||||
assert.deepEqual(entries[0]?.sections[1]?.items, [
|
||||
{ text: 'Overlay: shows the plain line immediately.', children: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('changelog parser flags sections inside the details block as internal', () => {
|
||||
const entries = parseChangelog(SAMPLE);
|
||||
const sections = entries[0]?.sections ?? [];
|
||||
|
||||
assert.deepEqual(
|
||||
sections.map((section) => section.internal),
|
||||
[false, false, true],
|
||||
);
|
||||
assert.deepEqual(sections[2]?.items, [{ text: 'Patched `undici`.', children: [] }]);
|
||||
});
|
||||
|
||||
test('changelog parser groups entries by major.minor', () => {
|
||||
const entries = parseChangelog(SAMPLE);
|
||||
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.groupKey),
|
||||
['0.19', '0.19', '0.18'],
|
||||
);
|
||||
assert.equal(resolveChangelogGroupKey('1.2.3'), '1.2');
|
||||
});
|
||||
|
||||
test('changelog parser keeps bullets that precede any section heading', () => {
|
||||
const entries = parseChangelog('## v0.1.0 (2025-01-01)\n\n- Initial release.\n');
|
||||
|
||||
assert.deepEqual(entries[0]?.sections, [
|
||||
{
|
||||
heading: 'Changes',
|
||||
items: [{ text: 'Initial release.', children: [] }],
|
||||
internal: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('changelog parser drops empty sections and tolerates missing dates', () => {
|
||||
const entries = parseChangelog('## v0.2.0\n\n### Added\n\n### Fixed\n- One fix.\n');
|
||||
|
||||
assert.equal(entries[0]?.date, '');
|
||||
assert.deepEqual(
|
||||
entries[0]?.sections.map((section) => section.heading),
|
||||
['Fixed'],
|
||||
);
|
||||
});
|
||||
|
||||
test('changelog parser keeps indented sub-bullets nested under their lead bullet', () => {
|
||||
const entries = parseChangelog(SAMPLE);
|
||||
const added = entries[1]?.sections.find((section) => section.heading === 'Added');
|
||||
|
||||
assert.deepEqual(added?.items, [
|
||||
{
|
||||
text: '**Word Card Type:**',
|
||||
children: [
|
||||
{ text: 'Adds a setting.', children: [] },
|
||||
{ text: 'Flags clear each other.', children: [] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('changelog parser nests three bullet levels and rejoins wrapped lines', () => {
|
||||
const entries = parseChangelog(
|
||||
[
|
||||
'## v0.9.0 (2025-05-05)',
|
||||
'',
|
||||
'### Added',
|
||||
'- Top level',
|
||||
' - Second level',
|
||||
' - Third level',
|
||||
' continued on the next line',
|
||||
' - Back to second level',
|
||||
'- Another top level',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.deepEqual(entries[0]?.sections[0]?.items, [
|
||||
{
|
||||
text: 'Top level',
|
||||
children: [
|
||||
{
|
||||
text: 'Second level',
|
||||
children: [{ text: 'Third level continued on the next line', children: [] }],
|
||||
},
|
||||
{ text: 'Back to second level', children: [] },
|
||||
],
|
||||
},
|
||||
{ text: 'Another top level', children: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('changelog parser reads prerelease and build metadata version headings', () => {
|
||||
const entries = parseChangelog(
|
||||
[
|
||||
'## v0.16.0 (2026-06-01)',
|
||||
'',
|
||||
'### Added',
|
||||
'- New in 0.16.',
|
||||
'',
|
||||
'## v0.15.0-rc.1+build.2 (2026-05-29)',
|
||||
'',
|
||||
'### Added',
|
||||
'- Release candidate note.',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
// The prerelease heading has to become its own entry. Asserting the exact
|
||||
// version list is what catches the failure mode: a heading the regex misses
|
||||
// is not skipped, its notes silently fold into the release above it.
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.version),
|
||||
['0.16.0', '0.15.0-rc.1+build.2'],
|
||||
);
|
||||
assert.equal(entries[1]?.date, '2026-05-29');
|
||||
assert.equal(entries[1]?.groupKey, '0.15');
|
||||
assert.equal(entries[0]?.sections.length, 1);
|
||||
// The prerelease body has to land on its own entry, not fold into 0.16.0.
|
||||
assert.deepEqual(entries[1]?.sections, [
|
||||
{
|
||||
heading: 'Added',
|
||||
items: [{ text: 'Release candidate note.', children: [] }],
|
||||
internal: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('changelog parser handles the repo CHANGELOG.md', () => {
|
||||
const markdown = fs.readFileSync(path.join(process.cwd(), 'CHANGELOG.md'), 'utf8');
|
||||
const entries = parseChangelog(markdown);
|
||||
|
||||
assert.ok(entries.length > 3);
|
||||
for (const entry of entries) {
|
||||
assert.match(entry.version, /^\d+\.\d+\.\d+/);
|
||||
assert.ok(entry.sections.length > 0, `expected sections for v${entry.version}`);
|
||||
for (const section of entry.sections) {
|
||||
for (const item of section.items) {
|
||||
assert.ok(item.text.length > 0, `empty bullet in v${entry.version}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Older entries group notes under a bold lead bullet; nesting must survive.
|
||||
const breaking = entries
|
||||
.find((entry) => entry.version === '0.15.0')
|
||||
?.sections.find((section) => section.heading === 'Breaking Changes');
|
||||
assert.deepEqual(
|
||||
breaking?.items.map((item) => `${item.text}:${item.children.length}`),
|
||||
['**Subsync:**:2', '**N+1 Highlighting:**:2'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ChangelogEntry, ChangelogItem, ChangelogSection } from '../../types/changelog';
|
||||
|
||||
// Prerelease and build metadata are matched separately: a single `[-+]`-led
|
||||
// group cannot span `-rc.1+build.2`, and an unmatched heading silently folds
|
||||
// that release's notes into the previous entry.
|
||||
const VERSION_HEADING =
|
||||
/^##\s+v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\s*(?:\(([^)]*)\))?\s*$/;
|
||||
const SECTION_HEADING = /^###\s+(.+?)\s*$/;
|
||||
const BULLET = /^(\s*)[-*]\s+(.*)$/;
|
||||
|
||||
/**
|
||||
* Entries are grouped by `major.minor` so the whole current minor line renders
|
||||
* expanded, matching how docs-site/changelog.md splits current vs previous.
|
||||
*/
|
||||
export function resolveChangelogGroupKey(version: string): string {
|
||||
const match = version.match(/^(\d+)\.(\d+)/);
|
||||
if (!match) return version;
|
||||
return `${match[1]}.${match[2]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the repo CHANGELOG.md into version entries. Bullets keep their inline
|
||||
* markdown and their nesting: older entries group related notes under a bold
|
||||
* lead bullet with indented children, and flattening them loses that structure.
|
||||
*/
|
||||
export function parseChangelog(markdown: string): ChangelogEntry[] {
|
||||
const entries: ChangelogEntry[] = [];
|
||||
let entry: ChangelogEntry | null = null;
|
||||
let section: ChangelogSection | null = null;
|
||||
let internal = false;
|
||||
// Open bullets from outermost to innermost, used to place the next bullet.
|
||||
let openItems: Array<{ indent: number; item: ChangelogItem }> = [];
|
||||
|
||||
function startSection(heading: string): void {
|
||||
section = { heading, items: [], internal };
|
||||
openItems = [];
|
||||
entry?.sections.push(section);
|
||||
}
|
||||
|
||||
function addBullet(indent: number, text: string): void {
|
||||
if (!section) {
|
||||
// Bullets before any "###" heading (older entries) land in a generic group.
|
||||
startSection('Changes');
|
||||
}
|
||||
const item: ChangelogItem = { text, children: [] };
|
||||
|
||||
while (openItems.length > 0 && (openItems[openItems.length - 1]?.indent ?? 0) >= indent) {
|
||||
openItems.pop();
|
||||
}
|
||||
const parent = openItems[openItems.length - 1];
|
||||
if (parent) {
|
||||
parent.item.children.push(item);
|
||||
} else {
|
||||
section?.items.push(item);
|
||||
}
|
||||
openItems.push({ indent, item });
|
||||
}
|
||||
|
||||
function appendContinuation(text: string): void {
|
||||
const current = openItems[openItems.length - 1];
|
||||
if (!current) return;
|
||||
current.item.text = `${current.item.text} ${text}`;
|
||||
}
|
||||
|
||||
for (const rawLine of markdown.split(/\r?\n/)) {
|
||||
const line = rawLine.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
|
||||
const versionMatch = trimmed.match(VERSION_HEADING);
|
||||
if (versionMatch) {
|
||||
const version = versionMatch[1] ?? '';
|
||||
entry = {
|
||||
version,
|
||||
date: versionMatch[2]?.trim() ?? '',
|
||||
groupKey: resolveChangelogGroupKey(version),
|
||||
sections: [],
|
||||
};
|
||||
entries.push(entry);
|
||||
section = null;
|
||||
internal = false;
|
||||
openItems = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry) continue;
|
||||
|
||||
if (trimmed.startsWith('<details')) {
|
||||
internal = true;
|
||||
section = null;
|
||||
openItems = [];
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith('</details')) {
|
||||
internal = false;
|
||||
section = null;
|
||||
openItems = [];
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith('<summary')) continue;
|
||||
|
||||
const sectionMatch = trimmed.match(SECTION_HEADING);
|
||||
if (sectionMatch) {
|
||||
startSection(sectionMatch[1] ?? '');
|
||||
continue;
|
||||
}
|
||||
|
||||
const bulletMatch = line.match(BULLET);
|
||||
if (bulletMatch) {
|
||||
addBullet((bulletMatch[1] ?? '').length, bulletMatch[2] ?? '');
|
||||
continue;
|
||||
}
|
||||
|
||||
// An indented non-bullet line continues the bullet above it, including
|
||||
// across a blank line: that is CommonMark's continuation paragraph, and
|
||||
// dropping the open bullets here would silently discard the text.
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (/^\s/.test(line)) {
|
||||
appendContinuation(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return entries.map((item) => ({
|
||||
...item,
|
||||
sections: item.sections.filter((entrySection) => entrySection.items.length > 0),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Loose semver ordering shared by the updater and the changelog UI.
|
||||
* Returns >0 when `a` is newer, <0 when older, 0 when equal.
|
||||
*/
|
||||
export function compareSemverLike(a: string, b: string): number {
|
||||
const parse = (
|
||||
value: string,
|
||||
): {
|
||||
core: number[];
|
||||
prerelease: Array<number | string>;
|
||||
} => {
|
||||
// Build metadata ("+build.2") is not part of precedence per semver, and
|
||||
// leaving it attached makes it leak into the prerelease comparison.
|
||||
const normalized = value.replace(/^v/i, '').split('+', 1)[0] ?? '';
|
||||
const [coreText = '', ...prereleaseParts] = normalized.split('-');
|
||||
const core = coreText
|
||||
.split('.')
|
||||
.slice(0, 3)
|
||||
.map((part) => Number.parseInt(part, 10) || 0);
|
||||
while (core.length < 3) core.push(0);
|
||||
const prereleaseText = prereleaseParts.join('-');
|
||||
return {
|
||||
core,
|
||||
prerelease: prereleaseText
|
||||
? prereleaseText.split('.').map((part) => {
|
||||
const numeric = Number.parseInt(part, 10);
|
||||
return /^\d+$/.test(part) ? numeric : part;
|
||||
})
|
||||
: [],
|
||||
};
|
||||
};
|
||||
const left = parse(a);
|
||||
const right = parse(b);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const diff = (left.core[i] ?? 0) - (right.core[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
|
||||
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
|
||||
if (left.prerelease.length === 0) return 1;
|
||||
if (right.prerelease.length === 0) return -1;
|
||||
|
||||
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const leftPart = left.prerelease[i];
|
||||
const rightPart = right.prerelease[i];
|
||||
if (leftPart === undefined && rightPart === undefined) return 0;
|
||||
if (leftPart === undefined) return -1;
|
||||
if (rightPart === undefined) return 1;
|
||||
if (leftPart === rightPart) continue;
|
||||
if (typeof leftPart === 'number' && typeof rightPart === 'number') {
|
||||
return leftPart - rightPart;
|
||||
}
|
||||
if (typeof leftPart === 'number') return -1;
|
||||
if (typeof rightPart === 'number') return 1;
|
||||
return leftPart > rightPart ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
+76
-10
@@ -468,6 +468,8 @@ import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku
|
||||
import { openTsukihimeModal as openTsukihimeModalRuntime } from './main/runtime/tsukihime-open';
|
||||
import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open';
|
||||
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
|
||||
import { openChangelogModal as openChangelogModalRuntime } from './main/runtime/changelog-open';
|
||||
import { createChangelogRuntime } from './main/runtime/changelog/changelog-runtime';
|
||||
import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open';
|
||||
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
||||
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
||||
@@ -487,6 +489,7 @@ import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility
|
||||
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
|
||||
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
|
||||
import { createCharacterDictionaryImageLookup } from './main/character-dictionary-runtime/image-lookup';
|
||||
import { createCharacterNameCandidateLookup } from './main/character-dictionary-runtime/name-candidates';
|
||||
import {
|
||||
createCharacterDictionaryAutoSyncRuntimeService,
|
||||
getCharacterDictionaryManagerSnapshot,
|
||||
@@ -506,6 +509,7 @@ import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'
|
||||
import {
|
||||
INSTALL_UPDATE_ACTION_ID,
|
||||
UPDATE_AVAILABLE_NOTIFICATION_ID,
|
||||
VIEW_CHANGELOG_ACTION_ID,
|
||||
} from './main/runtime/update/update-notifications';
|
||||
import { createOverlayNotificationsRuntime } from './main/runtime/overlay-notifications-runtime';
|
||||
import {
|
||||
@@ -1816,7 +1820,7 @@ function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
||||
endTime: appState.mpvClient?.currentSubEnd ?? null,
|
||||
};
|
||||
}
|
||||
function emitSubtitlePayload(payload: SubtitleData): void {
|
||||
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
|
||||
const timedPayload = withCurrentSubtitleTiming(payload);
|
||||
const currentSubtitleData = appState.currentSubtitleData;
|
||||
const isAnnotationUpgrade = isSubtitleAnnotationUpgrade(currentSubtitleData, timedPayload);
|
||||
@@ -1833,7 +1837,13 @@ function emitSubtitlePayload(payload: SubtitleData): void {
|
||||
}
|
||||
annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions);
|
||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true });
|
||||
subtitlePrefetchService?.resume();
|
||||
// resumePrefetch: false marks an emit that is not the end of the work for
|
||||
// this line; prefetch stays paused until the subtitle processing controller
|
||||
// settles so it does not compete with the on-screen line for the single
|
||||
// Yomitan parser window.
|
||||
if (options?.resumePrefetch !== false) {
|
||||
subtitlePrefetchService?.resume();
|
||||
}
|
||||
}
|
||||
function getCurrentAutoplaySubtitlePayload(): SubtitleData | null {
|
||||
const payload = appState.currentSubtitleData;
|
||||
@@ -1889,7 +1899,17 @@ const buildSubtitleProcessingControllerMainDepsHandler =
|
||||
createBuildSubtitleProcessingControllerMainDepsHandler({
|
||||
tokenizeSubtitle: async (text: string) =>
|
||||
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null },
|
||||
emitSubtitle: (payload) => emitSubtitlePayload(payload),
|
||||
// Controller emits never release the prefetch pause: the first emit for an
|
||||
// uncached line is the provisional plain payload, sent before tokenization
|
||||
// starts, so resuming on it would put prefetch back in contention with the
|
||||
// on-screen line for the single parser window.
|
||||
emitSubtitle: (payload) => emitSubtitlePayload(payload, { resumePrefetch: false }),
|
||||
// The pause is released once the controller has no work left, which covers
|
||||
// the runs that end without an emit (suppressed duplicate, failed
|
||||
// tokenization) as well as the ones that deliver a payload.
|
||||
onProcessingSettled: () => {
|
||||
subtitlePrefetchService?.resume();
|
||||
},
|
||||
logDebug: (message) => {
|
||||
logger.debug(`[subtitle-processing] ${message}`);
|
||||
},
|
||||
@@ -1926,7 +1946,7 @@ const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
|
||||
appState.activeParsedSubtitleMediaPath = mediaPath;
|
||||
},
|
||||
subtitleProcessingController,
|
||||
emitSubtitlePayload: (payload) => emitSubtitlePayload(payload),
|
||||
emitSubtitlePayload: (payload, options) => emitSubtitlePayload(payload, options),
|
||||
getSubtitlePrefetchService: () => subtitlePrefetchService,
|
||||
getLastObservedTimePos: () => lastObservedTimePos,
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
@@ -2532,6 +2552,10 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
},
|
||||
{
|
||||
hasParserWindow: () => Boolean(appState.yomitanParserWindow),
|
||||
invalidateCharacterDictionaryLookups: () => {
|
||||
characterDictionaryImageLookup.invalidate();
|
||||
characterNameCandidateLookup.invalidate();
|
||||
},
|
||||
clearParserCaches: () => {
|
||||
if (appState.yomitanParserWindow) {
|
||||
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
|
||||
@@ -2557,6 +2581,13 @@ const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
|
||||
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
||||
});
|
||||
|
||||
// Lets the Yomitan scan runtime skip name lookups at positions where no
|
||||
// character name can start; absent candidates just mean the exhaustive scan.
|
||||
const characterNameCandidateLookup = createCharacterNameCandidateLookup({
|
||||
userDataPath: USER_DATA_PATH,
|
||||
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
||||
});
|
||||
|
||||
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
createBuildOverlayVisibilityRuntimeMainDepsHandler({
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
@@ -2828,6 +2859,14 @@ function openSessionHelpOverlay(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function openChangelogOverlay(): void {
|
||||
openOverlayHostedModalWithOsd(
|
||||
openChangelogModalRuntime,
|
||||
'Changelog overlay unavailable.',
|
||||
'Failed to open changelog overlay.',
|
||||
);
|
||||
}
|
||||
|
||||
function openCharacterDictionaryManagerOverlay(): void {
|
||||
openCharacterDictionaryManagerWithConfigGate({
|
||||
isCharacterDictionaryEnabled: () => configService.getConfig().subtitleStyle.nameMatchEnabled,
|
||||
@@ -3971,7 +4010,10 @@ const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
|
||||
}
|
||||
subtitleProcessingController.invalidateTokenizationCache();
|
||||
subtitlePrefetchService?.onSeek(lastObservedTimePos);
|
||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||
if (!subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText)) {
|
||||
// Idle controller: no settle is coming to release the pause above.
|
||||
subtitlePrefetchService?.resume();
|
||||
}
|
||||
};
|
||||
let hasAttemptedImmersionTrackerStartup = false;
|
||||
const ensureImmersionTrackerStarted = (): void => {
|
||||
@@ -4372,9 +4414,15 @@ const {
|
||||
emitSubtitlePayload(payload);
|
||||
},
|
||||
onSubtitleChange: (text) => {
|
||||
// Pause only; restarting the prefetch run here would discard in-flight
|
||||
// tokenization work on every line. Real seeks restart via onTimePosUpdate.
|
||||
subtitlePrefetchService?.pause();
|
||||
subtitlePrefetchService?.onSeek(lastObservedTimePos);
|
||||
subtitleProcessingController.onSubtitleChange(text);
|
||||
if (!subtitleProcessingController.onSubtitleChange(text)) {
|
||||
// Repeat of the current text: the controller is idle, so no settle is
|
||||
// coming to release the pause. Resume now instead of idling prefetch
|
||||
// for the rest of the cue.
|
||||
subtitlePrefetchService?.resume();
|
||||
}
|
||||
},
|
||||
refreshDiscordPresence: () => {
|
||||
discordPresenceRuntime.publishDiscordPresence();
|
||||
@@ -4618,6 +4666,7 @@ const {
|
||||
getCharacterNameImage: (term) => characterDictionaryImageLookup.get(term),
|
||||
getCurrentCharacterDictionaryMediaId: () =>
|
||||
characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
||||
getCharacterNameCandidates: () => characterNameCandidateLookup.get(),
|
||||
getFrequencyDictionaryEnabled: () =>
|
||||
getRuntimeBooleanOption(
|
||||
'subtitle.annotation.frequency',
|
||||
@@ -5095,6 +5144,18 @@ flushPendingMpvLogWrites = () => {
|
||||
void flushMpvLog();
|
||||
};
|
||||
|
||||
const { getChangelogSnapshot } = createChangelogRuntime({
|
||||
getInstalledVersion: () => app.getVersion(),
|
||||
getUpdateChannel: () => configService.getConfig().updates.channel,
|
||||
resourcesPath: process.resourcesPath,
|
||||
appPath: app.getAppPath(),
|
||||
dirname: __dirname,
|
||||
joinPath: (...parts) => path.join(...parts),
|
||||
fileExists: (candidate) => fs.existsSync(candidate),
|
||||
readFile: (candidate) => fs.readFileSync(candidate, 'utf8'),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
});
|
||||
|
||||
const { getUpdateService } = createUpdateServiceRuntime({
|
||||
userDataPath: USER_DATA_PATH,
|
||||
getUpdatesConfig: () => configService.getConfig().updates,
|
||||
@@ -5463,6 +5524,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
logger.warn('Failed to install update from overlay notification action:', error);
|
||||
});
|
||||
}
|
||||
if (
|
||||
notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID &&
|
||||
actionId === VIEW_CHANGELOG_ACTION_ID
|
||||
) {
|
||||
openChangelogOverlay();
|
||||
}
|
||||
if (actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined) {
|
||||
void openAnkiCardFromNotification(noteId).catch((error) => {
|
||||
logger.warn('Failed to open Anki card from overlay notification action:', error);
|
||||
@@ -5672,7 +5739,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
if (result.ok && result.rebuildRequired) {
|
||||
try {
|
||||
await characterDictionaryAutoSyncRuntime.runSyncNow();
|
||||
characterDictionaryImageLookup.invalidate();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to rebuild character dictionary after manager override:', error);
|
||||
}
|
||||
@@ -5703,7 +5769,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
if (result.ok && result.rebuildRequired) {
|
||||
try {
|
||||
await characterDictionaryAutoSyncRuntime.runSyncNow();
|
||||
characterDictionaryImageLookup.invalidate();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to rebuild character dictionary after manager removal:', error);
|
||||
}
|
||||
@@ -5720,7 +5785,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
if (result.ok && result.rebuildRequired) {
|
||||
try {
|
||||
await characterDictionaryAutoSyncRuntime.runSyncNow();
|
||||
characterDictionaryImageLookup.invalidate();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to rebuild character dictionary after manager reorder:', error);
|
||||
}
|
||||
@@ -5728,6 +5792,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
return result;
|
||||
},
|
||||
appendClipboardVideoToQueue: () => appendClipboardVideoToQueueHandler(),
|
||||
getChangelogSnapshot: (options) => getChangelogSnapshot(options),
|
||||
...playlistBrowserMainDeps,
|
||||
getImmersionTracker: () => appState.immersionTracker,
|
||||
},
|
||||
@@ -6118,6 +6183,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
openSessionHelpModal: () => openSessionHelpOverlay(),
|
||||
openChangelogModal: () => openChangelogOverlay(),
|
||||
openTexthookerInBrowser: () =>
|
||||
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
|
||||
showTexthookerPage: () => shouldShowTexthookerTrayEntry(configService.getConfig()),
|
||||
|
||||
@@ -1903,7 +1903,7 @@ test('generateForCurrentMedia logs progress while resolving and rebuilding snaps
|
||||
'[dictionary] current anime guess: The Eminence in Shadow (episode 5)',
|
||||
'[dictionary] AniList match: The Eminence in Shadow -> AniList 130298',
|
||||
'[dictionary] snapshot miss for AniList 130298, fetching characters',
|
||||
'[dictionary] downloaded AniList character page 1 for AniList 130298',
|
||||
'[dictionary] downloaded AniList character page 1 for AniList 130298 (1 characters)',
|
||||
'[dictionary] downloading 1 images for AniList 130298',
|
||||
'[dictionary] stored snapshot for AniList 130298: 16 terms',
|
||||
'[dictionary] building ZIP for AniList 130298',
|
||||
|
||||
@@ -64,6 +64,7 @@ export type {
|
||||
CharacterDictionarySnapshotProgress,
|
||||
CharacterDictionarySnapshotProgressCallbacks,
|
||||
CharacterDictionarySnapshotResult,
|
||||
CharacterDictionarySnapshotStageProgress,
|
||||
MergedCharacterDictionaryBuildResult,
|
||||
} from './character-dictionary-runtime/types';
|
||||
|
||||
@@ -363,19 +364,28 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
deps.logInfo?.(`[dictionary] snapshot stale for AniList ${mediaId}: ${refreshReason}`);
|
||||
}
|
||||
|
||||
const progressMediaTitle = mediaTitleHint || `AniList ${mediaId}`;
|
||||
progress?.onGenerating?.({
|
||||
mediaId,
|
||||
mediaTitle: mediaTitleHint || `AniList ${mediaId}`,
|
||||
mediaTitle: progressMediaTitle,
|
||||
});
|
||||
deps.logInfo?.(`[dictionary] snapshot miss for AniList ${mediaId}, fetching characters`);
|
||||
|
||||
const { mediaTitle: fetchedMediaTitle, characters } = await fetchCharactersForMedia(
|
||||
mediaId,
|
||||
beforeRequest,
|
||||
(page) => {
|
||||
(page, charactersSoFar) => {
|
||||
deps.logInfo?.(
|
||||
`[dictionary] downloaded AniList character page ${page} for AniList ${mediaId}`,
|
||||
`[dictionary] downloaded AniList character page ${page} for AniList ${mediaId} (${charactersSoFar} characters)`,
|
||||
);
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId,
|
||||
mediaTitle: progressMediaTitle,
|
||||
stage: 'characters',
|
||||
completed: charactersSoFar,
|
||||
total: null,
|
||||
page,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (characters.length === 0) {
|
||||
@@ -403,12 +413,26 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
);
|
||||
}
|
||||
let hasAttemptedImageDownload = false;
|
||||
let attemptedImageCount = 0;
|
||||
for (const entry of allImageUrls) {
|
||||
if (hasAttemptedImageDownload) {
|
||||
await sleepMs(CHARACTER_IMAGE_DOWNLOAD_DELAY_MS);
|
||||
}
|
||||
hasAttemptedImageDownload = true;
|
||||
const image = await downloadCharacterImage(entry.url, entry.id);
|
||||
attemptedImageCount += 1;
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId,
|
||||
mediaTitle: progressMediaTitle,
|
||||
stage: 'images',
|
||||
completed: attemptedImageCount,
|
||||
total: allImageUrls.length,
|
||||
});
|
||||
if (attemptedImageCount % 100 === 0) {
|
||||
deps.logInfo?.(
|
||||
`[dictionary] downloaded ${attemptedImageCount}/${allImageUrls.length} images for AniList ${mediaId}`,
|
||||
);
|
||||
}
|
||||
if (!image) continue;
|
||||
if (entry.kind === 'character') {
|
||||
imagesByCharacterId.set(entry.id, {
|
||||
@@ -425,11 +449,31 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
|
||||
const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable();
|
||||
const resolvedNameSplits = nameSplitTokenizerAvailable
|
||||
? await resolveJapaneseNameSplits(characters, deps.tokenizeJapaneseName!, deps.logWarn)
|
||||
? await resolveJapaneseNameSplits(
|
||||
characters,
|
||||
deps.tokenizeJapaneseName!,
|
||||
deps.logWarn,
|
||||
(completed, total) => {
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId,
|
||||
mediaTitle: progressMediaTitle,
|
||||
stage: 'names',
|
||||
completed,
|
||||
total,
|
||||
});
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
const nameSplitSource =
|
||||
resolvedNameSplits && resolvedNameSplits.size > 0 ? 'mecab' : 'heuristic';
|
||||
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId,
|
||||
mediaTitle: progressMediaTitle,
|
||||
stage: 'saving',
|
||||
completed: 0,
|
||||
total: null,
|
||||
});
|
||||
const snapshot = buildSnapshotFromCharacters(
|
||||
mediaId,
|
||||
fetchedMediaTitle || mediaTitleHint || `AniList ${mediaId}`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||
export const ANILIST_REQUEST_DELAY_MS = 2000;
|
||||
export const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
|
||||
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 19;
|
||||
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 20;
|
||||
export const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
|
||||
|
||||
export const HONORIFIC_SUFFIXES = [
|
||||
|
||||
@@ -278,7 +278,7 @@ export async function fetchAniListMediaCandidateById(
|
||||
export async function fetchCharactersForMedia(
|
||||
mediaId: number,
|
||||
beforeRequest?: () => Promise<void>,
|
||||
onPageFetched?: (page: number) => void,
|
||||
onPageFetched?: (page: number, charactersSoFar: number) => void,
|
||||
): Promise<{
|
||||
mediaTitle: string;
|
||||
characters: CharacterRecord[];
|
||||
@@ -345,7 +345,6 @@ export async function fetchCharactersForMedia(
|
||||
},
|
||||
beforeRequest,
|
||||
);
|
||||
onPageFetched?.(page);
|
||||
|
||||
const media = data.Media;
|
||||
if (!media) {
|
||||
@@ -415,6 +414,8 @@ export async function fetchCharactersForMedia(
|
||||
});
|
||||
}
|
||||
|
||||
onPageFetched?.(page, characters.length);
|
||||
|
||||
const hasNextPage = Boolean(media.characters?.pageInfo?.hasNextPage);
|
||||
if (!hasNextPage) {
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import test from 'node:test';
|
||||
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||
import { createCharacterNameCandidateLookup } from './name-candidates';
|
||||
|
||||
function writeSnapshot(outputDir: string, mediaId: number, entries: Array<[string, string]>): void {
|
||||
const snapshotsDir = path.join(outputDir, 'snapshots');
|
||||
fs.mkdirSync(snapshotsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(snapshotsDir, `anilist-${mediaId}.json`),
|
||||
JSON.stringify({
|
||||
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
||||
mediaId,
|
||||
mediaTitle: `title-${mediaId}`,
|
||||
entryCount: entries.length,
|
||||
updatedAt: 1,
|
||||
termEntries: entries.map(([term, reading]) => [
|
||||
term,
|
||||
reading,
|
||||
'name main',
|
||||
'',
|
||||
100,
|
||||
[],
|
||||
0,
|
||||
'',
|
||||
]),
|
||||
images: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function withTempDir<T>(run: (dir: string) => T): T {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-name-candidates-'));
|
||||
try {
|
||||
return run(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('collects terms and readings for the current media', () => {
|
||||
withTempDir((dir) => {
|
||||
writeSnapshot(dir, 1, [
|
||||
['ミナト', 'みなと'],
|
||||
['湊', 'みなと'],
|
||||
]);
|
||||
writeSnapshot(dir, 2, [['カズマ', 'かずま']]);
|
||||
|
||||
const lookup = createCharacterNameCandidateLookup({
|
||||
outputDir: dir,
|
||||
getCurrentMediaId: () => 1,
|
||||
});
|
||||
const candidates = lookup.get();
|
||||
|
||||
assert.ok(candidates);
|
||||
assert.deepEqual([...candidates.forms].sort(), ['みなと', 'ミナト', '湊'].sort());
|
||||
// Deduplicated: both entries share the みなと reading.
|
||||
assert.equal(candidates.forms.length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null without a media scope so the scanner stays exhaustive', () => {
|
||||
withTempDir((dir) => {
|
||||
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
|
||||
|
||||
const lookup = createCharacterNameCandidateLookup({
|
||||
outputDir: dir,
|
||||
getCurrentMediaId: () => null,
|
||||
});
|
||||
|
||||
assert.equal(lookup.get(), null);
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for a media with no cached snapshot', () => {
|
||||
withTempDir((dir) => {
|
||||
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
|
||||
|
||||
const lookup = createCharacterNameCandidateLookup({
|
||||
outputDir: dir,
|
||||
getCurrentMediaId: () => 999,
|
||||
});
|
||||
|
||||
assert.equal(lookup.get(), null);
|
||||
});
|
||||
});
|
||||
|
||||
test('key changes when the snapshot content changes', () => {
|
||||
withTempDir((dir) => {
|
||||
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
|
||||
const lookup = createCharacterNameCandidateLookup({
|
||||
outputDir: dir,
|
||||
getCurrentMediaId: () => 1,
|
||||
});
|
||||
const first = lookup.get();
|
||||
|
||||
writeSnapshot(dir, 1, [
|
||||
['ミナト', 'みなと'],
|
||||
['アクア', 'あくあ'],
|
||||
]);
|
||||
lookup.invalidate();
|
||||
const second = lookup.get();
|
||||
|
||||
assert.ok(first && second);
|
||||
assert.notEqual(first.key, second.key);
|
||||
assert.equal(second.forms.length, 4);
|
||||
});
|
||||
});
|
||||
|
||||
// The lookup runs once per subtitle line, so it must not stat the snapshot
|
||||
// directory every call. Asserted behaviorally: an unannounced on-disk change is
|
||||
// invisible until the recheck interval elapses, which can only be true if the
|
||||
// filesystem is not consulted per lookup.
|
||||
test('does not re-read the snapshot directory on every lookup', () => {
|
||||
withTempDir((dir) => {
|
||||
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
|
||||
let nowMs = 1_000_000;
|
||||
const lookup = createCharacterNameCandidateLookup({
|
||||
outputDir: dir,
|
||||
getCurrentMediaId: () => 1,
|
||||
now: () => nowMs,
|
||||
});
|
||||
|
||||
assert.equal(lookup.get()?.forms.length, 2);
|
||||
|
||||
writeSnapshot(dir, 1, [
|
||||
['ミナト', 'みなと'],
|
||||
['アクア', 'あくあ'],
|
||||
]);
|
||||
|
||||
nowMs += 1000;
|
||||
assert.equal(lookup.get()?.forms.length, 2, 'expected the cached list within the interval');
|
||||
|
||||
nowMs += 10_000;
|
||||
assert.equal(lookup.get()?.forms.length, 4, 'expected a refresh past the interval');
|
||||
});
|
||||
});
|
||||
|
||||
test('invalidate picks up a snapshot change immediately', () => {
|
||||
withTempDir((dir) => {
|
||||
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
|
||||
let nowMs = 1_000_000;
|
||||
const lookup = createCharacterNameCandidateLookup({
|
||||
outputDir: dir,
|
||||
getCurrentMediaId: () => 1,
|
||||
now: () => nowMs,
|
||||
});
|
||||
|
||||
assert.equal(lookup.get()?.forms.length, 2);
|
||||
|
||||
writeSnapshot(dir, 1, [
|
||||
['ミナト', 'みなと'],
|
||||
['アクア', 'あくあ'],
|
||||
]);
|
||||
nowMs += 1;
|
||||
lookup.invalidate();
|
||||
|
||||
assert.equal(lookup.get()?.forms.length, 4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { readCachedSnapshots } from './cache';
|
||||
import type { CharacterDictionarySnapshot } from './types';
|
||||
|
||||
// Candidate name forms for the greedy name pre-pass in the Yomitan scan
|
||||
// runtime. The scanner otherwise has to ask the backend at every Japanese
|
||||
// position, because a character name can start mid-token; knowing which forms
|
||||
// exist lets it look up only where a name can actually begin.
|
||||
//
|
||||
// A form is any string Yomitan could match a character entry by: the term and
|
||||
// its reading. Both come from the dictionary SubMiner generated, so the pair is
|
||||
// the complete matchable set for an entry. Callers treat a missing list as
|
||||
// "scan every position", so a stale or absent snapshot costs speed, never a
|
||||
// missed name.
|
||||
|
||||
function getSnapshotsDir(outputDir: string): string {
|
||||
return path.join(outputDir, 'snapshots');
|
||||
}
|
||||
|
||||
function collectSnapshotNameForms(snapshot: CharacterDictionarySnapshot): string[] {
|
||||
const forms = new Set<string>();
|
||||
for (const entry of snapshot.termEntries) {
|
||||
const term = typeof entry[0] === 'string' ? entry[0].trim() : '';
|
||||
if (term) {
|
||||
forms.add(term);
|
||||
}
|
||||
const reading = typeof entry[1] === 'string' ? entry[1].trim() : '';
|
||||
if (reading) {
|
||||
forms.add(reading);
|
||||
}
|
||||
}
|
||||
return [...forms];
|
||||
}
|
||||
|
||||
// The signature grows with the size of the dictionary library, and it rides
|
||||
// along in every per-line scan call, so it is folded into a fixed-width digest
|
||||
// first. Collisions only matter against the immediately previous signature (the
|
||||
// runtime compares keys for equality), and FNV-1a over the file list is far
|
||||
// beyond what that needs.
|
||||
function digestSnapshotDirectorySignature(signature: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < signature.length; index += 1) {
|
||||
hash ^= signature.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function getSnapshotDirectorySignature(outputDir: string): string {
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !/^anilist-\d+\.json$/.test(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(path.join(getSnapshotsDir(outputDir), entry.name));
|
||||
parts.push(`${entry.name}:${stat.mtimeMs}:${stat.size}`);
|
||||
} catch {
|
||||
// Ignore files that disappear during a refresh; the next lookup rebuilds.
|
||||
}
|
||||
}
|
||||
return parts.sort().join('|');
|
||||
}
|
||||
|
||||
export interface CharacterNameCandidateSet {
|
||||
/** Identifies this exact form list, so the scan runtime can cache it. */
|
||||
key: string;
|
||||
forms: string[];
|
||||
}
|
||||
|
||||
// This lookup is consulted once per subtitle line, so it must not stat the
|
||||
// snapshot directory every time. Dictionary writes are rare and always call
|
||||
// invalidate(), which forces the next lookup to re-read; the interval only
|
||||
// bounds staleness from changes made behind our back.
|
||||
const SNAPSHOT_SIGNATURE_RECHECK_INTERVAL_MS = 5000;
|
||||
|
||||
export function createCharacterNameCandidateLookup(deps: {
|
||||
userDataPath?: string;
|
||||
outputDir?: string;
|
||||
getCurrentMediaId?: () => number | null | undefined;
|
||||
now?: () => number;
|
||||
}): {
|
||||
get: (mediaId?: number | null) => CharacterNameCandidateSet | null;
|
||||
invalidate: () => void;
|
||||
} {
|
||||
const outputDir =
|
||||
deps.outputDir ??
|
||||
(deps.userDataPath ? path.join(deps.userDataPath, 'character-dictionaries') : '');
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
let signature: string | null = null;
|
||||
let lastSignatureCheckAtMs = 0;
|
||||
let formsByMediaId = new Map<number, string[]>();
|
||||
|
||||
function refreshIfNeeded(): void {
|
||||
if (!outputDir) {
|
||||
formsByMediaId = new Map<number, string[]>();
|
||||
signature = '';
|
||||
return;
|
||||
}
|
||||
const nowMs = now();
|
||||
if (
|
||||
signature !== null &&
|
||||
nowMs - lastSignatureCheckAtMs < SNAPSHOT_SIGNATURE_RECHECK_INTERVAL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastSignatureCheckAtMs = nowMs;
|
||||
const nextSignature = getSnapshotDirectorySignature(outputDir);
|
||||
if (nextSignature === signature) {
|
||||
return;
|
||||
}
|
||||
signature = nextSignature;
|
||||
formsByMediaId = new Map<number, string[]>();
|
||||
for (const snapshot of readCachedSnapshots(outputDir)) {
|
||||
const forms = collectSnapshotNameForms(snapshot);
|
||||
if (forms.length > 0) {
|
||||
formsByMediaId.set(snapshot.mediaId, forms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get(mediaId?: number | null): CharacterNameCandidateSet | null {
|
||||
refreshIfNeeded();
|
||||
const rawMediaId = mediaId ?? deps.getCurrentMediaId?.() ?? null;
|
||||
const normalizedMediaId =
|
||||
typeof rawMediaId === 'number' && Number.isFinite(rawMediaId) && rawMediaId > 0
|
||||
? Math.floor(rawMediaId)
|
||||
: null;
|
||||
|
||||
// Without a media scope the pre-pass would need every character of every
|
||||
// cached title, which is both slow to match and pointless: report no
|
||||
// candidates so the scanner keeps its exhaustive behavior.
|
||||
if (normalizedMediaId === null) {
|
||||
return null;
|
||||
}
|
||||
const forms = formsByMediaId.get(normalizedMediaId);
|
||||
if (!forms || forms.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key: `${digestSnapshotDirectorySignature(signature ?? '')}:${normalizedMediaId}`,
|
||||
forms,
|
||||
};
|
||||
},
|
||||
invalidate(): void {
|
||||
signature = null;
|
||||
lastSignatureCheckAtMs = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isHanCodePoint } from '../../core/text/han-code-points';
|
||||
import { HONORIFIC_SUFFIXES } from './constants';
|
||||
import type { JapaneseNameParts, NameReadings, ResolvedNameSplits } from './types';
|
||||
|
||||
@@ -26,10 +27,12 @@ export function buildReading(term: string): string {
|
||||
return katakanaToHiragana(compact);
|
||||
}
|
||||
|
||||
// Code points, not code units: a supplementary-plane kanji (𠮷, U+20BB7) is a
|
||||
// surrogate pair, and reading only the high surrogate would classify a real
|
||||
// single-character name as non-kanji and drop it.
|
||||
export function containsKanji(value: string): boolean {
|
||||
for (const char of value) {
|
||||
const code = char.charCodeAt(0);
|
||||
if ((code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3400 && code <= 0x4dbf)) {
|
||||
if (isHanCodePoint(char.codePointAt(0) ?? 0)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +86,10 @@ export async function resolveJapaneseNameSplits(
|
||||
characters: CharacterRecord[],
|
||||
tokenize: NameSplitTokenizer,
|
||||
logWarn?: (message: string) => void,
|
||||
onCharacterResolved?: (completed: number, total: number) => void,
|
||||
): Promise<Map<string, ResolvedNameSplit>> {
|
||||
const splits = new Map<string, ResolvedNameSplit>();
|
||||
let resolvedCharacters = 0;
|
||||
for (const character of characters) {
|
||||
const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || '');
|
||||
const givenHintReading = buildReadingFromHint(character.firstNameHint?.trim() || '');
|
||||
@@ -113,6 +115,8 @@ export async function resolveJapaneseNameSplits(
|
||||
splits.set(name, { family, given });
|
||||
}
|
||||
}
|
||||
resolvedCharacters += 1;
|
||||
onCharacterResolved?.(resolvedCharacters, characters.length);
|
||||
}
|
||||
return splits;
|
||||
}
|
||||
|
||||
@@ -36,3 +36,134 @@ test('buildNameTerms adds surname honorifics from Japanese localized aliases', (
|
||||
assert.ok(terms.includes('馬渕さん'));
|
||||
assert.ok(!terms.includes('송치'));
|
||||
});
|
||||
|
||||
test('buildNameTerms drops the disambiguator letter of a mob character name', () => {
|
||||
const terms = buildNameTerms(
|
||||
characterRecord({
|
||||
firstNameHint: '',
|
||||
lastNameHint: '',
|
||||
fullName: 'Joshi A',
|
||||
nativeName: '女子A',
|
||||
}),
|
||||
);
|
||||
|
||||
// ア would match every あ〜 in the subtitles; the letter is a disambiguator
|
||||
// (Girl A / Girl B), not a name.
|
||||
assert.ok(!terms.includes('ア'));
|
||||
assert.ok(!terms.includes('アさん'));
|
||||
assert.ok(terms.includes('女子A'));
|
||||
assert.ok(terms.includes('ジョシア'));
|
||||
});
|
||||
|
||||
test('buildNameTerms keeps a character whose whole name is one kana', () => {
|
||||
const terms = buildNameTerms(
|
||||
characterRecord({
|
||||
firstNameHint: '',
|
||||
lastNameHint: '',
|
||||
fullName: 'A',
|
||||
nativeName: 'あ',
|
||||
}),
|
||||
);
|
||||
|
||||
// The mob-label rule only judges parts a name was split into; a name the
|
||||
// source gives us whole is the character's actual name.
|
||||
assert.ok(terms.includes('あ'));
|
||||
assert.ok(terms.includes('あさん'));
|
||||
// Romanized forms are never lookup targets (the subtitles are Japanese), and
|
||||
// the single-kana alias "A" transliterates to is dropped as a collision.
|
||||
assert.ok(!terms.includes('A'));
|
||||
assert.ok(!terms.includes('ア'));
|
||||
});
|
||||
|
||||
test('buildNameTerms keeps a one-character name written in another script', () => {
|
||||
const terms = buildNameTerms(
|
||||
characterRecord({
|
||||
firstNameHint: '',
|
||||
lastNameHint: '',
|
||||
fullName: 'Byeol',
|
||||
nativeName: '별',
|
||||
alternativeNames: ['Я'],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.ok(terms.includes('별'));
|
||||
assert.ok(terms.includes('별さん'));
|
||||
assert.ok(terms.includes('Я'));
|
||||
});
|
||||
|
||||
test('buildNameTerms yields nothing for a character whose only name is a bare letter', () => {
|
||||
// Documented policy rather than an oversight: a romanized name is never a
|
||||
// term on its own (the subtitles are Japanese), and the single kana a bare
|
||||
// letter transliterates to would match every あ〜 in the line.
|
||||
assert.deepEqual(
|
||||
buildNameTerms(
|
||||
characterRecord({
|
||||
firstNameHint: '',
|
||||
lastNameHint: '',
|
||||
fullName: 'A',
|
||||
nativeName: '',
|
||||
}),
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildNameTerms keeps one-character split parts that are not mob labels', () => {
|
||||
const hangul = buildNameTerms(
|
||||
characterRecord({
|
||||
firstNameHint: '',
|
||||
lastNameHint: '',
|
||||
fullName: 'Byeol Kim',
|
||||
nativeName: '별 김',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.ok(hangul.includes('별'));
|
||||
assert.ok(hangul.includes('김'));
|
||||
|
||||
const middleDot = buildNameTerms(
|
||||
characterRecord({
|
||||
firstNameHint: '',
|
||||
lastNameHint: '',
|
||||
fullName: 'A Be',
|
||||
nativeName: 'ア・ベ',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.ok(middleDot.includes('ア'));
|
||||
assert.ok(middleDot.includes('ベ'));
|
||||
});
|
||||
|
||||
test('buildNameTerms keeps a single-kanji name part', () => {
|
||||
// The name is an alias, not the native name, so the parts come from the
|
||||
// space split rather than from the native-name split.
|
||||
const terms = buildNameTerms(
|
||||
characterRecord({
|
||||
firstNameHint: 'Sora',
|
||||
lastNameHint: 'Yamada',
|
||||
fullName: 'Sora Yamada',
|
||||
nativeName: '',
|
||||
alternativeNames: ['山田 空'],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.ok(terms.includes('山田'));
|
||||
assert.ok(terms.includes('空'));
|
||||
});
|
||||
|
||||
test('buildNameTerms keeps a single supplementary-plane kanji name part', () => {
|
||||
// 𠮷 (U+20BB7) is a surrogate pair: a code-unit kanji check reads only the
|
||||
// high surrogate and drops the part as if it were a mob disambiguator.
|
||||
const terms = buildNameTerms(
|
||||
characterRecord({
|
||||
firstNameHint: 'Tsukasa',
|
||||
lastNameHint: 'Yoshi',
|
||||
fullName: 'Tsukasa Yoshi',
|
||||
nativeName: '',
|
||||
alternativeNames: ['𠮷 司'],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.ok(terms.includes('𠮷'));
|
||||
assert.ok(terms.includes('司'));
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { HAN_REGEXP_CLASS_BODY } from '../../core/text/han-code-points';
|
||||
import { HONORIFIC_SUFFIXES } from './constants';
|
||||
import {
|
||||
addRomanizedKanaAliases,
|
||||
@@ -42,11 +43,29 @@ export function expandRawNameVariants(rawName: string): string[] {
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
// The label AniList appends to unnamed mob characters: one letter or digit,
|
||||
// halfwidth or fullwidth (女子A / "Joshi A" / 女子1). Nothing else qualifies —
|
||||
// a one-character part in any script is a real name part (별 김, ア・ベ, 山田 空).
|
||||
const SINGLE_LABEL_CHARACTER = /^[0-9A-Za-z\uff10-\uff19\uff21-\uff3a\uff41-\uff5a]$/u;
|
||||
|
||||
// Judged on split parts only: a name the source gives us whole in a script the
|
||||
// subtitles can contain is kept whatever it looks like, because a character
|
||||
// really can be called あ or 별. (A romanized name is a separate matter: it is
|
||||
// never a term on its own, only a source of kana aliases. See below.)
|
||||
function isUsableNameSplitPart(part: string): boolean {
|
||||
return !SINGLE_LABEL_CHARACTER.test(part);
|
||||
}
|
||||
|
||||
// Kana, Han (shared ranges), and the marks that only ever appear inside a
|
||||
// Japanese name: iteration marks and the small ka/ke used in place names.
|
||||
const JAPANESE_NAME_CHARACTERS = new RegExp(
|
||||
`^[\\u3040-\\u30ff${HAN_REGEXP_CLASS_BODY}\u3005\u3006\u30f5\u30f6\u30fc]+$`,
|
||||
'u',
|
||||
);
|
||||
|
||||
export function isJapaneseNameSplitCandidate(name: string): boolean {
|
||||
const compact = name.replace(/[\s\u3000・・·•]/g, '');
|
||||
return (
|
||||
containsKanji(compact) && /^[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff々〆ヵヶー]+$/.test(compact)
|
||||
);
|
||||
return containsKanji(compact) && JAPANESE_NAME_CHARACTERS.test(compact);
|
||||
}
|
||||
|
||||
function addJapaneseNameParts(
|
||||
@@ -97,8 +116,11 @@ export function buildNameTerms(
|
||||
|
||||
const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0);
|
||||
if (split.length === 2) {
|
||||
target.add(split[0]!);
|
||||
target.add(split[1]!);
|
||||
for (const part of split) {
|
||||
if (isUsableNameSplitPart(part)) {
|
||||
target.add(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const splitByMiddleDot = name
|
||||
@@ -107,7 +129,9 @@ export function buildNameTerms(
|
||||
.filter((part) => part.length > 0);
|
||||
if (splitByMiddleDot.length >= 2) {
|
||||
for (const part of splitByMiddleDot) {
|
||||
target.add(part);
|
||||
if (isUsableNameSplitPart(part)) {
|
||||
target.add(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +141,15 @@ export function buildNameTerms(
|
||||
}
|
||||
}
|
||||
|
||||
// Romanized names never become terms themselves — the subtitles are Japanese,
|
||||
// so "Joshi A" would never appear in one — they only contribute the kana a
|
||||
// Japanese writer would spell them with.
|
||||
for (const alias of addRomanizedKanaAliases(romanizedBase)) {
|
||||
// Except when the whole name is one letter: it transliterates to a single
|
||||
// kana (A → ア) that matches every あ〜 in the subtitles. A character whose
|
||||
// only recorded name is a bare letter therefore yields no terms at all,
|
||||
// which is the intended outcome: those are unnamed mob characters.
|
||||
if ([...alias].length === 1) continue;
|
||||
base.add(alias);
|
||||
}
|
||||
|
||||
|
||||
@@ -122,9 +122,24 @@ export type CharacterDictionarySnapshotProgress = {
|
||||
mediaTitle: string;
|
||||
};
|
||||
|
||||
export type CharacterDictionarySnapshotStage = 'characters' | 'images' | 'names' | 'saving';
|
||||
|
||||
/**
|
||||
* Fine-grained generation progress. `total` is null while the work size is still unknown (AniList
|
||||
* paginates characters, so the character count only settles on the last page).
|
||||
*/
|
||||
export type CharacterDictionarySnapshotStageProgress = CharacterDictionarySnapshotProgress & {
|
||||
stage: CharacterDictionarySnapshotStage;
|
||||
completed: number;
|
||||
total: number | null;
|
||||
/** AniList page currently being downloaded; only set during the `characters` stage. */
|
||||
page?: number;
|
||||
};
|
||||
|
||||
export type CharacterDictionarySnapshotProgressCallbacks = {
|
||||
onChecking?: (progress: CharacterDictionarySnapshotProgress) => void;
|
||||
onGenerating?: (progress: CharacterDictionarySnapshotProgress) => void;
|
||||
onGenerateProgress?: (progress: CharacterDictionarySnapshotStageProgress) => void;
|
||||
};
|
||||
|
||||
export type MergedCharacterDictionaryBuildResult = {
|
||||
|
||||
@@ -109,6 +109,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
removeCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['removeCharacterDictionaryManagedEntry'];
|
||||
moveCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['moveCharacterDictionaryManagedEntry'];
|
||||
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
||||
getChangelogSnapshot?: IpcDepsRuntimeOptions['getChangelogSnapshot'];
|
||||
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
|
||||
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
|
||||
playPlaylistBrowserIndex: IpcDepsRuntimeOptions['playPlaylistBrowserIndex'];
|
||||
@@ -302,6 +303,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
removeCharacterDictionaryManagedEntry: params.removeCharacterDictionaryManagedEntry,
|
||||
moveCharacterDictionaryManagedEntry: params.moveCharacterDictionaryManagedEntry,
|
||||
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
||||
getChangelogSnapshot: params.getChangelogSnapshot,
|
||||
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
|
||||
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
|
||||
playPlaylistBrowserIndex: params.playPlaylistBrowserIndex,
|
||||
|
||||
@@ -223,7 +223,7 @@ test('update overlay notification action triggers install flow', () => {
|
||||
assert.match(runtimeSource, /fallbackClient\.openNoteInBrowser\(noteId\)/);
|
||||
});
|
||||
|
||||
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
|
||||
test('subtitle change pauses prefetch without restarting its run before tokenizing current line', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/onSubtitleChange:\s*\(text\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n refreshDiscordPresence:/,
|
||||
@@ -231,15 +231,19 @@ test('subtitle change re-prioritizes prefetch around live playback before tokeni
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(actionBlock, /subtitlePrefetchService\?\.pause\(\);/);
|
||||
assert.match(actionBlock, /subtitlePrefetchService\?\.onSeek\(lastObservedTimePos\);/);
|
||||
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\);/);
|
||||
// Restarting the run per line (onSeek) discards in-flight prefetch work;
|
||||
// only real seeks restart via onTimePosUpdate.
|
||||
assert.doesNotMatch(actionBlock, /subtitlePrefetchService\?\.onSeek\(/);
|
||||
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\)/);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('subtitlePrefetchService?.pause();') <
|
||||
actionBlock.indexOf('subtitlePrefetchService?.onSeek(lastObservedTimePos);'),
|
||||
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text)'),
|
||||
);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('subtitlePrefetchService?.onSeek(lastObservedTimePos);') <
|
||||
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text);'),
|
||||
// A repeated subtitle emits nothing, so the pause has to be released here or
|
||||
// prefetching idles until the next distinct line.
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/if \(!subtitleProcessingController\.onSubtitleChange\(text\)\) \{[\s\S]*?subtitlePrefetchService\?\.resume\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -489,16 +493,35 @@ test('known-word updates invalidate prefetched tokenizations before refreshing c
|
||||
assert.match(actionBlock, /subtitlePrefetchService\?\.onSeek\(lastObservedTimePos\);/);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/subtitleProcessingController\.refreshCurrentSubtitle\(appState\.currentSubText\);/,
|
||||
/if \(!subtitleProcessingController\.refreshCurrentSubtitle\(appState\.currentSubText\)\) \{[\s\S]*?subtitlePrefetchService\?\.resume\(\);/,
|
||||
);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('subtitleProcessingController.invalidateTokenizationCache();') <
|
||||
actionBlock.indexOf(
|
||||
'subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);',
|
||||
'subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText)',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle processing controller resumes prefetch on settle, not on its emits', () => {
|
||||
const source = readMainSource();
|
||||
const depsBlock = source.match(
|
||||
/createBuildSubtitleProcessingControllerMainDepsHandler\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(depsBlock);
|
||||
// A controller emit can be the provisional plain payload sent before the
|
||||
// scan runs, so it must not release the prefetch pause.
|
||||
assert.match(
|
||||
depsBlock,
|
||||
/emitSubtitle: \(payload\) => emitSubtitlePayload\(payload, \{ resumePrefetch: false \}\),/,
|
||||
);
|
||||
assert.match(
|
||||
depsBlock,
|
||||
/onProcessingSettled: \(\) => \{\s+subtitlePrefetchService\?\.resume\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('manual visible overlay changes notify mpv plugin visibility state', () => {
|
||||
const source = readMainSource();
|
||||
const setBlock = source.match(
|
||||
@@ -593,7 +616,7 @@ test('YouTube media cache lifecycle routes through configured status notificatio
|
||||
test('subtitle broadcasts share one frequency options snapshot per emitted payload', () => {
|
||||
const source = readMainSource();
|
||||
const emitBlock = source.match(
|
||||
/function emitSubtitlePayload\(payload: SubtitleData\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
/function emitSubtitlePayload\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const frequencyOptionsSnapshot = emitBlock?.match(
|
||||
/const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;(?<body>[\s\S]*?)\n \};/,
|
||||
@@ -616,7 +639,7 @@ test('subtitle broadcasts share one frequency options snapshot per emitted paylo
|
||||
test('annotation upgrades skip the duplicate basic websocket event', () => {
|
||||
const source = readMainSource();
|
||||
const emitBlock = source.match(
|
||||
/function emitSubtitlePayload\(payload: SubtitleData\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
/function emitSubtitlePayload\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(emitBlock);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller';
|
||||
import type { SubtitleData } from '../../types';
|
||||
import {
|
||||
createAutoplaySubtitlePrimingRuntime,
|
||||
setMpvCurrentSecondarySubText,
|
||||
@@ -42,8 +44,9 @@ test('scheduleSubtitlePrefetchRefresh logs refresh failures from timer callback'
|
||||
setActiveParsedSubtitleMediaPath: () => {},
|
||||
subtitleProcessingController: {
|
||||
consumeCachedSubtitle: () => null,
|
||||
onSubtitleChange: () => {},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
onSubtitleChange: () => true,
|
||||
refreshCurrentSubtitle: () => true,
|
||||
notePlainSubtitleEmitted: () => {},
|
||||
},
|
||||
emitSubtitlePayload: () => {},
|
||||
getSubtitlePrefetchService: () => null,
|
||||
@@ -93,13 +96,25 @@ test('primeCurrentSubtitleForAutoplay refreshes active subtitle cues when mpv su
|
||||
setActiveParsedSubtitleMediaPath: () => {},
|
||||
subtitleProcessingController: {
|
||||
consumeCachedSubtitle: () => null,
|
||||
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text ?? ''}`),
|
||||
onSubtitleChange: (text) => {
|
||||
calls.push(`change:${text}`);
|
||||
return true;
|
||||
},
|
||||
refreshCurrentSubtitle: (text) => {
|
||||
calls.push(`refresh:${text ?? ''}`);
|
||||
return true;
|
||||
},
|
||||
notePlainSubtitleEmitted: () => {},
|
||||
},
|
||||
emitSubtitlePayload: (payload) => calls.push(`emit:${payload.text}`),
|
||||
emitSubtitlePayload: (payload, options) =>
|
||||
calls.push(`emit:${payload.text}:resume=${options?.resumePrefetch !== false}`),
|
||||
getSubtitlePrefetchService: () => ({
|
||||
pause: () => calls.push('prefetch:pause'),
|
||||
onSeek: (timePos) => calls.push(`prefetch:seek:${timePos}`),
|
||||
pause: () => {
|
||||
calls.push('prefetch:pause');
|
||||
},
|
||||
resume: () => {
|
||||
calls.push('prefetch:resume');
|
||||
},
|
||||
}),
|
||||
getLastObservedTimePos: () => 12,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
@@ -120,8 +135,10 @@ test('primeCurrentSubtitleForAutoplay refreshes active subtitle cues when mpv su
|
||||
'request:time-pos',
|
||||
'set:起動字幕',
|
||||
'prefetch:pause',
|
||||
'emit:起動字幕',
|
||||
'change:起動字幕',
|
||||
'emit:起動字幕:resume=false',
|
||||
// Uncached priming refreshes rather than announcing a change, so an
|
||||
// invalidated-but-unchanged line is still re-tokenized.
|
||||
'refresh:起動字幕',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -151,13 +168,25 @@ test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before
|
||||
setActiveParsedSubtitleMediaPath: () => {},
|
||||
subtitleProcessingController: {
|
||||
consumeCachedSubtitle: () => null,
|
||||
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text ?? ''}`),
|
||||
onSubtitleChange: (text) => {
|
||||
calls.push(`change:${text}`);
|
||||
return true;
|
||||
},
|
||||
refreshCurrentSubtitle: (text) => {
|
||||
calls.push(`refresh:${text ?? ''}`);
|
||||
return true;
|
||||
},
|
||||
notePlainSubtitleEmitted: () => {},
|
||||
},
|
||||
emitSubtitlePayload: (payload) => calls.push(`emit:${payload.text}`),
|
||||
emitSubtitlePayload: (payload, options) =>
|
||||
calls.push(`emit:${payload.text}:resume=${options?.resumePrefetch !== false}`),
|
||||
getSubtitlePrefetchService: () => ({
|
||||
pause: () => calls.push('prefetch:pause'),
|
||||
onSeek: (timePos) => calls.push(`prefetch:seek:${timePos}`),
|
||||
pause: () => {
|
||||
calls.push('prefetch:pause');
|
||||
},
|
||||
resume: () => {
|
||||
calls.push('prefetch:resume');
|
||||
},
|
||||
}),
|
||||
getLastObservedTimePos: () => 12,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
@@ -175,7 +204,228 @@ test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before
|
||||
'request:sub-text',
|
||||
'set:起動字幕',
|
||||
'prefetch:pause',
|
||||
'emit:起動字幕',
|
||||
'change:起動字幕',
|
||||
'emit:起動字幕:resume=false',
|
||||
// Uncached priming refreshes rather than announcing a change, so an
|
||||
// invalidated-but-unchanged line is still re-tokenized.
|
||||
'refresh:起動字幕',
|
||||
]);
|
||||
});
|
||||
|
||||
// Driven by the real processing controller rather than a stub: the failure this
|
||||
// covers is a disagreement between the priming path and the controller's own
|
||||
// staleness rules, which a hand-written stub cannot reproduce.
|
||||
function createPrimingRuntimeWithRealController(options: {
|
||||
text: string;
|
||||
calls: string[];
|
||||
onTokenize: () => void;
|
||||
tokenize?: (text: string) => SubtitleData | null | Promise<SubtitleData | null>;
|
||||
cacheLimit?: number;
|
||||
}) {
|
||||
const { text, calls } = options;
|
||||
let currentSubText = '';
|
||||
let currentSubtitleData: SubtitleData | null = null;
|
||||
const mediaPath = '/media/video.mkv';
|
||||
|
||||
const prefetchService = {
|
||||
pause: () => calls.push('prefetch:pause'),
|
||||
resume: () => calls.push('prefetch:resume'),
|
||||
};
|
||||
// Mirrors main.ts emitSubtitlePayload: an emit resumes prefetching unless it
|
||||
// is explicitly marked as not the end of the work for the line, and every
|
||||
// controller emit is so marked.
|
||||
const emitSubtitlePayload = (
|
||||
payload: SubtitleData,
|
||||
emitOptions?: { resumePrefetch?: boolean },
|
||||
): void => {
|
||||
currentSubtitleData = payload;
|
||||
calls.push(
|
||||
emitOptions?.resumePrefetch === false
|
||||
? `emit-raw:${payload.text}`
|
||||
: `emit-direct:${payload.text}`,
|
||||
);
|
||||
if (emitOptions?.resumePrefetch !== false) {
|
||||
prefetchService.resume();
|
||||
}
|
||||
};
|
||||
|
||||
const subtitleProcessingController = createSubtitleProcessingController({
|
||||
tokenizeSubtitle: async (subtitleText) => {
|
||||
options.onTokenize();
|
||||
return options.tokenize ? options.tokenize(subtitleText) : { text: subtitleText, tokens: [] };
|
||||
},
|
||||
// main.ts routes controller emits through emitSubtitlePayload with
|
||||
// resumePrefetch: false, so they never release the pause on their own.
|
||||
emitSubtitle: (payload) => {
|
||||
currentSubtitleData = payload;
|
||||
calls.push(`emit:${payload.text}:tokens=${payload.tokens === null ? 'none' : 'yes'}`);
|
||||
},
|
||||
onProcessingSettled: () => {
|
||||
prefetchService.resume();
|
||||
},
|
||||
...(options.cacheLimit === undefined ? {} : { cacheLimit: options.cacheLimit }),
|
||||
});
|
||||
|
||||
const runtime = createAutoplaySubtitlePrimingRuntime({
|
||||
getCurrentMediaPath: () => mediaPath,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentVideoPath: mediaPath,
|
||||
requestProperty: async (name) => (name === 'sub-text' ? text : null),
|
||||
}),
|
||||
setCurrentSubText: (value) => {
|
||||
currentSubText = value;
|
||||
},
|
||||
getCurrentSubText: () => currentSubText,
|
||||
getCurrentSubtitleData: () => currentSubtitleData,
|
||||
getActiveParsedSubtitleCues: () => [],
|
||||
setActiveParsedSubtitleMediaPath: () => {},
|
||||
subtitleProcessingController,
|
||||
emitSubtitlePayload,
|
||||
getSubtitlePrefetchService: () => prefetchService,
|
||||
getLastObservedTimePos: () => 12,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
emitSecondarySubtitle: () => {},
|
||||
initSubtitlePrefetch: async () => {},
|
||||
refreshSubtitlePrefetchFromActiveTrack: async () => {},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
return { runtime, subtitleProcessingController, mediaPath };
|
||||
}
|
||||
|
||||
test('primeCurrentSubtitleForAutoplay re-tokenizes text whose cached annotation was invalidated', async () => {
|
||||
const calls: string[] = [];
|
||||
let tokenizations = 0;
|
||||
const text = '起動字幕';
|
||||
const { runtime, subtitleProcessingController, mediaPath } =
|
||||
createPrimingRuntimeWithRealController({
|
||||
text,
|
||||
calls,
|
||||
onTokenize: () => {
|
||||
tokenizations += 1;
|
||||
},
|
||||
});
|
||||
|
||||
// The line was already tokenized and cached during normal playback.
|
||||
subtitleProcessingController.onSubtitleChange(text);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const tokenizationsBeforeInvalidation = tokenizations;
|
||||
|
||||
// Mining a card drops every cached tokenization.
|
||||
subtitleProcessingController.invalidateTokenizationCache();
|
||||
calls.length = 0;
|
||||
|
||||
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// The cache miss must schedule fresh work, or the line stays unannotated for
|
||||
// as long as it is on screen.
|
||||
assert.equal(
|
||||
tokenizations,
|
||||
tokenizationsBeforeInvalidation + 1,
|
||||
'expected the invalidated subtitle to be tokenized again',
|
||||
);
|
||||
assert.ok(
|
||||
calls.includes(`emit:${text}:tokens=yes`),
|
||||
`expected an annotated emit, saw ${JSON.stringify(calls)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('primeCurrentSubtitleForAutoplay releases the prefetch pause when nothing is scheduled', async () => {
|
||||
const calls: string[] = [];
|
||||
const text = '起動字幕';
|
||||
const { runtime, subtitleProcessingController, mediaPath } =
|
||||
createPrimingRuntimeWithRealController({
|
||||
text,
|
||||
calls,
|
||||
onTokenize: () => {},
|
||||
cacheLimit: 1,
|
||||
});
|
||||
|
||||
// Emitted at the current cache generation, then evicted from the one-entry
|
||||
// cache: priming misses the cache but the controller has nothing to redo, so
|
||||
// no emit is coming and the pause must be released here.
|
||||
subtitleProcessingController.onSubtitleChange(text);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
subtitleProcessingController.preCacheTokenization('別の字幕', {
|
||||
text: '別の字幕',
|
||||
tokens: [],
|
||||
});
|
||||
calls.length = 0;
|
||||
|
||||
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(calls, ['prefetch:pause', `emit-raw:${text}`, 'prefetch:resume']);
|
||||
});
|
||||
|
||||
test('primeCurrentSubtitleForAutoplay releases the prefetch pause when tokenization emits nothing', async () => {
|
||||
const calls: string[] = [];
|
||||
const text = '起動字幕';
|
||||
const { runtime, subtitleProcessingController, mediaPath } =
|
||||
createPrimingRuntimeWithRealController({
|
||||
text,
|
||||
calls,
|
||||
onTokenize: () => {},
|
||||
// Transient tokenizer failure: the controller falls back to plain text it
|
||||
// has already shown, so it suppresses the emit entirely.
|
||||
tokenize: () => null,
|
||||
});
|
||||
|
||||
subtitleProcessingController.onSubtitleChange(text);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
subtitleProcessingController.invalidateTokenizationCache();
|
||||
calls.length = 0;
|
||||
|
||||
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.ok(
|
||||
!calls.some((call) => call.startsWith('emit:')),
|
||||
`expected no controller emit, saw ${JSON.stringify(calls)}`,
|
||||
);
|
||||
assert.equal(
|
||||
calls.filter((call) => call === 'prefetch:resume').length,
|
||||
1,
|
||||
`expected the prefetch pause to be released, saw ${JSON.stringify(calls)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('prefetch stays paused until tokenization of an uncached line completes', async () => {
|
||||
const calls: string[] = [];
|
||||
const text = '起動字幕';
|
||||
let finishTokenization = (): void => {};
|
||||
const tokenizationGate = new Promise<void>((resolve) => {
|
||||
finishTokenization = resolve;
|
||||
});
|
||||
const { runtime, mediaPath } = createPrimingRuntimeWithRealController({
|
||||
text,
|
||||
calls,
|
||||
onTokenize: () => {},
|
||||
tokenize: async (subtitleText) => {
|
||||
await tokenizationGate;
|
||||
return { text: subtitleText, tokens: [] };
|
||||
},
|
||||
});
|
||||
|
||||
// Driven through the priming path, which is what takes the pause out.
|
||||
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Neither the priming emit nor the controller's provisional plain emit may
|
||||
// release the pause: the expensive scan is still ahead of them and would
|
||||
// compete with prefetching for the parser.
|
||||
// One plain payload, not two: priming paints it and tells the controller, so
|
||||
// the controller goes straight for the tokenized one. And it does not resume
|
||||
// prefetch, because the expensive scan is still ahead of it.
|
||||
assert.deepEqual(calls, ['prefetch:pause', `emit-raw:${text}`]);
|
||||
|
||||
finishTokenization();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [
|
||||
'prefetch:pause',
|
||||
`emit-raw:${text}`,
|
||||
`emit:${text}:tokens=yes`,
|
||||
'prefetch:resume',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ type AutoplaySubtitlePrimingMpvClient = {
|
||||
|
||||
type AutoplaySubtitlePrimingPrefetchService = {
|
||||
pause: () => void;
|
||||
onSeek: (timePos: number) => void;
|
||||
resume: () => void;
|
||||
};
|
||||
|
||||
export interface AutoplaySubtitlePrimingRuntimeDeps {
|
||||
@@ -30,10 +30,12 @@ export interface AutoplaySubtitlePrimingRuntimeDeps {
|
||||
setActiveParsedSubtitleMediaPath: (mediaPath: string | null) => void;
|
||||
subtitleProcessingController: {
|
||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
refreshCurrentSubtitle: (text: string) => void;
|
||||
// Both report whether processing is pending; see pausePrefetchUntilProcessed.
|
||||
onSubtitleChange: (text: string) => boolean;
|
||||
refreshCurrentSubtitle: (text: string) => boolean;
|
||||
notePlainSubtitleEmitted: (text: string) => void;
|
||||
};
|
||||
emitSubtitlePayload: (payload: SubtitleData) => void;
|
||||
emitSubtitlePayload: (payload: SubtitleData, options?: { resumePrefetch?: boolean }) => void;
|
||||
getSubtitlePrefetchService: () => AutoplaySubtitlePrimingPrefetchService | null;
|
||||
getLastObservedTimePos: () => number;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
@@ -64,6 +66,19 @@ export function setMpvCurrentSecondarySubText(
|
||||
export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimingRuntimeDeps) {
|
||||
const { subtitleProcessingController, emitSubtitlePayload } = deps;
|
||||
|
||||
// Prefetching is paused so the on-screen line gets the parser to itself; the
|
||||
// resume rides on the controller settling (see onProcessingSettled), not on
|
||||
// an emit, which a suppressed duplicate or a failed tokenization never sends.
|
||||
// When the controller reports it has nothing scheduled, no settle is coming
|
||||
// either, so release the pause here or prefetching idles indefinitely.
|
||||
function pausePrefetchUntilProcessed(scheduleTokenization: () => boolean): void {
|
||||
const prefetch = deps.getSubtitlePrefetchService();
|
||||
prefetch?.pause();
|
||||
if (!scheduleTokenization()) {
|
||||
prefetch?.resume();
|
||||
}
|
||||
}
|
||||
|
||||
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let autoplaySubtitlePrimedMediaPath: string | null = null;
|
||||
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null =
|
||||
@@ -104,12 +119,25 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
||||
const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text);
|
||||
if (cachedPayload) {
|
||||
subtitleProcessingController.onSubtitleChange(text);
|
||||
// This emit resumes prefetching, so no pause is left outstanding.
|
||||
emitSubtitlePayload(cachedPayload);
|
||||
return true;
|
||||
}
|
||||
|
||||
emitSubtitlePayload({ text, tokens: null });
|
||||
subtitleProcessingController.onSubtitleChange(text);
|
||||
// Provisional raw emit: keep prefetch paused until the processing
|
||||
// controller is done with this line, and tell it this line has already been
|
||||
// painted plain so it does not broadcast the same payload again.
|
||||
emitSubtitlePayload({ text, tokens: null }, { resumePrefetch: false });
|
||||
subtitleProcessingController.notePlainSubtitleEmitted(text);
|
||||
// refreshCurrentSubtitle, not onSubtitleChange: the cache miss above can be
|
||||
// an invalidation (mining a card) on text the controller still holds, and
|
||||
// onSubtitleChange treats unchanged text as nothing to do, which would
|
||||
// leave this line permanently unannotated. refreshCurrentSubtitle also
|
||||
// re-tokenizes for a new cache generation.
|
||||
if (!subtitleProcessingController.refreshCurrentSubtitle(text)) {
|
||||
// Nothing scheduled, so no settle is coming to release the pause.
|
||||
deps.getSubtitlePrefetchService()?.resume();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -153,14 +181,12 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
||||
getCurrentSubtitleData: () => deps.getCurrentSubtitleData(),
|
||||
consumeCachedSubtitle: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
|
||||
onSubtitleChange: (text) => {
|
||||
deps.getSubtitlePrefetchService()?.pause();
|
||||
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
||||
subtitleProcessingController.onSubtitleChange(text);
|
||||
pausePrefetchUntilProcessed(() => subtitleProcessingController.onSubtitleChange(text));
|
||||
},
|
||||
refreshCurrentSubtitle: (text) => {
|
||||
deps.getSubtitlePrefetchService()?.pause();
|
||||
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
||||
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||
pausePrefetchUntilProcessed(() =>
|
||||
subtitleProcessingController.refreshCurrentSubtitle(text),
|
||||
);
|
||||
},
|
||||
deferUncachedRefresh: true,
|
||||
emitSubtitle: (payload) => emitSubtitlePayload(payload),
|
||||
@@ -204,9 +230,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
deps.getSubtitlePrefetchService()?.pause();
|
||||
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
||||
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||
pausePrefetchUntilProcessed(() => subtitleProcessingController.refreshCurrentSubtitle(text));
|
||||
}, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS);
|
||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const CHANGELOG_MODAL: OverlayHostedModal = 'changelog';
|
||||
const CHANGELOG_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openChangelogModal(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: CHANGELOG_MODAL,
|
||||
timeoutMs: CHANGELOG_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Changelog modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.changelogOpen,
|
||||
modal: CHANGELOG_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { readBundledChangelog, resolveBundledChangelogPath } from './bundled-changelog';
|
||||
|
||||
test('bundled changelog path prefers the packaged resources copy', () => {
|
||||
const resolved = resolveBundledChangelogPath({
|
||||
resourcesPath: '/res',
|
||||
appPath: '/app',
|
||||
dirname: '/app/dist/main',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
fileExists: (candidate) => candidate === '/res/CHANGELOG.md',
|
||||
});
|
||||
|
||||
assert.equal(resolved, '/res/CHANGELOG.md');
|
||||
});
|
||||
|
||||
test('bundled changelog path falls back to the repo root during development', () => {
|
||||
const resolved = resolveBundledChangelogPath({
|
||||
resourcesPath: '/res',
|
||||
appPath: '/app',
|
||||
dirname: '/repo/dist/main',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
fileExists: (candidate) => candidate === '/repo/dist/main/../../CHANGELOG.md',
|
||||
});
|
||||
|
||||
assert.equal(resolved, '/repo/dist/main/../../CHANGELOG.md');
|
||||
});
|
||||
|
||||
test('bundled changelog returns null when no copy is installed', () => {
|
||||
const result = readBundledChangelog({
|
||||
resolvePath: () => null,
|
||||
readFile: () => {
|
||||
throw new Error('should not read');
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('bundled changelog logs and returns null when the file cannot be read', () => {
|
||||
const warnings: string[] = [];
|
||||
const result = readBundledChangelog({
|
||||
resolvePath: () => '/res/CHANGELOG.md',
|
||||
readFile: () => {
|
||||
throw new Error('EACCES');
|
||||
},
|
||||
logWarn: (message) => warnings.push(message),
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0] ?? '', /EACCES/);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
export function resolveBundledChangelogPath(deps: {
|
||||
resourcesPath: string;
|
||||
appPath: string;
|
||||
dirname: string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
fileExists: (path: string) => boolean;
|
||||
}): string | null {
|
||||
const candidates = [
|
||||
deps.joinPath(deps.resourcesPath, 'CHANGELOG.md'),
|
||||
deps.joinPath(deps.appPath, 'CHANGELOG.md'),
|
||||
deps.joinPath(deps.dirname, '..', 'CHANGELOG.md'),
|
||||
deps.joinPath(deps.dirname, '..', '..', 'CHANGELOG.md'),
|
||||
];
|
||||
|
||||
return candidates.find((candidate) => deps.fileExists(candidate)) ?? null;
|
||||
}
|
||||
|
||||
export function readBundledChangelog(deps: {
|
||||
resolvePath: () => string | null;
|
||||
readFile: (path: string) => string;
|
||||
logWarn: (message: string) => void;
|
||||
}): string | null {
|
||||
const changelogPath = deps.resolvePath();
|
||||
if (!changelogPath) return null;
|
||||
try {
|
||||
return deps.readFile(changelogPath);
|
||||
} catch (error) {
|
||||
deps.logWarn(
|
||||
`Failed to read bundled changelog at ${changelogPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
CHANGELOG_REQUEST_TIMEOUT_MS,
|
||||
createChangelogRuntime,
|
||||
withRequestTimeout,
|
||||
} from './changelog-runtime';
|
||||
import type { FetchLike, FetchResponseLike } from '../update/release-assets';
|
||||
import { createCurlFetch } from '../update/fetch-adapter';
|
||||
|
||||
function okResponse(body: string): FetchResponseLike {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => JSON.parse(body),
|
||||
text: async () => body,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
};
|
||||
}
|
||||
|
||||
test('request timeout wrapper attaches an abort signal to every request', async () => {
|
||||
const seen: Array<{ url: string; init?: Record<string, unknown> }> = [];
|
||||
const wrapped = withRequestTimeout(async (url, init) => {
|
||||
seen.push({ url, init });
|
||||
return okResponse('body');
|
||||
}, 1_234);
|
||||
|
||||
await wrapped('https://example.test/a');
|
||||
await wrapped('https://example.test/b', { headers: { 'User-Agent': 'SubMiner' } });
|
||||
|
||||
assert.equal(seen.length, 2);
|
||||
for (const request of seen) {
|
||||
assert.ok(request.init?.signal instanceof AbortSignal, 'each request carries a signal');
|
||||
assert.equal((request.init?.signal as AbortSignal).aborted, false);
|
||||
}
|
||||
// Existing init is preserved rather than replaced.
|
||||
assert.deepEqual(seen[1]?.init?.headers, { 'User-Agent': 'SubMiner' });
|
||||
});
|
||||
|
||||
test('request timeout wrapper aborts a request that never settles', async () => {
|
||||
let observed: AbortSignal | undefined;
|
||||
const wrapped = withRequestTimeout((_url, init) => {
|
||||
observed = init?.signal as AbortSignal;
|
||||
return new Promise<FetchResponseLike>(() => {
|
||||
// Never resolves, standing in for a stalled connection.
|
||||
});
|
||||
}, 10);
|
||||
|
||||
void wrapped('https://example.test/stalled');
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
|
||||
assert.equal(observed?.aborted, true);
|
||||
});
|
||||
|
||||
test('changelog runtime times out both requests instead of hanging the modal', async () => {
|
||||
const signals: AbortSignal[] = [];
|
||||
// Stays pending until the deadline fires, standing in for a stalled server.
|
||||
const stalling: FetchLike = (_url, init) => {
|
||||
const signal = init?.signal as AbortSignal;
|
||||
signals.push(signal);
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
};
|
||||
|
||||
const runtime = createChangelogRuntime({
|
||||
getInstalledVersion: () => '0.19.2',
|
||||
getUpdateChannel: () => 'stable',
|
||||
resourcesPath: '/res',
|
||||
appPath: '/app',
|
||||
dirname: '/app/dist/main',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
fileExists: () => false,
|
||||
readFile: () => '',
|
||||
logWarn: () => {},
|
||||
createFetch: () => withRequestTimeout(stalling, 10),
|
||||
});
|
||||
|
||||
const snapshot = await runtime.getChangelogSnapshot();
|
||||
|
||||
// Release lookup and changelog download both hit the deadline rather than hang.
|
||||
assert.equal(signals.length, 2);
|
||||
assert.ok(
|
||||
signals.every((signal) => signal.aborted),
|
||||
'every stalled request was aborted',
|
||||
);
|
||||
// No bundled copy is readable here, so the timeout surfaces as an error state
|
||||
// that does not claim a bundled changelog is on screen.
|
||||
assert.match(snapshot.error ?? '', /^Changelog unavailable: /);
|
||||
assert.match(snapshot.error ?? '', /timed out|timeout|abort/i);
|
||||
assert.deepEqual(snapshot.entries, []);
|
||||
});
|
||||
|
||||
test('changelog timeout reaches the curl transport, not just global fetch', async () => {
|
||||
// The POSIX transport is curl, whose own --max-time is 60s; the changelog
|
||||
// deadline is shorter, so the signal has to actually terminate the process.
|
||||
let killed: string | undefined;
|
||||
const curlFetch = createCurlFetch({
|
||||
execFile: ((
|
||||
_file: string,
|
||||
_args: readonly string[],
|
||||
_options: unknown,
|
||||
_callback: unknown,
|
||||
) => ({
|
||||
kill: (signal?: string) => {
|
||||
killed = signal;
|
||||
return true;
|
||||
},
|
||||
})) as never,
|
||||
});
|
||||
|
||||
const wrapped = withRequestTimeout(curlFetch, 10);
|
||||
await assert.rejects(wrapped('https://example.test/stalled'));
|
||||
|
||||
assert.equal(killed, 'SIGKILL', 'the stalled curl process is killed at the changelog deadline');
|
||||
});
|
||||
|
||||
test('changelog request timeout is finite', () => {
|
||||
assert.ok(Number.isFinite(CHANGELOG_REQUEST_TIMEOUT_MS));
|
||||
assert.ok(CHANGELOG_REQUEST_TIMEOUT_MS > 0);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ChangelogSnapshot } from '../../../types/changelog';
|
||||
import type { UpdateChannel } from '../../../types/config';
|
||||
import { createCurlFetch, createGlobalFetch } from '../update/fetch-adapter';
|
||||
import { fetchLatestStableRelease, type FetchLike } from '../update/release-assets';
|
||||
import { readBundledChangelog, resolveBundledChangelogPath } from './bundled-changelog';
|
||||
import { createChangelogSource } from './changelog-source';
|
||||
|
||||
export interface ChangelogRuntimeDeps {
|
||||
getInstalledVersion: () => string;
|
||||
getUpdateChannel: () => UpdateChannel;
|
||||
resourcesPath: string;
|
||||
appPath: string;
|
||||
dirname: string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
fileExists: (path: string) => boolean;
|
||||
readFile: (path: string) => string;
|
||||
logWarn: (message: string) => void;
|
||||
/** Injected in tests; production picks curl on POSIX and global fetch on Windows. */
|
||||
createFetch?: () => FetchLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* curl enforces its own `--max-time`, but the global-fetch transport has no
|
||||
* deadline: without this a stalled connection leaves the modal on "Loading
|
||||
* changelog..." with no way back except closing it.
|
||||
*/
|
||||
export const CHANGELOG_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
export function withRequestTimeout(fetchImpl: FetchLike, timeoutMs: number): FetchLike {
|
||||
return (url, init) => {
|
||||
if (typeof AbortSignal?.timeout !== 'function') return fetchImpl(url, init);
|
||||
return fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
||||
};
|
||||
}
|
||||
|
||||
export function createChangelogRuntime(deps: ChangelogRuntimeDeps): {
|
||||
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||
} {
|
||||
// curl matches the updater's transport choice: Electron's global fetch is
|
||||
// unreliable for GitHub on some Linux builds.
|
||||
const fetchImpl = withRequestTimeout(
|
||||
deps.createFetch?.() ??
|
||||
(process.platform === 'win32' ? createGlobalFetch() : createCurlFetch()),
|
||||
CHANGELOG_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const source = createChangelogSource({
|
||||
fetchLatestReleaseTag: async () => {
|
||||
const release = await fetchLatestStableRelease({
|
||||
fetch: fetchImpl,
|
||||
channel: deps.getUpdateChannel(),
|
||||
});
|
||||
return release?.tag_name ?? null;
|
||||
},
|
||||
fetchText: async (url) => {
|
||||
const response = await fetchImpl(url, {
|
||||
headers: { 'User-Agent': 'SubMiner changelog' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Changelog request failed with ${response.status}`);
|
||||
}
|
||||
return await response.text();
|
||||
},
|
||||
readBundledChangelog: () =>
|
||||
readBundledChangelog({
|
||||
resolvePath: () =>
|
||||
resolveBundledChangelogPath({
|
||||
resourcesPath: deps.resourcesPath,
|
||||
appPath: deps.appPath,
|
||||
dirname: deps.dirname,
|
||||
joinPath: deps.joinPath,
|
||||
fileExists: deps.fileExists,
|
||||
}),
|
||||
readFile: deps.readFile,
|
||||
logWarn: deps.logWarn,
|
||||
}),
|
||||
getInstalledVersion: deps.getInstalledVersion,
|
||||
now: () => Date.now(),
|
||||
logWarn: deps.logWarn,
|
||||
});
|
||||
|
||||
return {
|
||||
getChangelogSnapshot: (options?: { refresh?: boolean }) => source.getSnapshot(options),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ChangelogSnapshot, ChangelogSourceKind } from '../../../types/changelog';
|
||||
import { parseChangelog } from '../../../core/utils/changelog-parse';
|
||||
import { compareSemverLike } from '../update/release-assets';
|
||||
|
||||
export function buildChangelogSnapshot(
|
||||
markdown: string,
|
||||
options: {
|
||||
installedVersion: string;
|
||||
source: ChangelogSourceKind;
|
||||
releaseTag?: string;
|
||||
warning?: string;
|
||||
},
|
||||
): ChangelogSnapshot {
|
||||
const entries = parseChangelog(markdown);
|
||||
const latest = entries.reduce<string | null>(
|
||||
(best, entry) =>
|
||||
best === null || compareSemverLike(entry.version, best) > 0 ? entry.version : best,
|
||||
null,
|
||||
);
|
||||
const latestEntry = entries.find((entry) => entry.version === latest) ?? entries[0] ?? null;
|
||||
|
||||
return {
|
||||
entries,
|
||||
installedVersion: options.installedVersion,
|
||||
latestVersion: latest,
|
||||
expandedGroupKey: latestEntry?.groupKey ?? null,
|
||||
source: options.source,
|
||||
...(options.releaseTag ? { releaseTag: options.releaseTag } : {}),
|
||||
...(options.warning ? { warning: options.warning } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEmptyChangelogSnapshot(options: {
|
||||
installedVersion: string;
|
||||
error: string;
|
||||
}): ChangelogSnapshot {
|
||||
return {
|
||||
entries: [],
|
||||
installedVersion: options.installedVersion,
|
||||
latestVersion: null,
|
||||
expandedGroupKey: null,
|
||||
source: 'bundled',
|
||||
error: options.error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildRawChangelogUrl, createChangelogSource } from './changelog-source';
|
||||
import { buildChangelogSnapshot } from './changelog-snapshot';
|
||||
|
||||
const REMOTE = `# Changelog
|
||||
|
||||
## v0.20.0 (2026-09-01)
|
||||
|
||||
### Added
|
||||
- Remote only entry.
|
||||
|
||||
## v0.19.2 (2026-08-04)
|
||||
|
||||
### Fixed
|
||||
- Installed entry.
|
||||
`;
|
||||
|
||||
const BUNDLED = `# Changelog
|
||||
|
||||
## v0.19.2 (2026-08-04)
|
||||
|
||||
### Fixed
|
||||
- Installed entry.
|
||||
`;
|
||||
|
||||
function createDeps(overrides: Partial<Parameters<typeof createChangelogSource>[0]> = {}) {
|
||||
return {
|
||||
fetchLatestReleaseTag: async () => 'v0.20.0',
|
||||
fetchText: async () => REMOTE,
|
||||
readBundledChangelog: () => BUNDLED,
|
||||
getInstalledVersion: () => '0.19.2',
|
||||
now: () => 1_000,
|
||||
logWarn: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('changelog source reads the changelog at the latest release tag', async () => {
|
||||
const urls: string[] = [];
|
||||
const source = createChangelogSource(
|
||||
createDeps({
|
||||
fetchText: async (url: string) => {
|
||||
urls.push(url);
|
||||
return REMOTE;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const snapshot = await source.getSnapshot();
|
||||
|
||||
assert.deepEqual(urls, [
|
||||
'https://raw.githubusercontent.com/ksyasuda/SubMiner/v0.20.0/CHANGELOG.md',
|
||||
]);
|
||||
assert.equal(snapshot.source, 'remote');
|
||||
assert.equal(snapshot.releaseTag, 'v0.20.0');
|
||||
assert.equal(snapshot.latestVersion, '0.20.0');
|
||||
assert.equal(snapshot.installedVersion, '0.19.2');
|
||||
assert.equal(snapshot.expandedGroupKey, '0.20');
|
||||
});
|
||||
|
||||
test('changelog source falls back to the default branch when no release tag resolves', async () => {
|
||||
const urls: string[] = [];
|
||||
const source = createChangelogSource(
|
||||
createDeps({
|
||||
fetchLatestReleaseTag: async () => null,
|
||||
fetchText: async (url: string) => {
|
||||
urls.push(url);
|
||||
return REMOTE;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const snapshot = await source.getSnapshot();
|
||||
|
||||
assert.deepEqual(urls, ['https://raw.githubusercontent.com/ksyasuda/SubMiner/main/CHANGELOG.md']);
|
||||
assert.equal(snapshot.source, 'remote');
|
||||
assert.equal(snapshot.releaseTag, undefined);
|
||||
});
|
||||
|
||||
test('changelog source falls back to the bundled changelog when the download fails', async () => {
|
||||
const warnings: string[] = [];
|
||||
const source = createChangelogSource(
|
||||
createDeps({
|
||||
fetchText: async () => {
|
||||
throw new Error('offline');
|
||||
},
|
||||
logWarn: (message: string) => warnings.push(message),
|
||||
}),
|
||||
);
|
||||
|
||||
const snapshot = await source.getSnapshot();
|
||||
|
||||
assert.equal(snapshot.source, 'bundled');
|
||||
assert.match(snapshot.warning ?? '', /offline/);
|
||||
assert.equal(snapshot.latestVersion, '0.19.2');
|
||||
assert.equal(warnings.length, 1);
|
||||
});
|
||||
|
||||
test('changelog source reports an error when no changelog can be loaded', async () => {
|
||||
const source = createChangelogSource(
|
||||
createDeps({
|
||||
fetchText: async () => {
|
||||
throw new Error('offline');
|
||||
},
|
||||
readBundledChangelog: () => null,
|
||||
}),
|
||||
);
|
||||
|
||||
const snapshot = await source.getSnapshot();
|
||||
|
||||
assert.deepEqual(snapshot.entries, []);
|
||||
assert.match(snapshot.error ?? '', /offline/);
|
||||
// Nothing is rendered, so the message must not promise a bundled changelog.
|
||||
assert.match(snapshot.error ?? '', /^Changelog unavailable: /);
|
||||
assert.doesNotMatch(snapshot.error ?? '', /Showing the bundled changelog/);
|
||||
assert.equal(snapshot.installedVersion, '0.19.2');
|
||||
});
|
||||
|
||||
test('changelog source caches remote results and refreshes on demand', async () => {
|
||||
let fetches = 0;
|
||||
let clock = 0;
|
||||
const source = createChangelogSource(
|
||||
createDeps({
|
||||
now: () => clock,
|
||||
fetchText: async () => {
|
||||
fetches += 1;
|
||||
return REMOTE;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await source.getSnapshot();
|
||||
await source.getSnapshot();
|
||||
assert.equal(fetches, 1);
|
||||
|
||||
await source.getSnapshot({ refresh: true });
|
||||
assert.equal(fetches, 2);
|
||||
|
||||
clock = 11 * 60 * 1000;
|
||||
await source.getSnapshot();
|
||||
assert.equal(fetches, 3);
|
||||
});
|
||||
|
||||
test('changelog source retries the network after a bundled fallback', async () => {
|
||||
let fetches = 0;
|
||||
const source = createChangelogSource(
|
||||
createDeps({
|
||||
fetchText: async () => {
|
||||
fetches += 1;
|
||||
throw new Error('offline');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await source.getSnapshot();
|
||||
await source.getSnapshot();
|
||||
|
||||
assert.equal(fetches, 2);
|
||||
});
|
||||
|
||||
test('changelog source treats an empty remote changelog as a failure', async () => {
|
||||
const source = createChangelogSource(createDeps({ fetchText: async () => ' ' }));
|
||||
|
||||
const snapshot = await source.getSnapshot();
|
||||
|
||||
assert.equal(snapshot.source, 'bundled');
|
||||
});
|
||||
|
||||
test('changelog source falls back when the remote body parses to no releases', () => {
|
||||
const warnings: string[] = [];
|
||||
const source = createChangelogSource(
|
||||
createDeps({
|
||||
// A 200 that is not a changelog, e.g. a redirect landing page.
|
||||
fetchText: async () => '<!doctype html><html><body>Moved</body></html>',
|
||||
logWarn: (message: string) => warnings.push(message),
|
||||
}),
|
||||
);
|
||||
|
||||
return source.getSnapshot().then((snapshot) => {
|
||||
assert.equal(snapshot.source, 'bundled');
|
||||
assert.equal(snapshot.entries.length, 1);
|
||||
assert.match(snapshot.warning ?? '', /no releases/);
|
||||
assert.equal(warnings.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('raw changelog urls encode the release ref', () => {
|
||||
assert.equal(
|
||||
buildRawChangelogUrl('v1.0.0', 'owner', 'repo'),
|
||||
'https://raw.githubusercontent.com/owner/repo/v1.0.0/CHANGELOG.md',
|
||||
);
|
||||
});
|
||||
|
||||
test('snapshot expansion uses the newest version even when file order is unsorted', () => {
|
||||
const snapshot = buildChangelogSnapshot(
|
||||
'## v0.18.0 (2026-01-01)\n\n### Fixed\n- Old.\n\n## v0.19.0 (2026-02-01)\n\n### Fixed\n- New.\n',
|
||||
{ installedVersion: '0.18.0', source: 'bundled' },
|
||||
);
|
||||
|
||||
assert.equal(snapshot.latestVersion, '0.19.0');
|
||||
assert.equal(snapshot.expandedGroupKey, '0.19');
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { ChangelogSnapshot } from '../../../types/changelog';
|
||||
import { buildChangelogSnapshot, buildEmptyChangelogSnapshot } from './changelog-snapshot';
|
||||
|
||||
const DEFAULT_OWNER = 'ksyasuda';
|
||||
const DEFAULT_REPO = 'SubMiner';
|
||||
const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface ChangelogSourceDeps {
|
||||
/** Resolves the release the changelog should be read from, or null when unknown. */
|
||||
fetchLatestReleaseTag: () => Promise<string | null>;
|
||||
fetchText: (url: string) => Promise<string>;
|
||||
/** Reads the CHANGELOG.md shipped with the install; null when unavailable. */
|
||||
readBundledChangelog: () => string | null;
|
||||
getInstalledVersion: () => string;
|
||||
now: () => number;
|
||||
logWarn: (message: string) => void;
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
cacheTtlMs?: number;
|
||||
}
|
||||
|
||||
export function buildRawChangelogUrl(ref: string, owner: string, repo: string): string {
|
||||
return `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(ref)}/CHANGELOG.md`;
|
||||
}
|
||||
|
||||
function summarize(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function createChangelogSource(deps: ChangelogSourceDeps): {
|
||||
getSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||
} {
|
||||
const owner = deps.owner ?? DEFAULT_OWNER;
|
||||
const repo = deps.repo ?? DEFAULT_REPO;
|
||||
const cacheTtlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
||||
|
||||
let cached: { snapshot: ChangelogSnapshot; fetchedAt: number } | null = null;
|
||||
let inFlight: Promise<ChangelogSnapshot> | null = null;
|
||||
|
||||
/** `reason` is the raw failure; each branch phrases it for what it can show. */
|
||||
function fallbackToBundled(reason: string): ChangelogSnapshot {
|
||||
const bundled = deps.readBundledChangelog();
|
||||
if (bundled === null) {
|
||||
// Nothing is on screen, so promising a bundled changelog would be a lie.
|
||||
return buildEmptyChangelogSnapshot({
|
||||
installedVersion: deps.getInstalledVersion(),
|
||||
error: `Changelog unavailable: ${reason}`,
|
||||
});
|
||||
}
|
||||
return buildChangelogSnapshot(bundled, {
|
||||
installedVersion: deps.getInstalledVersion(),
|
||||
source: 'bundled',
|
||||
warning: `Showing the bundled changelog: ${reason}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSnapshot(): Promise<ChangelogSnapshot> {
|
||||
let releaseTag: string | null = null;
|
||||
try {
|
||||
releaseTag = await deps.fetchLatestReleaseTag();
|
||||
} catch (error) {
|
||||
deps.logWarn(`Changelog release lookup failed: ${summarize(error)}`);
|
||||
}
|
||||
|
||||
// Without a release tag the default branch still gives the newest published
|
||||
// changelog, so try it before falling back to the bundled copy.
|
||||
const ref = releaseTag ?? 'main';
|
||||
try {
|
||||
const markdown = await deps.fetchText(buildRawChangelogUrl(ref, owner, repo));
|
||||
if (markdown.trim().length === 0) {
|
||||
throw new Error('Remote changelog was empty.');
|
||||
}
|
||||
const snapshot = buildChangelogSnapshot(markdown, {
|
||||
installedVersion: deps.getInstalledVersion(),
|
||||
source: 'remote',
|
||||
...(releaseTag ? { releaseTag } : {}),
|
||||
});
|
||||
// A 200 that isn't a changelog (a redirect landing page, a renamed repo)
|
||||
// parses to nothing; the bundled copy beats showing an empty modal.
|
||||
if (snapshot.entries.length === 0) {
|
||||
throw new Error('Remote changelog contained no releases.');
|
||||
}
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
const message = summarize(error);
|
||||
deps.logWarn(`Changelog download failed (${ref}): ${message}`);
|
||||
return fallbackToBundled(message);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async getSnapshot(options?: { refresh?: boolean }): Promise<ChangelogSnapshot> {
|
||||
const refresh = options?.refresh === true;
|
||||
if (!refresh && cached && deps.now() - cached.fetchedAt < cacheTtlMs) {
|
||||
return cached.snapshot;
|
||||
}
|
||||
if (inFlight) return await inFlight;
|
||||
|
||||
inFlight = loadSnapshot()
|
||||
.then((snapshot) => {
|
||||
// Only a successful remote read is worth caching; a bundled fallback
|
||||
// should retry the network on the next open.
|
||||
if (snapshot.source === 'remote') {
|
||||
cached = { snapshot, fetchedAt: deps.now() };
|
||||
} else {
|
||||
cached = null;
|
||||
}
|
||||
return snapshot;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
|
||||
return await inFlight;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -53,3 +53,52 @@ test('character dictionary sync completion refreshes subtitle state when diction
|
||||
'log:[dictionary:auto-sync] refreshed current subtitle after sync (AniList 1, changed=yes, title=Frieren)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('character dictionary sync completion drops cached dictionary reads before refreshing', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
handleCharacterDictionaryAutoSyncComplete(
|
||||
{
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Frieren',
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
hasParserWindow: () => true,
|
||||
invalidateCharacterDictionaryLookups: () => calls.push('invalidate-dictionary-lookups'),
|
||||
clearParserCaches: () => calls.push('clear-parser'),
|
||||
invalidateTokenizationCache: () => calls.push('invalidate'),
|
||||
refreshSubtitlePrefetch: () => calls.push('prefetch'),
|
||||
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
|
||||
logInfo: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
// Must run before the refreshes, or they re-tokenize against the character
|
||||
// names and images from the previous dictionary build.
|
||||
assert.equal(calls[0], 'invalidate-dictionary-lookups');
|
||||
assert.ok(calls.indexOf('invalidate-dictionary-lookups') < calls.indexOf('refresh-subtitle'));
|
||||
});
|
||||
|
||||
test('character dictionary sync completion leaves cached dictionary reads alone when unchanged', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
handleCharacterDictionaryAutoSyncComplete(
|
||||
{
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Frieren',
|
||||
changed: false,
|
||||
},
|
||||
{
|
||||
hasParserWindow: () => true,
|
||||
invalidateCharacterDictionaryLookups: () => calls.push('invalidate-dictionary-lookups'),
|
||||
clearParserCaches: () => calls.push('clear-parser'),
|
||||
invalidateTokenizationCache: () => calls.push('invalidate'),
|
||||
refreshSubtitlePrefetch: () => calls.push('prefetch'),
|
||||
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
|
||||
logInfo: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,12 @@ export function handleCharacterDictionaryAutoSyncComplete(
|
||||
deps: {
|
||||
hasParserWindow: () => boolean;
|
||||
clearParserCaches: () => void;
|
||||
/**
|
||||
* Drops cached reads of the generated dictionary (character images, and the
|
||||
* name candidates the scanner uses to skip lookups). Runs before the
|
||||
* refreshes below so they re-tokenize against the new dictionary content.
|
||||
*/
|
||||
invalidateCharacterDictionaryLookups?: () => void;
|
||||
invalidateTokenizationCache: () => void;
|
||||
refreshSubtitlePrefetch: () => void;
|
||||
refreshCurrentSubtitle: () => void;
|
||||
@@ -14,6 +20,7 @@ export function handleCharacterDictionaryAutoSyncComplete(
|
||||
},
|
||||
): void {
|
||||
if (completion.changed) {
|
||||
deps.invalidateCharacterDictionaryLookups?.();
|
||||
if (deps.hasParserWindow()) {
|
||||
deps.clearParserCaches();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-char-dict-auto-sync-'));
|
||||
}
|
||||
|
||||
async function waitUntil(predicate: () => boolean, label: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
@@ -187,7 +195,7 @@ test('auto sync imports merged dictionary and persists MRU state', async () => {
|
||||
'[dictionary:auto-sync] syncing current anime snapshot',
|
||||
'[dictionary:auto-sync] active AniList media set: 130298 - The Eminence in Shadow',
|
||||
'[dictionary:auto-sync] rebuilding merged dictionary for active anime set',
|
||||
'[dictionary:auto-sync] importing merged dictionary: /tmp/subminer-character-dictionary.zip',
|
||||
'[dictionary:auto-sync] importing merged dictionary: /tmp/subminer-character-dictionary.zip (timeout 120000ms)',
|
||||
'[dictionary:auto-sync] applying Yomitan settings for SubMiner Character Dictionary',
|
||||
'[dictionary:auto-sync] synced AniList 130298: SubMiner Character Dictionary (2544 entries)',
|
||||
]);
|
||||
@@ -958,11 +966,9 @@ test('auto sync emits building while merged dictionary generation is in flight',
|
||||
});
|
||||
|
||||
const syncPromise = runtime.runSyncNow();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(
|
||||
events.some((event) => event.phase === 'building'),
|
||||
true,
|
||||
await waitUntil(
|
||||
() => events.some((event) => event.phase === 'building'),
|
||||
'the building status event',
|
||||
);
|
||||
|
||||
buildDeferred.resolve({
|
||||
@@ -1029,8 +1035,7 @@ test('auto sync waits for tokenization-ready gate before Yomitan mutations', asy
|
||||
});
|
||||
|
||||
const syncPromise = runtime.runSyncNow();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await waitUntil(() => calls.includes('wait'), 'the tokenization-ready gate');
|
||||
|
||||
assert.deepEqual(calls, ['build', 'wait']);
|
||||
|
||||
@@ -1039,3 +1044,332 @@ test('auto sync waits for tokenization-ready gate before Yomitan mutations', asy
|
||||
|
||||
assert.deepEqual(calls, ['build', 'wait', 'info', 'import', 'settings']);
|
||||
});
|
||||
|
||||
test('auto sync scales the import timeout with the merged dictionary size', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
|
||||
fs.mkdirSync(dictionariesDir, { recursive: true });
|
||||
const zipPath = path.join(dictionariesDir, 'merged.zip');
|
||||
// 2 MB of merged dictionary buys ~12s of import budget on top of the base.
|
||||
fs.writeFileSync(zipPath, Buffer.alloc(2 * 1024 * 1024));
|
||||
const events: Array<{ phase: string; message: string }> = [];
|
||||
let importedRevision: string | null = null;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
entryCount: 4000,
|
||||
fromCache: false,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath,
|
||||
revision: 'rev-21',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 4000,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () =>
|
||||
importedRevision
|
||||
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||
: [],
|
||||
importYomitanDictionary: async () => {
|
||||
// Far longer than the quick-operation budget, well inside the size-scaled one.
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
importedRevision = 'rev-21';
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => 1000,
|
||||
operationTimeoutMs: 5,
|
||||
dictionaryImportTimeoutBaseMs: 20,
|
||||
onSyncStatus: (event) => {
|
||||
events.push({ phase: event.phase, message: event.message });
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.equal(
|
||||
events.some((event) => event.phase === 'failed'),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(events.at(-1), {
|
||||
phase: 'ready',
|
||||
message: 'Character dictionary ready for ONE PIECE',
|
||||
});
|
||||
});
|
||||
|
||||
test('auto sync reports the scaled budget when an import really does hang', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const events: Array<{ phase: string; message: string }> = [];
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
entryCount: 4000,
|
||||
fromCache: false,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath: path.join(userDataPath, 'character-dictionaries', 'missing.zip'),
|
||||
revision: 'rev-21',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 4000,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () => [],
|
||||
importYomitanDictionary: () => new Promise<boolean>(() => {}),
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => 1000,
|
||||
dictionaryImportTimeoutBaseMs: 20,
|
||||
onSyncStatus: (event) => {
|
||||
events.push({ phase: event.phase, message: event.message });
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
runtime.runSyncNow(),
|
||||
/importYomitanDictionary\(missing\.zip\) timed out after 20ms/,
|
||||
);
|
||||
assert.equal(events.at(-1)?.phase, 'failed');
|
||||
});
|
||||
|
||||
test('auto sync ticks the importing notification while the import runs', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const events: Array<{ phase: string; message: string }> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const importDeferred = createDeferred<boolean>();
|
||||
let clock = 1000;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
entryCount: 4000,
|
||||
fromCache: false,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath: '/tmp/merged.zip',
|
||||
revision: 'rev-21',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 4000,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () => [],
|
||||
importYomitanDictionary: () => importDeferred.promise,
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => clock,
|
||||
schedule: (fn) => {
|
||||
scheduled.push(fn);
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearSchedule: () => undefined,
|
||||
onSyncStatus: (event) => {
|
||||
events.push({ phase: event.phase, message: event.message });
|
||||
},
|
||||
});
|
||||
|
||||
const syncPromise = runtime.runSyncNow();
|
||||
await waitUntil(
|
||||
() => events.some((event) => event.phase === 'importing'),
|
||||
'the importing status event',
|
||||
);
|
||||
|
||||
clock = 1000 + 65_000;
|
||||
// The importing heartbeat is the most recently scheduled tick.
|
||||
scheduled.at(-1)!();
|
||||
|
||||
assert.deepEqual(events.at(-1), {
|
||||
phase: 'importing',
|
||||
message: 'Importing character dictionary for ONE PIECE (1m 05s)...',
|
||||
});
|
||||
|
||||
importDeferred.resolve(true);
|
||||
await syncPromise;
|
||||
assert.equal(events.at(-1)?.phase, 'ready');
|
||||
});
|
||||
|
||||
test('auto sync reports character and image counts while generating a snapshot', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const events: Array<{ phase: string; message: string }> = [];
|
||||
let clock = 1000;
|
||||
let importedRevision: string | null = null;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
|
||||
getOrCreateCurrentSnapshot: async (_targetPath, progress) => {
|
||||
progress?.onGenerating?.({ mediaId: 21, mediaTitle: 'ONE PIECE' });
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
stage: 'characters',
|
||||
completed: 50,
|
||||
total: null,
|
||||
page: 12,
|
||||
});
|
||||
// Same stage, same clock tick: throttled away so a 33-page fetch cannot spam the overlay.
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
stage: 'characters',
|
||||
completed: 100,
|
||||
total: null,
|
||||
page: 13,
|
||||
});
|
||||
// A stage change always reports, throttle window or not.
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
stage: 'images',
|
||||
completed: 1,
|
||||
total: 1220,
|
||||
});
|
||||
clock += 2000;
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
stage: 'images',
|
||||
completed: 240,
|
||||
total: 1220,
|
||||
});
|
||||
clock += 6000;
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
stage: 'names',
|
||||
completed: 800,
|
||||
total: 1220,
|
||||
});
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
stage: 'saving',
|
||||
completed: 0,
|
||||
total: null,
|
||||
});
|
||||
return {
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
entryCount: 4000,
|
||||
fromCache: false,
|
||||
updatedAt: 1000,
|
||||
};
|
||||
},
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath: '/tmp/merged.zip',
|
||||
revision: 'rev-21',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 4000,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () =>
|
||||
importedRevision
|
||||
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||
: [],
|
||||
importYomitanDictionary: async () => {
|
||||
importedRevision = 'rev-21';
|
||||
return true;
|
||||
},
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => clock,
|
||||
onSyncStatus: (event) => {
|
||||
events.push({ phase: event.phase, message: event.message });
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.runSyncNow();
|
||||
|
||||
assert.deepEqual(
|
||||
events.filter((event) => event.phase === 'generating').map((event) => event.message),
|
||||
[
|
||||
'Generating character dictionary for ONE PIECE...',
|
||||
'Generating character dictionary for ONE PIECE (page 12, 50 characters)...',
|
||||
'Generating character dictionary for ONE PIECE (image 1/1220)...',
|
||||
'Generating character dictionary for ONE PIECE (image 240/1220, ~10s left)...',
|
||||
'Generating character dictionary for ONE PIECE (name 800/1220 · 8s)...',
|
||||
'Generating character dictionary for ONE PIECE (saving snapshot · 8s)...',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('auto sync keeps the generating clock ticking when a stage stalls', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const events: Array<{ phase: string; message: string }> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const snapshotDeferred = createDeferred<{
|
||||
mediaId: number;
|
||||
mediaTitle: string;
|
||||
entryCount: number;
|
||||
fromCache: boolean;
|
||||
updatedAt: number;
|
||||
}>();
|
||||
let clock = 1000;
|
||||
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath,
|
||||
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
|
||||
getOrCreateCurrentSnapshot: async (_targetPath, progress) => {
|
||||
progress?.onGenerating?.({ mediaId: 21, mediaTitle: 'ONE PIECE' });
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
stage: 'images',
|
||||
completed: 240,
|
||||
total: 1220,
|
||||
});
|
||||
return await snapshotDeferred.promise;
|
||||
},
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath: '/tmp/merged.zip',
|
||||
revision: 'rev-21',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 4000,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () => [],
|
||||
importYomitanDictionary: async () => true,
|
||||
deleteYomitanDictionary: async () => true,
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => clock,
|
||||
schedule: (fn) => {
|
||||
scheduled.push(fn);
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearSchedule: () => undefined,
|
||||
onSyncStatus: (event) => {
|
||||
events.push({ phase: event.phase, message: event.message });
|
||||
},
|
||||
});
|
||||
|
||||
const syncPromise = runtime.runSyncNow();
|
||||
await waitUntil(() => scheduled.length > 0, 'the generating heartbeat');
|
||||
|
||||
// No further progress arrives: only the clock moves.
|
||||
clock += 95_000;
|
||||
scheduled.at(-1)!();
|
||||
|
||||
assert.deepEqual(events.at(-1), {
|
||||
phase: 'generating',
|
||||
message: 'Generating character dictionary for ONE PIECE (image 240/1220 · 1m 35s)...',
|
||||
});
|
||||
|
||||
snapshotDeferred.resolve({
|
||||
mediaId: 21,
|
||||
mediaTitle: 'ONE PIECE',
|
||||
entryCount: 4000,
|
||||
fromCache: false,
|
||||
updatedAt: 1000,
|
||||
});
|
||||
await syncPromise;
|
||||
assert.equal(events.at(-1)?.phase, 'ready');
|
||||
});
|
||||
|
||||
@@ -5,9 +5,14 @@ import type { AnilistCharacterDictionaryProfileScope } from '../../types';
|
||||
import type {
|
||||
CharacterDictionarySnapshotProgressCallbacks,
|
||||
CharacterDictionarySnapshotResult,
|
||||
CharacterDictionarySnapshotStageProgress,
|
||||
MergedCharacterDictionaryBuildResult,
|
||||
} from '../character-dictionary-runtime';
|
||||
|
||||
const DEFAULT_IMPORT_TIMEOUT_BASE_MS = 120_000;
|
||||
const IMPORT_TIMEOUT_MS_PER_MB = 6_000;
|
||||
const IMPORT_TIMEOUT_MAX_MS = 1_800_000;
|
||||
|
||||
type AutoSyncMediaEntry = {
|
||||
mediaId: number;
|
||||
label: string;
|
||||
@@ -72,7 +77,15 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps {
|
||||
now: () => number;
|
||||
schedule?: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
clearSchedule?: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
/** Budget for the quick Yomitan queries (dictionary info, settings upsert). */
|
||||
operationTimeoutMs?: number;
|
||||
/**
|
||||
* Base budget for the slow Yomitan mutations (delete + import). The effective budget grows with
|
||||
* the merged ZIP size, because importing a large dictionary can take several minutes.
|
||||
*/
|
||||
dictionaryImportTimeoutBaseMs?: number;
|
||||
heartbeatMs?: number;
|
||||
progressThrottleMs?: number;
|
||||
logInfo?: (message: string) => void;
|
||||
logWarn?: (message: string) => void;
|
||||
onSyncStatus?: (event: CharacterDictionaryAutoSyncStatusEvent) => void;
|
||||
@@ -353,12 +366,70 @@ function buildCheckingMessage(mediaTitle: string): string {
|
||||
return `Checking character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
|
||||
function buildGeneratingMessage(mediaTitle: string): string {
|
||||
return `Generating character dictionary for ${mediaTitle}...`;
|
||||
function buildGeneratingMessage(mediaTitle: string, detail?: string): string {
|
||||
return detail
|
||||
? `Generating character dictionary for ${mediaTitle} (${detail})...`
|
||||
: `Generating character dictionary for ${mediaTitle}...`;
|
||||
}
|
||||
|
||||
function buildImportingMessage(mediaTitle: string): string {
|
||||
return `Importing character dictionary for ${mediaTitle}...`;
|
||||
function formatCharacterDictionaryProgressDetail(
|
||||
progress: CharacterDictionarySnapshotStageProgress,
|
||||
remainingMs: number | null,
|
||||
): string {
|
||||
if (progress.stage === 'saving') {
|
||||
return 'saving snapshot';
|
||||
}
|
||||
if (progress.stage === 'names') {
|
||||
return progress.total !== null && progress.total > 0
|
||||
? `name ${progress.completed}/${progress.total}`
|
||||
: `${progress.completed} names`;
|
||||
}
|
||||
if (progress.stage === 'images') {
|
||||
const counted =
|
||||
progress.total !== null && progress.total > 0
|
||||
? `image ${progress.completed}/${progress.total}`
|
||||
: `${progress.completed} images`;
|
||||
return remainingMs !== null
|
||||
? `${counted}, ~${formatRemainingDuration(remainingMs)} left`
|
||||
: counted;
|
||||
}
|
||||
const page = typeof progress.page === 'number' ? `page ${progress.page}, ` : '';
|
||||
return `${page}${progress.completed} characters`;
|
||||
}
|
||||
|
||||
function formatElapsedDuration(elapsedMs: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, '0')}s` : `${seconds}s`;
|
||||
}
|
||||
|
||||
/** Coarser than the elapsed clock: an estimate that ticks every second reads as precision it lacks. */
|
||||
function formatRemainingDuration(remainingMs: number): string {
|
||||
const totalSeconds = Math.max(1, Math.round(remainingMs / 1000));
|
||||
if (totalSeconds >= 60) {
|
||||
return `${Math.max(1, Math.round(totalSeconds / 60))}m`;
|
||||
}
|
||||
return `${Math.max(5, Math.ceil(totalSeconds / 5) * 5)}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The elapsed clock is the part that proves the app is alive: a stalled network fetch freezes the
|
||||
* counts, but the clock keeps moving.
|
||||
*/
|
||||
function joinGeneratingDetail(detail: string | null, elapsedMs: number): string | undefined {
|
||||
const parts = [detail, elapsedMs >= 5_000 ? formatElapsedDuration(elapsedMs) : null].filter(
|
||||
(part): part is string => typeof part === 'string' && part.length > 0,
|
||||
);
|
||||
return parts.length > 0 ? parts.join(' · ') : undefined;
|
||||
}
|
||||
|
||||
function buildImportingMessage(mediaTitle: string, elapsedMs?: number): string {
|
||||
const elapsed =
|
||||
typeof elapsedMs === 'number' && elapsedMs >= 1000
|
||||
? ` (${formatElapsedDuration(elapsedMs)})`
|
||||
: '';
|
||||
return `Importing character dictionary for ${mediaTitle}${elapsed}...`;
|
||||
}
|
||||
|
||||
function buildBuildingMessage(mediaTitle: string): string {
|
||||
@@ -389,21 +460,31 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
const clearSchedule = deps.clearSchedule ?? ((timer) => clearTimeout(timer));
|
||||
const debounceMs = 800;
|
||||
const operationTimeoutMs = Math.max(1, Math.floor(deps.operationTimeoutMs ?? 7_000));
|
||||
const dictionaryImportTimeoutBaseMs = Math.max(
|
||||
1,
|
||||
Math.floor(deps.dictionaryImportTimeoutBaseMs ?? DEFAULT_IMPORT_TIMEOUT_BASE_MS),
|
||||
);
|
||||
const heartbeatMs = Math.max(1, Math.floor(deps.heartbeatMs ?? 5_000));
|
||||
const progressThrottleMs = Math.max(0, Math.floor(deps.progressThrottleMs ?? 1_000));
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let syncInFlight = false;
|
||||
let runQueued = false;
|
||||
let activeCurrentMediaId: number | null = null;
|
||||
|
||||
const withOperationTimeout = async <T>(label: string, promise: Promise<T>): Promise<T> => {
|
||||
const withTimeout = async <T>(
|
||||
label: string,
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
): Promise<T> => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${operationTimeoutMs}ms`));
|
||||
}, operationTimeoutMs);
|
||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
@@ -413,6 +494,57 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
}
|
||||
};
|
||||
|
||||
const withOperationTimeout = <T>(label: string, promise: Promise<T>): Promise<T> =>
|
||||
withTimeout(label, promise, operationTimeoutMs);
|
||||
|
||||
/**
|
||||
* Importing a merged dictionary means Yomitan writing every term and image into IndexedDB, which
|
||||
* scales with the ZIP: a single-season dictionary lands in seconds, One Piece takes minutes. Size
|
||||
* the budget off the archive instead of failing a healthy import on a flat deadline.
|
||||
*/
|
||||
const resolveImportTimeoutMs = (zipPath: string | null): number => {
|
||||
let bytes = 0;
|
||||
if (zipPath) {
|
||||
try {
|
||||
bytes = fs.statSync(zipPath).size;
|
||||
} catch {
|
||||
bytes = 0;
|
||||
}
|
||||
}
|
||||
const sizeAllowanceMs = (bytes / (1024 * 1024)) * IMPORT_TIMEOUT_MS_PER_MB;
|
||||
return Math.min(
|
||||
IMPORT_TIMEOUT_MAX_MS,
|
||||
Math.max(
|
||||
dictionaryImportTimeoutBaseMs,
|
||||
Math.round(dictionaryImportTimeoutBaseMs + sizeAllowanceMs),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/** Keeps the persistent notification ticking so a multi-minute import never looks hung. */
|
||||
const withHeartbeat = async <T>(
|
||||
run: () => Promise<T>,
|
||||
onTick: (elapsedMs: number) => void,
|
||||
): Promise<T> => {
|
||||
const startedAt = deps.now();
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const tick = (): void => {
|
||||
if (stopped) return;
|
||||
onTick(deps.now() - startedAt);
|
||||
timer = schedule(tick, heartbeatMs);
|
||||
};
|
||||
timer = schedule(tick, heartbeatMs);
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
stopped = true;
|
||||
if (timer !== null) {
|
||||
clearSchedule(timer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runSyncOnce = async (): Promise<void> => {
|
||||
const config = deps.getConfig();
|
||||
if (!config.enabled) {
|
||||
@@ -422,33 +554,112 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
|
||||
let currentMediaId: number | undefined;
|
||||
let currentMediaTitle: string | null = null;
|
||||
let lastProgressAt = Number.NEGATIVE_INFINITY;
|
||||
let lastProgressStage: CharacterDictionarySnapshotStageProgress['stage'] | null = null;
|
||||
let generating: {
|
||||
mediaId: number;
|
||||
mediaTitle: string;
|
||||
startedAt: number;
|
||||
detail: string | null;
|
||||
} | null = null;
|
||||
let imageRate: { startedAt: number; startCompleted: number } | null = null;
|
||||
|
||||
const emitGeneratingStatus = (): void => {
|
||||
if (!generating) {
|
||||
return;
|
||||
}
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'generating',
|
||||
mediaId: generating.mediaId,
|
||||
mediaTitle: generating.mediaTitle,
|
||||
message: buildGeneratingMessage(
|
||||
generating.mediaTitle,
|
||||
joinGeneratingDetail(generating.detail, deps.now() - generating.startedAt),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// Image downloads are serial and evenly paced, so the observed rate predicts the tail well.
|
||||
const estimateRemainingImageMs = (
|
||||
progress: CharacterDictionarySnapshotStageProgress,
|
||||
nowMs: number,
|
||||
): number | null => {
|
||||
if (progress.stage !== 'images' || progress.total === null || imageRate === null) {
|
||||
return null;
|
||||
}
|
||||
const done = progress.completed - imageRate.startCompleted;
|
||||
const elapsedMs = nowMs - imageRate.startedAt;
|
||||
const remaining = progress.total - progress.completed;
|
||||
if (done <= 0 || elapsedMs <= 0 || remaining <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.round((elapsedMs / done) * remaining);
|
||||
};
|
||||
|
||||
try {
|
||||
deps.logInfo?.('[dictionary:auto-sync] syncing current anime snapshot');
|
||||
const snapshot = await deps.getOrCreateCurrentSnapshot(undefined, {
|
||||
onChecking: ({ mediaId, mediaTitle }) => {
|
||||
currentMediaId = mediaId;
|
||||
currentMediaTitle = mediaTitle;
|
||||
activeCurrentMediaId = mediaId;
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'checking',
|
||||
mediaId,
|
||||
mediaTitle,
|
||||
message: buildCheckingMessage(mediaTitle),
|
||||
});
|
||||
},
|
||||
onGenerating: ({ mediaId, mediaTitle }) => {
|
||||
currentMediaId = mediaId;
|
||||
currentMediaTitle = mediaTitle;
|
||||
activeCurrentMediaId = mediaId;
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'generating',
|
||||
mediaId,
|
||||
mediaTitle,
|
||||
message: buildGeneratingMessage(mediaTitle),
|
||||
});
|
||||
},
|
||||
});
|
||||
const snapshot = await withHeartbeat(
|
||||
() =>
|
||||
deps.getOrCreateCurrentSnapshot(undefined, {
|
||||
onChecking: ({ mediaId, mediaTitle }) => {
|
||||
currentMediaId = mediaId;
|
||||
currentMediaTitle = mediaTitle;
|
||||
activeCurrentMediaId = mediaId;
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'checking',
|
||||
mediaId,
|
||||
mediaTitle,
|
||||
message: buildCheckingMessage(mediaTitle),
|
||||
});
|
||||
},
|
||||
onGenerating: ({ mediaId, mediaTitle }) => {
|
||||
currentMediaId = mediaId;
|
||||
currentMediaTitle = mediaTitle;
|
||||
activeCurrentMediaId = mediaId;
|
||||
lastProgressAt = Number.NEGATIVE_INFINITY;
|
||||
lastProgressStage = null;
|
||||
imageRate = null;
|
||||
generating = { mediaId, mediaTitle, startedAt: deps.now(), detail: null };
|
||||
emitGeneratingStatus();
|
||||
},
|
||||
// Long-running work (AniList character pages, then one image download per character
|
||||
// and voice actor, then MeCab name splits) reports counts so the notification shows
|
||||
// movement instead of a frozen "Generating..." for the minutes a large series takes.
|
||||
onGenerateProgress: (progress) => {
|
||||
const nowMs = deps.now();
|
||||
if (!generating) {
|
||||
generating = {
|
||||
mediaId: progress.mediaId,
|
||||
mediaTitle: progress.mediaTitle,
|
||||
startedAt: nowMs,
|
||||
detail: null,
|
||||
};
|
||||
}
|
||||
if (progress.stage === 'images' && imageRate === null) {
|
||||
imageRate = { startedAt: nowMs, startCompleted: progress.completed };
|
||||
}
|
||||
// Stage changes and the last item of a stage always report; the throttle only thins
|
||||
// out the run of identical-looking updates in between.
|
||||
const isFinal = progress.total !== null && progress.completed >= progress.total;
|
||||
const isStageChange = progress.stage !== lastProgressStage;
|
||||
if (!isFinal && !isStageChange && nowMs - lastProgressAt < progressThrottleMs) {
|
||||
return;
|
||||
}
|
||||
lastProgressAt = nowMs;
|
||||
lastProgressStage = progress.stage;
|
||||
generating.mediaId = progress.mediaId;
|
||||
generating.mediaTitle = progress.mediaTitle;
|
||||
generating.detail = formatCharacterDictionaryProgressDetail(
|
||||
progress,
|
||||
estimateRemainingImageMs(progress, nowMs),
|
||||
);
|
||||
emitGeneratingStatus();
|
||||
},
|
||||
}),
|
||||
// Ticks even when a step stalls, so the message keeps moving while the counts do not.
|
||||
() => emitGeneratingStatus(),
|
||||
);
|
||||
generating = null;
|
||||
currentMediaId = snapshot.mediaId;
|
||||
currentMediaTitle = snapshot.mediaTitle;
|
||||
activeCurrentMediaId = snapshot.mediaId;
|
||||
@@ -531,33 +742,54 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
mediaTitle: snapshot.mediaTitle,
|
||||
message: buildImportingMessage(snapshot.mediaTitle),
|
||||
});
|
||||
if (existing !== null) {
|
||||
await withOperationTimeout(
|
||||
`deleteYomitanDictionary(${dictionaryTitle})`,
|
||||
deps.deleteYomitanDictionary(dictionaryTitle),
|
||||
);
|
||||
}
|
||||
if (merged === null) {
|
||||
const existingMergedZipPath = path.join(dictionariesDir, 'merged.zip');
|
||||
if (fs.existsSync(existingMergedZipPath)) {
|
||||
merged = {
|
||||
zipPath: existingMergedZipPath,
|
||||
revision,
|
||||
dictionaryTitle,
|
||||
entryCount: snapshot.entryCount,
|
||||
};
|
||||
} else {
|
||||
merged = await deps.buildMergedDictionary(nextActiveMediaIdValues);
|
||||
}
|
||||
}
|
||||
deps.logInfo?.(`[dictionary:auto-sync] importing merged dictionary: ${merged.zipPath}`);
|
||||
const imported = await withOperationTimeout(
|
||||
`importYomitanDictionary(${path.basename(merged.zipPath)})`,
|
||||
deps.importYomitanDictionary(merged.zipPath),
|
||||
await withHeartbeat(
|
||||
async () => {
|
||||
const importTimeoutMs = resolveImportTimeoutMs(
|
||||
merged?.zipPath ?? path.join(dictionariesDir, 'merged.zip'),
|
||||
);
|
||||
if (existing !== null) {
|
||||
await withTimeout(
|
||||
`deleteYomitanDictionary(${dictionaryTitle})`,
|
||||
deps.deleteYomitanDictionary(dictionaryTitle),
|
||||
importTimeoutMs,
|
||||
);
|
||||
}
|
||||
if (merged === null) {
|
||||
const existingMergedZipPath = path.join(dictionariesDir, 'merged.zip');
|
||||
if (fs.existsSync(existingMergedZipPath)) {
|
||||
merged = {
|
||||
zipPath: existingMergedZipPath,
|
||||
revision,
|
||||
dictionaryTitle,
|
||||
entryCount: snapshot.entryCount,
|
||||
};
|
||||
} else {
|
||||
merged = await deps.buildMergedDictionary(nextActiveMediaIdValues);
|
||||
}
|
||||
}
|
||||
const mergedZipPath = merged.zipPath;
|
||||
const mergedImportTimeoutMs = resolveImportTimeoutMs(mergedZipPath);
|
||||
deps.logInfo?.(
|
||||
`[dictionary:auto-sync] importing merged dictionary: ${mergedZipPath} (timeout ${mergedImportTimeoutMs}ms)`,
|
||||
);
|
||||
const imported = await withTimeout(
|
||||
`importYomitanDictionary(${path.basename(mergedZipPath)})`,
|
||||
deps.importYomitanDictionary(mergedZipPath),
|
||||
mergedImportTimeoutMs,
|
||||
);
|
||||
if (!imported) {
|
||||
throw new Error(`Failed to import dictionary ZIP: ${merged.zipPath}`);
|
||||
}
|
||||
},
|
||||
(elapsedMs) => {
|
||||
deps.onSyncStatus?.({
|
||||
phase: 'importing',
|
||||
mediaId: snapshot.mediaId,
|
||||
mediaTitle: snapshot.mediaTitle,
|
||||
message: buildImportingMessage(snapshot.mediaTitle, elapsedMs),
|
||||
});
|
||||
},
|
||||
);
|
||||
if (!imported) {
|
||||
throw new Error(`Failed to import dictionary ZIP: ${merged.zipPath}`);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -225,24 +225,35 @@ export function composeMpvRuntimeHandlers<
|
||||
}
|
||||
return tokenizationWarmupInFlight;
|
||||
};
|
||||
// Built once and reused for every tokenization: per-call rebuilds create
|
||||
// fresh closures, which defeats identity-keyed caches downstream (the JLPT
|
||||
// lookup cache keys on the getJlptLevel function, and the mecab availability
|
||||
// WeakSet keys on the runtime deps instance).
|
||||
let cachedTokenizerRuntimeDeps: TTokenizerRuntimeDeps | null = null;
|
||||
const getTokenizerRuntimeDeps = (): TTokenizerRuntimeDeps => {
|
||||
if (cachedTokenizerRuntimeDeps) {
|
||||
return cachedTokenizerRuntimeDeps;
|
||||
}
|
||||
const tokenizerMainDeps = buildTokenizerDepsHandler();
|
||||
const baseOnTokenizationReady = tokenizerMainDeps.onTokenizationReady;
|
||||
tokenizerMainDeps.onTokenizationReady = (tokenizedText: string): void => {
|
||||
if (!shouldWarmupAnnotationDictionaries()) {
|
||||
baseOnTokenizationReady?.(tokenizedText);
|
||||
return;
|
||||
}
|
||||
markTokenizationPlaybackReady();
|
||||
baseOnTokenizationReady?.(tokenizedText);
|
||||
if (!tokenizationWarmupCompleted) {
|
||||
void prewarmSubtitleDictionaries({ showLoadingOsd: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
cachedTokenizerRuntimeDeps = options.tokenizer.createTokenizerRuntimeDeps(tokenizerMainDeps);
|
||||
return cachedTokenizerRuntimeDeps;
|
||||
};
|
||||
const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => {
|
||||
if (!tokenizationWarmupCompleted) void startTokenizationWarmups();
|
||||
await ensureTokenizationPrerequisites();
|
||||
const tokenizerMainDeps = buildTokenizerDepsHandler();
|
||||
if (shouldWarmupAnnotationDictionaries()) {
|
||||
const onTokenizationReady = tokenizerMainDeps.onTokenizationReady;
|
||||
tokenizerMainDeps.onTokenizationReady = (tokenizedText: string): void => {
|
||||
markTokenizationPlaybackReady();
|
||||
onTokenizationReady?.(tokenizedText);
|
||||
if (!tokenizationWarmupCompleted) {
|
||||
void prewarmSubtitleDictionaries({ showLoadingOsd: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
}
|
||||
return options.tokenizer.tokenizeSubtitle(
|
||||
text,
|
||||
options.tokenizer.createTokenizerRuntimeDeps(tokenizerMainDeps),
|
||||
);
|
||||
return options.tokenizer.tokenizeSubtitle(text, getTokenizerRuntimeDeps());
|
||||
};
|
||||
|
||||
const launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskFromStartup(
|
||||
|
||||
@@ -6,6 +6,7 @@ export function createBuildSubtitleProcessingControllerMainDepsHandler(
|
||||
return (): SubtitleProcessingControllerDeps => ({
|
||||
tokenizeSubtitle: (text: string) => deps.tokenizeSubtitle(text),
|
||||
emitSubtitle: (payload) => deps.emitSubtitle(payload),
|
||||
onProcessingSettled: () => deps.onProcessingSettled?.(),
|
||||
logDebug: deps.logDebug,
|
||||
now: deps.now,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
|
||||
getCurrentCharacterDictionaryMediaId?: NonNullable<
|
||||
TokenizerDepsRuntimeOptions['getCurrentCharacterDictionaryMediaId']
|
||||
>;
|
||||
getCharacterNameCandidates?: NonNullable<
|
||||
TokenizerDepsRuntimeOptions['getCharacterNameCandidates']
|
||||
>;
|
||||
getFrequencyDictionaryEnabled: NonNullable<
|
||||
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
|
||||
>;
|
||||
@@ -84,6 +87,11 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
|
||||
getCurrentCharacterDictionaryMediaId: () => deps.getCurrentCharacterDictionaryMediaId!(),
|
||||
}
|
||||
: {}),
|
||||
...(deps.getCharacterNameCandidates
|
||||
? {
|
||||
getCharacterNameCandidates: () => deps.getCharacterNameCandidates!(),
|
||||
}
|
||||
: {}),
|
||||
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
|
||||
getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(),
|
||||
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
|
||||
|
||||
@@ -65,6 +65,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => initialized,
|
||||
openSessionHelpModal: () => calls.push('help'),
|
||||
openChangelogModal: () => calls.push('changelog'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: () => true,
|
||||
showFirstRunSetup: () => true,
|
||||
@@ -120,6 +121,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
|
||||
initializeOverlayRuntime: () => calls.push('init'),
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
openSessionHelpModal: () => calls.push('help'),
|
||||
openChangelogModal: () => calls.push('changelog'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: () => true,
|
||||
showFirstRunSetup: () => false,
|
||||
|
||||
@@ -39,6 +39,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
platform?: string;
|
||||
openSessionHelp: () => void;
|
||||
openChangelog: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: boolean;
|
||||
openFirstRunSetup: () => void;
|
||||
@@ -60,6 +61,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
initializeOverlayRuntime: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
openSessionHelpModal: () => void;
|
||||
openChangelogModal: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: () => boolean;
|
||||
showFirstRunSetup: () => boolean;
|
||||
@@ -87,6 +89,12 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
}
|
||||
deps.openSessionHelpModal();
|
||||
},
|
||||
openChangelog: () => {
|
||||
if (!deps.isOverlayRuntimeInitialized()) {
|
||||
deps.initializeOverlayRuntime();
|
||||
}
|
||||
deps.openChangelogModal();
|
||||
},
|
||||
openTexthookerInBrowser: () => {
|
||||
deps.openTexthookerInBrowser();
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
initializeOverlayRuntime: () => calls.push('init'),
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
openSessionHelpModal: () => calls.push('help'),
|
||||
openChangelogModal: () => calls.push('changelog'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: () => true,
|
||||
showFirstRunSetup: () => true,
|
||||
@@ -50,6 +51,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
const template = menuDeps.buildTrayMenuTemplateRuntime({
|
||||
platform: menuDeps.platform,
|
||||
openSessionHelp: () => calls.push('open-help'),
|
||||
openChangelog: () => calls.push('open-changelog'),
|
||||
openTexthookerInBrowser: () => calls.push('open-texthooker'),
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => calls.push('open-setup'),
|
||||
|
||||
@@ -29,6 +29,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
platform?: string;
|
||||
openSessionHelp: () => void;
|
||||
openChangelog: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: boolean;
|
||||
openFirstRunSetup: () => void;
|
||||
@@ -50,6 +51,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
initializeOverlayRuntime: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
openSessionHelpModal: () => void;
|
||||
openChangelogModal: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: () => boolean;
|
||||
showFirstRunSetup: () => boolean;
|
||||
@@ -74,6 +76,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
||||
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
|
||||
openSessionHelpModal: deps.openSessionHelpModal,
|
||||
openChangelogModal: deps.openChangelogModal,
|
||||
openTexthookerInBrowser: deps.openTexthookerInBrowser,
|
||||
showTexthookerPage: deps.showTexthookerPage,
|
||||
showFirstRunSetup: deps.showFirstRunSetup,
|
||||
|
||||
@@ -25,6 +25,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => overlayInitialized,
|
||||
openSessionHelpModal: () => {},
|
||||
openChangelogModal: () => {},
|
||||
openTexthookerInBrowser: () => {},
|
||||
showTexthookerPage: () => true,
|
||||
showFirstRunSetup: () => true,
|
||||
|
||||
@@ -30,6 +30,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
const calls: string[] = [];
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => calls.push('help'),
|
||||
openChangelog: () => calls.push('changelog'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => calls.push('setup'),
|
||||
@@ -49,36 +50,53 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
assert.equal(template.length, 14);
|
||||
assert.equal(
|
||||
template.some((entry) => entry.label === 'Open Runtime Options'),
|
||||
false,
|
||||
// Resolve by label, not index: adding a menu entry should not force every
|
||||
// later assertion in this test to be renumbered.
|
||||
const entryFor = (label: string) => {
|
||||
const entry = template.find((candidate) => candidate.label === label);
|
||||
assert.ok(entry, `expected a "${label}" tray entry`);
|
||||
return entry;
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
template.map((entry) => entry.label ?? `<${entry.type}>`),
|
||||
[
|
||||
'Open Help',
|
||||
'View Changelog',
|
||||
'Open Texthooker',
|
||||
'Complete Setup',
|
||||
'Open SubMiner Setup',
|
||||
'Open Yomitan Settings',
|
||||
'Open SubMiner Settings',
|
||||
'Sync Stats && History',
|
||||
'Export Logs',
|
||||
'Configure Jellyfin',
|
||||
'Jellyfin Discovery',
|
||||
'Configure AniList',
|
||||
'Check for Updates',
|
||||
'<separator>',
|
||||
'Quit',
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
template.some((entry) => entry.label === 'Open Overlay'),
|
||||
false,
|
||||
);
|
||||
assert.equal(template[0]!.label, 'Open Help');
|
||||
assert.equal(template[3]!.label, 'Open SubMiner Setup');
|
||||
const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery');
|
||||
assert.equal(discovery?.type, 'checkbox');
|
||||
assert.equal(discovery?.checked, false);
|
||||
discovery?.click?.({ checked: true });
|
||||
template[0]!.click?.();
|
||||
assert.equal(template[1]!.label, 'Open Texthooker');
|
||||
template[1]!.click?.();
|
||||
assert.equal(template[5]!.label, 'Open SubMiner Settings');
|
||||
assert.equal(template[6]!.label, 'Sync Stats && History');
|
||||
template[6]!.click?.();
|
||||
assert.equal(template[7]!.label, 'Export Logs');
|
||||
template[7]!.click?.();
|
||||
assert.equal(template[11]!.label, 'Check for Updates');
|
||||
template[11]!.click?.();
|
||||
template[12]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[13]!.click?.();
|
||||
|
||||
const discovery = entryFor('Jellyfin Discovery');
|
||||
assert.equal(discovery.type, 'checkbox');
|
||||
assert.equal(discovery.checked, false);
|
||||
discovery.click?.({ checked: true });
|
||||
|
||||
entryFor('Open Help').click?.();
|
||||
entryFor('View Changelog').click?.();
|
||||
entryFor('Open Texthooker').click?.();
|
||||
entryFor('Sync Stats && History').click?.();
|
||||
entryFor('Export Logs').click?.();
|
||||
entryFor('Check for Updates').click?.();
|
||||
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
|
||||
entryFor('Quit').click?.();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'jellyfin-discovery:true',
|
||||
'help',
|
||||
'changelog',
|
||||
'texthooker',
|
||||
'sync-ui',
|
||||
'export-logs',
|
||||
@@ -91,6 +109,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
test('tray menu template omits first-run setup entry when setup is complete', () => {
|
||||
const labels = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => undefined,
|
||||
openChangelog: () => undefined,
|
||||
openTexthookerInBrowser: () => undefined,
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => undefined,
|
||||
@@ -120,6 +139,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
test('tray menu template omits texthooker entry when texthooker page is disabled', () => {
|
||||
const labels = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => undefined,
|
||||
openChangelog: () => undefined,
|
||||
openTexthookerInBrowser: () => undefined,
|
||||
showTexthookerPage: false,
|
||||
openFirstRunSetup: () => undefined,
|
||||
@@ -147,6 +167,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
|
||||
test('tray menu template renders active jellyfin discovery checkbox', () => {
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => undefined,
|
||||
openChangelog: () => undefined,
|
||||
openTexthookerInBrowser: () => undefined,
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => undefined,
|
||||
@@ -175,6 +196,7 @@ test('tray menu template renders a visible linux discovery check mark when activ
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
platform: 'linux',
|
||||
openSessionHelp: () => undefined,
|
||||
openChangelog: () => undefined,
|
||||
openTexthookerInBrowser: () => undefined,
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => undefined,
|
||||
|
||||
@@ -32,6 +32,7 @@ export function resolveTrayIconPathRuntime(deps: {
|
||||
export type TrayMenuActionHandlers = {
|
||||
platform?: string;
|
||||
openSessionHelp: () => void;
|
||||
openChangelog: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: boolean;
|
||||
openFirstRunSetup: () => void;
|
||||
@@ -72,6 +73,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
label: 'Open Help',
|
||||
click: handlers.openSessionHelp,
|
||||
},
|
||||
{
|
||||
label: 'View Changelog',
|
||||
click: handlers.openChangelog,
|
||||
},
|
||||
...(handlers.showTexthookerPage
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -112,3 +112,101 @@ test('createCurlFetch requests updater metadata without Electron networking', as
|
||||
assert.equal(calls[0]?.options.encoding, 'buffer');
|
||||
assert.equal(calls[0]?.options.timeout, 65_000);
|
||||
});
|
||||
|
||||
test('curl fetch kills the child process when the caller aborts', async () => {
|
||||
let killed: string | undefined;
|
||||
let settle: ((error: Error | null, stdout: Buffer, stderr: Buffer) => void) | null = null;
|
||||
const controller = new AbortController();
|
||||
|
||||
const curlFetch = createCurlFetch({
|
||||
execFile: ((
|
||||
_file: string,
|
||||
_args: readonly string[],
|
||||
_options: unknown,
|
||||
callback: (error: Error | null, stdout: Buffer, stderr: Buffer) => void,
|
||||
) => {
|
||||
settle = callback;
|
||||
return {
|
||||
kill: (signal?: string) => {
|
||||
killed = signal;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const pending = curlFetch('https://example.test/slow', { signal: controller.signal });
|
||||
controller.abort(new Error('deadline reached'));
|
||||
|
||||
await assert.rejects(pending, /deadline reached/);
|
||||
assert.equal(killed, 'SIGKILL', 'the stalled curl process is terminated');
|
||||
assert.ok(settle, 'the callback is still held by the stub');
|
||||
});
|
||||
|
||||
test('curl fetch preserves a non-Error abort reason', async () => {
|
||||
const controller = new AbortController();
|
||||
const curlFetch = createCurlFetch({
|
||||
execFile: (() => ({ kill: () => true })) as never,
|
||||
});
|
||||
|
||||
const pending = curlFetch('https://example.test/slow', { signal: controller.signal });
|
||||
controller.abort('plain string reason');
|
||||
|
||||
await pending.then(
|
||||
() => assert.fail('expected the aborted request to reject'),
|
||||
(error) => assert.equal(error, 'plain string reason'),
|
||||
);
|
||||
});
|
||||
|
||||
test('curl fetch synthesises an error when the signal carries no reason', async () => {
|
||||
const controller = new AbortController();
|
||||
const curlFetch = createCurlFetch({
|
||||
execFile: (() => ({ kill: () => true })) as never,
|
||||
});
|
||||
|
||||
const pending = curlFetch('https://example.test/slow', { signal: controller.signal });
|
||||
controller.abort();
|
||||
|
||||
// A bare abort() still supplies a DOMException reason, so that is what surfaces.
|
||||
await pending.then(
|
||||
() => assert.fail('expected the aborted request to reject'),
|
||||
(error) => assert.ok(error instanceof Error),
|
||||
);
|
||||
});
|
||||
|
||||
test('curl fetch rejects immediately when the signal is already aborted', async () => {
|
||||
let spawned = 0;
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('already gone'));
|
||||
|
||||
const curlFetch = createCurlFetch({
|
||||
execFile: (() => {
|
||||
spawned += 1;
|
||||
return { kill: () => true };
|
||||
}) as never,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
curlFetch('https://example.test/x', { signal: controller.signal }),
|
||||
/already gone/,
|
||||
);
|
||||
assert.equal(spawned, 0, 'no curl process is started for an aborted request');
|
||||
});
|
||||
|
||||
test('curl fetch still resolves normally when no signal is supplied', async () => {
|
||||
const curlFetch = createCurlFetch({
|
||||
execFile: ((
|
||||
_file: string,
|
||||
_args: readonly string[],
|
||||
_options: unknown,
|
||||
callback: (error: Error | null, stdout: Buffer, stderr: Buffer) => void,
|
||||
) => {
|
||||
callback(null, Buffer.from('{"ok":true}'), Buffer.alloc(0));
|
||||
return { kill: () => true };
|
||||
}) as never,
|
||||
});
|
||||
|
||||
const response = await curlFetch('https://example.test/x');
|
||||
assert.equal(response.ok, true);
|
||||
assert.deepEqual(await response.json(), { ok: true });
|
||||
});
|
||||
|
||||
@@ -79,8 +79,24 @@ export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike {
|
||||
];
|
||||
addHeaderArgs(args, init.headers);
|
||||
args.push(url);
|
||||
// curl has its own --max-time, but a caller-supplied signal must be able to
|
||||
// cut the request short of it; otherwise a shorter caller deadline is a lie.
|
||||
const signal = init.signal instanceof AbortSignal ? init.signal : undefined;
|
||||
// Mirror fetch: reject with the caller's reason whatever its type, and only
|
||||
// synthesise an error when the signal carries none.
|
||||
const abortReason = (): unknown =>
|
||||
signal?.reason !== undefined ? signal.reason : new Error('curl request aborted');
|
||||
if (signal?.aborted) throw abortReason();
|
||||
|
||||
const body = await new Promise<Buffer>((resolve, reject) => {
|
||||
execFile(
|
||||
let onAbort: (() => void) | null = null;
|
||||
const settle = (run: () => void) => {
|
||||
if (onAbort) signal?.removeEventListener('abort', onAbort);
|
||||
onAbort = null;
|
||||
run();
|
||||
};
|
||||
|
||||
const child = execFile(
|
||||
curlPath,
|
||||
args,
|
||||
{
|
||||
@@ -93,12 +109,21 @@ export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike {
|
||||
const stderrMessage = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr;
|
||||
const errno = (error as NodeJS.ErrnoException).code;
|
||||
const fallback = errno ? `curl failed (${errno})` : 'curl failed';
|
||||
reject(new Error(stderrMessage.trim() || fallback));
|
||||
settle(() => reject(new Error(stderrMessage.trim() || fallback)));
|
||||
return;
|
||||
}
|
||||
resolve(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout));
|
||||
settle(() => resolve(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout)));
|
||||
},
|
||||
);
|
||||
|
||||
if (signal) {
|
||||
onAbort = () => {
|
||||
onAbort = null;
|
||||
child.kill('SIGKILL');
|
||||
reject(abortReason());
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -51,6 +51,13 @@ test('compareSemverLike orders prerelease identifiers within the same base versi
|
||||
assert.equal(compareSemverLike('0.15.0', '0.15.0-rc.1') > 0, true);
|
||||
});
|
||||
|
||||
test('compareSemverLike ignores build metadata, which carries no precedence', () => {
|
||||
assert.equal(compareSemverLike('0.15.0+build.2', '0.15.0+build.1'), 0);
|
||||
assert.equal(compareSemverLike('0.15.0-rc.1+build.2', '0.15.0-rc.1+build.1'), 0);
|
||||
assert.equal(compareSemverLike('0.15.1+build.1', '0.15.0+build.9') > 0, true);
|
||||
assert.equal(compareSemverLike('0.15.0+build.1', '0.15.0-rc.1') > 0, true);
|
||||
});
|
||||
|
||||
test('findReleaseAsset finds exact asset names only', () => {
|
||||
const release = {
|
||||
tag_name: 'v0.14.1',
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { UpdateChannel } from '../../../types/config';
|
||||
import { compareSemverLike } from '../../../core/utils/semver-compare';
|
||||
|
||||
export { compareSemverLike };
|
||||
|
||||
export interface GitHubReleaseAsset {
|
||||
name: string;
|
||||
@@ -130,57 +133,3 @@ export function parseReleaseVersion(
|
||||
if (!release) return null;
|
||||
return release.tag_name.replace(/^v/i, '');
|
||||
}
|
||||
|
||||
export function compareSemverLike(a: string, b: string): number {
|
||||
const parse = (
|
||||
value: string,
|
||||
): {
|
||||
core: number[];
|
||||
prerelease: Array<number | string>;
|
||||
} => {
|
||||
const normalized = value.replace(/^v/i, '');
|
||||
const [coreText = '', ...prereleaseParts] = normalized.split('-');
|
||||
const core = coreText
|
||||
.split('.')
|
||||
.slice(0, 3)
|
||||
.map((part) => Number.parseInt(part, 10) || 0);
|
||||
while (core.length < 3) core.push(0);
|
||||
const prereleaseText = prereleaseParts.join('-');
|
||||
return {
|
||||
core,
|
||||
prerelease: prereleaseText
|
||||
? prereleaseText.split('.').map((part) => {
|
||||
const numeric = Number.parseInt(part, 10);
|
||||
return /^\d+$/.test(part) ? numeric : part;
|
||||
})
|
||||
: [],
|
||||
};
|
||||
};
|
||||
const left = parse(a);
|
||||
const right = parse(b);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const diff = (left.core[i] ?? 0) - (right.core[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
|
||||
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
|
||||
if (left.prerelease.length === 0) return 1;
|
||||
if (right.prerelease.length === 0) return -1;
|
||||
|
||||
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const leftPart = left.prerelease[i];
|
||||
const rightPart = right.prerelease[i];
|
||||
if (leftPart === undefined && rightPart === undefined) return 0;
|
||||
if (leftPart === undefined) return -1;
|
||||
if (rightPart === undefined) return 1;
|
||||
if (leftPart === rightPart) continue;
|
||||
if (typeof leftPart === 'number' && typeof rightPart === 'number') {
|
||||
return leftPart - rightPart;
|
||||
}
|
||||
if (typeof leftPart === 'number') return -1;
|
||||
if (typeof rightPart === 'number') return 1;
|
||||
return leftPart > rightPart ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ test('notifyUpdateAvailable routes notification surfaces from config', async ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('notifyUpdateAvailable adds an install action to overlay update notifications', async () => {
|
||||
test('notifyUpdateAvailable adds install and changelog actions to overlay update notifications', async () => {
|
||||
const payloads: OverlayNotificationPayload[] = [];
|
||||
|
||||
await notifyUpdateAvailable(
|
||||
@@ -53,7 +53,10 @@ test('notifyUpdateAvailable adds an install action to overlay update notificatio
|
||||
|
||||
const payload = payloads[0];
|
||||
assert.ok(payload);
|
||||
assert.deepEqual(payload.actions, [{ id: 'install-update', label: 'Update' }]);
|
||||
assert.deepEqual(payload.actions, [
|
||||
{ id: 'install-update', label: 'Update' },
|
||||
{ id: 'view-changelog', label: "What's New", keepOpen: true },
|
||||
]);
|
||||
assert.equal(payload.id, 'subminer-update-available');
|
||||
assert.equal(payload.persistent, true);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OverlayNotificationPayload } from '../../../types/notification';
|
||||
|
||||
export const UPDATE_AVAILABLE_NOTIFICATION_ID = 'subminer-update-available';
|
||||
export const INSTALL_UPDATE_ACTION_ID = 'install-update';
|
||||
export const VIEW_CHANGELOG_ACTION_ID = 'view-changelog';
|
||||
|
||||
export interface UpdateNotificationDeps {
|
||||
showSystemNotification: (title: string, body: string) => void;
|
||||
@@ -25,7 +26,10 @@ export async function notifyUpdateAvailable(
|
||||
body: message,
|
||||
variant: 'info',
|
||||
persistent: true,
|
||||
actions: [{ id: INSTALL_UPDATE_ACTION_ID, label: 'Update' }],
|
||||
actions: [
|
||||
{ id: INSTALL_UPDATE_ACTION_ID, label: 'Update' },
|
||||
{ id: VIEW_CHANGELOG_ACTION_ID, label: "What's New", keepOpen: true },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (options.notificationType === 'osd' || options.notificationType === 'osd-system') {
|
||||
|
||||
@@ -68,6 +68,7 @@ import type {
|
||||
YoutubePickerResolveResult,
|
||||
OverlayNotificationEventPayload,
|
||||
OverlayNotificationPosition,
|
||||
ChangelogSnapshot,
|
||||
} from './types';
|
||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||
|
||||
@@ -166,6 +167,7 @@ function createLatestValueIpcListenerWithPayload<T>(
|
||||
|
||||
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
|
||||
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
|
||||
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
|
||||
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.characterDictionaryManagerOpen,
|
||||
);
|
||||
@@ -447,6 +449,9 @@ const electronAPI: ElectronAPI = {
|
||||
},
|
||||
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
|
||||
onOpenSessionHelp: onOpenSessionHelpEvent,
|
||||
onOpenChangelog: onOpenChangelogEvent,
|
||||
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options),
|
||||
onOpenControllerSelect: onOpenControllerSelectEvent,
|
||||
onOpenControllerDebug: onOpenControllerDebugEvent,
|
||||
onOpenJimaku: onOpenJimakuEvent,
|
||||
|
||||
@@ -456,6 +456,7 @@ function createKeyboardHandlerHarness() {
|
||||
let openControllerSelectCount = 0;
|
||||
let openControllerDebugCount = 0;
|
||||
let playlistBrowserKeydownCount = 0;
|
||||
let changelogKeydownCount = 0;
|
||||
|
||||
const createWordNode = (left: number) => ({
|
||||
classList: createClassList(),
|
||||
@@ -504,6 +505,10 @@ function createKeyboardHandlerHarness() {
|
||||
return true;
|
||||
},
|
||||
handleSessionHelpKeydown: () => false,
|
||||
handleChangelogKeydown: () => {
|
||||
changelogKeydownCount += 1;
|
||||
return true;
|
||||
},
|
||||
openSessionHelpModal: () => {},
|
||||
openControllerSelectModal: () => {
|
||||
openControllerSelectCount += 1;
|
||||
@@ -522,6 +527,7 @@ function createKeyboardHandlerHarness() {
|
||||
openControllerSelectCount: () => openControllerSelectCount,
|
||||
openControllerDebugCount: () => openControllerDebugCount,
|
||||
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
|
||||
changelogKeydownCount: () => changelogKeydownCount,
|
||||
setWordCount: (count: number) => {
|
||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||
},
|
||||
@@ -1404,6 +1410,50 @@ test('keyboard mode: playlist browser modal handles h before lookup controls', a
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: changelog modal handles h/l fold keys before lookup controls', async () => {
|
||||
const { ctx, testGlobals, handlers, changelogKeydownCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
ctx.state.changelogModalOpen = true;
|
||||
ctx.state.keyboardSelectedWordIndex = 2;
|
||||
|
||||
// H and L fold/unfold changelog entries; they must not move the subtitle
|
||||
// word selection or seek mpv behind the open modal.
|
||||
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
|
||||
testGlobals.dispatchKeydown({ key: 'l', code: 'KeyL' });
|
||||
|
||||
assert.equal(changelogKeydownCount(), 2);
|
||||
assert.equal(ctx.state.keyboardSelectedWordIndex, 2);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: changelog modal handles arrow keys before yomitan popup', async () => {
|
||||
const { ctx, testGlobals, handlers, changelogKeydownCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.state.changelogModalOpen = true;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
|
||||
|
||||
assert.equal(changelogKeydownCount(), 1);
|
||||
assert.equal(
|
||||
testGlobals.commandEvents.some(
|
||||
(event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown',
|
||||
),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export function createKeyboardHandlers(
|
||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleChangelogKeydown: (e: KeyboardEvent) => boolean;
|
||||
openSessionHelpModal: (opening: {
|
||||
bindingKey: 'KeyH' | 'KeyK';
|
||||
fallbackUsed: boolean;
|
||||
@@ -1095,6 +1096,14 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
// Ahead of the keyboard-driven lookup controls: the changelog modal binds
|
||||
// arrows/H/L for folding, and those would otherwise move the subtitle word
|
||||
// selection (and seek mpv) behind the open modal.
|
||||
if (ctx.state.changelogModalOpen) {
|
||||
options.handleChangelogKeydown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleKeyboardDrivenModeLookupControls(e)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
|
||||
@@ -467,6 +467,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="changelogModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content changelog-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Changelog</div>
|
||||
<div class="changelog-header-actions">
|
||||
<button id="changelogRefresh" class="changelog-refresh" type="button">Refresh</button>
|
||||
<button id="changelogClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="changelog-meta">
|
||||
<span id="changelogInstalled" class="changelog-installed"></span>
|
||||
<span id="changelogSource" class="changelog-source"></span>
|
||||
</div>
|
||||
<div id="changelogWarning" class="changelog-warning"></div>
|
||||
<div id="changelogStatus" class="changelog-status"></div>
|
||||
<div id="changelogList" class="changelog-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="playlistBrowserModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content playlist-browser-content">
|
||||
<div class="modal-header">
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { ChangelogEntry, ChangelogSnapshot } from '../../types/changelog';
|
||||
import {
|
||||
createChangelogEntryNode,
|
||||
describeChangelogSource,
|
||||
resolveEntryBadge,
|
||||
shouldEntryStartExpanded,
|
||||
tokenizeInlineMarkdown,
|
||||
} from './changelog-render';
|
||||
|
||||
type FakeNode = {
|
||||
tagName: string;
|
||||
className: string;
|
||||
title: string;
|
||||
open: boolean;
|
||||
tabIndex: number;
|
||||
dataset: Record<string, string>;
|
||||
children: FakeNode[];
|
||||
textContent: string;
|
||||
};
|
||||
|
||||
function createFakeNode(tagName: string): FakeNode {
|
||||
const node: FakeNode = {
|
||||
tagName: tagName.toLowerCase(),
|
||||
className: '',
|
||||
title: '',
|
||||
open: false,
|
||||
tabIndex: 0,
|
||||
dataset: {},
|
||||
children: [],
|
||||
textContent: '',
|
||||
};
|
||||
return Object.assign(node, {
|
||||
appendChild: (child: FakeNode) => {
|
||||
node.children.push(child);
|
||||
return child;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function withFakeDocument<T>(run: () => T): T {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
createElement: (tagName: string) => createFakeNode(tagName),
|
||||
createTextNode: (value: string) => ({
|
||||
tagName: '#text',
|
||||
textContent: value,
|
||||
children: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
if (previous) {
|
||||
Object.defineProperty(globalThis, 'document', previous);
|
||||
} else {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flatten(node: FakeNode): FakeNode[] {
|
||||
return [node, ...(node.children ?? []).flatMap((child) => flatten(child as FakeNode))];
|
||||
}
|
||||
|
||||
const ENTRY: ChangelogEntry = {
|
||||
version: '0.19.2',
|
||||
date: '2026-08-04',
|
||||
groupKey: '0.19',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Fixed',
|
||||
items: [
|
||||
{
|
||||
text: '**Overlay:**',
|
||||
children: [
|
||||
{ text: 'Fixed `something`.', children: [] },
|
||||
{ text: 'Fixed another thing.', children: [] },
|
||||
],
|
||||
},
|
||||
{ text: 'Standalone fix.', children: [] },
|
||||
],
|
||||
internal: false,
|
||||
},
|
||||
{
|
||||
heading: 'Internal',
|
||||
items: [{ text: 'Patched deps.', children: [] }],
|
||||
internal: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('inline markdown tokenizer splits code, bold, and links', () => {
|
||||
assert.deepEqual(tokenizeInlineMarkdown('Patched `undici` and **brace**.'), [
|
||||
{ kind: 'text', value: 'Patched ' },
|
||||
{ kind: 'code', value: 'undici' },
|
||||
{ kind: 'text', value: ' and ' },
|
||||
{ kind: 'strong', value: 'brace' },
|
||||
{ kind: 'text', value: '.' },
|
||||
]);
|
||||
assert.deepEqual(tokenizeInlineMarkdown('See [docs](https://example.com).'), [
|
||||
{ kind: 'text', value: 'See ' },
|
||||
{ kind: 'link', value: 'docs', href: 'https://example.com' },
|
||||
{ kind: 'text', value: '.' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('inline markdown tokenizer leaves plain text untouched', () => {
|
||||
assert.deepEqual(tokenizeInlineMarkdown('No markup here'), [
|
||||
{ kind: 'text', value: 'No markup here' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('entry badges mark the installed version and newer releases', () => {
|
||||
assert.equal(resolveEntryBadge('0.19.2', '0.19.2'), 'installed');
|
||||
assert.equal(resolveEntryBadge('0.20.0', '0.19.2'), 'newer');
|
||||
assert.equal(resolveEntryBadge('0.19.1', '0.19.2'), null);
|
||||
});
|
||||
|
||||
test('only entries in the newest major.minor line start expanded', () => {
|
||||
const snapshot = { expandedGroupKey: '0.19' } as Pick<ChangelogSnapshot, 'expandedGroupKey'>;
|
||||
|
||||
assert.equal(shouldEntryStartExpanded(ENTRY, snapshot), true);
|
||||
assert.equal(shouldEntryStartExpanded({ ...ENTRY, groupKey: '0.18' }, snapshot), false);
|
||||
assert.equal(shouldEntryStartExpanded(ENTRY, { expandedGroupKey: null }), false);
|
||||
});
|
||||
|
||||
test('entry node renders a collapsible section with badge and internal block', () => {
|
||||
const node = withFakeDocument(() =>
|
||||
createChangelogEntryNode(ENTRY, { expanded: true, badge: 'installed', index: 0 }),
|
||||
) as unknown as FakeNode;
|
||||
|
||||
assert.equal(node.tagName, 'details');
|
||||
assert.equal(node.open, true);
|
||||
assert.equal(node.dataset.changelogVersion, '0.19.2');
|
||||
|
||||
const nodes = flatten(node);
|
||||
const summary = nodes.find((child) => child.className === 'changelog-entry-summary');
|
||||
assert.ok(summary);
|
||||
assert.equal(summary?.dataset.changelogIndex, '0');
|
||||
assert.equal(
|
||||
nodes.find((child) => child.className === 'changelog-entry-version')?.textContent,
|
||||
'v0.19.2',
|
||||
);
|
||||
assert.equal(
|
||||
nodes.find((child) => child.className?.includes('changelog-entry-badge-installed'))
|
||||
?.textContent,
|
||||
'Installed',
|
||||
);
|
||||
assert.equal(
|
||||
nodes.filter((child) => child.className === 'changelog-internal').length,
|
||||
1,
|
||||
'internal sections stay behind their own fold',
|
||||
);
|
||||
});
|
||||
|
||||
test('entry node renders sub-bullets as a nested list under their lead bullet', () => {
|
||||
const node = withFakeDocument(() =>
|
||||
createChangelogEntryNode(ENTRY, { expanded: true, badge: null, index: 0 }),
|
||||
) as unknown as FakeNode;
|
||||
|
||||
const lists = flatten(node).filter((child) => child.className?.startsWith('changelog-items'));
|
||||
const topLevel = lists.find((list) => list.className === 'changelog-items');
|
||||
const nested = lists.filter((list) => list.className?.includes('changelog-items-nested'));
|
||||
|
||||
assert.ok(topLevel);
|
||||
assert.equal(topLevel?.children.length, 2, 'lead bullet and standalone fix stay siblings');
|
||||
assert.equal(nested.length, 1, 'children render in exactly one nested list');
|
||||
assert.equal(nested[0]?.children.length, 2);
|
||||
|
||||
// The nested list hangs off its parent <li>, not off the section.
|
||||
const leadItem = topLevel?.children[0];
|
||||
assert.equal(
|
||||
leadItem?.children.some((child) => child.className?.includes('changelog-items-nested')),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('entry node renders collapsed when it is outside the current line', () => {
|
||||
const node = withFakeDocument(() =>
|
||||
createChangelogEntryNode(ENTRY, { expanded: false, badge: null, index: 3 }),
|
||||
) as unknown as FakeNode;
|
||||
|
||||
assert.equal(node.open, false);
|
||||
assert.equal(
|
||||
flatten(node).some((child) => child.className?.startsWith('changelog-entry-badge')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('source description names the release the changelog came from', () => {
|
||||
const base: ChangelogSnapshot = {
|
||||
entries: [],
|
||||
installedVersion: '0.19.2',
|
||||
latestVersion: null,
|
||||
expandedGroupKey: null,
|
||||
source: 'remote',
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
describeChangelogSource({ ...base, releaseTag: 'v0.20.0' }),
|
||||
'Latest release v0.20.0',
|
||||
);
|
||||
assert.equal(describeChangelogSource(base), 'Latest published changelog');
|
||||
assert.equal(describeChangelogSource({ ...base, source: 'bundled' }), 'Bundled changelog');
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import type {
|
||||
ChangelogEntry,
|
||||
ChangelogItem,
|
||||
ChangelogSection,
|
||||
ChangelogSnapshot,
|
||||
} from '../../types/changelog';
|
||||
import { compareSemverLike } from '../../core/utils/semver-compare';
|
||||
|
||||
const SECTION_ICON: Record<string, string> = {
|
||||
Added: '✦',
|
||||
Changed: '⟲',
|
||||
Fixed: '✔',
|
||||
Docs: '▤',
|
||||
Internal: '⚙',
|
||||
'Breaking Changes': '⚠',
|
||||
Changes: '•',
|
||||
};
|
||||
|
||||
export type ChangelogEntryBadge = 'installed' | 'newer' | null;
|
||||
|
||||
export function resolveEntryBadge(
|
||||
entryVersion: string,
|
||||
installedVersion: string,
|
||||
): ChangelogEntryBadge {
|
||||
const comparison = compareSemverLike(entryVersion, installedVersion);
|
||||
if (comparison === 0) return 'installed';
|
||||
if (comparison > 0) return 'newer';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version entries in the newest major.minor line start expanded, mirroring the
|
||||
* docs-site changelog where older lines sit behind "Previous Versions".
|
||||
*/
|
||||
export function shouldEntryStartExpanded(
|
||||
entry: ChangelogEntry,
|
||||
snapshot: Pick<ChangelogSnapshot, 'expandedGroupKey'>,
|
||||
): boolean {
|
||||
if (!snapshot.expandedGroupKey) return false;
|
||||
return entry.groupKey === snapshot.expandedGroupKey;
|
||||
}
|
||||
|
||||
type InlineToken =
|
||||
| { kind: 'text'; value: string }
|
||||
| { kind: 'code'; value: string }
|
||||
| { kind: 'strong'; value: string }
|
||||
| { kind: 'link'; value: string; href: string };
|
||||
|
||||
/**
|
||||
* Minimal inline-markdown tokenizer for changelog bullets: backtick code,
|
||||
* bold, and links. Anything else stays literal text.
|
||||
*/
|
||||
export function tokenizeInlineMarkdown(text: string): InlineToken[] {
|
||||
const tokens: InlineToken[] = [];
|
||||
const pattern = /`([^`]+)`|\*\*([^*]+)\*\*|\[([^\]]+)\]\(([^)\s]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push({ kind: 'text', value: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
if (match[1] !== undefined) {
|
||||
tokens.push({ kind: 'code', value: match[1] });
|
||||
} else if (match[2] !== undefined) {
|
||||
tokens.push({ kind: 'strong', value: match[2] });
|
||||
} else if (match[3] !== undefined && match[4] !== undefined) {
|
||||
tokens.push({ kind: 'link', value: match[3], href: match[4] });
|
||||
}
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push({ kind: 'text', value: text.slice(lastIndex) });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function appendInlineMarkdown(target: HTMLElement, text: string): void {
|
||||
for (const token of tokenizeInlineMarkdown(text)) {
|
||||
if (token.kind === 'text') {
|
||||
target.appendChild(document.createTextNode(token.value));
|
||||
continue;
|
||||
}
|
||||
if (token.kind === 'code') {
|
||||
const code = document.createElement('code');
|
||||
code.className = 'changelog-code';
|
||||
code.textContent = token.value;
|
||||
target.appendChild(code);
|
||||
continue;
|
||||
}
|
||||
if (token.kind === 'strong') {
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = token.value;
|
||||
target.appendChild(strong);
|
||||
continue;
|
||||
}
|
||||
// Links stay inert: the overlay has nowhere to navigate to.
|
||||
const link = document.createElement('span');
|
||||
link.className = 'changelog-link';
|
||||
link.textContent = token.value;
|
||||
link.title = token.href;
|
||||
target.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
function createItemList(items: ChangelogItem[], depth: number): HTMLUListElement {
|
||||
const list = document.createElement('ul');
|
||||
list.className = depth === 0 ? 'changelog-items' : 'changelog-items changelog-items-nested';
|
||||
|
||||
for (const item of items) {
|
||||
const listItem = document.createElement('li');
|
||||
listItem.className = 'changelog-item';
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.className = 'changelog-item-text';
|
||||
appendInlineMarkdown(text, item.text);
|
||||
listItem.appendChild(text);
|
||||
|
||||
if (item.children.length > 0) {
|
||||
listItem.appendChild(createItemList(item.children, depth + 1));
|
||||
}
|
||||
list.appendChild(listItem);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function createSectionNode(section: ChangelogSection): HTMLElement {
|
||||
const node = document.createElement('section');
|
||||
node.className = 'changelog-section';
|
||||
|
||||
const title = document.createElement('h4');
|
||||
title.className = 'changelog-section-title';
|
||||
title.textContent = `${SECTION_ICON[section.heading] ?? '•'} ${section.heading}`;
|
||||
node.appendChild(title);
|
||||
|
||||
node.appendChild(createItemList(section.items, 0));
|
||||
return node;
|
||||
}
|
||||
|
||||
function createInternalNode(sections: ChangelogSection[]): HTMLElement {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'changelog-internal';
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
summary.className = 'changelog-internal-summary';
|
||||
summary.textContent = 'Internal changes';
|
||||
details.appendChild(summary);
|
||||
|
||||
for (const section of sections) {
|
||||
details.appendChild(createSectionNode(section));
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
export function createChangelogEntryNode(
|
||||
entry: ChangelogEntry,
|
||||
options: { expanded: boolean; badge: ChangelogEntryBadge; index: number },
|
||||
): HTMLDetailsElement {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'changelog-entry';
|
||||
details.open = options.expanded;
|
||||
details.dataset.changelogVersion = entry.version;
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
summary.className = 'changelog-entry-summary';
|
||||
summary.dataset.changelogIndex = String(options.index);
|
||||
summary.tabIndex = -1;
|
||||
|
||||
const version = document.createElement('span');
|
||||
version.className = 'changelog-entry-version';
|
||||
version.textContent = `v${entry.version}`;
|
||||
summary.appendChild(version);
|
||||
|
||||
if (entry.date) {
|
||||
const date = document.createElement('span');
|
||||
date.className = 'changelog-entry-date';
|
||||
date.textContent = entry.date;
|
||||
summary.appendChild(date);
|
||||
}
|
||||
|
||||
if (options.badge) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `changelog-entry-badge changelog-entry-badge-${options.badge}`;
|
||||
badge.textContent = options.badge === 'installed' ? 'Installed' : 'New';
|
||||
summary.appendChild(badge);
|
||||
}
|
||||
|
||||
details.appendChild(summary);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'changelog-entry-body';
|
||||
const publicSections = entry.sections.filter((section) => !section.internal);
|
||||
const internalSections = entry.sections.filter((section) => section.internal);
|
||||
|
||||
for (const section of publicSections) {
|
||||
body.appendChild(createSectionNode(section));
|
||||
}
|
||||
if (internalSections.length > 0) {
|
||||
body.appendChild(createInternalNode(internalSections));
|
||||
}
|
||||
if (publicSections.length === 0 && internalSections.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'changelog-empty-entry';
|
||||
empty.textContent = 'No release notes recorded for this version.';
|
||||
body.appendChild(empty);
|
||||
}
|
||||
|
||||
details.appendChild(body);
|
||||
return details;
|
||||
}
|
||||
|
||||
export function describeChangelogSource(snapshot: ChangelogSnapshot): string {
|
||||
if (snapshot.source === 'remote') {
|
||||
return snapshot.releaseTag
|
||||
? `Latest release ${snapshot.releaseTag}`
|
||||
: 'Latest published changelog';
|
||||
}
|
||||
return 'Bundled changelog';
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { ChangelogSnapshot } from '../../types/changelog';
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createChangelogModal } from './changelog.js';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.add(entry);
|
||||
},
|
||||
remove: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.delete(entry);
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
toggle: (entry: string, force?: boolean) => {
|
||||
if (force === true) tokens.add(entry);
|
||||
else if (force === false) tokens.delete(entry);
|
||||
else if (tokens.has(entry)) tokens.delete(entry);
|
||||
else tokens.add(entry);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type SummaryStub = {
|
||||
classList: ReturnType<typeof createClassList>;
|
||||
tabIndex: number;
|
||||
dataset: Record<string, string>;
|
||||
getClientRects: () => Array<{ width: number; height: number }>;
|
||||
focusCount: number;
|
||||
scrollCount: number;
|
||||
focus: () => void;
|
||||
scrollIntoView: () => void;
|
||||
};
|
||||
|
||||
function createSummaryStub(index: number): SummaryStub {
|
||||
const summary: SummaryStub = {
|
||||
classList: createClassList(),
|
||||
tabIndex: -1,
|
||||
dataset: { changelogIndex: String(index) },
|
||||
getClientRects: () => [{ width: 10, height: 10 }],
|
||||
focusCount: 0,
|
||||
scrollCount: 0,
|
||||
focus: () => {
|
||||
summary.focusCount += 1;
|
||||
},
|
||||
scrollIntoView: () => {
|
||||
summary.scrollCount += 1;
|
||||
},
|
||||
};
|
||||
return summary;
|
||||
}
|
||||
|
||||
function createElementStub() {
|
||||
const listeners = new Map<string, Array<(event?: unknown) => void>>();
|
||||
return {
|
||||
value: '',
|
||||
textContent: '',
|
||||
innerHTML: '',
|
||||
classList: createClassList(['hidden']),
|
||||
contains: () => false,
|
||||
setAttribute: () => {},
|
||||
addEventListener: (type: string, listener: (event?: unknown) => void) => {
|
||||
listeners.set(type, [...(listeners.get(type) ?? []), listener]);
|
||||
},
|
||||
removeEventListener: () => {},
|
||||
appendChild: () => {},
|
||||
summaries: [] as SummaryStub[],
|
||||
querySelectorAll(this: { summaries: SummaryStub[] }) {
|
||||
return this.summaries;
|
||||
},
|
||||
focus: () => {},
|
||||
dispatchEventType: (type: string, event?: unknown) => {
|
||||
for (const listener of listeners.get(type) ?? []) listener(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const SNAPSHOT: ChangelogSnapshot = {
|
||||
entries: [],
|
||||
installedVersion: '0.19.2',
|
||||
latestVersion: '0.19.2',
|
||||
expandedGroupKey: '0.19',
|
||||
source: 'remote',
|
||||
releaseTag: 'v0.19.2',
|
||||
};
|
||||
|
||||
type Harness = {
|
||||
modal: ReturnType<typeof createChangelogModal>;
|
||||
dom: Record<string, ReturnType<typeof createElementStub>>;
|
||||
snapshotRequests: Array<{ refresh?: boolean } | undefined>;
|
||||
modalClosedNotifications: string[];
|
||||
restore: () => void;
|
||||
};
|
||||
|
||||
function createHarness(
|
||||
options: {
|
||||
getChangelogSnapshot?: (request?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||
} = {},
|
||||
): Harness {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const previousHTMLElement = Object.getOwnPropertyDescriptor(globalThis, 'HTMLElement');
|
||||
const previousElement = Object.getOwnPropertyDescriptor(globalThis, 'Element');
|
||||
const previousDetails = Object.getOwnPropertyDescriptor(globalThis, 'HTMLDetailsElement');
|
||||
|
||||
const snapshotRequests: Array<{ refresh?: boolean } | undefined> = [];
|
||||
const modalClosedNotifications: string[] = [];
|
||||
|
||||
class TestElement {}
|
||||
for (const name of ['HTMLElement', 'Element'] as const) {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: TestElement,
|
||||
});
|
||||
}
|
||||
// getSelectedEntry() narrows with `instanceof HTMLDetailsElement`.
|
||||
class TestDetailsElement {
|
||||
open = false;
|
||||
}
|
||||
Object.defineProperty(globalThis, 'HTMLDetailsElement', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: TestDetailsElement,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
focusMainWindow: async () => {},
|
||||
setIgnoreMouseEvents: () => {},
|
||||
notifyOverlayModalClosed: (modal: string) => {
|
||||
modalClosedNotifications.push(modal);
|
||||
},
|
||||
getChangelogSnapshot: async (request?: { refresh?: boolean }) => {
|
||||
snapshotRequests.push(request);
|
||||
return options.getChangelogSnapshot
|
||||
? await options.getChangelogSnapshot(request)
|
||||
: SNAPSHOT;
|
||||
},
|
||||
},
|
||||
focus: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
setTimeout: (callback: () => void) => setTimeout(callback, 0),
|
||||
clearTimeout: (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const dom = {
|
||||
overlay: createElementStub(),
|
||||
changelogModal: createElementStub(),
|
||||
changelogClose: createElementStub(),
|
||||
changelogRefresh: createElementStub(),
|
||||
changelogInstalled: createElementStub(),
|
||||
changelogSource: createElementStub(),
|
||||
changelogWarning: createElementStub(),
|
||||
changelogStatus: createElementStub(),
|
||||
changelogList: createElementStub(),
|
||||
};
|
||||
|
||||
const modal = createChangelogModal(
|
||||
{
|
||||
state: createRendererState(),
|
||||
platform: {
|
||||
overlayLayer: 'modal',
|
||||
isModalLayer: true,
|
||||
isLinuxPlatform: false,
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: true,
|
||||
shouldToggleMouseIgnore: false,
|
||||
},
|
||||
dom,
|
||||
} as never,
|
||||
{
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
modal,
|
||||
dom,
|
||||
snapshotRequests,
|
||||
modalClosedNotifications,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of [
|
||||
['window', previousWindow],
|
||||
['document', previousDocument],
|
||||
['HTMLElement', previousHTMLElement],
|
||||
['Element', previousElement],
|
||||
['HTMLDetailsElement', previousDetails],
|
||||
] as const) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, name, descriptor);
|
||||
} else {
|
||||
delete (globalThis as Record<string, unknown>)[name];
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('changelog modal loads a snapshot on open and shows the installed version', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(harness.snapshotRequests, [undefined]);
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, 'Installed v0.19.2');
|
||||
assert.equal(harness.dom.changelogSource?.textContent, 'Latest release v0.19.2');
|
||||
assert.equal(harness.dom.changelogModal?.classList.contains('hidden'), false);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal surfaces the bundled-fallback warning', async () => {
|
||||
const harness = createHarness({
|
||||
getChangelogSnapshot: async () => ({
|
||||
...SNAPSHOT,
|
||||
source: 'bundled',
|
||||
releaseTag: undefined,
|
||||
warning: 'Showing the bundled changelog: offline',
|
||||
}),
|
||||
});
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(
|
||||
harness.dom.changelogWarning?.textContent,
|
||||
'Showing the bundled changelog: offline',
|
||||
);
|
||||
assert.equal(harness.dom.changelogSource?.textContent, 'Bundled changelog');
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal reports a load failure instead of hanging on the spinner text', async () => {
|
||||
const harness = createHarness({
|
||||
getChangelogSnapshot: async () => {
|
||||
throw new Error('ipc down');
|
||||
},
|
||||
});
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.match(
|
||||
harness.dom.changelogList?.textContent ?? '',
|
||||
/Changelog failed to load: ipc down/,
|
||||
);
|
||||
assert.equal(harness.dom.changelogStatus?.textContent, 'Press Esc to close.');
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal clears stale metadata when a later open fails to load', async () => {
|
||||
let shouldFail = false;
|
||||
const harness = createHarness({
|
||||
getChangelogSnapshot: async () => {
|
||||
if (shouldFail) throw new Error('offline');
|
||||
return SNAPSHOT;
|
||||
},
|
||||
});
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, 'Installed v0.19.2');
|
||||
harness.modal.closeChangelogModal();
|
||||
|
||||
shouldFail = true;
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// The previous session's values must not linger behind the error message.
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, '');
|
||||
assert.equal(harness.dom.changelogSource?.textContent, '');
|
||||
assert.match(harness.dom.changelogList?.textContent ?? '', /Changelog failed to load: offline/);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal clears stale metadata when a refresh fails', async () => {
|
||||
let shouldFail = false;
|
||||
const harness = createHarness({
|
||||
getChangelogSnapshot: async () => {
|
||||
if (shouldFail) throw new Error('refresh offline');
|
||||
return { ...SNAPSHOT, warning: 'Showing the bundled changelog: earlier failure' };
|
||||
},
|
||||
});
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, 'Installed v0.19.2');
|
||||
|
||||
shouldFail = true;
|
||||
harness.modal.handleChangelogKeydown({
|
||||
key: 'r',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// The rendered snapshot is gone, so nothing may still describe it.
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, '');
|
||||
assert.equal(harness.dom.changelogSource?.textContent, '');
|
||||
assert.equal(harness.dom.changelogWarning?.textContent, '');
|
||||
assert.match(
|
||||
harness.dom.changelogList?.textContent ?? '',
|
||||
/Changelog failed to load: refresh offline/,
|
||||
);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal moves selection styling and focus together on J/K', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const summaries = [createSummaryStub(0), createSummaryStub(1), createSummaryStub(2)];
|
||||
harness.dom.changelogList!.summaries = summaries;
|
||||
|
||||
harness.modal.handleChangelogKeydown({ key: 'j', preventDefault: () => {} } as KeyboardEvent);
|
||||
|
||||
assert.deepEqual(
|
||||
summaries.map((summary) => summary.classList.contains('active')),
|
||||
[false, true, false],
|
||||
);
|
||||
assert.deepEqual(
|
||||
summaries.map((summary) => summary.tabIndex),
|
||||
[-1, 0, -1],
|
||||
);
|
||||
assert.equal(summaries[1]?.focusCount, 1, 'the keyboard path focuses the new selection');
|
||||
assert.equal(summaries[1]?.scrollCount, 1);
|
||||
|
||||
// Wraps backwards past the start.
|
||||
harness.modal.handleChangelogKeydown({ key: 'k', preventDefault: () => {} } as KeyboardEvent);
|
||||
harness.modal.handleChangelogKeydown({ key: 'k', preventDefault: () => {} } as KeyboardEvent);
|
||||
assert.deepEqual(
|
||||
summaries.map((summary) => summary.classList.contains('active')),
|
||||
[false, false, true],
|
||||
);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal click selection restyles without stealing focus back', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.modal.wireDomEvents();
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const summaries = [createSummaryStub(0), createSummaryStub(1)];
|
||||
harness.dom.changelogList!.summaries = summaries;
|
||||
|
||||
// The handler guards on `instanceof Element`, so the target has to inherit
|
||||
// from the Element stand-in the harness installs.
|
||||
const elementCtor = (globalThis as unknown as { Element: { prototype: object } }).Element;
|
||||
const clickTarget = Object.assign(Object.create(elementCtor.prototype), {
|
||||
closest: (selector: string) =>
|
||||
selector === '.changelog-entry-summary' ? summaries[1] : null,
|
||||
});
|
||||
harness.dom.changelogList!.dispatchEventType('click', { target: clickTarget });
|
||||
|
||||
assert.deepEqual(
|
||||
summaries.map((summary) => summary.classList.contains('active')),
|
||||
[false, true],
|
||||
);
|
||||
assert.deepEqual(
|
||||
summaries.map((summary) => summary.tabIndex),
|
||||
[-1, 0],
|
||||
);
|
||||
// The browser already focused the clicked summary; re-focusing would fight it.
|
||||
assert.equal(summaries[1]?.focusCount, 0);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal folds on Enter only from the selected summary', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const detailsCtor = (
|
||||
globalThis as unknown as { HTMLDetailsElement: new () => { open: boolean } }
|
||||
).HTMLDetailsElement;
|
||||
const entry = new detailsCtor();
|
||||
entry.open = true;
|
||||
|
||||
const summaries = [createSummaryStub(0), createSummaryStub(1)];
|
||||
Object.assign(summaries[0]!, { parentElement: entry });
|
||||
harness.dom.changelogList!.summaries = summaries;
|
||||
|
||||
let prevented = 0;
|
||||
const press = (target: unknown) =>
|
||||
harness.modal.handleChangelogKeydown({
|
||||
key: 'Enter',
|
||||
target,
|
||||
preventDefault: () => {
|
||||
prevented += 1;
|
||||
},
|
||||
} as unknown as KeyboardEvent);
|
||||
|
||||
// Close button focused: the button must keep its own Enter activation.
|
||||
assert.equal(press(harness.dom.changelogClose), true);
|
||||
assert.equal(prevented, 0, 'Enter on a button is not swallowed');
|
||||
assert.equal(entry.open, true);
|
||||
|
||||
// A non-selected summary (the nested "Internal changes" fold) is left alone.
|
||||
assert.equal(press(summaries[1]), true);
|
||||
assert.equal(prevented, 0);
|
||||
assert.equal(entry.open, true);
|
||||
|
||||
// The selected summary does fold, exactly once.
|
||||
assert.equal(press(summaries[0]), true);
|
||||
assert.equal(prevented, 1);
|
||||
assert.equal(entry.open, false);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal closes on Escape and notifies the main process', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const handled = harness.modal.handleChangelogKeydown({
|
||||
key: 'Escape',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.modalClosedNotifications, ['changelog']);
|
||||
assert.equal(harness.dom.changelogModal?.classList.contains('hidden'), true);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal refetches on R and ignores keys while closed', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
assert.equal(
|
||||
harness.modal.handleChangelogKeydown({ key: 'r', preventDefault: () => {} } as KeyboardEvent),
|
||||
false,
|
||||
);
|
||||
|
||||
harness.modal.openChangelogModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
harness.modal.handleChangelogKeydown({
|
||||
key: 'r',
|
||||
preventDefault: () => {},
|
||||
} as KeyboardEvent);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(harness.snapshotRequests, [undefined, { refresh: true }]);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('changelog modal drops a late in-flight load after close', async () => {
|
||||
const pending: Array<(snapshot: ChangelogSnapshot) => void> = [];
|
||||
const harness = createHarness({
|
||||
getChangelogSnapshot: () =>
|
||||
new Promise<ChangelogSnapshot>((resolve) => {
|
||||
pending.push(resolve);
|
||||
}),
|
||||
});
|
||||
try {
|
||||
harness.modal.openChangelogModal();
|
||||
harness.modal.closeChangelogModal();
|
||||
pending[0]?.(SNAPSHOT);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(harness.dom.changelogInstalled?.textContent, '');
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { ChangelogSnapshot } from '../../types/changelog';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import {
|
||||
createChangelogEntryNode,
|
||||
describeChangelogSource,
|
||||
resolveEntryBadge,
|
||||
shouldEntryStartExpanded,
|
||||
} from './changelog-render';
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
|
||||
export function createChangelogModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
let priorFocus: Element | null = null;
|
||||
let loadToken = 0;
|
||||
|
||||
function getSummaries(): HTMLElement[] {
|
||||
return Array.from(
|
||||
ctx.dom.changelogList.querySelectorAll('.changelog-entry-summary'),
|
||||
) as HTMLElement[];
|
||||
}
|
||||
|
||||
function applySelectionStyles(index: number): HTMLElement[] {
|
||||
const summaries = getSummaries();
|
||||
ctx.state.changelogSelectedIndex = index;
|
||||
summaries.forEach((summary, idx) => {
|
||||
summary.classList.toggle('active', idx === index);
|
||||
summary.tabIndex = idx === index ? 0 : -1;
|
||||
});
|
||||
return summaries;
|
||||
}
|
||||
|
||||
function setSelected(index: number): void {
|
||||
const count = getSummaries().length;
|
||||
if (count === 0) return;
|
||||
|
||||
const wrapped = index % count;
|
||||
const next = wrapped < 0 ? wrapped + count : wrapped;
|
||||
|
||||
// Only the keyboard path moves focus; clicking already focused the summary.
|
||||
const active = applySelectionStyles(next)[next];
|
||||
if (!active) return;
|
||||
active.focus({ preventScroll: true });
|
||||
active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
}
|
||||
|
||||
function getSelectedEntry(): HTMLDetailsElement | null {
|
||||
const summary = getSummaries()[ctx.state.changelogSelectedIndex];
|
||||
const entry = summary?.parentElement;
|
||||
return entry instanceof HTMLDetailsElement ? entry : null;
|
||||
}
|
||||
|
||||
const focus = createModalFocusGuard({
|
||||
isOpen: () => ctx.state.changelogModalOpen,
|
||||
getModalRoot: () => ctx.dom.changelogModal,
|
||||
getPreferredFocusTargets: () => getSummaries(),
|
||||
getFallbackFocusTarget: () => ctx.dom.changelogClose,
|
||||
isModalLayer: ctx.platform.isModalLayer,
|
||||
});
|
||||
|
||||
function renderSnapshot(snapshot: ChangelogSnapshot): void {
|
||||
ctx.dom.changelogList.innerHTML = '';
|
||||
ctx.dom.changelogList.classList.remove('changelog-list-empty');
|
||||
|
||||
ctx.dom.changelogInstalled.textContent = `Installed v${snapshot.installedVersion}`;
|
||||
ctx.dom.changelogSource.textContent = describeChangelogSource(snapshot);
|
||||
ctx.dom.changelogWarning.textContent = snapshot.warning ?? '';
|
||||
|
||||
if (snapshot.entries.length === 0) {
|
||||
ctx.dom.changelogList.classList.add('changelog-list-empty');
|
||||
ctx.dom.changelogList.textContent =
|
||||
snapshot.error ?? 'No changelog entries are available right now.';
|
||||
ctx.state.changelogSelectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot.entries.forEach((entry, index) => {
|
||||
ctx.dom.changelogList.appendChild(
|
||||
createChangelogEntryNode(entry, {
|
||||
expanded: shouldEntryStartExpanded(entry, snapshot),
|
||||
badge: resolveEntryBadge(entry.version, snapshot.installedVersion),
|
||||
index,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
setSelected(0);
|
||||
}
|
||||
|
||||
async function load(options?: { refresh?: boolean }): Promise<void> {
|
||||
const token = ++loadToken;
|
||||
ctx.dom.changelogStatus.textContent = options?.refresh
|
||||
? 'Refreshing changelog...'
|
||||
: 'Loading changelog...';
|
||||
|
||||
try {
|
||||
const snapshot = await window.electronAPI.getChangelogSnapshot(options);
|
||||
if (token !== loadToken || !ctx.state.changelogModalOpen) return;
|
||||
renderSnapshot(snapshot);
|
||||
ctx.dom.changelogStatus.textContent =
|
||||
'J/K or arrows to move, Enter to fold, R to refresh, Esc closes.';
|
||||
} catch (error) {
|
||||
if (token !== loadToken || !ctx.state.changelogModalOpen) return;
|
||||
const message = error instanceof Error ? error.message : 'Unknown error.';
|
||||
ctx.dom.changelogList.innerHTML = '';
|
||||
ctx.dom.changelogList.classList.add('changelog-list-empty');
|
||||
// A failed refresh replaces an already-rendered snapshot, so the metadata
|
||||
// line has to be cleared here too or it keeps describing stale entries.
|
||||
ctx.dom.changelogInstalled.textContent = '';
|
||||
ctx.dom.changelogSource.textContent = '';
|
||||
ctx.dom.changelogWarning.textContent = '';
|
||||
ctx.dom.changelogList.textContent = `Changelog failed to load: ${message}`;
|
||||
ctx.dom.changelogStatus.textContent = 'Press Esc to close.';
|
||||
}
|
||||
}
|
||||
|
||||
function openChangelogModal(): void {
|
||||
if (ctx.state.changelogModalOpen) return;
|
||||
priorFocus = document.activeElement;
|
||||
|
||||
ctx.state.changelogModalOpen = true;
|
||||
ctx.state.changelogSelectedIndex = 0;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.changelogModal.classList.remove('hidden');
|
||||
ctx.dom.changelogModal.setAttribute('aria-hidden', 'false');
|
||||
ctx.dom.changelogModal.setAttribute('tabindex', '-1');
|
||||
ctx.dom.changelogList.innerHTML = '';
|
||||
ctx.dom.changelogWarning.textContent = '';
|
||||
// Reset the metadata line too, so a failed load can't leave the previous
|
||||
// session's installed/source values on screen.
|
||||
ctx.dom.changelogInstalled.textContent = '';
|
||||
ctx.dom.changelogSource.textContent = '';
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
|
||||
focus.attach();
|
||||
focus.requestOverlayFocus();
|
||||
window.focus();
|
||||
focus.enforceModalFocus();
|
||||
|
||||
void load();
|
||||
}
|
||||
|
||||
function closeChangelogModal(): void {
|
||||
if (!ctx.state.changelogModalOpen) return;
|
||||
|
||||
ctx.state.changelogModalOpen = false;
|
||||
loadToken += 1;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.changelogModal.classList.add('hidden');
|
||||
ctx.dom.changelogModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('changelog');
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
|
||||
focus.detach();
|
||||
|
||||
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
|
||||
priorFocus.focus({ preventScroll: true });
|
||||
} else if (ctx.dom.overlay instanceof HTMLElement) {
|
||||
ctx.dom.overlay.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
}
|
||||
window.focus();
|
||||
}
|
||||
|
||||
function handleChangelogKeydown(e: KeyboardEvent): boolean {
|
||||
if (!ctx.state.changelogModalOpen) return false;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeChangelogModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
if (key === 'r' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
void load({ refresh: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
const summaries = getSummaries();
|
||||
if (summaries.length === 0) return true;
|
||||
|
||||
if (key === 'arrowdown' || key === 'j') {
|
||||
e.preventDefault();
|
||||
setSelected(ctx.state.changelogSelectedIndex + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'arrowup' || key === 'k') {
|
||||
e.preventDefault();
|
||||
setSelected(ctx.state.changelogSelectedIndex - 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'enter' || key === ' ') {
|
||||
// Only the selected release summary folds from here. The Close/Refresh
|
||||
// buttons and the nested "Internal changes" fold activate themselves, and
|
||||
// swallowing Enter/Space would make them unreachable by keyboard.
|
||||
if (e.target !== summaries[ctx.state.changelogSelectedIndex]) {
|
||||
return true;
|
||||
}
|
||||
e.preventDefault();
|
||||
const entry = getSelectedEntry();
|
||||
if (entry) entry.open = !entry.open;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'arrowleft' || key === 'h') {
|
||||
e.preventDefault();
|
||||
const entry = getSelectedEntry();
|
||||
if (entry) entry.open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === 'arrowright' || key === 'l') {
|
||||
e.preventDefault();
|
||||
const entry = getSelectedEntry();
|
||||
if (entry) entry.open = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.changelogClose.addEventListener('click', () => {
|
||||
closeChangelogModal();
|
||||
});
|
||||
|
||||
ctx.dom.changelogRefresh.addEventListener('click', () => {
|
||||
void load({ refresh: true });
|
||||
});
|
||||
|
||||
ctx.dom.changelogList.addEventListener('click', (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const summary = target.closest('.changelog-entry-summary') as HTMLElement | null;
|
||||
if (!summary) return;
|
||||
const index = Number.parseInt(summary.dataset.changelogIndex ?? '', 10);
|
||||
if (!Number.isFinite(index)) return;
|
||||
applySelectionStyles(index);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closeChangelogModal,
|
||||
handleChangelogKeydown,
|
||||
openChangelogModal,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
|
||||
type Listener = (event?: unknown) => void;
|
||||
|
||||
function createRoot(contains: boolean) {
|
||||
const listeners: Array<{ type: string; listener: Listener }> = [];
|
||||
return {
|
||||
contains: () => contains,
|
||||
addEventListener: (type: string, listener: Listener) => {
|
||||
listeners.push({ type, listener });
|
||||
},
|
||||
removeEventListener: (type: string, listener: Listener) => {
|
||||
const index = listeners.findIndex(
|
||||
(entry) => entry.type === type && entry.listener === listener,
|
||||
);
|
||||
if (index >= 0) listeners.splice(index, 1);
|
||||
},
|
||||
listeners,
|
||||
};
|
||||
}
|
||||
|
||||
type Harness = {
|
||||
guard: ReturnType<typeof createModalFocusGuard>;
|
||||
root: ReturnType<typeof createRoot>;
|
||||
focusMainWindowCalls: () => number;
|
||||
focused: () => string[];
|
||||
documentListeners: () => string[];
|
||||
windowListeners: () => string[];
|
||||
handlerFor: (scope: 'document' | 'window', type: string) => Listener | undefined;
|
||||
setActiveElement: (value: unknown) => void;
|
||||
advanceClock: (ms: number) => void;
|
||||
runTimers: () => void;
|
||||
clearedTimers: () => number[];
|
||||
pendingTimerCount: () => number;
|
||||
restore: () => void;
|
||||
};
|
||||
|
||||
function createHarness(
|
||||
options: {
|
||||
isOpen?: () => boolean;
|
||||
isModalLayer?: boolean;
|
||||
contains?: boolean;
|
||||
preferredVisible?: boolean;
|
||||
preferredAcceptsFocus?: boolean;
|
||||
extraPreferred?: boolean;
|
||||
fallback?: 'element' | null;
|
||||
} = {},
|
||||
): Harness {
|
||||
const previous = (['window', 'document', 'HTMLElement', 'Element'] as const).map(
|
||||
(name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const,
|
||||
);
|
||||
|
||||
class TestElement {}
|
||||
for (const name of ['HTMLElement', 'Element'] as const) {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: TestElement,
|
||||
});
|
||||
}
|
||||
|
||||
let focusMainWindowCalls = 0;
|
||||
let now = 1_000;
|
||||
const realDateNow = Date.now;
|
||||
Date.now = () => now;
|
||||
const focused: string[] = [];
|
||||
const documentListeners: Array<{ type: string; listener: Listener }> = [];
|
||||
const windowListeners: Array<{ type: string; listener: Listener }> = [];
|
||||
|
||||
const register = (registry: Array<{ type: string; listener: Listener }>) => ({
|
||||
add: (type: string, listener: Listener) => {
|
||||
registry.push({ type, listener });
|
||||
},
|
||||
remove: (type: string, listener: Listener) => {
|
||||
const index = registry.findIndex(
|
||||
(entry) => entry.type === type && entry.listener === listener,
|
||||
);
|
||||
if (index >= 0) registry.splice(index, 1);
|
||||
},
|
||||
});
|
||||
const windowRegistry = register(windowListeners);
|
||||
const documentRegistry = register(documentListeners);
|
||||
const timers = new Map<number, () => void>();
|
||||
let nextTimerId = 0;
|
||||
const clearedTimers: number[] = [];
|
||||
let activeElement: unknown = null;
|
||||
|
||||
const root = createRoot(options.contains ?? false);
|
||||
|
||||
const preferred = Object.assign(new TestElement(), {
|
||||
// A position:fixed element has a null offsetParent but still has rects.
|
||||
offsetParent: null,
|
||||
getClientRects: () => (options.preferredVisible === false ? [] : [{ width: 10, height: 10 }]),
|
||||
focus: () => {
|
||||
focused.push('preferred');
|
||||
// A rendered-but-unfocusable target (e.g. disabled) never becomes active.
|
||||
if (options.preferredAcceptsFocus !== false) activeElement = preferred;
|
||||
},
|
||||
});
|
||||
const secondPreferred = Object.assign(new TestElement(), {
|
||||
getClientRects: () => [{ width: 10, height: 10 }],
|
||||
focus: () => {
|
||||
focused.push('second');
|
||||
activeElement = secondPreferred;
|
||||
},
|
||||
});
|
||||
const fallback =
|
||||
options.fallback === null
|
||||
? null
|
||||
: Object.assign(new TestElement(), {
|
||||
focus: () => {
|
||||
focused.push('fallback');
|
||||
activeElement = fallback;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
focusMainWindow: async () => {
|
||||
focusMainWindowCalls += 1;
|
||||
},
|
||||
},
|
||||
focus: () => {
|
||||
focused.push('window');
|
||||
},
|
||||
addEventListener: windowRegistry.add,
|
||||
removeEventListener: windowRegistry.remove,
|
||||
setTimeout: (callback: () => void) => {
|
||||
nextTimerId += 1;
|
||||
timers.set(nextTimerId, callback);
|
||||
return nextTimerId;
|
||||
},
|
||||
clearTimeout: (id: number) => {
|
||||
clearedTimers.push(id);
|
||||
timers.delete(id);
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
get activeElement() {
|
||||
return activeElement;
|
||||
},
|
||||
addEventListener: documentRegistry.add,
|
||||
removeEventListener: documentRegistry.remove,
|
||||
},
|
||||
});
|
||||
|
||||
const guard = createModalFocusGuard({
|
||||
isOpen: options.isOpen ?? (() => true),
|
||||
getModalRoot: () => root as unknown as Element,
|
||||
getPreferredFocusTargets: () =>
|
||||
(options.extraPreferred
|
||||
? [preferred, secondPreferred]
|
||||
: [preferred]) as unknown as HTMLElement[],
|
||||
getFallbackFocusTarget: () => fallback as unknown as Element | null,
|
||||
isModalLayer: options.isModalLayer ?? true,
|
||||
});
|
||||
|
||||
return {
|
||||
guard,
|
||||
root,
|
||||
focusMainWindowCalls: () => focusMainWindowCalls,
|
||||
focused: () => focused,
|
||||
documentListeners: () => documentListeners.map((entry) => entry.type),
|
||||
windowListeners: () => windowListeners.map((entry) => entry.type),
|
||||
handlerFor: (scope: 'document' | 'window', type: string) =>
|
||||
(scope === 'document' ? documentListeners : windowListeners).find(
|
||||
(entry) => entry.type === type,
|
||||
)?.listener,
|
||||
setActiveElement: (value: unknown) => {
|
||||
activeElement = value;
|
||||
},
|
||||
advanceClock: (ms: number) => {
|
||||
now += ms;
|
||||
},
|
||||
runTimers: () => {
|
||||
const pending = [...timers.values()];
|
||||
timers.clear();
|
||||
for (const callback of pending) callback();
|
||||
},
|
||||
clearedTimers: () => clearedTimers,
|
||||
pendingTimerCount: () => timers.size,
|
||||
restore: () => {
|
||||
Date.now = realDateNow;
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, name, descriptor);
|
||||
} else {
|
||||
delete (globalThis as Record<string, unknown>)[name];
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('modal focus guard attaches once and detaches every listener', () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.guard.attach();
|
||||
harness.guard.attach();
|
||||
|
||||
assert.deepEqual(harness.documentListeners(), ['focusin']);
|
||||
assert.deepEqual(harness.windowListeners(), ['blur', 'focus']);
|
||||
assert.deepEqual(
|
||||
harness.root.listeners.map((entry) => entry.type),
|
||||
['pointerdown', 'click'],
|
||||
);
|
||||
|
||||
harness.guard.detach();
|
||||
|
||||
assert.deepEqual(harness.documentListeners(), []);
|
||||
assert.deepEqual(harness.windowListeners(), []);
|
||||
assert.deepEqual(harness.root.listeners, []);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard pulls focus back when focusin lands outside the modal', () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.guard.attach();
|
||||
|
||||
const focusin = harness.handlerFor('document', 'focusin');
|
||||
assert.ok(focusin, 'attach registers a focusin handler');
|
||||
|
||||
// focusin is not cancelable, so recovery is the only observable effect.
|
||||
focusin?.({ target: {} });
|
||||
assert.deepEqual(harness.focused(), ['preferred']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard ignores focusin while the modal is closed', () => {
|
||||
const harness = createHarness({ isOpen: () => false });
|
||||
try {
|
||||
harness.guard.attach();
|
||||
harness.handlerFor('document', 'focusin')?.({ target: {} });
|
||||
|
||||
assert.deepEqual(harness.focused(), []);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard restores focus to the first rendered target', () => {
|
||||
// The target is position:fixed (null offsetParent) yet visible, so it must
|
||||
// still win over the fallback.
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['preferred']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard tries the next target when one refuses focus', () => {
|
||||
const harness = createHarness({ preferredAcceptsFocus: false, extraPreferred: true });
|
||||
try {
|
||||
assert.equal(harness.guard.focusFallbackTarget(), true);
|
||||
assert.deepEqual(harness.focused(), ['preferred', 'second']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard reaches the fallback when no preferred target takes focus', () => {
|
||||
const harness = createHarness({ preferredAcceptsFocus: false });
|
||||
try {
|
||||
assert.equal(harness.guard.focusFallbackTarget(), true);
|
||||
assert.deepEqual(harness.focused(), ['preferred', 'fallback']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard falls back when no preferred target is rendered', () => {
|
||||
const harness = createHarness({ preferredVisible: false });
|
||||
try {
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['fallback']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard focuses the window when nothing else can take focus', () => {
|
||||
const harness = createHarness({ preferredVisible: false, fallback: null });
|
||||
try {
|
||||
assert.equal(harness.guard.focusFallbackTarget(), false);
|
||||
assert.deepEqual(harness.focused(), ['window']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard leaves focus alone while it is already inside the modal', () => {
|
||||
const harness = createHarness({ contains: true });
|
||||
try {
|
||||
harness.setActiveElement(
|
||||
Object.create((globalThis as { Element: { prototype: object } }).Element.prototype),
|
||||
);
|
||||
harness.guard.enforceModalFocus();
|
||||
|
||||
assert.deepEqual(harness.focused(), []);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard does nothing while the modal is closed', () => {
|
||||
const harness = createHarness({ isOpen: () => false });
|
||||
try {
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), []);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard clears recovery state on detach so a reopen is not blocked', () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.guard.attach();
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['preferred']);
|
||||
assert.equal(harness.pendingTimerCount(), 1, 'recovery armed the debounce timer');
|
||||
|
||||
// Close while recovery is still in flight.
|
||||
harness.guard.detach();
|
||||
assert.equal(harness.clearedTimers().length, 1, 'the pending debounce timer is cancelled');
|
||||
assert.equal(harness.pendingTimerCount(), 0);
|
||||
|
||||
// Immediate reopen: recovery must work straight away, not 120 ms later.
|
||||
harness.guard.attach();
|
||||
harness.setActiveElement(null);
|
||||
harness.guard.enforceModalFocus();
|
||||
|
||||
assert.deepEqual(harness.focused(), ['preferred', 'preferred']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard debounces recovery so a focus fight cannot spin', () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
harness.guard.enforceModalFocus();
|
||||
harness.setActiveElement(null);
|
||||
|
||||
// Re-entry guard: the recovery timer has not fired yet.
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['preferred']);
|
||||
|
||||
// Timer cleared the re-entry flag, but the debounce window still holds.
|
||||
harness.runTimers();
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['preferred'], 'debounce still blocks the retry');
|
||||
|
||||
// Past the window, recovery resumes.
|
||||
harness.advanceClock(200);
|
||||
harness.setActiveElement(null);
|
||||
harness.guard.enforceModalFocus();
|
||||
assert.deepEqual(harness.focused(), ['preferred', 'preferred']);
|
||||
} finally {
|
||||
harness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('modal focus guard asks the main window for focus off the modal layer only', () => {
|
||||
const onModalLayer = createHarness({ isModalLayer: true });
|
||||
try {
|
||||
onModalLayer.guard.requestOverlayFocus();
|
||||
assert.equal(onModalLayer.focusMainWindowCalls(), 0);
|
||||
} finally {
|
||||
onModalLayer.restore();
|
||||
}
|
||||
|
||||
const onOverlayLayer = createHarness({ isModalLayer: false });
|
||||
try {
|
||||
onOverlayLayer.guard.requestOverlayFocus();
|
||||
assert.equal(onOverlayLayer.focusMainWindowCalls(), 1);
|
||||
} finally {
|
||||
onOverlayLayer.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Keeps focus inside an overlay-hosted modal.
|
||||
*
|
||||
* The overlay can lose focus to mpv or to the compositor while a modal is up,
|
||||
* which leaves the modal visible but inert. Recovery is debounced (and guarded
|
||||
* against re-entry) so a focus fight with the window manager cannot spin.
|
||||
*/
|
||||
export type ModalFocusGuardDeps = {
|
||||
isOpen: () => boolean;
|
||||
/** Modal root; focus inside it counts as "still in the modal". */
|
||||
getModalRoot: () => Element;
|
||||
/** Preferred focus targets in order; the first rendered one wins. */
|
||||
getPreferredFocusTargets: () => HTMLElement[];
|
||||
/** Used when no preferred target is rendered, e.g. the close button. */
|
||||
getFallbackFocusTarget: () => Element | null;
|
||||
/** Modal-layer windows own their focus; other layers ask the main window. */
|
||||
isModalLayer: boolean;
|
||||
};
|
||||
|
||||
const FOCUS_RECOVERY_DEBOUNCE_MS = 120;
|
||||
|
||||
export function createModalFocusGuard(deps: ModalFocusGuardDeps) {
|
||||
let focusinGuard: ((event: FocusEvent) => void) | null = null;
|
||||
let windowFocusGuard: (() => void) | null = null;
|
||||
let pointerFocusGuard: ((event: Event) => void) | null = null;
|
||||
let pointerFocusRoot: Element | null = null;
|
||||
let isRecovering = false;
|
||||
let lastRecoveryAt = 0;
|
||||
// Browser setTimeout id; typed loosely because @types/node widens the global.
|
||||
let recoveryTimer: number | null = null;
|
||||
|
||||
function isModalFocusTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Element && deps.getModalRoot().contains(target);
|
||||
}
|
||||
|
||||
function requestOverlayFocus(): void {
|
||||
if (!deps.isModalLayer) {
|
||||
// Best-effort: a rejected focus request must not surface as an unhandled
|
||||
// rejection, since this runs from blur/focus handlers.
|
||||
void Promise.resolve(window.electronAPI.focusMainWindow()).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function focusFallbackTarget(): boolean {
|
||||
requestOverlayFocus();
|
||||
|
||||
// getClientRects() rather than offsetParent: the latter is null for
|
||||
// position:fixed elements, which would skip a perfectly visible target.
|
||||
// Rendered is not the same as focusable, so keep trying until one sticks
|
||||
// instead of giving up on the first candidate that refuses focus.
|
||||
for (const target of deps.getPreferredFocusTargets()) {
|
||||
if (target.getClientRects().length === 0) continue;
|
||||
target.focus({ preventScroll: true });
|
||||
if (document.activeElement === target) return true;
|
||||
}
|
||||
|
||||
const fallback = deps.getFallbackFocusTarget();
|
||||
if (fallback instanceof HTMLElement) {
|
||||
fallback.focus({ preventScroll: true });
|
||||
return document.activeElement === fallback;
|
||||
}
|
||||
|
||||
window.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
function enforceModalFocus(): void {
|
||||
if (!deps.isOpen()) return;
|
||||
if (isModalFocusTarget(document.activeElement)) return;
|
||||
if (isRecovering) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastRecoveryAt < FOCUS_RECOVERY_DEBOUNCE_MS) return;
|
||||
|
||||
isRecovering = true;
|
||||
lastRecoveryAt = now;
|
||||
focusFallbackTarget();
|
||||
recoveryTimer = window.setTimeout(() => {
|
||||
recoveryTimer = null;
|
||||
isRecovering = false;
|
||||
}, FOCUS_RECOVERY_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/** Idempotent; safe to call on every open. */
|
||||
function attach(): void {
|
||||
if (focusinGuard === null) {
|
||||
// focusin is not cancelable, so there is nothing to preventDefault here;
|
||||
// focus is taken back afterwards instead.
|
||||
focusinGuard = (event: FocusEvent) => {
|
||||
if (!deps.isOpen()) return;
|
||||
if (!isModalFocusTarget(event.target)) {
|
||||
enforceModalFocus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('focusin', focusinGuard);
|
||||
}
|
||||
|
||||
if (pointerFocusGuard === null) {
|
||||
pointerFocusGuard = () => {
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
// Remember the root we bound to: resolving it again on detach could
|
||||
// return a different element and leak the listeners on the old one.
|
||||
pointerFocusRoot = deps.getModalRoot();
|
||||
pointerFocusRoot.addEventListener('pointerdown', pointerFocusGuard);
|
||||
pointerFocusRoot.addEventListener('click', pointerFocusGuard);
|
||||
}
|
||||
|
||||
if (windowFocusGuard === null) {
|
||||
windowFocusGuard = () => {
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
window.addEventListener('blur', windowFocusGuard);
|
||||
window.addEventListener('focus', windowFocusGuard);
|
||||
}
|
||||
}
|
||||
|
||||
function detach(): void {
|
||||
if (focusinGuard) {
|
||||
document.removeEventListener('focusin', focusinGuard);
|
||||
focusinGuard = null;
|
||||
}
|
||||
|
||||
if (pointerFocusGuard) {
|
||||
pointerFocusRoot?.removeEventListener('pointerdown', pointerFocusGuard);
|
||||
pointerFocusRoot?.removeEventListener('click', pointerFocusGuard);
|
||||
pointerFocusGuard = null;
|
||||
pointerFocusRoot = null;
|
||||
}
|
||||
|
||||
if (windowFocusGuard) {
|
||||
window.removeEventListener('blur', windowFocusGuard);
|
||||
window.removeEventListener('focus', windowFocusGuard);
|
||||
windowFocusGuard = null;
|
||||
}
|
||||
|
||||
// Closing mid-recovery must not leave the debounce armed: a modal reopened
|
||||
// straight away would otherwise get no focus recovery for the next 120 ms.
|
||||
if (recoveryTimer !== null) {
|
||||
window.clearTimeout(recoveryTimer);
|
||||
recoveryTimer = null;
|
||||
}
|
||||
isRecovering = false;
|
||||
lastRecoveryAt = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
attach,
|
||||
detach,
|
||||
enforceModalFocus,
|
||||
focusFallbackTarget,
|
||||
isModalFocusTarget,
|
||||
requestOverlayFocus,
|
||||
};
|
||||
}
|
||||
@@ -279,6 +279,7 @@ test('modal-layer session help does not focus hidden main overlay and still clos
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
setTimeout: (callback: () => void) => setTimeout(callback, 0),
|
||||
clearTimeout: (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from './session-help-sections';
|
||||
import { createSessionHelpSectionNode } from './session-help-render';
|
||||
import { buildVisibleSessionHelpSections, createSessionHelpTabBar } from './session-help-tabs';
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
|
||||
export {
|
||||
buildSessionHelpSections,
|
||||
@@ -69,11 +70,6 @@ export function createSessionHelpModal(
|
||||
let helpFilterValue = '';
|
||||
let helpSections: SessionHelpSection[] = [];
|
||||
let activeTabId: SessionHelpTabId = 'essentials';
|
||||
let focusGuard: ((event: FocusEvent) => void) | null = null;
|
||||
let windowFocusGuard: (() => void) | null = null;
|
||||
let modalPointerFocusGuard: ((event: Event) => void) | null = null;
|
||||
let isRecoveringModalFocus = false;
|
||||
let lastFocusRecoveryAt = 0;
|
||||
|
||||
function getItems(): HTMLButtonElement[] {
|
||||
return Array.from(
|
||||
@@ -102,47 +98,13 @@ export function createSessionHelpModal(
|
||||
});
|
||||
}
|
||||
|
||||
function isSessionHelpModalFocusTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Element && ctx.dom.sessionHelpModal.contains(target);
|
||||
}
|
||||
|
||||
function focusFallbackTarget(): boolean {
|
||||
if (!ctx.platform.isModalLayer) {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
}
|
||||
const items = getItems();
|
||||
const firstItem = items.find((item) => item.offsetParent !== null);
|
||||
if (firstItem) {
|
||||
firstItem.focus({ preventScroll: true });
|
||||
return document.activeElement === firstItem;
|
||||
}
|
||||
|
||||
if (ctx.dom.sessionHelpClose instanceof HTMLElement) {
|
||||
ctx.dom.sessionHelpClose.focus({ preventScroll: true });
|
||||
return document.activeElement === ctx.dom.sessionHelpClose;
|
||||
}
|
||||
|
||||
window.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
function enforceModalFocus(): void {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
if (!isSessionHelpModalFocusTarget(document.activeElement)) {
|
||||
if (isRecoveringModalFocus) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastFocusRecoveryAt < 120) return;
|
||||
|
||||
isRecoveringModalFocus = true;
|
||||
lastFocusRecoveryAt = now;
|
||||
focusFallbackTarget();
|
||||
|
||||
window.setTimeout(() => {
|
||||
isRecoveringModalFocus = false;
|
||||
}, 120);
|
||||
}
|
||||
}
|
||||
const focus = createModalFocusGuard({
|
||||
isOpen: () => ctx.state.sessionHelpModalOpen,
|
||||
getModalRoot: () => ctx.dom.sessionHelpModal,
|
||||
getPreferredFocusTargets: () => getItems(),
|
||||
getFallbackFocusTarget: () => ctx.dom.sessionHelpClose,
|
||||
isModalLayer: ctx.platform.isModalLayer,
|
||||
});
|
||||
|
||||
function isFilterInputFocused(): boolean {
|
||||
return document.activeElement === ctx.dom.sessionHelpFilter;
|
||||
@@ -192,48 +154,6 @@ export function createSessionHelpModal(
|
||||
setSelected(0);
|
||||
}
|
||||
|
||||
function requestOverlayFocus(): void {
|
||||
if (!ctx.platform.isModalLayer) {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
}
|
||||
}
|
||||
|
||||
function addPointerFocusListener(): void {
|
||||
if (modalPointerFocusGuard) return;
|
||||
|
||||
modalPointerFocusGuard = () => {
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
ctx.dom.sessionHelpModal.addEventListener('pointerdown', modalPointerFocusGuard);
|
||||
ctx.dom.sessionHelpModal.addEventListener('click', modalPointerFocusGuard);
|
||||
}
|
||||
|
||||
function removePointerFocusListener(): void {
|
||||
if (!modalPointerFocusGuard) return;
|
||||
ctx.dom.sessionHelpModal.removeEventListener('pointerdown', modalPointerFocusGuard);
|
||||
ctx.dom.sessionHelpModal.removeEventListener('click', modalPointerFocusGuard);
|
||||
modalPointerFocusGuard = null;
|
||||
}
|
||||
|
||||
function startFocusRecoveryGuards(): void {
|
||||
if (windowFocusGuard) return;
|
||||
|
||||
windowFocusGuard = () => {
|
||||
requestOverlayFocus();
|
||||
enforceModalFocus();
|
||||
};
|
||||
window.addEventListener('blur', windowFocusGuard);
|
||||
window.addEventListener('focus', windowFocusGuard);
|
||||
}
|
||||
|
||||
function stopFocusRecoveryGuards(): void {
|
||||
if (!windowFocusGuard) return;
|
||||
window.removeEventListener('blur', windowFocusGuard);
|
||||
window.removeEventListener('focus', windowFocusGuard);
|
||||
windowFocusGuard = null;
|
||||
}
|
||||
|
||||
function showRenderError(message: string): void {
|
||||
helpSections = [];
|
||||
helpFilterValue = '';
|
||||
@@ -310,22 +230,10 @@ export function createSessionHelpModal(
|
||||
}
|
||||
ctx.dom.sessionHelpStatus.textContent = 'Loading session help data...';
|
||||
|
||||
if (focusGuard === null) {
|
||||
focusGuard = (event: FocusEvent) => {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
if (!isSessionHelpModalFocusTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
enforceModalFocus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('focusin', focusGuard);
|
||||
}
|
||||
|
||||
addPointerFocusListener();
|
||||
startFocusRecoveryGuards();
|
||||
requestOverlayFocus();
|
||||
focus.attach();
|
||||
focus.requestOverlayFocus();
|
||||
window.focus();
|
||||
enforceModalFocus();
|
||||
focus.enforceModalFocus();
|
||||
|
||||
void render().then((dataLoaded) => {
|
||||
if (!ctx.state.sessionHelpModalOpen) return;
|
||||
@@ -353,12 +261,7 @@ export function createSessionHelpModal(
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
|
||||
if (focusGuard) {
|
||||
document.removeEventListener('focusin', focusGuard);
|
||||
focusGuard = null;
|
||||
}
|
||||
removePointerFocusListener();
|
||||
stopFocusRecoveryGuards();
|
||||
focus.detach();
|
||||
|
||||
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
|
||||
priorFocus.focus({ preventScroll: true });
|
||||
@@ -395,7 +298,7 @@ export function createSessionHelpModal(
|
||||
helpFilterValue = '';
|
||||
ctx.dom.sessionHelpFilter.value = '';
|
||||
applyFilterAndRender();
|
||||
focusFallbackTarget();
|
||||
focus.focusFallbackTarget();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -442,7 +345,7 @@ export function createSessionHelpModal(
|
||||
ctx.dom.sessionHelpFilter.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
focusFallbackTarget();
|
||||
focus.focusFallbackTarget();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -297,6 +297,88 @@ test('overlay notification action buttons send action ids', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('overlay notification keepOpen actions leave the card on screen', () => {
|
||||
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const stack = createFakeElement();
|
||||
const sentActions: string[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
createElement: (tagName: string) => createFakeElement(tagName),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
clearTimeout: () => undefined,
|
||||
setTimeout: () => 1,
|
||||
electronAPI: {
|
||||
sendOverlayNotificationAction: (_notificationId: string, actionId: string) => {
|
||||
sentActions.push(actionId);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const renderer = createOverlayNotificationRenderer({
|
||||
dom: {
|
||||
overlayNotificationStack: stack,
|
||||
},
|
||||
state: {
|
||||
isOverOverlayNotification: false,
|
||||
},
|
||||
} as never);
|
||||
|
||||
renderer.show({
|
||||
id: 'subminer-update-available',
|
||||
title: 'SubMiner update available',
|
||||
body: 'SubMiner v0.15.0 is available',
|
||||
persistent: true,
|
||||
actions: [
|
||||
{ id: 'install-update', label: 'Update' },
|
||||
{ id: 'view-changelog', label: "What's New", keepOpen: true },
|
||||
],
|
||||
});
|
||||
|
||||
const card = stack.children[0];
|
||||
if (!card) {
|
||||
assert.fail('Expected overlay notification card.');
|
||||
}
|
||||
const buttons: typeof card.children = [];
|
||||
const collect = (node: typeof card): void => {
|
||||
if (node.className === 'overlay-notification-action') buttons.push(node);
|
||||
for (const child of node.children) collect(child);
|
||||
};
|
||||
collect(card);
|
||||
assert.equal(buttons.length, 2);
|
||||
|
||||
// "What's New" opens the changelog but must not drop the Update affordance.
|
||||
buttons[1]?.dispatchEventType('click');
|
||||
assert.deepEqual(sentActions, ['view-changelog']);
|
||||
assert.equal(card.classList.contains('leaving'), false);
|
||||
|
||||
buttons[0]?.dispatchEventType('click');
|
||||
assert.deepEqual(sentActions, ['view-changelog', 'install-update']);
|
||||
assert.equal(card.classList.contains('leaving'), true);
|
||||
} finally {
|
||||
if (originalDocument) {
|
||||
Object.defineProperty(globalThis, 'document', originalDocument);
|
||||
} else {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
}
|
||||
if (originalWindow) {
|
||||
Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
} else {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('overlay notification renderer updates same-id progress without replacing the spinner', () => {
|
||||
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
|
||||
@@ -293,7 +293,9 @@ export function createOverlayNotificationRenderer(
|
||||
window.electronAPI.sendOverlayNotificationAction?.(entry.id, action.id, {
|
||||
noteId: action.noteId,
|
||||
});
|
||||
remove(entry.id);
|
||||
if (action.keepOpen !== true) {
|
||||
remove(entry.id);
|
||||
}
|
||||
});
|
||||
actions.append(button);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { createKikuModal } from './modals/kiku.js';
|
||||
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
|
||||
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
||||
import { createSessionHelpModal } from './modals/session-help.js';
|
||||
import { createChangelogModal } from './modals/changelog.js';
|
||||
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
||||
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
||||
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
|
||||
@@ -149,6 +150,12 @@ const modalDescriptors = [
|
||||
close: () => sessionHelpModal.closeSessionHelpModal(),
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
{
|
||||
id: 'changelog',
|
||||
isOpen: () => ctx.state.changelogModalOpen,
|
||||
close: () => changelogModal.closeChangelogModal(),
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
] satisfies readonly ModalDescriptor<OverlayHostedModal>[];
|
||||
|
||||
const modalRegistry = createModalRegistry(modalDescriptors);
|
||||
@@ -213,6 +220,10 @@ const sessionHelpModal = createSessionHelpModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const changelogModal = createChangelogModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
shouldRestoreOpenOnStartup: async () =>
|
||||
@@ -266,6 +277,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||
handleChangelogKeydown: changelogModal.handleChangelogKeydown,
|
||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||
openControllerSelectModal: () => {
|
||||
if (controllerSelectModal.openControllerSelectModal()) {
|
||||
@@ -525,6 +537,12 @@ function registerModalOpenHandlers(): void {
|
||||
window.electronAPI.notifyOverlayModalOpened('session-help');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenChangelog(() => {
|
||||
runGuarded('changelog:open', () => {
|
||||
changelogModal.openChangelogModal();
|
||||
window.electronAPI.notifyOverlayModalOpened('changelog');
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenControllerSelect(() => {
|
||||
runGuarded('controller-select:open', () => {
|
||||
if (controllerSelectModal.openControllerSelectModal()) {
|
||||
@@ -794,6 +812,7 @@ async function init(): Promise<void> {
|
||||
controllerSelectModal.wireDomEvents();
|
||||
controllerDebugModal.wireDomEvents();
|
||||
sessionHelpModal.wireDomEvents();
|
||||
changelogModal.wireDomEvents();
|
||||
subtitleSidebarModal.wireDomEvents();
|
||||
characterDictionaryModal.wireDomEvents();
|
||||
window.addEventListener('beforeunload', () => {
|
||||
|
||||
@@ -100,6 +100,8 @@ export type RendererState = {
|
||||
|
||||
sessionHelpModalOpen: boolean;
|
||||
sessionHelpSelectedIndex: number;
|
||||
changelogModalOpen: boolean;
|
||||
changelogSelectedIndex: number;
|
||||
playlistBrowserModalOpen: boolean;
|
||||
playlistBrowserSnapshot: PlaylistBrowserSnapshot | null;
|
||||
playlistBrowserStatus: string;
|
||||
@@ -228,6 +230,8 @@ export function createRendererState(): RendererState {
|
||||
|
||||
sessionHelpModalOpen: false,
|
||||
sessionHelpSelectedIndex: 0,
|
||||
changelogModalOpen: false,
|
||||
changelogSelectedIndex: 0,
|
||||
playlistBrowserModalOpen: false,
|
||||
playlistBrowserSnapshot: null,
|
||||
playlistBrowserStatus: '',
|
||||
|
||||
@@ -3182,3 +3182,267 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Changelog modal */
|
||||
.changelog-content {
|
||||
width: min(820px, 94%);
|
||||
max-height: 86%;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.changelog-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.changelog-refresh {
|
||||
min-height: 28px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid rgba(110, 115, 141, 0.3);
|
||||
background: rgba(49, 50, 68, 0.76);
|
||||
color: var(--ctp-subtext1);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.changelog-refresh:hover,
|
||||
.changelog-refresh:focus-visible {
|
||||
border-color: rgba(138, 173, 244, 0.5);
|
||||
color: var(--ctp-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.changelog-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.changelog-installed {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(166, 218, 149, 0.45);
|
||||
background: rgba(166, 218, 149, 0.14);
|
||||
color: var(--ctp-green);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.changelog-source {
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.changelog-warning {
|
||||
min-height: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.changelog-warning:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.changelog-status {
|
||||
min-height: 18px;
|
||||
font-size: 12px;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.changelog-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: calc(86vh - 190px);
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.changelog-list-empty {
|
||||
color: var(--ctp-subtext0);
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.changelog-entry {
|
||||
/* Flex children shrink by default, which crushes expanded entries inside the
|
||||
scrolling list; pin them to their content height instead. */
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(110, 115, 141, 0.18);
|
||||
border-radius: 10px;
|
||||
background: rgba(54, 58, 79, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.changelog-entry[open] {
|
||||
border-color: rgba(138, 173, 244, 0.28);
|
||||
background: rgba(54, 58, 79, 0.26);
|
||||
}
|
||||
|
||||
.changelog-entry-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.changelog-entry-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.changelog-entry-summary::before {
|
||||
content: '▸';
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 11px;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.changelog-entry[open] > .changelog-entry-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.changelog-entry-summary:hover,
|
||||
.changelog-entry-summary:focus-visible,
|
||||
.changelog-entry-summary.active {
|
||||
background: rgba(138, 173, 244, 0.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.changelog-entry-summary.active {
|
||||
box-shadow: inset 3px 0 0 var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.changelog-entry-version {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.changelog-entry-date {
|
||||
font-size: 12px;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.changelog-entry-badge {
|
||||
margin-left: auto;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.changelog-entry-badge-installed {
|
||||
border: 1px solid rgba(166, 218, 149, 0.5);
|
||||
background: rgba(166, 218, 149, 0.16);
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.changelog-entry-badge-newer {
|
||||
border: 1px solid rgba(238, 212, 159, 0.55);
|
||||
background: rgba(238, 212, 159, 0.16);
|
||||
color: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.changelog-entry-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 4px 14px 14px;
|
||||
}
|
||||
|
||||
.changelog-section-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.changelog-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.changelog-items-nested {
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
padding-left: 16px;
|
||||
list-style: circle;
|
||||
}
|
||||
|
||||
.changelog-item {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.changelog-item::marker {
|
||||
color: var(--ctp-overlay1);
|
||||
}
|
||||
|
||||
.changelog-items-nested > .changelog-item {
|
||||
font-size: 12.5px;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.changelog-item strong {
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.changelog-code {
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
background: rgba(24, 25, 38, 0.85);
|
||||
color: var(--ctp-peach);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.changelog-link {
|
||||
color: var(--ctp-blue);
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
.changelog-internal {
|
||||
border-top: 1px dashed rgba(110, 115, 141, 0.25);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.changelog-internal-summary {
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ctp-overlay1);
|
||||
}
|
||||
|
||||
.changelog-empty-entry {
|
||||
font-size: 12px;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.changelog-list {
|
||||
max-height: calc(86vh - 200px);
|
||||
}
|
||||
|
||||
.changelog-entry-badge {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,15 @@ export type RendererDom = {
|
||||
subtitleSidebarStatus: HTMLDivElement;
|
||||
subtitleSidebarList: HTMLUListElement;
|
||||
|
||||
changelogModal: HTMLDivElement;
|
||||
changelogClose: HTMLButtonElement;
|
||||
changelogRefresh: HTMLButtonElement;
|
||||
changelogInstalled: HTMLSpanElement;
|
||||
changelogSource: HTMLSpanElement;
|
||||
changelogWarning: HTMLDivElement;
|
||||
changelogStatus: HTMLDivElement;
|
||||
changelogList: HTMLDivElement;
|
||||
|
||||
sessionHelpModal: HTMLDivElement;
|
||||
sessionHelpClose: HTMLButtonElement;
|
||||
sessionHelpShortcut: HTMLDivElement;
|
||||
@@ -289,6 +298,14 @@ export function resolveRendererDom(): RendererDom {
|
||||
subtitleSidebarStatus: getRequiredElement<HTMLDivElement>('subtitleSidebarStatus'),
|
||||
subtitleSidebarList: getRequiredElement<HTMLUListElement>('subtitleSidebarList'),
|
||||
|
||||
changelogModal: getRequiredElement<HTMLDivElement>('changelogModal'),
|
||||
changelogClose: getRequiredElement<HTMLButtonElement>('changelogClose'),
|
||||
changelogRefresh: getRequiredElement<HTMLButtonElement>('changelogRefresh'),
|
||||
changelogInstalled: getRequiredElement<HTMLSpanElement>('changelogInstalled'),
|
||||
changelogSource: getRequiredElement<HTMLSpanElement>('changelogSource'),
|
||||
changelogWarning: getRequiredElement<HTMLDivElement>('changelogWarning'),
|
||||
changelogStatus: getRequiredElement<HTMLDivElement>('changelogStatus'),
|
||||
changelogList: getRequiredElement<HTMLDivElement>('changelogList'),
|
||||
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
||||
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
||||
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),
|
||||
|
||||
@@ -14,6 +14,7 @@ export const OVERLAY_HOSTED_MODALS = [
|
||||
'subtitle-sidebar',
|
||||
'session-help',
|
||||
'character-dictionary',
|
||||
'changelog',
|
||||
] as const;
|
||||
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
||||
|
||||
@@ -123,6 +124,7 @@ export const IPC_CHANNELS = {
|
||||
syncUiDeleteSnapshot: 'sync-ui:delete-snapshot',
|
||||
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
|
||||
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
|
||||
getChangelogSnapshot: 'changelog:get-snapshot',
|
||||
},
|
||||
event: {
|
||||
subtitleSet: 'subtitle:set',
|
||||
@@ -146,6 +148,7 @@ export const IPC_CHANNELS = {
|
||||
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||
sessionHelpOpen: 'session-help:open',
|
||||
changelogOpen: 'changelog:open',
|
||||
characterDictionaryManagerOpen: 'character-dictionary:manager-open',
|
||||
controllerSelectOpen: 'controller-select:open',
|
||||
controllerDebugOpen: 'controller-debug:open',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './types/anki';
|
||||
export * from './types/changelog';
|
||||
export * from './types/config';
|
||||
export * from './types/integrations';
|
||||
export * from './types/notification';
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export type ChangelogItem = {
|
||||
/** Bullet text with inline markdown preserved. */
|
||||
text: string;
|
||||
/** Nested bullets, as written with indentation in CHANGELOG.md. */
|
||||
children: ChangelogItem[];
|
||||
};
|
||||
|
||||
export type ChangelogSection = {
|
||||
/** Section heading as written in CHANGELOG.md, e.g. "Added", "Fixed". */
|
||||
heading: string;
|
||||
items: ChangelogItem[];
|
||||
/** True when the section lived inside the collapsed "Internal changes" block. */
|
||||
internal: boolean;
|
||||
};
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
date: string;
|
||||
/** major.minor of `version`, used to decide which entries render expanded. */
|
||||
groupKey: string;
|
||||
sections: ChangelogSection[];
|
||||
};
|
||||
|
||||
export type ChangelogSourceKind = 'remote' | 'bundled';
|
||||
|
||||
export type ChangelogSnapshot = {
|
||||
entries: ChangelogEntry[];
|
||||
/** Version this build reports, e.g. "0.19.2". */
|
||||
installedVersion: string;
|
||||
/** Newest version present in the fetched changelog, or null when empty. */
|
||||
latestVersion: string | null;
|
||||
/** Group key whose entries should start expanded. */
|
||||
expandedGroupKey: string | null;
|
||||
source: ChangelogSourceKind;
|
||||
/** Release tag the remote changelog was read from, when known. */
|
||||
releaseTag?: string;
|
||||
/** Populated when the remote fetch failed and a fallback was used. */
|
||||
warning?: string;
|
||||
/** Populated when no changelog could be loaded at all. */
|
||||
error?: string;
|
||||
};
|
||||
@@ -20,6 +20,8 @@ export interface OverlayNotificationAction {
|
||||
id: string;
|
||||
label: string;
|
||||
noteId?: number;
|
||||
/** Leaves the notification on screen after the action fires (default: dismiss). */
|
||||
keepOpen?: boolean;
|
||||
}
|
||||
|
||||
export interface OverlayNotificationPayload {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
KikuMergePreviewRequest,
|
||||
KikuMergePreviewResponse,
|
||||
} from './anki';
|
||||
import type { ChangelogSnapshot } from './changelog';
|
||||
import type { ResolvedConfig, ShortcutsConfig } from './config';
|
||||
import type {
|
||||
CompiledSessionBinding,
|
||||
@@ -507,6 +508,8 @@ export interface ElectronAPI {
|
||||
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
|
||||
onOpenRuntimeOptions: (callback: () => void) => void;
|
||||
onOpenSessionHelp: (callback: () => void) => void;
|
||||
onOpenChangelog: (callback: () => void) => void;
|
||||
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
|
||||
onOpenControllerSelect: (callback: () => void) => void;
|
||||
onOpenControllerDebug: (callback: () => void) => void;
|
||||
onOpenJimaku: (callback: () => void) => void;
|
||||
@@ -563,7 +566,8 @@ export interface ElectronAPI {
|
||||
| 'controller-debug'
|
||||
| 'subtitle-sidebar'
|
||||
| 'session-help'
|
||||
| 'character-dictionary',
|
||||
| 'character-dictionary'
|
||||
| 'changelog',
|
||||
) => void;
|
||||
notifyOverlayModalOpened: (
|
||||
modal:
|
||||
@@ -578,7 +582,8 @@ export interface ElectronAPI {
|
||||
| 'controller-debug'
|
||||
| 'subtitle-sidebar'
|
||||
| 'session-help'
|
||||
| 'character-dictionary',
|
||||
| 'character-dictionary'
|
||||
| 'changelog',
|
||||
) => void;
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||
|
||||
Reference in New Issue
Block a user