|
|
|
|
@@ -1,22 +1,35 @@
|
|
|
|
|
import { join } from 'node:path';
|
|
|
|
|
import { mkdir } from 'node:fs/promises';
|
|
|
|
|
import type { ExportService } from './ExportService.js';
|
|
|
|
|
import type { ImportService } from './ImportService.js';
|
|
|
|
|
import { GitClient } from './GitClient.js';
|
|
|
|
|
|
|
|
|
|
export interface SyncConflict {
|
|
|
|
|
noteId: string;
|
|
|
|
|
localText: string;
|
|
|
|
|
remoteText: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SyncStatus {
|
|
|
|
|
ok: boolean;
|
|
|
|
|
reason?: string; // why the sync was skipped or failed
|
|
|
|
|
changed?: boolean; // true if a new commit was created
|
|
|
|
|
sha?: string | null;
|
|
|
|
|
reason?: string;
|
|
|
|
|
changed?: boolean;
|
|
|
|
|
localSha?: string | null;
|
|
|
|
|
pushed?: boolean;
|
|
|
|
|
importedCount?: number;
|
|
|
|
|
conflicts?: SyncConflict[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class SyncService {
|
|
|
|
|
private syncDir: string;
|
|
|
|
|
private lastConflicts: SyncConflict[] = [];
|
|
|
|
|
private lastResult: SyncStatus | null = null;
|
|
|
|
|
private lastAt: string | null = null;
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private profileDir: string,
|
|
|
|
|
private exportSvc: ExportService,
|
|
|
|
|
private importSvc: ImportService,
|
|
|
|
|
private now: () => Date = () => new Date()
|
|
|
|
|
) {
|
|
|
|
|
this.syncDir = join(profileDir, 'sync');
|
|
|
|
|
@@ -33,31 +46,97 @@ export class SyncService {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getLastStatus(): { lastAt: string | null; lastResult: SyncStatus | null } {
|
|
|
|
|
return { lastAt: this.lastAt, lastResult: this.lastResult };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
listConflicts(): SyncConflict[] {
|
|
|
|
|
return this.lastConflicts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async sync(): Promise<SyncStatus> {
|
|
|
|
|
if (!(await this.isConfigured())) {
|
|
|
|
|
return { ok: false, reason: 'not_configured' };
|
|
|
|
|
const result = await this.runSync();
|
|
|
|
|
this.lastResult = result;
|
|
|
|
|
this.lastAt = this.now().toISOString();
|
|
|
|
|
if (result.reason === 'conflict' && result.conflicts) {
|
|
|
|
|
this.lastConflicts = result.conflicts;
|
|
|
|
|
} else if (result.ok) {
|
|
|
|
|
this.lastConflicts = [];
|
|
|
|
|
}
|
|
|
|
|
// 1. Re-export the full tree into syncDir (idempotent).
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async runSync(): Promise<SyncStatus> {
|
|
|
|
|
if (!(await this.isConfigured())) return { ok: false, reason: 'not_configured' };
|
|
|
|
|
|
|
|
|
|
const git = new GitClient(this.syncDir);
|
|
|
|
|
|
|
|
|
|
// 1. local export
|
|
|
|
|
try {
|
|
|
|
|
await mkdir(this.syncDir, { recursive: true });
|
|
|
|
|
await this.exportSvc.export(this.syncDir, { includeMedia: true });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return { ok: false, reason: `export failed: ${(e as Error).message}` };
|
|
|
|
|
}
|
|
|
|
|
// 2. git add + commit + push
|
|
|
|
|
const git = new GitClient(this.syncDir);
|
|
|
|
|
|
|
|
|
|
// 2. local commit (only if changed)
|
|
|
|
|
let localSha: string | null = null;
|
|
|
|
|
let localChanged = false;
|
|
|
|
|
try {
|
|
|
|
|
await git.addAll();
|
|
|
|
|
const ts = this.now().toISOString();
|
|
|
|
|
const message = `chore(notes): sync ${ts}`;
|
|
|
|
|
const commit = await git.commit(message);
|
|
|
|
|
if (!commit.changed) {
|
|
|
|
|
return { ok: true, changed: false, pushed: false };
|
|
|
|
|
localChanged = await git.hasUncommittedChanges();
|
|
|
|
|
if (localChanged) {
|
|
|
|
|
const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`);
|
|
|
|
|
localSha = c.sha;
|
|
|
|
|
}
|
|
|
|
|
await git.push();
|
|
|
|
|
return { ok: true, changed: true, sha: commit.sha, pushed: true };
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return { ok: false, reason: (e as Error).message };
|
|
|
|
|
return { ok: false, reason: `local commit failed: ${(e as Error).message}` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. fetch
|
|
|
|
|
const fetchR = await git.fetch();
|
|
|
|
|
if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` };
|
|
|
|
|
|
|
|
|
|
// 4. rebase — skip if origin/main doesn't exist yet (first-push, empty remote)
|
|
|
|
|
const hasOriginMain = await git.refExists('origin/main');
|
|
|
|
|
if (hasOriginMain) {
|
|
|
|
|
const rebaseR = await git.rebaseOnto('origin/main');
|
|
|
|
|
if (rebaseR.exitCode !== 0) {
|
|
|
|
|
const files = await git.listConflicts();
|
|
|
|
|
await git.rebaseAbort();
|
|
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
reason: 'conflict',
|
|
|
|
|
conflicts: files.map((path) => ({ noteId: this.pathToNoteId(path), localText: '', remoteText: '' }))
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 5. re-import
|
|
|
|
|
let importedCount = 0;
|
|
|
|
|
try {
|
|
|
|
|
const r = await this.importSvc.applySyncFromDir(this.syncDir);
|
|
|
|
|
importedCount = r.changedCount;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return { ok: false, reason: `re-import failed: ${(e as Error).message}` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 6. push
|
|
|
|
|
try {
|
|
|
|
|
await git.push();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return { ok: false, reason: `push failed: ${(e as Error).message}` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { ok: true, changed: localChanged || importedCount > 0, localSha, importedCount, pushed: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private pathToNoteId(path: string): string {
|
|
|
|
|
// notes/<id>.md or notes/<date>-<id8>-<slug>.md → extract id (best effort).
|
|
|
|
|
// Cut E note: F5 export uses date-id8-slug filename; full id is in frontmatter.
|
|
|
|
|
// For Cut E conflict listing we expose the file path-derived heuristic; the
|
|
|
|
|
// conflict modal will read frontmatter to recover the full id when needed.
|
|
|
|
|
const m = /notes\/(.+)\.md$/.exec(path);
|
|
|
|
|
return m ? m[1]! : path;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|