mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
c9f85473bb
- reject option-like tokens as flag values (--snapshot --force wrote a file named --force); --flag=-value still works - PowerShell remote quoting uses single-quoted literals so $() in a quoted path cannot expand - sync-hosts.json written via temp file + rename; a crash mid-write truncated it and the reader's corrupt-fallback dropped every host - cancelled sync child escalates SIGTERM -> SIGKILL after 5s grace - NDJSON progress events validated field-by-field before casting - snapshot filenames include milliseconds to avoid same-second overwrite - syncAutoScheduler.stop() wired into will-quit cleanup - sync --ui exclusivity also rejects --make-temp/--remove-temp/--json - document --sync-window in app help; group --make-temp/--remove-temp under modes in sync usage
133 lines
4.0 KiB
TypeScript
133 lines
4.0 KiB
TypeScript
// NDJSON progress protocol emitted by `subminer sync --json` and consumed by
|
|
// the sync UI's launcher client. Keep this file free of Electron/bun imports —
|
|
// it is shared between the launcher (bun) and the app main process (node).
|
|
|
|
export interface SyncMergeSummary {
|
|
sessionsMerged: number;
|
|
sessionsAlreadyPresent: number;
|
|
activeSessionsSkipped: number;
|
|
animeAdded: number;
|
|
videosAdded: number;
|
|
wordsAdded: number;
|
|
kanjiAdded: number;
|
|
subtitleLinesAdded: number;
|
|
telemetryRowsAdded: number;
|
|
eventsAdded: number;
|
|
excludedWordsAdded: number;
|
|
dailyRollupsCopied: number;
|
|
monthlyRollupsCopied: number;
|
|
rollupGroupsRecomputed: number;
|
|
}
|
|
|
|
export type SyncStage =
|
|
| 'snapshot-local'
|
|
| 'snapshot-remote'
|
|
| 'download'
|
|
| 'upload'
|
|
| 'merge-local'
|
|
| 'merge-remote';
|
|
|
|
export type SyncProgressEvent =
|
|
| { type: 'stage'; stage: SyncStage; message: string }
|
|
| { type: 'merge-summary'; target: 'local' | 'remote'; summary: SyncMergeSummary }
|
|
| { type: 'remote-output'; text: string }
|
|
| { type: 'snapshot-created'; path: string }
|
|
| {
|
|
type: 'check-result';
|
|
host: string;
|
|
sshOk: boolean;
|
|
remoteCommand: string | null;
|
|
remoteVersion: string | null;
|
|
ok: boolean;
|
|
error: string | null;
|
|
}
|
|
| { type: 'result'; ok: boolean; error: string | null };
|
|
|
|
const SUMMARY_KEYS: readonly (keyof SyncMergeSummary)[] = [
|
|
'sessionsMerged',
|
|
'sessionsAlreadyPresent',
|
|
'activeSessionsSkipped',
|
|
'animeAdded',
|
|
'videosAdded',
|
|
'wordsAdded',
|
|
'kanjiAdded',
|
|
'subtitleLinesAdded',
|
|
'telemetryRowsAdded',
|
|
'eventsAdded',
|
|
'excludedWordsAdded',
|
|
'dailyRollupsCopied',
|
|
'monthlyRollupsCopied',
|
|
'rollupGroupsRecomputed',
|
|
];
|
|
|
|
const STAGES: readonly SyncStage[] = [
|
|
'snapshot-local',
|
|
'snapshot-remote',
|
|
'download',
|
|
'upload',
|
|
'merge-local',
|
|
'merge-remote',
|
|
];
|
|
|
|
function isString(value: unknown): value is string {
|
|
return typeof value === 'string';
|
|
}
|
|
|
|
function isNullableString(value: unknown): value is string | null {
|
|
return value === null || typeof value === 'string';
|
|
}
|
|
|
|
function isMergeSummary(value: unknown): value is SyncMergeSummary {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
const record = value as Record<string, unknown>;
|
|
return SUMMARY_KEYS.every((key) => typeof record[key] === 'number');
|
|
}
|
|
|
|
/**
|
|
* The events cross a process boundary (the sync child's stdout), so each variant
|
|
* is validated field-by-field: a half-formed `result` or `merge-summary` would
|
|
* otherwise be cast straight into the UI, which reads `.ok`/`.summary` directly.
|
|
*/
|
|
export function parseSyncProgressLine(line: string): SyncProgressEvent | null {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith('{')) return null;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(trimmed);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
const event = parsed as Record<string, unknown>;
|
|
|
|
switch (event.type) {
|
|
case 'stage':
|
|
return STAGES.includes(event.stage as SyncStage) && isString(event.message)
|
|
? (parsed as SyncProgressEvent)
|
|
: null;
|
|
case 'merge-summary':
|
|
return (event.target === 'local' || event.target === 'remote') && isMergeSummary(event.summary)
|
|
? (parsed as SyncProgressEvent)
|
|
: null;
|
|
case 'remote-output':
|
|
return isString(event.text) ? (parsed as SyncProgressEvent) : null;
|
|
case 'snapshot-created':
|
|
return isString(event.path) ? (parsed as SyncProgressEvent) : null;
|
|
case 'check-result':
|
|
return isString(event.host) &&
|
|
typeof event.sshOk === 'boolean' &&
|
|
typeof event.ok === 'boolean' &&
|
|
isNullableString(event.remoteCommand) &&
|
|
isNullableString(event.remoteVersion) &&
|
|
isNullableString(event.error)
|
|
? (parsed as SyncProgressEvent)
|
|
: null;
|
|
case 'result':
|
|
return typeof event.ok === 'boolean' && isNullableString(event.error)
|
|
? (parsed as SyncProgressEvent)
|
|
: null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|