mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-11 04:46:07 -08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e9ec3722e
|
||
|
|
f94eb84179
|
||
|
|
0b02f4ec7b
|
||
|
|
bf2ea99178
|
||
|
|
ce680b746a
|
||
|
|
4677f5624b
|
||
|
|
a5d0b63792 |
No files matched your search
@@ -21,7 +21,7 @@
|
||||
"@electron/asar": "3.4.1",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "42.10.0",
|
||||
"electron": "43.7.7",
|
||||
"electron-builder": "26.16.1",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^10.8.0",
|
||||
@@ -347,7 +347,7 @@
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
"electron": ["electron@42.10.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-yBWnZO8tKId0ADvGa4+imPmm3+8Kfs8MtkUPhQQJz1AjTnUUDwCTWkQEU0CrArVVL97Jo4Q/0mVcLHciicBDGQ=="],
|
||||
"electron": ["electron@43.7.7", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-y0pg+KhWLAkW2Ts2njkFFNFl3KtB1FmbUp9Te9i+R2Og2v1fcI/I6MADJ7jvnzB5UKdt+U+0EUoCnVL79xMPMA=="],
|
||||
|
||||
"electron-builder": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="],
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
type: fixed
|
||||
area: dictionary
|
||||
|
||||
- Upgraded the desktop runtime to Electron 43.7.7 and added profile guards that block unsupported runtimes and Electron major or minor downgrades before Yomitan storage is loaded. Patch downgrades within the same major and minor version are allowed.
|
||||
- Development launches now use a separate `SubMiner-dev` profile unless production-profile access is explicitly requested.
|
||||
- Automatic character-dictionary changes now stop when a previously non-empty Yomitan profile suddenly reports zero dictionaries.
|
||||
@@ -0,0 +1,7 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Fixed sentence mining (single and multi-line) counting each created card twice in session and lifetime stats.
|
||||
- Fixed deleting a session leaving word and kanji "last seen" dates pointing at the deleted session; affected rows are repaired once in the background on next launch.
|
||||
- Fixed re-seen words that the vocabulary filter hides still being counted in the new-words charts, so charts and the vocabulary summary now agree. Existing mismatches are repaired by the same background pass.
|
||||
- Fixed Overview totals, charts, activity calendar, and vocabulary summary staying stale after deleting sessions. Failed refreshes keep the dashboard visible and offer a retry.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: stats
|
||||
|
||||
- Documented the automatic Overview refresh after deleting sessions or session groups and retrying failed refreshes.
|
||||
@@ -47,6 +47,10 @@ electron . --start --dev --log-level debug # verbose launch of an existing bui
|
||||
electron . --background # tray/background mode
|
||||
```
|
||||
|
||||
Development and debug launches use a separate `SubMiner-dev` profile so runtime experiments cannot modify the installed app's configuration or Yomitan dictionaries. To intentionally use the production profile for a development launch, set `SUBMINER_USE_PRODUCTION_PROFILE=1`. Use that override only after backing up the profile.
|
||||
|
||||
Launch source builds through `bun run dev` or `bun run electron`. SubMiner refuses to load its profile when the running Electron major differs from the version pinned by the repository.
|
||||
|
||||
To test through the mpv plugin without exporting `SUBMINER_BINARY_PATH` each time, point `mpv.subminerBinaryPath` in your config at the dev script. The launcher passes it to the plugin at runtime:
|
||||
|
||||
```json
|
||||
|
||||
@@ -42,6 +42,8 @@ To share stats and watch history between machines, use [`subminer sync <host>`](
|
||||
|
||||
Recent sessions, a streak calendar, watch-time history, and totals for completed episodes and shows.
|
||||
|
||||
Deleting a session or session group refreshes the Overview totals, charts, calendar, and vocabulary summary automatically. If a refresh fails, Overview keeps the previous totals visible and offers **Retry**.
|
||||
|
||||

|
||||
|
||||
### Library
|
||||
|
||||
@@ -67,6 +67,16 @@ If you have not set up dictionaries yet, start with [Yomitan setup](/usage#yomit
|
||||
|
||||
Word boundaries come from Yomitan's parser. Some splits will be wrong, since Japanese has no spaces.
|
||||
|
||||
## "Electron downgrade blocked" or "Unsupported Electron runtime"
|
||||
|
||||
SubMiner refuses to load Yomitan storage when the current Electron major does not match the app build, or when the profile was previously opened by a newer Electron major or minor version. Patch downgrades within the same major and minor version are allowed, such as `43.7.3` to `43.7.2`. Downgrades from `43.7.x` to `43.6.x`, or from Electron 43 to 42, are blocked. Launch the packaged app or use the repository's `bun run dev` command. Do not delete the runtime safety record to force an older major or minor version to open the profile.
|
||||
|
||||
Older SubMiner builds without this guard cannot enforce the safety record. When testing Electron 43 before returning to an older Electron 42 build, use the separate `SubMiner-dev` profile described in [development](/development#run-locally) so the older build never opens the upgraded profile.
|
||||
|
||||
## "Yomitan reported zero dictionaries after previously reporting ..."
|
||||
|
||||
SubMiner detected that a previously non-empty Yomitan profile suddenly appears empty. Automatic character-dictionary changes are blocked so they cannot overwrite the suspicious state. Close SubMiner, preserve the profile directory, and restore a known-good backup before importing or deleting dictionaries.
|
||||
|
||||
## "Yomitan extension not found in any search path"
|
||||
|
||||
The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `build/yomitan`.
|
||||
|
||||
@@ -145,6 +145,10 @@ Press `Alt+C` to choose a controller and remap buttons. Click an action's **Lear
|
||||
|
||||
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`. Remap it with `Alt+C`. All options are in [Configuration](/configuration#controller-support).
|
||||
|
||||
## Development profiles
|
||||
|
||||
`--dev` and `--debug` use a separate `SubMiner-dev` profile. They do not read or modify dictionaries and configuration from the installed app unless `SUBMINER_USE_PRODUCTION_PROFILE=1` is explicitly set. See [Development](/development#run-locally).
|
||||
|
||||
## Changing settings while you watch
|
||||
|
||||
SubMiner watches your config file and applies most changes without a restart, including subtitle style, keybindings, and most Anki settings. If a change needs a restart, SubMiner tells you. If the file has an error, it keeps the last working config and shows a notification. See [Configuration](/configuration).
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@
|
||||
"@electron/asar": "3.4.1",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "42.10.0",
|
||||
"electron": "43.7.7",
|
||||
"electron-builder": "26.16.1",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^10.8.0",
|
||||
|
||||
@@ -47,7 +47,7 @@ test('stripFilenameTags normalizes common media-title formats', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchIfMissing backfills a missing blob from an existing cover URL', async () => {
|
||||
async function backfillMissingCoverBlob(): Promise<void> {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
ensureSchema(db);
|
||||
@@ -103,9 +103,15 @@ test('fetchIfMissing backfills a missing blob from an existing cover URL', async
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('fetchIfMissing reuses cached cover art from another video in the same anime', async () => {
|
||||
test(
|
||||
'fetchIfMissing backfills a missing blob from an existing cover URL',
|
||||
{ timeout: 15_000 },
|
||||
backfillMissingCoverBlob,
|
||||
);
|
||||
|
||||
async function reuseCachedAnimeCoverArt(): Promise<void> {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
ensureSchema(db);
|
||||
@@ -179,7 +185,13 @@ test('fetchIfMissing reuses cached cover art from another video in the same anim
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
'fetchIfMissing reuses cached cover art from another video in the same anime',
|
||||
{ timeout: 15_000 },
|
||||
reuseCachedAnimeCoverArt,
|
||||
);
|
||||
|
||||
function createJsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
|
||||
@@ -30,7 +30,13 @@ function cleanupDbPath(dbPath: string): void {
|
||||
*/
|
||||
function seed(
|
||||
db: DatabaseSync,
|
||||
lines: Array<{ session: 1 | 2; wordId: number; dayOffset: number; count?: number }>,
|
||||
lines: Array<{
|
||||
session: 1 | 2;
|
||||
wordId: number;
|
||||
dayOffset: number;
|
||||
msOffset?: number;
|
||||
count?: number;
|
||||
}>,
|
||||
options: { legacyRows?: boolean } = {},
|
||||
): void {
|
||||
db.exec(`
|
||||
@@ -65,7 +71,7 @@ function seed(
|
||||
let lineId = 0;
|
||||
for (const line of lines) {
|
||||
lineId += 1;
|
||||
const seenMs = BASE_MS + line.dayOffset * DAY_MS;
|
||||
const seenMs = BASE_MS + line.dayOffset * DAY_MS + (line.msOffset ?? 0);
|
||||
insertLine.run(lineId, line.session, line.session, lineId, `line ${lineId}`, seenMs, seenMs);
|
||||
insertWord.run(line.wordId, `語${line.wordId}`, `語${line.wordId}`);
|
||||
if (options.legacyRows) {
|
||||
@@ -83,13 +89,13 @@ function seed(
|
||||
FROM imm_word_line_occurrences o WHERE o.word_id = imm_words.id
|
||||
),
|
||||
first_seen = (
|
||||
SELECT MIN(sl.CREATED_DATE) / 1000
|
||||
SELECT MIN(sl.CREATED_DATE) / 1000.0
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||
WHERE o.word_id = imm_words.id
|
||||
),
|
||||
last_seen = (
|
||||
SELECT MAX(sl.LAST_UPDATE_DATE) / 1000
|
||||
SELECT MAX(sl.LAST_UPDATE_DATE) / 1000.0
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||
WHERE o.word_id = imm_words.id
|
||||
@@ -200,6 +206,52 @@ test('deleting the latest session moves last_seen back to the surviving line', (
|
||||
}
|
||||
});
|
||||
|
||||
for (const legacyRows of [false, true]) {
|
||||
test(`deleting the latest session keeps fractional seen timestamps exact (legacyRows=${legacyRows})`, () => {
|
||||
// Stored seen values are fractional seconds; whole-second rounding used to miss
|
||||
// the removed extreme (or truncate the refreshed one).
|
||||
const { db, dbPath } = createDb(
|
||||
[
|
||||
{ session: 1, wordId: 22, dayOffset: 0, msOffset: 251 },
|
||||
{ session: 2, wordId: 22, dayOffset: 5, msOffset: 882 },
|
||||
],
|
||||
{ legacyRows },
|
||||
);
|
||||
|
||||
try {
|
||||
deleteSession(db, 2);
|
||||
|
||||
const word = readWord(db, 22);
|
||||
assert.equal(word?.frequency, 1);
|
||||
assert.equal(word?.firstSeen, (BASE_MS + 251) / 1000);
|
||||
assert.equal(word?.lastSeen, (BASE_MS + 251) / 1000);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('deleting the earliest session moves first_seen even when the stored bound predates seen_ms', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
{ session: 1, wordId: 23, dayOffset: 0, msOffset: 400 },
|
||||
{ session: 2, wordId: 23, dayOffset: 5, msOffset: 400 },
|
||||
]);
|
||||
|
||||
try {
|
||||
// first_seen is stamped when the line is queued (and older refreshes rounded
|
||||
// it down), so it sits slightly before the occurrence's flush-time seen_ms.
|
||||
db.prepare('UPDATE imm_words SET first_seen = ? WHERE id = 23').run(BASE_MS / 1000);
|
||||
|
||||
deleteSession(db, 1);
|
||||
|
||||
assert.equal(readWord(db, 23)?.firstSeen, (BASE_MS + 5 * DAY_MS + 400) / 1000);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleting an interior occurrence leaves the surrounding extremes untouched', () => {
|
||||
// Session 2 carries the middle occurrence; sessions bracket it in time.
|
||||
const { db, dbPath } = createDb([
|
||||
|
||||
@@ -4567,13 +4567,13 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li
|
||||
|
||||
assert.ok(sharedWordRow);
|
||||
assert.equal(sharedWordRow.frequency, 1);
|
||||
assert.equal(sharedWordRow.first_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(sharedWordRow.last_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(sharedWordRow.first_seen, keptTs / 1000);
|
||||
assert.equal(sharedWordRow.last_seen, keptTs / 1000);
|
||||
assert.equal(deletedOnlyWordRow ?? null, null);
|
||||
assert.ok(sharedKanjiRow);
|
||||
assert.equal(sharedKanjiRow.frequency, 1);
|
||||
assert.equal(sharedKanjiRow.first_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(sharedKanjiRow.last_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(sharedKanjiRow.first_seen, keptTs / 1000);
|
||||
assert.equal(sharedKanjiRow.last_seen, keptTs / 1000);
|
||||
assert.equal(deletedOnlyKanjiRow ?? null, null);
|
||||
} finally {
|
||||
db.close();
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
} from './query-lexical';
|
||||
import { Database } from './sqlite';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { ensureSchema } from './storage';
|
||||
import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from './storage';
|
||||
import { BASE_MS, createDb, seedEndedSession, seedVideo } from './__tests__/lifetime-test-fixtures';
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollups-'));
|
||||
@@ -125,6 +126,121 @@ test('lexical rollup rebuild excludes rows hidden by vocabulary persistence rule
|
||||
}
|
||||
});
|
||||
|
||||
test('re-seeing a filtered legacy word keeps charts consistent with the summary', () => {
|
||||
const db = createDb();
|
||||
|
||||
try {
|
||||
const videoId = seedVideo(db, null, 'episode');
|
||||
const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
|
||||
const firstSeen = BASE_MS / 1000;
|
||||
// Legacy row whose stored merged-token POS fails the persistence filter.
|
||||
db.prepare(
|
||||
`INSERT INTO imm_words(
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES ('二死', 'にし', 'にし', 'other', '助詞|動詞', '格助詞|自立', '一般|*', ?, ?, 1)`,
|
||||
).run(firstSeen, firstSeen);
|
||||
rebuildLexicalDailyRollups(db);
|
||||
|
||||
// The incoming token passes the filter, but the upsert keeps the stored POS.
|
||||
executeQueuedWrite(
|
||||
{
|
||||
kind: 'subtitleLine',
|
||||
sessionId,
|
||||
videoId,
|
||||
lineIndex: 1,
|
||||
segmentStartMs: null,
|
||||
segmentEndMs: null,
|
||||
text: 'にし',
|
||||
wordOccurrences: [
|
||||
{
|
||||
headword: '二死',
|
||||
word: 'にし',
|
||||
reading: 'にし',
|
||||
partOfSpeech: 'verb',
|
||||
pos1: '動詞',
|
||||
pos2: '自立',
|
||||
pos3: '*',
|
||||
frequencyRank: null,
|
||||
occurrenceCount: 1,
|
||||
},
|
||||
],
|
||||
kanjiOccurrences: [],
|
||||
firstSeen: firstSeen + 60,
|
||||
lastSeen: firstSeen + 60,
|
||||
},
|
||||
createTrackerPreparedStatements(db),
|
||||
);
|
||||
|
||||
const plotted = getVocabularyChartData(db).newWordsTimeline.reduce(
|
||||
(sum, day) => sum + day.wordCount,
|
||||
0,
|
||||
);
|
||||
assert.equal(getVocabularySummary(db, null).uniqueWords, 0);
|
||||
assert.equal(plotted, 0);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('lexical rollup rebuild repairs last_seen left pointing at deleted history', () => {
|
||||
const db = createDb();
|
||||
|
||||
try {
|
||||
const videoId = seedVideo(db, null, 'episode');
|
||||
const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
|
||||
const lineId = Number(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO imm_subtitle_lines(session_id, video_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, 1, '猫', ?, ?)`,
|
||||
)
|
||||
.run(sessionId, videoId, BASE_MS, BASE_MS).lastInsertRowid,
|
||||
);
|
||||
const seen = (BASE_MS + 251) / 1000;
|
||||
// first_seen this early may be real history pruned by retention; never touched.
|
||||
const earlyFirst = seen - 86_400.5;
|
||||
const staleLast = seen + 86_400.631;
|
||||
db.prepare(
|
||||
`INSERT INTO imm_words(id, headword, word, reading, first_seen, last_seen, frequency)
|
||||
VALUES (1, '猫', '猫', 'ねこ', ?, ?, 1), (2, '犬', '犬', 'いぬ', ?, ?, 1)`,
|
||||
).run(earlyFirst, staleLast, seen - 0.1, seen - 0.1);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency) VALUES (1, '猫', ?, ?, 1)`,
|
||||
).run(earlyFirst, staleLast);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms)
|
||||
VALUES (?, 1, 1, ?), (?, 2, 1, ?)`,
|
||||
).run(lineId, BASE_MS + 251, lineId, BASE_MS + 251);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms)
|
||||
VALUES (?, 1, 1, ?)`,
|
||||
).run(lineId, BASE_MS + 251);
|
||||
|
||||
rebuildLexicalDailyRollups(db);
|
||||
|
||||
const bounds = (table: string): Array<[number, number]> =>
|
||||
(
|
||||
db
|
||||
.prepare(`SELECT first_seen AS first, last_seen AS last FROM ${table} ORDER BY id`)
|
||||
.all() as Array<{
|
||||
first: number;
|
||||
last: number;
|
||||
}>
|
||||
).map((row) => [row.first, row.last]);
|
||||
assert.deepEqual(
|
||||
bounds('imm_words'),
|
||||
[
|
||||
[earlyFirst, seen],
|
||||
[seen - 0.1, seen - 0.1],
|
||||
],
|
||||
'healthy rows are untouched',
|
||||
);
|
||||
assert.deepEqual(bounds('imm_kanji'), [[earlyFirst, seen]]);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('lexical rollup rebuild tolerates nullable legacy vocabulary text', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -268,7 +384,7 @@ test('current lexical rollup readiness accepts legacy integer state storage', ()
|
||||
state_value INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO imm_rollup_state(state_key, state_value)
|
||||
VALUES ('lexical_daily_rollups_version', 2);
|
||||
VALUES ('lexical_daily_rollups_version', 3);
|
||||
`);
|
||||
|
||||
assert.equal(areLexicalDailyRollupsReady(db), true);
|
||||
|
||||
@@ -18,7 +18,9 @@ const LOCAL_EPOCH_DAY_SQL = `
|
||||
) - 2440587.5 AS INTEGER)
|
||||
`;
|
||||
|
||||
const LEXICAL_DAILY_ROLLUP_VERSION = '2';
|
||||
// v3 re-derives visibility for rows a re-seen word forced visible and repairs
|
||||
// last_seen values left behind by session deletes.
|
||||
const LEXICAL_DAILY_ROLLUP_VERSION = '3';
|
||||
const LEXICAL_DAILY_ROLLUP_VERSION_KEY = 'lexical_daily_rollups_version';
|
||||
const VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE = 5_000;
|
||||
|
||||
@@ -26,6 +28,32 @@ export function localEpochDaySql(value: string): string {
|
||||
return LOCAL_EPOCH_DAY_SQL.replaceAll('%VALUE%', value);
|
||||
}
|
||||
|
||||
// Session deletes once rounded removed timestamps to whole seconds, missing
|
||||
// fractional last_seen extremes that pointed at deleted history. last_seen is
|
||||
// stamped at queue time, before seen_ms is stamped at flush, and retention
|
||||
// prunes the oldest sessions first, so healthy rows never exceed their newest
|
||||
// surviving occurrence. Rows with undated occurrences are skipped.
|
||||
//
|
||||
// first_seen is deliberately not repaired: a stale bound is indistinguishable
|
||||
// from real history whose occurrences retention pruned, and frequency does not
|
||||
// reliably tell them apart.
|
||||
function repairStaleLastSeen(db: DatabaseSync): void {
|
||||
for (const [table, occurrenceTable, col] of [
|
||||
['imm_words', 'imm_word_line_occurrences', 'word_id'],
|
||||
['imm_kanji', 'imm_kanji_line_occurrences', 'kanji_id'],
|
||||
] as const) {
|
||||
const newestSeen = `(SELECT MAX(o.seen_ms) FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id) / 1000.0`;
|
||||
db.exec(`
|
||||
UPDATE ${table}
|
||||
SET last_seen = ${newestSeen}
|
||||
WHERE last_seen > ${newestSeen}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id AND o.seen_ms IS NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
function createWordRollupTriggers(db: DatabaseSync): void {
|
||||
const dayForNew = localEpochDaySql('NEW.first_seen');
|
||||
const dayForOld = localEpochDaySql('OLD.first_seen');
|
||||
@@ -186,6 +214,7 @@ export function rebuildLexicalDailyRollups(db: DatabaseSync): void {
|
||||
lastId = vocabularyRows[vocabularyRows.length - 1]!.id;
|
||||
if (vocabularyRows.length < VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE) break;
|
||||
}
|
||||
repairStaleLastSeen(db);
|
||||
db.exec('DELETE FROM imm_lexical_daily_rollups');
|
||||
db.exec(`
|
||||
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
|
||||
|
||||
@@ -298,9 +298,11 @@ export function planLexicalRemovalsForVideos(
|
||||
return planLexicalRemovals(db, `sl.video_id IN (${makePlaceholders(videoIds)})`, videoIds);
|
||||
}
|
||||
|
||||
// first_seen/last_seen are REAL seconds that keep millisecond fractions; never round
|
||||
// them, or comparisons against stored extremes miss and refreshes drift.
|
||||
function toStoredSeenSeconds(ms: number | null): number | null {
|
||||
if (ms === null || !Number.isFinite(ms)) return null;
|
||||
return Math.floor(ms / 1000);
|
||||
return ms / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -380,11 +382,16 @@ function applyRemovalsForEntity(
|
||||
continue;
|
||||
}
|
||||
|
||||
const removedFirstSeen = toStoredSeenSeconds(removal.removedFirstSeenMs);
|
||||
// Stored bounds are stamped when a line is queued, before its seen_ms is
|
||||
// stamped at flush, and older refreshes rounded them down to whole seconds.
|
||||
// So first_seen can sit just before the occurrence that produced it; decide
|
||||
// against the surviving minimum instead. last_seen errs the safe way.
|
||||
const minSeenMs = (minSeenStmt.get(removal.id) as { value: number | null }).value;
|
||||
const removedLastSeen = toStoredSeenSeconds(removal.removedLastSeenMs);
|
||||
const firstSeenMayHaveMoved =
|
||||
current.firstSeen === null ||
|
||||
(removedFirstSeen !== null && removedFirstSeen <= current.firstSeen);
|
||||
(removal.removedFirstSeenMs !== null &&
|
||||
(minSeenMs === null || Number(removal.removedFirstSeenMs) <= Number(minSeenMs)));
|
||||
const lastSeenMayHaveMoved =
|
||||
current.lastSeen === null ||
|
||||
(removedLastSeen !== null && removedLastSeen >= current.lastSeen);
|
||||
@@ -395,7 +402,6 @@ function applyRemovalsForEntity(
|
||||
needsExactRefresh.push(removal.id);
|
||||
continue;
|
||||
}
|
||||
const minSeenMs = (minSeenStmt.get(removal.id) as { value: number | null }).value;
|
||||
const maxSeenMs = (maxSeenStmt.get(removal.id) as { value: number | null }).value;
|
||||
if (minSeenMs === null || maxSeenMs === null) {
|
||||
// Frequency says occurrences remain but none exist: stale row, let the
|
||||
@@ -405,8 +411,8 @@ function applyRemovalsForEntity(
|
||||
}
|
||||
updateAggregatesStmt.run(
|
||||
nextFrequency,
|
||||
Math.floor(Number(minSeenMs) / 1000),
|
||||
Math.floor(Number(maxSeenMs) / 1000),
|
||||
toStoredSeenSeconds(Number(minSeenMs)),
|
||||
toStoredSeenSeconds(Number(maxSeenMs)),
|
||||
removal.id,
|
||||
);
|
||||
continue;
|
||||
@@ -471,8 +477,8 @@ function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
|
||||
}
|
||||
updateStmt.run(
|
||||
row.frequency,
|
||||
Math.floor(row.firstSeen / 1000),
|
||||
Math.floor(row.lastSeen / 1000),
|
||||
toStoredSeenSeconds(row.firstSeen),
|
||||
toStoredSeenSeconds(row.lastSeen),
|
||||
row.wordId,
|
||||
);
|
||||
}
|
||||
@@ -527,8 +533,8 @@ function refreshKanjiAggregates(db: DatabaseSync, kanjiIds: number[]): void {
|
||||
}
|
||||
updateStmt.run(
|
||||
row.frequency,
|
||||
Math.floor(row.firstSeen / 1000),
|
||||
Math.floor(row.lastSeen / 1000),
|
||||
toStoredSeenSeconds(row.firstSeen),
|
||||
toStoredSeenSeconds(row.lastSeen),
|
||||
row.kanjiId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { normalizeTitleIdentity } from '../../utils/title-normalization';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { nowMs } from './time';
|
||||
import { ensureLexicalDailyRollupTables, markLexicalDailyRollupsReady } from './lexical-rollups';
|
||||
import { isVocabularyStatsRowVisible, type VocabularyVisibilityRow } from './vocabulary-visibility';
|
||||
import { SCHEMA_VERSION } from './types';
|
||||
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
|
||||
import { toDbMs, toDbTimestamp } from './query-shared';
|
||||
@@ -17,7 +18,8 @@ export interface TrackerPreparedStatements {
|
||||
wordUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
kanjiUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
subtitleLineInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordIdSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordVisibilityUpdateStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
kanjiIdSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
kanjiLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
@@ -1759,7 +1761,6 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
||||
pos1 = COALESCE(NULLIF(imm_words.pos1, ''), excluded.pos1),
|
||||
pos2 = COALESCE(NULLIF(imm_words.pos2, ''), excluded.pos2),
|
||||
pos3 = COALESCE(NULLIF(imm_words.pos3, ''), excluded.pos3),
|
||||
vocabulary_visible = 1,
|
||||
first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen),
|
||||
last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen),
|
||||
frequency_rank = CASE
|
||||
@@ -1787,10 +1788,16 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`),
|
||||
wordIdSelectStmt: db.prepare(`
|
||||
SELECT id FROM imm_words
|
||||
wordSelectStmt: db.prepare(`
|
||||
SELECT id, word, headword, reading, part_of_speech AS partOfSpeech,
|
||||
pos1, pos2, pos3, frequency_rank AS frequencyRank,
|
||||
vocabulary_visible AS vocabularyVisible
|
||||
FROM imm_words
|
||||
WHERE headword = ? AND word = ? AND reading = ?
|
||||
`),
|
||||
wordVisibilityUpdateStmt: db.prepare(`
|
||||
UPDATE imm_words SET vocabulary_visible = ? WHERE id = ?
|
||||
`),
|
||||
kanjiIdSelectStmt: db.prepare(`
|
||||
SELECT id FROM imm_kanji
|
||||
WHERE kanji = ?
|
||||
@@ -1842,14 +1849,18 @@ function incrementWordAggregate(
|
||||
occurrence.frequencyRank ?? null,
|
||||
);
|
||||
}
|
||||
const row = stmts.wordIdSelectStmt.get(
|
||||
occurrence.headword,
|
||||
occurrence.word,
|
||||
occurrence.reading,
|
||||
) as { id: number } | null;
|
||||
const row = stmts.wordSelectStmt.get(occurrence.headword, occurrence.word, occurrence.reading) as
|
||||
| (VocabularyVisibilityRow & { id: number; vocabularyVisible: number })
|
||||
| null;
|
||||
if (!row?.id) {
|
||||
throw new Error(`Failed to resolve imm_words id for ${occurrence.headword}`);
|
||||
}
|
||||
// The upsert keeps the first-seen POS metadata, so derive visibility from the
|
||||
// stored row (as the vocabulary summary does) rather than the incoming token.
|
||||
const visible = isVocabularyStatsRowVisible(row) ? 1 : 0;
|
||||
if (row.vocabularyVisible !== visible) {
|
||||
stmts.wordVisibilityUpdateStmt.run(visible, row.id);
|
||||
}
|
||||
return row.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -276,7 +276,6 @@ test('handleMultiCopyDigit copies backward from the current subtitle after a bac
|
||||
test('handleMineSentenceDigit reports async create failures', async () => {
|
||||
const osd: string[] = [];
|
||||
const logs: Array<{ message: string; err: unknown }> = [];
|
||||
let cardsMined = 0;
|
||||
|
||||
handleMineSentenceDigit(2, {
|
||||
subtitleTimingTracker: {
|
||||
@@ -296,9 +295,6 @@ test('handleMineSentenceDigit reports async create failures', async () => {
|
||||
getCurrentSecondarySubText: () => 'sub2',
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
logError: (message, err) => logs.push({ message, err }),
|
||||
onCardsMined: (count) => {
|
||||
cardsMined += count;
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
@@ -307,37 +303,6 @@ test('handleMineSentenceDigit reports async create failures', async () => {
|
||||
assert.equal(logs[0]?.message, 'mineSentenceMultiple failed:');
|
||||
assert.equal((logs[0]?.err as Error).message, 'mine boom');
|
||||
assert.ok(osd.some((entry) => entry.includes('Mine sentence failed: mine boom')));
|
||||
assert.equal(cardsMined, 0);
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit increments successful card count', async () => {
|
||||
const osd: string[] = [];
|
||||
let cardsMined = 0;
|
||||
|
||||
handleMineSentenceDigit(2, {
|
||||
subtitleTimingTracker: {
|
||||
getRecentBlocks: () => ['one', 'two'],
|
||||
getCurrentSubtitle: () => null,
|
||||
findTiming: (text) =>
|
||||
text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 },
|
||||
},
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async () => true,
|
||||
},
|
||||
getCurrentSecondarySubText: () => 'sub2',
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
logError: () => {},
|
||||
onCardsMined: (count) => {
|
||||
cardsMined += count;
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(cardsMined, 1);
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => {
|
||||
|
||||
@@ -201,7 +201,6 @@ export function handleMineSentenceDigit(
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined?: (count: number) => void;
|
||||
},
|
||||
): void {
|
||||
if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return;
|
||||
@@ -233,14 +232,9 @@ export function handleMineSentenceDigit(
|
||||
entries,
|
||||
deps.getCurrentSecondarySubText,
|
||||
);
|
||||
const cardsToMine = 1;
|
||||
// Card creation records the mined card (with its note id) itself.
|
||||
deps.ankiIntegration
|
||||
.createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText)
|
||||
.then((created) => {
|
||||
if (created) {
|
||||
deps.onCardsMined?.(cardsToMine);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
deps.logError('mineSentenceMultiple failed:', err);
|
||||
deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`);
|
||||
|
||||
@@ -39,6 +39,7 @@ test('non-macOS modal overlay remains a regular window', () => {
|
||||
});
|
||||
|
||||
assert.equal(options.type, undefined);
|
||||
assert.equal(options.roundedCorners, false);
|
||||
});
|
||||
|
||||
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ export function buildOverlayWindowOptions(
|
||||
paintWhenInitiallyHidden: true,
|
||||
backgroundColor: '#00000000',
|
||||
frame: false,
|
||||
...(platform === 'linux' ? { roundedCorners: false } : {}),
|
||||
alwaysOnTop: shouldStartAlwaysOnTop,
|
||||
skipTaskbar: true,
|
||||
resizable: shouldAllowCompositorResize,
|
||||
|
||||
@@ -67,6 +67,7 @@ export function buildStatsWindowOptions(options: {
|
||||
width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH,
|
||||
height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT,
|
||||
frame: false,
|
||||
...(platform === 'linux' ? { roundedCorners: false } : {}),
|
||||
transparent: false,
|
||||
alwaysOnTop: true,
|
||||
resizable: false,
|
||||
|
||||
@@ -57,6 +57,7 @@ test('buildStatsWindowOptions remains a regular window off macOS', () => {
|
||||
});
|
||||
|
||||
assert.equal(options.type, undefined);
|
||||
assert.equal(options.roundedCorners, false);
|
||||
});
|
||||
|
||||
test('stats panels present after document load on macOS', () => {
|
||||
|
||||
@@ -640,3 +640,116 @@ test('configureEarlyAppPaths creates a fresh macOS config directory before Elect
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('configureEarlyAppPaths isolates development runs from the production profile', () => {
|
||||
const calls: string[] = [];
|
||||
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-dev-profile-'));
|
||||
const devProfileDir = path.posix.join(xdgConfigHome, 'SubMiner-dev');
|
||||
try {
|
||||
const userDataPath = configureEarlyAppPaths(
|
||||
{
|
||||
setName: (name) => calls.push(`name:${name}`),
|
||||
setPath: (key, value) => calls.push(`path:${key}:${value}`),
|
||||
},
|
||||
{
|
||||
platform: 'linux',
|
||||
homeDir: xdgConfigHome,
|
||||
xdgConfigHome,
|
||||
existsSync: () => false,
|
||||
argv: ['electron', '.', '--start', '--dev'],
|
||||
env: {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(userDataPath, devProfileDir);
|
||||
assert.deepEqual(calls, ['name:SubMiner', `path:userData:${devProfileDir}`]);
|
||||
} finally {
|
||||
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('configureEarlyAppPaths ignores development flags forwarded to mpv', () => {
|
||||
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-mpv-flags-'));
|
||||
const configDir = path.posix.join(xdgConfigHome, 'SubMiner');
|
||||
try {
|
||||
for (const forwardedFlag of ['--dev', '--debug']) {
|
||||
let selectedPath = '';
|
||||
const userDataPath = configureEarlyAppPaths(
|
||||
{
|
||||
setName: () => {},
|
||||
setPath: (_key, value) => {
|
||||
selectedPath = value;
|
||||
},
|
||||
},
|
||||
{
|
||||
platform: 'linux',
|
||||
homeDir: xdgConfigHome,
|
||||
xdgConfigHome,
|
||||
existsSync: () => false,
|
||||
argv: ['electron', '.', '--launch-mpv', forwardedFlag],
|
||||
env: {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(userDataPath, configDir);
|
||||
assert.equal(selectedPath, configDir);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('configureEarlyAppPaths uses the supplied environment for config discovery', () => {
|
||||
const paths: string[] = [];
|
||||
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-injected-env-'));
|
||||
const configDir = path.posix.join(xdgConfigHome, 'SubMiner');
|
||||
try {
|
||||
const userDataPath = configureEarlyAppPaths(
|
||||
{
|
||||
setName: () => {},
|
||||
setPath: (_key, value) => paths.push(value),
|
||||
},
|
||||
{
|
||||
platform: 'linux',
|
||||
homeDir: path.posix.join(xdgConfigHome, 'home'),
|
||||
existsSync: () => false,
|
||||
argv: ['electron', '.', '--start'],
|
||||
env: { XDG_CONFIG_HOME: xdgConfigHome },
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(userDataPath, configDir);
|
||||
assert.deepEqual(paths, [configDir]);
|
||||
} finally {
|
||||
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('configureEarlyAppPaths allows an explicit production-profile development run', () => {
|
||||
const paths: string[] = [];
|
||||
const configBaseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-prod-profile-'));
|
||||
// Use the host platform so the selected directory is a real path mkdirSync can create.
|
||||
const configDir = path.join(configBaseDir, 'SubMiner');
|
||||
try {
|
||||
const userDataPath = configureEarlyAppPaths(
|
||||
{
|
||||
setName: () => {},
|
||||
setPath: (_key, value) => paths.push(value),
|
||||
},
|
||||
{
|
||||
platform: process.platform,
|
||||
appDataDir: configBaseDir,
|
||||
xdgConfigHome: configBaseDir,
|
||||
homeDir: configBaseDir,
|
||||
existsSync: () => false,
|
||||
argv: ['electron', '.', '--debug'],
|
||||
env: { SUBMINER_USE_PRODUCTION_PROFILE: '1' },
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(userDataPath, configDir);
|
||||
assert.deepEqual(paths, [configDir]);
|
||||
} finally {
|
||||
fs.rmSync(configBaseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
|
||||
import { resolveConfigDir } from './config/path-resolution';
|
||||
@@ -14,6 +15,8 @@ const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
||||
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||
const MAX_TRANSPORTED_APP_ARGS = 256;
|
||||
const APP_NAME = 'SubMiner';
|
||||
const DEVELOPMENT_APP_NAME = 'SubMiner-dev';
|
||||
export const USE_PRODUCTION_PROFILE_ENV = 'SUBMINER_USE_PRODUCTION_PROFILE';
|
||||
const DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS = 500;
|
||||
const MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS = 3000;
|
||||
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
|
||||
@@ -53,6 +56,8 @@ type EarlyAppPathOptions = {
|
||||
xdgConfigHome?: string;
|
||||
homeDir?: string;
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
argv?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
|
||||
@@ -252,13 +257,25 @@ export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): st
|
||||
}
|
||||
|
||||
export function configureEarlyAppPaths(app: EarlyAppLike, options?: EarlyAppPathOptions): string {
|
||||
const userDataPath = resolveConfigDir({
|
||||
platform: options?.platform ?? process.platform,
|
||||
appDataDir: options?.appDataDir ?? process.env.APPDATA,
|
||||
xdgConfigHome: options?.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const env = options?.env ?? process.env;
|
||||
const configDir = resolveConfigDir({
|
||||
platform,
|
||||
appDataDir: options?.appDataDir ?? env.APPDATA,
|
||||
xdgConfigHome: options?.xdgConfigHome ?? env.XDG_CONFIG_HOME,
|
||||
homeDir: options?.homeDir ?? os.homedir(),
|
||||
existsSync: options?.existsSync ?? fs.existsSync,
|
||||
});
|
||||
const argv = options?.argv ?? process.argv;
|
||||
const launchMpvIndex = argv.indexOf('--launch-mpv');
|
||||
const appArgv = launchMpvIndex === -1 ? argv : argv.slice(0, launchMpvIndex);
|
||||
const useDevelopmentProfile =
|
||||
(appArgv.includes('--dev') || appArgv.includes('--debug')) &&
|
||||
env[USE_PRODUCTION_PROFILE_ENV] !== '1';
|
||||
const platformPath = platform === 'win32' ? path.win32 : path.posix;
|
||||
const userDataPath = useDevelopmentProfile
|
||||
? platformPath.join(platformPath.dirname(configDir), DEVELOPMENT_APP_NAME)
|
||||
: configDir;
|
||||
|
||||
// The entry process requests its singleton lock before main-process config bootstrap.
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
|
||||
+20
-2
@@ -22,7 +22,10 @@ import {
|
||||
shouldHandleStatsDaemonCommandAtEntry,
|
||||
spawnDetachedApp,
|
||||
} from './main-entry-runtime';
|
||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||
import {
|
||||
requestSingleInstanceLockEarly,
|
||||
shouldBypassSingleInstanceLockForArgv,
|
||||
} from './main/early-single-instance';
|
||||
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
||||
import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client';
|
||||
import {
|
||||
@@ -35,6 +38,7 @@ import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/win
|
||||
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
||||
import { handleSyncCliAtEntry } from './main/sync-cli';
|
||||
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
|
||||
import { enforceElectronRuntimeGuard } from './main/electron-runtime-guard';
|
||||
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
|
||||
import {
|
||||
applyLogFileTogglesToEnv,
|
||||
@@ -192,11 +196,25 @@ registerFatalErrorHandlers({
|
||||
});
|
||||
|
||||
function startMainProcess(): void {
|
||||
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
|
||||
// Normal launches serialize the runtime guard with the profile-scoped lock. Stats daemon
|
||||
// commands keep their existing lock bypass when Electron runs in Node mode.
|
||||
const gotSingleInstanceLock =
|
||||
shouldBypassSingleInstanceLockForArgv(process.argv) || requestSingleInstanceLockEarly(app);
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeGuard = enforceElectronRuntimeGuard({
|
||||
electronVersion: process.versions.electron ?? '',
|
||||
userDataPath,
|
||||
});
|
||||
if (!runtimeGuard.ok) {
|
||||
console.error(runtimeGuard.details);
|
||||
dialog.showErrorBox(runtimeGuard.title, runtimeGuard.details);
|
||||
app.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
require('./main.js');
|
||||
} catch (error) {
|
||||
|
||||
+12
-9
@@ -38,6 +38,10 @@ import { readMpvInputBindings } from './main/runtime/mpv-input-bindings';
|
||||
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
|
||||
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
|
||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||
import {
|
||||
assertYomitanDictionaryMutationSafe,
|
||||
observeYomitanDictionaryCount,
|
||||
} from './main/runtime/yomitan-dictionary-integrity';
|
||||
import { startAppControlServer } from './main/runtime/app-control-server';
|
||||
import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup';
|
||||
import {
|
||||
@@ -772,6 +776,7 @@ type BootServices = MainBootServicesResult<
|
||||
const bootServices = createMainBootServices({
|
||||
platform: process.platform,
|
||||
argv: process.argv,
|
||||
configDir: app.getPath('userData'),
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
@@ -1480,6 +1485,10 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
error: (message, ...args) => logger.error(message, ...args),
|
||||
info: (message, ...args) => logger.info(message, ...args),
|
||||
});
|
||||
const integrity = observeYomitanDictionaryCount(USER_DATA_PATH, dictionaries.length);
|
||||
if (!integrity.safe) {
|
||||
logger.error(`[dictionary:integrity] ${integrity.message}`);
|
||||
}
|
||||
return dictionaries.length;
|
||||
},
|
||||
isExternalYomitanConfigured: () =>
|
||||
@@ -2627,10 +2636,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
),
|
||||
getYomitanDictionaryInfo: async () => {
|
||||
await ensureYomitanExtensionLoaded();
|
||||
return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
||||
const dictionaries = await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
||||
error: (message, ...args) => logger.error(message, ...args),
|
||||
info: (message, ...args) => logger.info(message, ...args),
|
||||
});
|
||||
assertYomitanDictionaryMutationSafe(USER_DATA_PATH, dictionaries.length);
|
||||
return dictionaries;
|
||||
},
|
||||
importYomitanDictionary: async (zipPath) => {
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
@@ -5486,10 +5497,6 @@ const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDeps
|
||||
getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(),
|
||||
showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text),
|
||||
mineSentenceCardCore,
|
||||
recordCardsMined: (count, noteIds) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
||||
},
|
||||
});
|
||||
const mineSentenceCardHandler = createMineSentenceCardHandler(
|
||||
buildMineSentenceCardMainDepsHandler(),
|
||||
@@ -5522,10 +5529,6 @@ const buildHandleMineSentenceDigitMainDepsHandler =
|
||||
logError: (message, err) => {
|
||||
logger.error(message, err);
|
||||
},
|
||||
onCardsMined: (cards) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordCardsMined(cards);
|
||||
},
|
||||
handleMineSentenceDigitCore,
|
||||
});
|
||||
const handleMineSentenceDigitMainDeps = buildHandleMineSentenceDigitMainDepsHandler();
|
||||
|
||||
@@ -138,3 +138,56 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
|
||||
assert.equal(setPathValue, '/tmp/subminer-config');
|
||||
});
|
||||
|
||||
test('createMainBootServices honors the profile selected by the early entrypoint', () => {
|
||||
const services = createMainBootServices({
|
||||
platform: 'linux',
|
||||
argv: ['electron', '.', '--dev'],
|
||||
configDir: '/tmp/SubMiner-dev',
|
||||
appDataDir: undefined,
|
||||
xdgConfigHome: undefined,
|
||||
homeDir: '/home/tester',
|
||||
defaultMpvLogFile: '/tmp/default.log',
|
||||
envMpvLog: undefined,
|
||||
defaultTexthookerPort: 5174,
|
||||
getDefaultSocketPath: () => '/tmp/subminer.sock',
|
||||
resolveConfigDir: () => {
|
||||
throw new Error('early profile should be authoritative');
|
||||
},
|
||||
existsSync: () => false,
|
||||
mkdirSync: () => {},
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
app: {
|
||||
setPath: () => {},
|
||||
quit: () => {},
|
||||
exit: () => {},
|
||||
on: () => ({}),
|
||||
whenReady: async () => {},
|
||||
},
|
||||
shouldBypassSingleInstanceLock: () => false,
|
||||
requestSingleInstanceLockEarly: () => true,
|
||||
registerSecondInstanceHandlerEarly: () => {},
|
||||
onConfigStartupParseError: () => {},
|
||||
createConfigService: (configDir) => ({ configDir }),
|
||||
createAnilistTokenStore: (targetPath) => ({ targetPath }),
|
||||
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
|
||||
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
|
||||
createSubtitleWebSocket: (payloadMode) => ({ payloadMode }),
|
||||
createLogger: () => ({ warn: () => {}, info: () => {}, error: () => {} }),
|
||||
createMainRuntimeRegistry: () => ({}),
|
||||
createOverlayManager: () => ({ getMainWindow: () => null, getModalWindow: () => null }),
|
||||
createOverlayModalInputState: () => ({
|
||||
getModalInputExclusive: () => false,
|
||||
handleModalInputStateChange: () => {},
|
||||
}),
|
||||
createOverlayContentMeasurementStore: () => ({}),
|
||||
getSyncOverlayShortcutsForModal: () => () => {},
|
||||
getSyncOverlayVisibilityForModal: () => () => {},
|
||||
createOverlayModalRuntime: () => ({}),
|
||||
createAppState: (input) => input,
|
||||
});
|
||||
|
||||
assert.equal(services.configDir, '/tmp/SubMiner-dev');
|
||||
assert.equal(services.userDataPath, '/tmp/SubMiner-dev');
|
||||
assert.deepEqual(services.configService, { configDir: '/tmp/SubMiner-dev' });
|
||||
});
|
||||
@@ -31,6 +31,7 @@ export interface MainBootServicesParams<
|
||||
> {
|
||||
platform: NodeJS.Platform;
|
||||
argv: string[];
|
||||
configDir?: string;
|
||||
appDataDir: string | undefined;
|
||||
xdgConfigHome: string | undefined;
|
||||
homeDir: string;
|
||||
@@ -174,13 +175,15 @@ export function createMainBootServices<
|
||||
TAppState,
|
||||
TAppLifecycleApp
|
||||
> {
|
||||
const configDir = params.resolveConfigDir({
|
||||
platform: params.platform,
|
||||
appDataDir: params.appDataDir,
|
||||
xdgConfigHome: params.xdgConfigHome,
|
||||
homeDir: params.homeDir,
|
||||
existsSync: params.existsSync,
|
||||
});
|
||||
const configDir =
|
||||
params.configDir ??
|
||||
params.resolveConfigDir({
|
||||
platform: params.platform,
|
||||
appDataDir: params.appDataDir,
|
||||
xdgConfigHome: params.xdgConfigHome,
|
||||
homeDir: params.homeDir,
|
||||
existsSync: params.existsSync,
|
||||
});
|
||||
const userDataPath = configDir;
|
||||
const defaultMpvLogPath = params.envMpvLog?.trim() || params.defaultMpvLogFile;
|
||||
const defaultImmersionDbPath = params.joinPath(userDataPath, 'immersion.sqlite');
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { enforceElectronRuntimeGuard, SUPPORTED_ELECTRON_MAJOR } from './electron-runtime-guard';
|
||||
|
||||
function withTempDir(run: (directory: string) => void): void {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-electron-guard-'));
|
||||
try {
|
||||
run(directory);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('runtime guard major matches the pinned Electron dependency', () => {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'),
|
||||
) as { devDependencies: { electron: string } };
|
||||
|
||||
assert.equal(Number.parseInt(packageJson.devDependencies.electron.split('.', 1)[0]!, 10), 43);
|
||||
assert.equal(SUPPORTED_ELECTRON_MAJOR, 43);
|
||||
});
|
||||
|
||||
test('runtime guard records the supported Electron major', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '43.4.1',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 43,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(result.statePath, 'utf8')), {
|
||||
highestElectronMajor: 43,
|
||||
lastElectronVersion: '43.4.1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard rejects a runtime outside the build major without writing state', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '44.0.0',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 43,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.title, 'Unsupported Electron runtime');
|
||||
assert.match(result.details, /requires Electron 43/);
|
||||
assert.equal(fs.existsSync(result.statePath), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard rejects prerelease Electron versions without writing state', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '43.4.1-beta.1',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 43,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.title, 'SubMiner could not verify Electron');
|
||||
assert.equal(fs.existsSync(result.statePath), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard blocks a profile downgrade before rewriting its safety record', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({ highestElectronMajor: 44, lastElectronVersion: '44.1.0' }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '43.4.1',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 43,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.title, 'Electron downgrade blocked');
|
||||
assert.match(result.details, /destroy Yomitan dictionaries/);
|
||||
assert.equal(result.details.includes(statePath), true);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
|
||||
highestElectronMajor: 44,
|
||||
lastElectronVersion: '44.1.0',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard blocks a minor downgrade even when the patch is higher', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({ highestElectronMajor: 43, lastElectronVersion: '43.4.1' }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '43.3.99',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 43,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.title, 'Electron downgrade blocked');
|
||||
assert.equal(result.details.includes(statePath), true);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
|
||||
highestElectronMajor: 43,
|
||||
lastElectronVersion: '43.4.1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard allows a patch downgrade while preserving minor downgrade protection', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({ highestElectronMajor: 43, lastElectronVersion: '43.7.3' }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '43.7.2',
|
||||
userDataPath,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const updatedState = fs.readFileSync(statePath, 'utf8');
|
||||
assert.deepEqual(JSON.parse(updatedState), {
|
||||
highestElectronMajor: 43,
|
||||
lastElectronVersion: '43.7.2',
|
||||
});
|
||||
|
||||
const minorDowngrade = enforceElectronRuntimeGuard({
|
||||
electronVersion: '43.6.99',
|
||||
userDataPath,
|
||||
});
|
||||
assert.equal(minorDowngrade.ok, false);
|
||||
if (minorDowngrade.ok) return;
|
||||
assert.equal(minorDowngrade.title, 'Electron downgrade blocked');
|
||||
assert.equal(fs.readFileSync(statePath, 'utf8'), updatedState);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard blocks an Electron 42 build from reopening an Electron 43 profile', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||
const previousState = JSON.stringify({
|
||||
highestElectronMajor: 43,
|
||||
lastElectronVersion: '43.7.2',
|
||||
});
|
||||
fs.writeFileSync(statePath, previousState, 'utf8');
|
||||
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '42.11.8',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 42,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.title, 'Electron downgrade blocked');
|
||||
assert.equal(fs.readFileSync(statePath, 'utf8'), previousState);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime guard fails closed when its safety record is malformed', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'electron-runtime.json');
|
||||
fs.writeFileSync(statePath, '{}', 'utf8');
|
||||
|
||||
const result = enforceElectronRuntimeGuard({
|
||||
electronVersion: '43.4.1',
|
||||
userDataPath,
|
||||
supportedElectronMajor: 43,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.title, 'SubMiner profile safety check failed');
|
||||
assert.match(result.details, /invalid format/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { writeTextFileAtomicallyDurable } from '../shared/fs-utils';
|
||||
|
||||
export const SUPPORTED_ELECTRON_MAJOR = 43;
|
||||
const RUNTIME_STATE_FILE_NAME = 'electron-runtime.json';
|
||||
|
||||
type ElectronRuntimeState = {
|
||||
highestElectronMajor: number;
|
||||
lastElectronVersion: string;
|
||||
};
|
||||
|
||||
type ParsedElectronVersion = {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
};
|
||||
|
||||
type ValidatedElectronRuntimeState = {
|
||||
state: ElectronRuntimeState;
|
||||
version: ParsedElectronVersion;
|
||||
};
|
||||
|
||||
export type ElectronRuntimeGuardResult =
|
||||
| { ok: true; statePath: string }
|
||||
| { ok: false; title: string; details: string; statePath: string };
|
||||
|
||||
function parseElectronVersion(version: string): ParsedElectronVersion | null {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const major = Number.parseInt(match[1]!, 10);
|
||||
const minor = Number.parseInt(match[2]!, 10);
|
||||
const patch = Number.parseInt(match[3]!, 10);
|
||||
if (![major, minor, patch].every((part) => Number.isSafeInteger(part) && part >= 0)) {
|
||||
return null;
|
||||
}
|
||||
if (major === 0) return null;
|
||||
return { major, minor, patch };
|
||||
}
|
||||
|
||||
function compareElectronMajorMinorVersions(
|
||||
left: ParsedElectronVersion,
|
||||
right: ParsedElectronVersion,
|
||||
): number {
|
||||
return left.major - right.major || left.minor - right.minor;
|
||||
}
|
||||
|
||||
function readRuntimeState(statePath: string): ValidatedElectronRuntimeState | null {
|
||||
if (!fs.existsSync(statePath)) return null;
|
||||
|
||||
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')) as Partial<ElectronRuntimeState>;
|
||||
const highestElectronMajor = parsed.highestElectronMajor;
|
||||
const lastElectronVersion =
|
||||
typeof parsed.lastElectronVersion === 'string'
|
||||
? parseElectronVersion(parsed.lastElectronVersion)
|
||||
: null;
|
||||
if (
|
||||
typeof highestElectronMajor !== 'number' ||
|
||||
!Number.isSafeInteger(highestElectronMajor) ||
|
||||
highestElectronMajor <= 0 ||
|
||||
lastElectronVersion === null
|
||||
) {
|
||||
throw new Error('The runtime safety record has an invalid format.');
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
highestElectronMajor,
|
||||
lastElectronVersion: parsed.lastElectronVersion!,
|
||||
},
|
||||
version: lastElectronVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function writeRuntimeState(statePath: string, state: ElectronRuntimeState): void {
|
||||
writeTextFileAtomicallyDurable(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export function enforceElectronRuntimeGuard(options: {
|
||||
electronVersion: string;
|
||||
userDataPath: string;
|
||||
supportedElectronMajor?: number;
|
||||
}): ElectronRuntimeGuardResult {
|
||||
const supportedElectronMajor = options.supportedElectronMajor ?? SUPPORTED_ELECTRON_MAJOR;
|
||||
const statePath = path.join(options.userDataPath, RUNTIME_STATE_FILE_NAME);
|
||||
const currentVersion = parseElectronVersion(options.electronVersion);
|
||||
|
||||
if (currentVersion === null) {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'SubMiner could not verify Electron',
|
||||
details: `Electron reported an invalid version: ${JSON.stringify(options.electronVersion)}. SubMiner did not load Yomitan storage.`,
|
||||
statePath,
|
||||
};
|
||||
}
|
||||
|
||||
if (currentVersion.major !== supportedElectronMajor) {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'Unsupported Electron runtime',
|
||||
details: [
|
||||
`This SubMiner build requires Electron ${supportedElectronMajor}.`,
|
||||
`The current runtime is Electron ${options.electronVersion}.`,
|
||||
'',
|
||||
'Launch SubMiner through its packaged application or the repository package scripts. Yomitan storage was not loaded.',
|
||||
].join('\n'),
|
||||
statePath,
|
||||
};
|
||||
}
|
||||
|
||||
let previousState: ValidatedElectronRuntimeState | null;
|
||||
try {
|
||||
previousState = readRuntimeState(statePath);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'SubMiner profile safety check failed',
|
||||
details: [
|
||||
`SubMiner could not read the runtime safety record at ${statePath}.`,
|
||||
(error as Error).message,
|
||||
'',
|
||||
'Yomitan storage was not loaded. Repair or remove only this safety record after verifying the profile backup.',
|
||||
].join('\n'),
|
||||
statePath,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
previousState &&
|
||||
(currentVersion.major < previousState.state.highestElectronMajor ||
|
||||
compareElectronMajorMinorVersions(currentVersion, previousState.version) < 0)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'Electron downgrade blocked',
|
||||
details: [
|
||||
`This profile was previously opened with Electron ${previousState.state.lastElectronVersion}.`,
|
||||
`The current runtime is Electron ${options.electronVersion}.`,
|
||||
`Runtime safety record: ${statePath}.`,
|
||||
'',
|
||||
'Opening Chromium storage with an older Electron major or minor version can destroy Yomitan dictionaries. Upgrade SubMiner before using this profile. Patch downgrades within the same major and minor version are allowed.',
|
||||
].join('\n'),
|
||||
statePath,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
writeRuntimeState(statePath, {
|
||||
highestElectronMajor: Math.max(
|
||||
currentVersion.major,
|
||||
previousState?.state.highestElectronMajor ?? 0,
|
||||
),
|
||||
lastElectronVersion: options.electronVersion,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'SubMiner profile safety check failed',
|
||||
details: [
|
||||
`SubMiner could not update the runtime safety record at ${statePath}.`,
|
||||
(error as Error).message,
|
||||
'',
|
||||
'Yomitan storage was not loaded.',
|
||||
].join('\n'),
|
||||
statePath,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, statePath };
|
||||
}
|
||||
@@ -67,7 +67,6 @@ test('anki action main deps builders map callbacks', async () => {
|
||||
getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }),
|
||||
showMpvOsd: (text) => calls.push(`mine:${text}`),
|
||||
mineSentenceCardCore: async () => true,
|
||||
recordCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
})();
|
||||
assert.deepEqual(mine.getMpvClient(), { connected: true });
|
||||
assert.deepEqual(mine.getPrimarySubtitle?.(), {
|
||||
@@ -81,16 +80,6 @@ test('anki action main deps builders map callbacks', async () => {
|
||||
mpvClient: { connected: true },
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
mine.recordCardsMined(1);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'osd:x',
|
||||
'update',
|
||||
'fg:fg',
|
||||
'trigger',
|
||||
'audio:a',
|
||||
'mark',
|
||||
'mine:m',
|
||||
'cards:1',
|
||||
]);
|
||||
assert.deepEqual(calls, ['osd:x', 'update', 'fg:fg', 'trigger', 'audio:a', 'mark', 'mine:m']);
|
||||
});
|
||||
@@ -80,7 +80,6 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
||||
primarySubtitle?: PrimarySubtitle;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
}) {
|
||||
return () => ({
|
||||
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
||||
@@ -95,6 +94,5 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
||||
primarySubtitle?: PrimarySubtitle;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => deps.mineSentenceCardCore(options),
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds),
|
||||
});
|
||||
}
|
||||
@@ -63,7 +63,7 @@ test('trigger and mark handlers delegate to core services', async () => {
|
||||
assert.deepEqual(calls, ['osd:group', 'group-core', 'osd:mark', 'mark-core']);
|
||||
});
|
||||
|
||||
test('mine sentence handler records mined cards only when core returns true', async () => {
|
||||
test('mine sentence handler leaves card counting to card creation', async () => {
|
||||
const calls: string[] = [];
|
||||
const integration = {};
|
||||
const mpvClient = {};
|
||||
@@ -78,14 +78,13 @@ test('mine sentence handler records mined cards only when core returns true', as
|
||||
options.showMpvOsd('mine');
|
||||
return created;
|
||||
},
|
||||
recordCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
});
|
||||
|
||||
created = false;
|
||||
await mineSentenceCard();
|
||||
created = true;
|
||||
await mineSentenceCard();
|
||||
assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']);
|
||||
assert.deepEqual(calls, ['osd:mine', 'osd:mine']);
|
||||
});
|
||||
|
||||
test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => {
|
||||
@@ -99,7 +98,6 @@ test('mine sentence handler forwards the canonical primary subtitle snapshot', a
|
||||
assert.equal(options.primarySubtitle, primarySubtitle);
|
||||
return true;
|
||||
},
|
||||
recordCardsMined: () => {},
|
||||
});
|
||||
|
||||
await mineSentenceCard();
|
||||
|
||||
@@ -83,18 +83,15 @@ export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
|
||||
primarySubtitle?: PrimarySubtitle;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
}) {
|
||||
// Card creation records the mined card (with its note id) itself.
|
||||
return async (): Promise<void> => {
|
||||
const primarySubtitle = deps.getPrimarySubtitle?.();
|
||||
const created = await deps.mineSentenceCardCore({
|
||||
await deps.mineSentenceCardCore({
|
||||
ankiIntegration: deps.getAnkiIntegration(),
|
||||
mpvClient: deps.getMpvClient(),
|
||||
...(primarySubtitle ? { primarySubtitle } : {}),
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
if (created) {
|
||||
deps.recordCardsMined(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -45,20 +45,17 @@ test('mining action main deps builders map callbacks', () => {
|
||||
getCurrentSecondarySubText: () => 'sub',
|
||||
showMpvOsd: (text) => calls.push(`mine-osd:${text}`),
|
||||
logError: (message) => calls.push(`err:${message}`),
|
||||
onCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
handleMineSentenceDigitCore: () => calls.push('mine-digit'),
|
||||
})();
|
||||
assert.equal(mineDigit.getCurrentSecondarySubText(), 'sub');
|
||||
mineDigit.showMpvOsd('done');
|
||||
mineDigit.logError('bad', null);
|
||||
mineDigit.onCardsMined(2);
|
||||
mineDigit.handleMineSentenceDigitCore(2, {
|
||||
subtitleTimingTracker: { track: true },
|
||||
ankiIntegration: { enabled: true },
|
||||
getCurrentSecondarySubText: () => 'sub',
|
||||
showMpvOsd: () => {},
|
||||
logError: () => {},
|
||||
onCardsMined: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
@@ -70,7 +67,6 @@ test('mining action main deps builders map callbacks', () => {
|
||||
'copy-current',
|
||||
'mine-osd:done',
|
||||
'err:bad',
|
||||
'cards:2',
|
||||
'mine-digit',
|
||||
]);
|
||||
});
|
||||
@@ -57,7 +57,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
handleMineSentenceDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
@@ -66,7 +65,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
},
|
||||
) => void;
|
||||
}) {
|
||||
@@ -76,7 +74,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
||||
getCurrentSecondarySubText: () => deps.getCurrentSecondarySubText(),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
logError: (message: string, err: unknown) => deps.logError(message, err),
|
||||
onCardsMined: (count: number) => deps.onCardsMined(count),
|
||||
handleMineSentenceDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
@@ -85,7 +82,6 @@ export function createBuildHandleMineSentenceDigitMainDepsHandler<
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
},
|
||||
) => deps.handleMineSentenceDigitCore(count, options),
|
||||
});
|
||||
|
||||
@@ -53,7 +53,6 @@ test('mine sentence digit handler forwards all dependencies', () => {
|
||||
getCurrentSecondarySubText: () => 'secondary',
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
logError: (message) => calls.push(`err:${message}`),
|
||||
onCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
handleMineSentenceDigitCore: (count, options) => {
|
||||
assert.equal(count, 4);
|
||||
assert.equal(options.subtitleTimingTracker, tracker);
|
||||
@@ -61,10 +60,9 @@ test('mine sentence digit handler forwards all dependencies', () => {
|
||||
assert.equal(options.getCurrentSecondarySubText(), 'secondary');
|
||||
options.showMpvOsd('mine');
|
||||
options.logError('boom', new Error('x'));
|
||||
options.onCardsMined(2);
|
||||
},
|
||||
});
|
||||
|
||||
handleMineSentenceDigit(4);
|
||||
assert.deepEqual(calls, ['osd:mine', 'err:boom', 'cards:2']);
|
||||
assert.deepEqual(calls, ['osd:mine', 'err:boom']);
|
||||
});
|
||||
@@ -48,7 +48,6 @@ export function createHandleMineSentenceDigitHandler<
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
handleMineSentenceDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
@@ -57,7 +56,6 @@ export function createHandleMineSentenceDigitHandler<
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
},
|
||||
) => void;
|
||||
}) {
|
||||
@@ -68,7 +66,6 @@ export function createHandleMineSentenceDigitHandler<
|
||||
getCurrentSecondarySubText: deps.getCurrentSecondarySubText,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
logError: deps.logError,
|
||||
onCardsMined: deps.onCardsMined,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
assertYomitanDictionaryMutationSafe,
|
||||
observeYomitanDictionaryCount,
|
||||
} from './yomitan-dictionary-integrity';
|
||||
|
||||
function withTempDir(run: (directory: string) => void): void {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-dictionary-integrity-'));
|
||||
try {
|
||||
run(directory);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('dictionary integrity observation establishes and updates a non-empty baseline', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 5), {
|
||||
safe: true,
|
||||
previousCount: null,
|
||||
});
|
||||
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 3), {
|
||||
safe: true,
|
||||
previousCount: 5,
|
||||
});
|
||||
const statePath = path.join(userDataPath, 'yomitan-dictionary-integrity.json');
|
||||
const unchangedTimestamp = new Date('2000-01-01T00:00:00.000Z');
|
||||
fs.utimesSync(statePath, unchangedTimestamp, unchangedTimestamp);
|
||||
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 3), {
|
||||
safe: true,
|
||||
previousCount: 3,
|
||||
});
|
||||
assert.equal(fs.statSync(statePath).mtimeMs, unchangedTimestamp.getTime());
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), { lastKnownNonEmptyCount: 3 });
|
||||
assert.deepEqual(fs.readdirSync(userDataPath), ['yomitan-dictionary-integrity.json']);
|
||||
});
|
||||
});
|
||||
|
||||
test('dictionary integrity permits an empty profile before dictionaries are installed', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 0), {
|
||||
safe: true,
|
||||
previousCount: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('dictionary integrity rejects invalid counts without creating a safety record', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
for (const invalidCount of [Number.NaN, Number.POSITIVE_INFINITY, -1]) {
|
||||
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, invalidCount), {
|
||||
safe: false,
|
||||
previousCount: null,
|
||||
message: 'SubMiner could not verify Yomitan dictionary storage: invalid dictionary count.',
|
||||
});
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(userDataPath, 'yomitan-dictionary-integrity.json')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('dictionary integrity migrates the previous count from first-run setup state', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
fs.writeFileSync(
|
||||
path.join(userDataPath, 'setup-state.json'),
|
||||
JSON.stringify({
|
||||
version: 4,
|
||||
status: 'completed',
|
||||
completedAt: '2026-08-18T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 4,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: {
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: false,
|
||||
},
|
||||
windowsMpvShortcutLastStatus: 'installed',
|
||||
bunInstallStatus: 'installed',
|
||||
launcherInstallStatus: 'installed',
|
||||
launcherInstallPath: '/home/tester/.local/bin/subminer',
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => assertYomitanDictionaryMutationSafe(userDataPath, 0),
|
||||
/reported zero dictionaries after previously reporting 4/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('dictionary integrity blocks automatic mutation after a non-empty profile becomes empty', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
observeYomitanDictionaryCount(userDataPath, 6);
|
||||
|
||||
assert.throws(
|
||||
() => assertYomitanDictionaryMutationSafe(userDataPath, 0),
|
||||
/reported zero dictionaries after previously reporting 6/,
|
||||
);
|
||||
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 0), {
|
||||
safe: false,
|
||||
previousCount: 6,
|
||||
message:
|
||||
'Yomitan reported zero dictionaries after previously reporting 6. SubMiner blocked automatic dictionary changes because Chromium storage may have been reset. Close SubMiner and restore or inspect the profile before changing dictionaries.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('dictionary integrity fails closed when its state is malformed', () => {
|
||||
withTempDir((userDataPath) => {
|
||||
const statePath = path.join(userDataPath, 'yomitan-dictionary-integrity.json');
|
||||
fs.writeFileSync(statePath, '{}', 'utf8');
|
||||
|
||||
assert.throws(
|
||||
() => assertYomitanDictionaryMutationSafe(userDataPath, 2),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /could not verify Yomitan dictionary storage/);
|
||||
assert.equal(error.message.includes(statePath), true);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { writeTextFileAtomicallyDurable } from '../../shared/fs-utils';
|
||||
import { getSetupStatePath, readSetupState } from '../../shared/setup-state';
|
||||
|
||||
const INTEGRITY_STATE_FILE_NAME = 'yomitan-dictionary-integrity.json';
|
||||
|
||||
type DictionaryIntegrityState = {
|
||||
lastKnownNonEmptyCount: number;
|
||||
};
|
||||
|
||||
export type DictionaryIntegrityObservation =
|
||||
| { safe: true; previousCount: number | null }
|
||||
| { safe: false; previousCount: number | null; message: string };
|
||||
|
||||
function getStatePath(userDataPath: string): string {
|
||||
return path.join(userDataPath, INTEGRITY_STATE_FILE_NAME);
|
||||
}
|
||||
|
||||
function readState(statePath: string): DictionaryIntegrityState | null {
|
||||
if (!fs.existsSync(statePath)) return null;
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(statePath, 'utf8'),
|
||||
) as Partial<DictionaryIntegrityState>;
|
||||
const lastKnownNonEmptyCount = parsed.lastKnownNonEmptyCount;
|
||||
if (
|
||||
typeof lastKnownNonEmptyCount !== 'number' ||
|
||||
!Number.isSafeInteger(lastKnownNonEmptyCount) ||
|
||||
lastKnownNonEmptyCount <= 0
|
||||
) {
|
||||
throw new Error('The dictionary integrity record has an invalid format.');
|
||||
}
|
||||
return { lastKnownNonEmptyCount };
|
||||
}
|
||||
|
||||
function writeState(statePath: string, state: DictionaryIntegrityState): void {
|
||||
writeTextFileAtomicallyDurable(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function readLegacySetupCount(userDataPath: string): number | null {
|
||||
const setupState = readSetupState(getSetupStatePath(userDataPath));
|
||||
return setupState && setupState.lastSeenYomitanDictionaryCount > 0
|
||||
? setupState.lastSeenYomitanDictionaryCount
|
||||
: null;
|
||||
}
|
||||
|
||||
export function observeYomitanDictionaryCount(
|
||||
userDataPath: string,
|
||||
dictionaryCount: number,
|
||||
): DictionaryIntegrityObservation {
|
||||
if (!Number.isSafeInteger(dictionaryCount) || dictionaryCount < 0) {
|
||||
return {
|
||||
safe: false,
|
||||
previousCount: null,
|
||||
message: 'SubMiner could not verify Yomitan dictionary storage: invalid dictionary count.',
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedCount = dictionaryCount;
|
||||
const statePath = getStatePath(userDataPath);
|
||||
let state: DictionaryIntegrityState | null;
|
||||
|
||||
try {
|
||||
state = readState(statePath);
|
||||
if (state === null) {
|
||||
const legacyCount = readLegacySetupCount(userDataPath);
|
||||
state = legacyCount === null ? null : { lastKnownNonEmptyCount: legacyCount };
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
safe: false,
|
||||
previousCount: null,
|
||||
message: `SubMiner could not verify Yomitan dictionary storage at ${statePath}: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedCount === 0 && state !== null) {
|
||||
return {
|
||||
safe: false,
|
||||
previousCount: state.lastKnownNonEmptyCount,
|
||||
message: [
|
||||
`Yomitan reported zero dictionaries after previously reporting ${state.lastKnownNonEmptyCount}.`,
|
||||
'SubMiner blocked automatic dictionary changes because Chromium storage may have been reset.',
|
||||
'Close SubMiner and restore or inspect the profile before changing dictionaries.',
|
||||
].join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedCount > 0 && normalizedCount !== state?.lastKnownNonEmptyCount) {
|
||||
try {
|
||||
writeState(statePath, { lastKnownNonEmptyCount: normalizedCount });
|
||||
} catch (error) {
|
||||
return {
|
||||
safe: false,
|
||||
previousCount: state?.lastKnownNonEmptyCount ?? null,
|
||||
message: `SubMiner could not update the Yomitan dictionary integrity record: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { safe: true, previousCount: state?.lastKnownNonEmptyCount ?? null };
|
||||
}
|
||||
|
||||
export function assertYomitanDictionaryMutationSafe(
|
||||
userDataPath: string,
|
||||
dictionaryCount: number,
|
||||
): void {
|
||||
const observation = observeYomitanDictionaryCount(userDataPath, dictionaryCount);
|
||||
if (!observation.safe) {
|
||||
throw new Error(observation.message);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
if (fs.existsSync(dirPath)) return;
|
||||
@@ -12,3 +13,48 @@ export function ensureDirForFile(filePath: string): void {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function syncDirectory(directoryPath: string): void {
|
||||
// Node cannot open Windows directory handles for fsync. The atomic rename still prevents
|
||||
// torn files there, while supported platforms also flush the directory entry below.
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const directory = fs.openSync(directoryPath, 'r');
|
||||
try {
|
||||
fs.fsyncSync(directory);
|
||||
} finally {
|
||||
fs.closeSync(directory);
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes and flushes a sibling temporary file before atomically replacing the target. */
|
||||
export function writeTextFileAtomicallyDurable(filePath: string, content: string): void {
|
||||
const directoryPath = path.dirname(filePath);
|
||||
ensureDir(directoryPath);
|
||||
const temporaryPath = path.join(directoryPath, `.${path.basename(filePath)}.${randomUUID()}.tmp`);
|
||||
let temporaryFile: number | undefined;
|
||||
|
||||
try {
|
||||
temporaryFile = fs.openSync(temporaryPath, 'wx', 0o600);
|
||||
fs.writeFileSync(temporaryFile, content, 'utf8');
|
||||
fs.fsyncSync(temporaryFile);
|
||||
fs.closeSync(temporaryFile);
|
||||
temporaryFile = undefined;
|
||||
fs.renameSync(temporaryPath, filePath);
|
||||
syncDirectory(directoryPath);
|
||||
} catch (error) {
|
||||
if (temporaryFile !== undefined) {
|
||||
try {
|
||||
fs.closeSync(temporaryFile);
|
||||
} catch {
|
||||
// Best effort cleanup preserves the original write failure.
|
||||
}
|
||||
}
|
||||
try {
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
} catch {
|
||||
// The temporary file is disposable; the existing target stays untouched before rename.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { setDeleteConfirmPresenter } from '../../lib/delete-confirm';
|
||||
import { localDayFromMs } from '../../lib/formatters';
|
||||
import type { OverviewData, SessionSummary } from '../../types/stats';
|
||||
import { OverviewTab } from './OverviewTab';
|
||||
|
||||
function installDom() {
|
||||
const window = new Window();
|
||||
const properties = {
|
||||
window,
|
||||
document: window.document,
|
||||
HTMLElement: window.HTMLElement,
|
||||
ResizeObserver: window.ResizeObserver,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
};
|
||||
const previous = Object.keys(properties).map(
|
||||
(key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
|
||||
);
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
|
||||
}
|
||||
return () => {
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
window.happyDOM.abort();
|
||||
};
|
||||
}
|
||||
|
||||
function session(sessionId: number): SessionSummary {
|
||||
return {
|
||||
sessionId,
|
||||
canonicalTitle: `Test episode ${sessionId}`,
|
||||
videoId: null,
|
||||
animeId: null,
|
||||
animeTitle: 'Test anime',
|
||||
startedAtMs: Date.now(),
|
||||
endedAtMs: Date.now(),
|
||||
totalWatchedMs: 60_000,
|
||||
activeWatchedMs: 60_000,
|
||||
linesSeen: 1,
|
||||
tokensSeen: 10,
|
||||
cardsMined: 2,
|
||||
lookupCount: 0,
|
||||
lookupHits: 0,
|
||||
yomitanLookupCount: 0,
|
||||
knownWordsSeen: 1,
|
||||
knownWordRate: 0.1,
|
||||
};
|
||||
}
|
||||
|
||||
function overview(sessions: SessionSummary[]): OverviewData {
|
||||
const count = sessions.length;
|
||||
return {
|
||||
sessions,
|
||||
rollups: count
|
||||
? [
|
||||
{
|
||||
rollupDayOrMonth: localDayFromMs(Date.now()),
|
||||
videoId: null,
|
||||
totalSessions: count,
|
||||
totalActiveMin: count,
|
||||
totalLinesSeen: count,
|
||||
totalTokensSeen: count * 10,
|
||||
totalCards: count * 2,
|
||||
cardsPerHour: null,
|
||||
tokensPerMin: null,
|
||||
lookupHitRate: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
hints: {
|
||||
totalSessions: count,
|
||||
activeSessions: 0,
|
||||
episodesToday: count,
|
||||
activeAnimeCount: count ? 1 : 0,
|
||||
totalEpisodesWatched: count,
|
||||
totalAnimeCompleted: 0,
|
||||
totalActiveMin: count,
|
||||
activeDays: count ? 1 : 0,
|
||||
totalCards: count * 2,
|
||||
totalTokensSeen: count * 10,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: count,
|
||||
newWordsThisWeek: count,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function metric(container: HTMLElement, label: string): string {
|
||||
const element = [...container.querySelectorAll('div')].find(
|
||||
(element) => element.textContent === label,
|
||||
);
|
||||
assert.ok(element?.parentElement, `expected metric ${label}`);
|
||||
return (element.parentElement.textContent ?? '').replace(label, '').trim();
|
||||
}
|
||||
|
||||
for (const mode of ['session', 'day', 'anime', 'failed'] as const) {
|
||||
test(`Overview refreshes all dependent data after ${mode} deletion`, async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = { ...apiClient };
|
||||
const restoreConfirm = setDeleteConfirmPresenter(() => true);
|
||||
let sessions =
|
||||
mode === 'session' || mode === 'failed'
|
||||
? [session(1)]
|
||||
: [
|
||||
{ ...session(1), animeId: 7 },
|
||||
{ ...session(2), animeId: 7 },
|
||||
];
|
||||
const reads = { overview: 0, sessions: 0, calendar: 0, words: 0 };
|
||||
const deletedIds: number[] = [];
|
||||
apiClient.getOverview = async () => {
|
||||
reads.overview++;
|
||||
return overview(sessions);
|
||||
};
|
||||
apiClient.getSessions = async () => {
|
||||
reads.sessions++;
|
||||
return sessions;
|
||||
};
|
||||
apiClient.getStreakCalendar = async () => {
|
||||
reads.calendar++;
|
||||
return [{ epochDay: localDayFromMs(Date.now()), totalActiveMin: sessions.length }];
|
||||
};
|
||||
apiClient.getKnownWordsSummary = async () => {
|
||||
reads.words++;
|
||||
return { totalUniqueWords: 10 + sessions.length, knownWordCount: 5 };
|
||||
};
|
||||
apiClient.getCoverImages = async () => ({ anime: {}, media: {} });
|
||||
async function deleteSessions(ids: number[]) {
|
||||
if (mode === 'failed') throw new Error('Deletion failed');
|
||||
deletedIds.push(...ids);
|
||||
sessions = sessions.filter((session) => !ids.includes(session.sessionId));
|
||||
}
|
||||
apiClient.deleteSession = async (id) => deleteSessions([id]);
|
||||
apiClient.deleteSessions = deleteSessions;
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<OverviewTab onNavigateToMediaDetail={() => {}} onNavigateToSession={() => {}} />,
|
||||
),
|
||||
);
|
||||
const count = sessions.length;
|
||||
assert.match(metric(container, 'Cards Mined Today'), new RegExp(`^${count * 2}`));
|
||||
assert.match(metric(container, 'Known Words'), new RegExp(`/ ${10 + count}`));
|
||||
assert.equal(container.querySelectorAll('.bg-ctp-green\\/30').length, 1);
|
||||
const prefix =
|
||||
mode === 'day'
|
||||
? 'Delete all sessions from '
|
||||
: mode === 'anime'
|
||||
? 'Delete all sessions for '
|
||||
: 'Delete session ';
|
||||
const button = [...container.querySelectorAll('button')].find((button) =>
|
||||
button.getAttribute('aria-label')?.startsWith(prefix),
|
||||
);
|
||||
assert.ok(button, `expected ${mode} delete button`);
|
||||
await act(async () => button.click());
|
||||
if (mode === 'failed') {
|
||||
assert.match(container.textContent ?? '', /Deletion failed/);
|
||||
assert.match(metric(container, 'Cards Mined Today'), /^2/);
|
||||
assert.deepEqual(reads, { overview: 1, sessions: 1, calendar: 1, words: 1 });
|
||||
assert.deepEqual(deletedIds, []);
|
||||
} else {
|
||||
assert.deepEqual(deletedIds, count === 1 ? [1] : [1, 2]);
|
||||
for (const label of [
|
||||
'Cards Mined Today',
|
||||
'Episodes Today',
|
||||
'Sessions',
|
||||
'Cards Mined',
|
||||
'Words Today',
|
||||
'New Words Today',
|
||||
]) {
|
||||
assert.equal(metric(container, label), '0', `${label} should reflect the deletion`);
|
||||
}
|
||||
assert.match(metric(container, 'Known Words'), /5\s*\/ 10/);
|
||||
assert.equal(container.querySelectorAll('.bg-ctp-green\\/30').length, 0);
|
||||
assert.match(container.textContent ?? '', /No sessions yet/);
|
||||
assert.deepEqual(reads, { overview: 2, sessions: 2, calendar: 2, words: 2 });
|
||||
}
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
restoreConfirm();
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const failedRead of ['overview', 'sessions'] as const) {
|
||||
test(`Overview stays available during a failed ${failedRead} refresh and can retry`, async () => {
|
||||
const uninstallDom = installDom();
|
||||
const original = { ...apiClient };
|
||||
const restoreConfirm = setDeleteConfirmPresenter(() => true);
|
||||
let sessions = [session(1)];
|
||||
let refreshing = false;
|
||||
let failRefresh = true;
|
||||
let rejectRefresh = (_error: Error) => {};
|
||||
const pendingRefresh = new Promise<never>((_resolve, reject) => {
|
||||
rejectRefresh = reject;
|
||||
});
|
||||
apiClient.getOverview = async () => {
|
||||
if (refreshing && failRefresh && failedRead === 'overview') return pendingRefresh;
|
||||
return overview(sessions);
|
||||
};
|
||||
apiClient.getSessions = async () => {
|
||||
if (refreshing && failRefresh && failedRead === 'sessions') return pendingRefresh;
|
||||
return sessions;
|
||||
};
|
||||
apiClient.getStreakCalendar = async () => [];
|
||||
apiClient.getKnownWordsSummary = async () => ({
|
||||
totalUniqueWords: 0,
|
||||
knownWordCount: 0,
|
||||
});
|
||||
apiClient.deleteSession = async () => {
|
||||
sessions = [];
|
||||
refreshing = true;
|
||||
};
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<OverviewTab onNavigateToMediaDetail={() => {}} onNavigateToSession={() => {}} />,
|
||||
),
|
||||
);
|
||||
const deleteButton = [...container.querySelectorAll('button')].find((button) =>
|
||||
button.getAttribute('aria-label')?.startsWith('Delete session '),
|
||||
);
|
||||
assert.ok(deleteButton);
|
||||
await act(async () => deleteButton.click());
|
||||
assert.match(container.textContent ?? '', /Tracking Snapshot/);
|
||||
assert.equal(metric(container, 'Cards Mined Today'), '2');
|
||||
assert.match(container.textContent ?? '', /No sessions yet/);
|
||||
|
||||
await act(async () => rejectRefresh(new Error(`${failedRead} unavailable`)));
|
||||
assert.match(container.textContent ?? '', /Tracking Snapshot/);
|
||||
assert.equal(metric(container, 'Cards Mined Today'), '2');
|
||||
assert.match(container.querySelector('[role="alert"]')?.textContent ?? '', /unavailable/);
|
||||
|
||||
const retryButton = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Retry',
|
||||
);
|
||||
assert.ok(retryButton, 'failed refresh should offer a retry');
|
||||
failRefresh = false;
|
||||
await act(async () => retryButton.click());
|
||||
assert.equal(metric(container, 'Cards Mined Today'), '0');
|
||||
assert.equal(container.querySelector('[role="alert"]'), null);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
restoreConfirm();
|
||||
Object.assign(apiClient, original);
|
||||
uninstallDom();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -27,8 +27,9 @@ export function OverviewTab({
|
||||
onNavigateToSession,
|
||||
isActive = true,
|
||||
}: OverviewTabProps) {
|
||||
const { data, sessions, setSessions, loading, error } = useOverview();
|
||||
const { calendar, loading: calLoading } = useStreakCalendar(90);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const { data, sessions, setSessions, loading, error } = useOverview(refreshKey);
|
||||
const { calendar, loading: calLoading } = useStreakCalendar(90, refreshKey);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [deletingIds, setDeletingIds] = useState<Set<number>>(new Set());
|
||||
const [knownWordsSummary, setKnownWordsSummary] = useState<{
|
||||
@@ -49,7 +50,7 @@ export function OverviewTab({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [refreshKey]);
|
||||
|
||||
const handleDeleteSession = async (session: SessionSummary) => {
|
||||
if (!(await confirmSessionDelete())) return;
|
||||
@@ -58,6 +59,7 @@ export function OverviewTab({
|
||||
try {
|
||||
await apiClient.deleteSession(session.sessionId);
|
||||
setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId));
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
||||
} finally {
|
||||
@@ -82,6 +84,7 @@ export function OverviewTab({
|
||||
await apiClient.deleteSessions(ids);
|
||||
const idSet = new Set(ids);
|
||||
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
|
||||
} finally {
|
||||
@@ -108,6 +111,7 @@ export function OverviewTab({
|
||||
await apiClient.deleteSessions(ids);
|
||||
const idSet = new Set(ids);
|
||||
setSessions((prev) => prev.filter((s) => !idSet.has(s.sessionId)));
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete sessions.');
|
||||
} finally {
|
||||
@@ -119,9 +123,29 @@ export function OverviewTab({
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
if (!data) return null;
|
||||
const errorNotice = error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-ctp-red/30 bg-ctp-red/10 p-3 text-sm text-ctp-red"
|
||||
>
|
||||
<span>
|
||||
{data
|
||||
? 'Could not refresh Overview. Displayed totals may be out of date.'
|
||||
: 'Could not load Overview.'}{' '}
|
||||
{error}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRefreshKey((prev) => prev + 1)}
|
||||
className="shrink-0 rounded border border-ctp-red/30 px-3 py-1 hover:bg-ctp-red/10"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (loading && !data) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (!data) return errorNotice;
|
||||
|
||||
const summary = buildOverviewSummary(data);
|
||||
const streakData = buildStreakCalendar(calendar);
|
||||
@@ -129,6 +153,7 @@ export function OverviewTab({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{errorNotice}
|
||||
<HeroStats summary={summary} sessions={sessions} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { OverviewData, SessionSummary } from '../types/stats';
|
||||
|
||||
export function useOverview() {
|
||||
export function useOverview(refreshKey = 0) {
|
||||
const [data, setData] = useState<OverviewData | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -30,7 +30,7 @@ export function useOverview() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [refreshKey]);
|
||||
|
||||
return { data, sessions, setSessions, loading, error };
|
||||
}
|
||||
@@ -2,13 +2,15 @@ import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { StreakCalendarDay } from '../types/stats';
|
||||
|
||||
export function useStreakCalendar(days = 90) {
|
||||
export function useStreakCalendar(days = 90, refreshKey = 0) {
|
||||
const [calendar, setCalendar] = useState<StreakCalendarDay[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getStatsClient()
|
||||
.getStreakCalendar(days)
|
||||
.then((data) => {
|
||||
@@ -23,7 +25,7 @@ export function useStreakCalendar(days = 90) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [days]);
|
||||
}, [days, refreshKey]);
|
||||
|
||||
return { calendar, loading, error };
|
||||
}
|
||||
Reference in new issue
Block a user