mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-02 07:21:33 -07:00
Sync Stats & History window, headless --sync-cli, and Windows remote support (#160)
This commit is contained in:
@@ -111,6 +111,18 @@ export const IPC_CHANNELS = {
|
||||
getConfigSettingsAnkiModelNames: 'config-settings:anki-model-names',
|
||||
getConfigSettingsAnkiModelFieldNames: 'config-settings:anki-model-field-names',
|
||||
getConfigSettingsYomitanAnkiDeckName: 'config-settings:yomitan-anki-deck-name',
|
||||
syncUiGetSnapshot: 'sync-ui:get-snapshot',
|
||||
syncUiSaveHost: 'sync-ui:save-host',
|
||||
syncUiRemoveHost: 'sync-ui:remove-host',
|
||||
syncUiSetAutoSyncInterval: 'sync-ui:set-auto-sync-interval',
|
||||
syncUiRunSync: 'sync-ui:run-sync',
|
||||
syncUiCancelRun: 'sync-ui:cancel-run',
|
||||
syncUiCheckHost: 'sync-ui:check-host',
|
||||
syncUiCreateSnapshot: 'sync-ui:create-snapshot',
|
||||
syncUiMergeSnapshotFile: 'sync-ui:merge-snapshot-file',
|
||||
syncUiDeleteSnapshot: 'sync-ui:delete-snapshot',
|
||||
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
|
||||
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
|
||||
},
|
||||
event: {
|
||||
subtitleSet: 'subtitle:set',
|
||||
@@ -142,6 +154,8 @@ export const IPC_CHANNELS = {
|
||||
configHotReload: 'config:hot-reload',
|
||||
overlayNotification: 'overlay:notification',
|
||||
notificationHistoryToggle: 'notification-history:toggle',
|
||||
syncUiProgress: 'sync-ui:progress',
|
||||
syncUiStateChanged: 'sync-ui:state-changed',
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import net from 'node:net';
|
||||
|
||||
/**
|
||||
* True when something is accepting connections on the unix socket / Windows
|
||||
* named pipe (400 ms probe). Electron-free; shared by the sync CLI's
|
||||
* running-app guard and the Windows mpv launch attach wait.
|
||||
*/
|
||||
export async function canConnectSocket(socketPath: string): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection(socketPath);
|
||||
let settled = false;
|
||||
|
||||
const finish = (value: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
socket.setTimeout(400, () => finish(false));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseSyncProgressLine } from './sync-events';
|
||||
|
||||
test('parseSyncProgressLine parses known event types', () => {
|
||||
assert.deepEqual(
|
||||
parseSyncProgressLine('{"type":"stage","stage":"snapshot-local","message":"Snapshotting"}'),
|
||||
{ type: 'stage', stage: 'snapshot-local', message: 'Snapshotting' },
|
||||
);
|
||||
const summaryLine = JSON.stringify({
|
||||
type: 'merge-summary',
|
||||
target: 'local',
|
||||
summary: {
|
||||
sessionsMerged: 2,
|
||||
sessionsAlreadyPresent: 0,
|
||||
activeSessionsSkipped: 0,
|
||||
animeAdded: 0,
|
||||
videosAdded: 0,
|
||||
wordsAdded: 0,
|
||||
kanjiAdded: 0,
|
||||
subtitleLinesAdded: 0,
|
||||
telemetryRowsAdded: 0,
|
||||
eventsAdded: 0,
|
||||
excludedWordsAdded: 0,
|
||||
dailyRollupsCopied: 0,
|
||||
monthlyRollupsCopied: 0,
|
||||
rollupGroupsRecomputed: 0,
|
||||
},
|
||||
});
|
||||
const parsed = parseSyncProgressLine(summaryLine);
|
||||
assert.equal(parsed?.type, 'merge-summary');
|
||||
assert.deepEqual(parseSyncProgressLine('{"type":"result","ok":true,"error":null}'), {
|
||||
type: 'result',
|
||||
ok: true,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseSyncProgressLine rejects non-events and garbage', () => {
|
||||
assert.equal(parseSyncProgressLine('plain text output'), null);
|
||||
assert.equal(parseSyncProgressLine('{"no":"type"}'), null);
|
||||
assert.equal(parseSyncProgressLine('{"type":"unknown-event"}'), null);
|
||||
assert.equal(parseSyncProgressLine(''), null);
|
||||
assert.equal(parseSyncProgressLine('42'), null);
|
||||
});
|
||||
|
||||
test('parseSyncProgressLine rejects events missing required fields', () => {
|
||||
// A half-formed event used to be cast straight through; the UI reads
|
||||
// `.ok` / `.summary` directly and would misreport a failed sync as done.
|
||||
assert.equal(parseSyncProgressLine('{"type":"result"}'), null);
|
||||
assert.equal(parseSyncProgressLine('{"type":"result","ok":"yes","error":null}'), null);
|
||||
assert.equal(parseSyncProgressLine('{"type":"merge-summary","target":"local"}'), null);
|
||||
assert.equal(
|
||||
parseSyncProgressLine('{"type":"merge-summary","target":"nowhere","summary":{}}'),
|
||||
null,
|
||||
);
|
||||
assert.equal(parseSyncProgressLine('{"type":"stage","stage":"bogus","message":"x"}'), null);
|
||||
assert.equal(parseSyncProgressLine('{"type":"snapshot-created"}'), null);
|
||||
assert.equal(parseSyncProgressLine('{"type":"check-result","host":"h","sshOk":true}'), null);
|
||||
|
||||
// Well-formed events still parse.
|
||||
assert.deepEqual(parseSyncProgressLine('{"type":"result","ok":true,"error":null}'), {
|
||||
type: 'result',
|
||||
ok: true,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createDefaultSyncHostsState,
|
||||
getSyncHostsPath,
|
||||
isValidSyncHost,
|
||||
normalizeSyncHostsState,
|
||||
readSyncHostsState,
|
||||
recordSyncResult,
|
||||
removeSyncHost,
|
||||
upsertSyncHost,
|
||||
writeSyncHostsState,
|
||||
} from './sync-hosts-store';
|
||||
|
||||
function withTempDir(fn: (dir: string) => void): void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-hosts-test-'));
|
||||
try {
|
||||
fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('createDefaultSyncHostsState returns empty v1 state', () => {
|
||||
assert.deepEqual(createDefaultSyncHostsState(), {
|
||||
version: 1,
|
||||
autoSyncIntervalMinutes: 60,
|
||||
hosts: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('isValidSyncHost accepts ssh destinations and rejects unsafe values', () => {
|
||||
assert.equal(isValidSyncHost('desktop'), true);
|
||||
assert.equal(isValidSyncHost('user@10.0.0.5'), true);
|
||||
assert.equal(isValidSyncHost('my-alias.local'), true);
|
||||
assert.equal(isValidSyncHost(''), false);
|
||||
assert.equal(isValidSyncHost(' '), false);
|
||||
assert.equal(isValidSyncHost('-oProxyCommand=evil'), false);
|
||||
assert.equal(isValidSyncHost('host with spaces'), false);
|
||||
});
|
||||
|
||||
test('upsertSyncHost adds a new host with defaults and preserves fields on update', () => {
|
||||
const state = createDefaultSyncHostsState();
|
||||
const added = upsertSyncHost(state, { host: 'user@laptop' }, 1000);
|
||||
assert.deepEqual(added.hosts, [
|
||||
{
|
||||
host: 'user@laptop',
|
||||
label: null,
|
||||
direction: 'both',
|
||||
autoSync: false,
|
||||
addedAtMs: 1000,
|
||||
lastSyncAtMs: null,
|
||||
lastSyncStatus: null,
|
||||
lastSyncDetail: null,
|
||||
},
|
||||
]);
|
||||
// original state untouched
|
||||
assert.equal(state.hosts.length, 0);
|
||||
|
||||
const synced = recordSyncResult(added, 'user@laptop', {
|
||||
atMs: 2000,
|
||||
status: 'success',
|
||||
detail: 'Sessions merged: 3',
|
||||
});
|
||||
const updated = upsertSyncHost(synced, { host: 'user@laptop', direction: 'push' }, 3000);
|
||||
assert.equal(updated.hosts.length, 1);
|
||||
const entry = updated.hosts[0]!;
|
||||
assert.equal(entry.direction, 'push');
|
||||
assert.equal(entry.addedAtMs, 1000);
|
||||
assert.equal(entry.lastSyncAtMs, 2000);
|
||||
assert.equal(entry.lastSyncStatus, 'success');
|
||||
assert.equal(entry.lastSyncDetail, 'Sessions merged: 3');
|
||||
});
|
||||
|
||||
test('upsertSyncHost trims host and throws on invalid host', () => {
|
||||
const state = upsertSyncHost(createDefaultSyncHostsState(), { host: ' desktop ' }, 1);
|
||||
assert.equal(state.hosts[0]!.host, 'desktop');
|
||||
assert.throws(() => upsertSyncHost(state, { host: '-bad' }, 2));
|
||||
});
|
||||
|
||||
test('removeSyncHost removes only the matching host', () => {
|
||||
let state = createDefaultSyncHostsState();
|
||||
state = upsertSyncHost(state, { host: 'a' }, 1);
|
||||
state = upsertSyncHost(state, { host: 'b' }, 2);
|
||||
const removed = removeSyncHost(state, 'a');
|
||||
assert.deepEqual(
|
||||
removed.hosts.map((entry) => entry.host),
|
||||
['b'],
|
||||
);
|
||||
assert.equal(removeSyncHost(removed, 'missing').hosts.length, 1);
|
||||
});
|
||||
|
||||
test('recordSyncResult upserts unknown hosts so CLI syncs are remembered', () => {
|
||||
const state = recordSyncResult(createDefaultSyncHostsState(), 'user@server', {
|
||||
atMs: 5000,
|
||||
status: 'error',
|
||||
detail: 'Remote merge failed',
|
||||
});
|
||||
assert.equal(state.hosts.length, 1);
|
||||
const entry = state.hosts[0]!;
|
||||
assert.equal(entry.host, 'user@server');
|
||||
assert.equal(entry.addedAtMs, 5000);
|
||||
assert.equal(entry.lastSyncAtMs, 5000);
|
||||
assert.equal(entry.lastSyncStatus, 'error');
|
||||
assert.equal(entry.lastSyncDetail, 'Remote merge failed');
|
||||
});
|
||||
|
||||
test('normalizeSyncHostsState drops invalid entries and falls back to defaults', () => {
|
||||
assert.deepEqual(normalizeSyncHostsState(null), createDefaultSyncHostsState());
|
||||
assert.deepEqual(normalizeSyncHostsState('junk'), createDefaultSyncHostsState());
|
||||
assert.deepEqual(normalizeSyncHostsState({ version: 99 }), createDefaultSyncHostsState());
|
||||
|
||||
const normalized = normalizeSyncHostsState({
|
||||
version: 1,
|
||||
autoSyncIntervalMinutes: 15,
|
||||
hosts: [
|
||||
{
|
||||
host: 'desktop',
|
||||
label: 'Desktop PC',
|
||||
direction: 'pull',
|
||||
autoSync: true,
|
||||
addedAtMs: 10,
|
||||
lastSyncAtMs: 20,
|
||||
lastSyncStatus: 'success',
|
||||
lastSyncDetail: 'ok',
|
||||
},
|
||||
{ host: '-bad', direction: 'both', autoSync: false, addedAtMs: 1 },
|
||||
{ host: 'dupe', direction: 'sideways', autoSync: false, addedAtMs: 1 },
|
||||
'not-an-object',
|
||||
],
|
||||
});
|
||||
assert.equal(normalized.autoSyncIntervalMinutes, 15);
|
||||
assert.deepEqual(normalized.hosts, [
|
||||
{
|
||||
host: 'desktop',
|
||||
label: 'Desktop PC',
|
||||
direction: 'pull',
|
||||
autoSync: true,
|
||||
addedAtMs: 10,
|
||||
lastSyncAtMs: 20,
|
||||
lastSyncStatus: 'success',
|
||||
lastSyncDetail: 'ok',
|
||||
},
|
||||
{
|
||||
host: 'dupe',
|
||||
label: null,
|
||||
direction: 'both',
|
||||
autoSync: false,
|
||||
addedAtMs: 1,
|
||||
lastSyncAtMs: null,
|
||||
lastSyncStatus: null,
|
||||
lastSyncDetail: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('read/write round-trips state and tolerates missing or corrupt files', () => {
|
||||
withTempDir((root) => {
|
||||
const filePath = getSyncHostsPath(root);
|
||||
assert.equal(filePath, path.join(root, 'sync-hosts.json'));
|
||||
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
|
||||
|
||||
fs.writeFileSync(filePath, '{corrupt');
|
||||
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
|
||||
|
||||
let state = createDefaultSyncHostsState();
|
||||
state = upsertSyncHost(state, { host: 'user@laptop', label: 'Laptop' }, 42);
|
||||
writeSyncHostsState(filePath, state);
|
||||
assert.deepEqual(readSyncHostsState(filePath), state);
|
||||
});
|
||||
});
|
||||
|
||||
test('writeSyncHostsState creates parent directories', () => {
|
||||
withTempDir((root) => {
|
||||
const filePath = path.join(root, 'nested', 'dir', 'sync-hosts.json');
|
||||
writeSyncHostsState(filePath, createDefaultSyncHostsState());
|
||||
assert.deepEqual(readSyncHostsState(filePath), createDefaultSyncHostsState());
|
||||
});
|
||||
});
|
||||
|
||||
test('writeSyncHostsState leaves the previous file intact when the write fails', () => {
|
||||
withTempDir((root) => {
|
||||
const filePath = getSyncHostsPath(root);
|
||||
let state = createDefaultSyncHostsState();
|
||||
state = upsertSyncHost(state, { host: 'user@laptop', label: 'Laptop' }, 42);
|
||||
writeSyncHostsState(filePath, state);
|
||||
|
||||
// A crash mid-write must not truncate the live file: readSyncHostsState
|
||||
// falls back to defaults on a corrupt file, which would drop every host.
|
||||
let next = upsertSyncHost(state, { host: 'user@desktop' }, 43);
|
||||
assert.throws(() =>
|
||||
writeSyncHostsState(filePath, next, {
|
||||
renameSync: () => {
|
||||
throw new Error('disk full');
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(readSyncHostsState(filePath), state);
|
||||
assert.equal(fs.existsSync(`${filePath}.tmp`), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export type SyncDirection = 'both' | 'push' | 'pull';
|
||||
export type SyncResultStatus = 'success' | 'error';
|
||||
|
||||
export interface SyncHostEntry {
|
||||
host: string;
|
||||
label: string | null;
|
||||
direction: SyncDirection;
|
||||
autoSync: boolean;
|
||||
addedAtMs: number;
|
||||
lastSyncAtMs: number | null;
|
||||
lastSyncStatus: SyncResultStatus | null;
|
||||
lastSyncDetail: string | null;
|
||||
}
|
||||
|
||||
export interface SyncHostsState {
|
||||
version: 1;
|
||||
autoSyncIntervalMinutes: number;
|
||||
hosts: SyncHostEntry[];
|
||||
}
|
||||
|
||||
export interface SyncHostUpdate {
|
||||
host: string;
|
||||
label?: string | null;
|
||||
direction?: SyncDirection;
|
||||
autoSync?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncResultUpdate {
|
||||
atMs: number;
|
||||
status: SyncResultStatus;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_AUTO_SYNC_INTERVAL_MINUTES = 60;
|
||||
|
||||
export function createDefaultSyncHostsState(): SyncHostsState {
|
||||
return {
|
||||
version: 1,
|
||||
autoSyncIntervalMinutes: DEFAULT_AUTO_SYNC_INTERVAL_MINUTES,
|
||||
hosts: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Hosts are passed as a single spawn argument to ssh, so the only dangerous
|
||||
// shapes are option injection (leading "-") and multi-token values.
|
||||
export function isValidSyncHost(host: string): boolean {
|
||||
const trimmed = host.trim();
|
||||
return trimmed.length > 0 && !trimmed.startsWith('-') && !/\s/.test(trimmed);
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function isDirection(value: unknown): value is SyncDirection {
|
||||
return value === 'both' || value === 'push' || value === 'pull';
|
||||
}
|
||||
|
||||
function normalizeHostEntry(value: unknown): SyncHostEntry | null {
|
||||
const record = asObject(value);
|
||||
if (!record) return null;
|
||||
const host = typeof record.host === 'string' ? record.host.trim() : '';
|
||||
if (!isValidSyncHost(host)) return null;
|
||||
return {
|
||||
host,
|
||||
label: typeof record.label === 'string' && record.label.trim() ? record.label.trim() : null,
|
||||
direction: isDirection(record.direction) ? record.direction : 'both',
|
||||
autoSync: record.autoSync === true,
|
||||
addedAtMs:
|
||||
typeof record.addedAtMs === 'number' && Number.isFinite(record.addedAtMs)
|
||||
? record.addedAtMs
|
||||
: 0,
|
||||
lastSyncAtMs:
|
||||
typeof record.lastSyncAtMs === 'number' && Number.isFinite(record.lastSyncAtMs)
|
||||
? record.lastSyncAtMs
|
||||
: null,
|
||||
lastSyncStatus:
|
||||
record.lastSyncStatus === 'success' || record.lastSyncStatus === 'error'
|
||||
? record.lastSyncStatus
|
||||
: null,
|
||||
lastSyncDetail: typeof record.lastSyncDetail === 'string' ? record.lastSyncDetail : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSyncHostsState(value: unknown): SyncHostsState {
|
||||
const record = asObject(value);
|
||||
if (!record || record.version !== 1) return createDefaultSyncHostsState();
|
||||
const hosts = Array.isArray(record.hosts)
|
||||
? record.hosts.map(normalizeHostEntry).filter((entry): entry is SyncHostEntry => entry !== null)
|
||||
: [];
|
||||
const interval = record.autoSyncIntervalMinutes;
|
||||
return {
|
||||
version: 1,
|
||||
autoSyncIntervalMinutes:
|
||||
typeof interval === 'number' && Number.isFinite(interval) && interval >= 1
|
||||
? Math.floor(interval)
|
||||
: DEFAULT_AUTO_SYNC_INTERVAL_MINUTES,
|
||||
hosts,
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertSyncHost(
|
||||
state: SyncHostsState,
|
||||
update: SyncHostUpdate,
|
||||
nowMs: number,
|
||||
): SyncHostsState {
|
||||
const host = update.host.trim();
|
||||
if (!isValidSyncHost(host)) {
|
||||
throw new Error(`Invalid sync host: ${JSON.stringify(update.host)}`);
|
||||
}
|
||||
const existing = state.hosts.find((entry) => entry.host === host);
|
||||
const base: SyncHostEntry = existing ?? {
|
||||
host,
|
||||
label: null,
|
||||
direction: 'both',
|
||||
autoSync: false,
|
||||
addedAtMs: nowMs,
|
||||
lastSyncAtMs: null,
|
||||
lastSyncStatus: null,
|
||||
lastSyncDetail: null,
|
||||
};
|
||||
const next: SyncHostEntry = {
|
||||
...base,
|
||||
label: update.label !== undefined ? update.label : base.label,
|
||||
direction: update.direction ?? base.direction,
|
||||
autoSync: update.autoSync ?? base.autoSync,
|
||||
};
|
||||
const hosts = existing
|
||||
? state.hosts.map((entry) => (entry.host === host ? next : entry))
|
||||
: [...state.hosts, next];
|
||||
return { ...state, hosts };
|
||||
}
|
||||
|
||||
export function removeSyncHost(state: SyncHostsState, host: string): SyncHostsState {
|
||||
return { ...state, hosts: state.hosts.filter((entry) => entry.host !== host.trim()) };
|
||||
}
|
||||
|
||||
export function recordSyncResult(
|
||||
state: SyncHostsState,
|
||||
host: string,
|
||||
result: SyncResultUpdate,
|
||||
): SyncHostsState {
|
||||
const upserted = upsertSyncHost(state, { host }, result.atMs);
|
||||
const hosts = upserted.hosts.map((entry) =>
|
||||
entry.host === host.trim()
|
||||
? {
|
||||
...entry,
|
||||
lastSyncAtMs: result.atMs,
|
||||
lastSyncStatus: result.status,
|
||||
lastSyncDetail: result.detail,
|
||||
}
|
||||
: entry,
|
||||
);
|
||||
return { ...upserted, hosts };
|
||||
}
|
||||
|
||||
export function getSyncHostsPath(configDir: string): string {
|
||||
return path.join(configDir, 'sync-hosts.json');
|
||||
}
|
||||
|
||||
export function readSyncHostsState(
|
||||
filePath: string,
|
||||
deps?: {
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
readFileSync?: (candidate: string, encoding: BufferEncoding) => string;
|
||||
},
|
||||
): SyncHostsState {
|
||||
const existsSync = deps?.existsSync ?? fs.existsSync;
|
||||
const readFileSync = deps?.readFileSync ?? fs.readFileSync;
|
||||
if (!existsSync(filePath)) return createDefaultSyncHostsState();
|
||||
try {
|
||||
return normalizeSyncHostsState(JSON.parse(readFileSync(filePath, 'utf8')));
|
||||
} catch {
|
||||
return createDefaultSyncHostsState();
|
||||
}
|
||||
}
|
||||
|
||||
// Written via a sibling temp file + rename so an interrupted write cannot leave
|
||||
// a truncated sync-hosts.json behind (readSyncHostsState would silently fall
|
||||
// back to defaults, dropping every configured host).
|
||||
export function writeSyncHostsState(
|
||||
filePath: string,
|
||||
state: SyncHostsState,
|
||||
deps?: {
|
||||
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
|
||||
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
|
||||
renameSync?: (from: string, to: string) => void;
|
||||
rmSync?: (target: string, options: { force: true }) => void;
|
||||
},
|
||||
): void {
|
||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
|
||||
const renameSync = deps?.renameSync ?? fs.renameSync;
|
||||
const rmSync = deps?.rmSync ?? fs.rmSync;
|
||||
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = `${filePath}.tmp`;
|
||||
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||
try {
|
||||
renameSync(tempPath, filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
rmSync(tempPath, { force: true });
|
||||
} catch {
|
||||
// best effort: the temp file is disposable
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user