mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-18 00:21:41 -07:00
Sync Stats & History window, headless --sync-cli, and Windows remote support (#160)
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
// NDJSON progress protocol emitted by `subminer sync --json` and consumed by
|
||||
// the sync UI's launcher client. Avoid Electron/bun imports because this file
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user