From 662abdb508cf0c32cca9f3c4d81fdc56f8064bd9 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 03:19:16 +0900 Subject: [PATCH 01/12] =?UTF-8?q?docs(plan):=20v0.3.0=20Cut=20E=20?= =?UTF-8?q?=E2=80=94=20=EC=96=91=EB=B0=A9=ED=96=A5=20git=20sync=20(spec=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95:=20=EB=8B=A8=EC=9C=84=20608,=20ImportService?= =?UTF-8?q?.run=20=ED=99=9C=EC=9A=A9,=20'sync'=20enum=20=EB=AF=B8=EB=8F=84?= =?UTF-8?q?=EC=9E=85,=20both=20deferred)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-05-10-v030-cut-e-bidirectional-sync.md | 1158 +++++++++++++++++ .../specs/2026-05-09-v030-cut-e-design.md | 73 +- 2 files changed, 1205 insertions(+), 26 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-10-v030-cut-e-bidirectional-sync.md diff --git a/docs/superpowers/plans/2026-05-10-v030-cut-e-bidirectional-sync.md b/docs/superpowers/plans/2026-05-10-v030-cut-e-bidirectional-sync.md new file mode 100644 index 0000000..fe14de8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-v030-cut-e-bidirectional-sync.md @@ -0,0 +1,1158 @@ +# v0.3.0 Cut E — 양방향 git sync + Configure UI + Conflict resolution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** F21 — 다기기 (Mac 업무 + Windows 개인) git-based 양방향 sync. push-only `SyncService` → fetch + rebase + re-import + push 6단계 흐름 + Configure UI + Conflict resolution + 자동 주기 sync. + +**Architecture:** `SyncService.sync()` 가 (1) export → (2) commit → (3) fetch → (4) rebase → (5) re-import (`upsertFromSync` single write path) → (6) push. rebase 충돌 시 `rebaseAbort` + conflicts 반환 → 설정 페이지 Conflict modal. settings 에 `sync_repo_url` / `sync_auto_enabled` / `sync_interval_min` 추가. main process 의 timer 가 interval=true 모드 sync 주기 호출. + +**Tech Stack:** Node child_process git CLI (better-sqlite3 미사용 path), Electron IPC, React 19 + zustand 5, vitest 4 + RTL. + +**선행 문서:** + +- `docs/superpowers/specs/2026-05-09-v030-cut-e-design.md` — source spec (단위 608 기준 + ImportService.run 활용 + 'sync' enum 미도입 정정) +- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F21 +- `docs/superpowers/strategy/v028plus-roadmap.md` — Cut E 위치 (semver MINOR — Major 영역 진입) + +--- + +## File Structure + +**Create:** + +- `src/renderer/inbox/components/settings/SyncSection.tsx` — Configure UI (URL 입력 + 자동 sync 토글 + interval + 지금 동기화 + 충돌 해결 버튼) +- `src/renderer/inbox/components/ConflictModal.tsx` — local/remote/both 선택 UI +- `src/main/services/SyncTimer.ts` — main process 의 자동 주기 sync timer (settings.sync_interval_min) +- `tests/unit/GitClient.fetch.test.ts` — fetch/rebase mock execFile 테스트 +- `tests/unit/SyncService.bidirectional.test.ts` — 양방향 6단계 흐름 + conflict 시나리오 +- `tests/unit/SyncService.resolveConflict.test.ts` — 3 choice 동작 +- `tests/unit/NoteRepository.upsertFromSync.test.ts` — 3 분기 시나리오 +- `tests/unit/ImportService.applySyncFromDir.test.ts` — sourceDir 흐름 +- `tests/unit/SyncSection.test.tsx` — UI 단위 +- `tests/unit/ConflictModal.test.tsx` — modal 단위 +- `tests/unit/SyncTimer.test.ts` — timer + interval mode + +**Modify:** + +- `src/main/services/GitClient.ts` — `fetch` / `rebaseOnto` / `rebaseAbort` / `hasUncommittedChanges` / `listConflicts` 5 메서드 추가 +- `src/main/services/SyncService.ts` — `sync()` 6단계 양방향 흐름 + `resolveConflict()` 신규 + `listConflicts()` 신규 + SyncStatus 확장 +- `src/main/services/ImportService.ts` — `applySyncFromDir(dir)` 신규 (parsedToInput → repo.upsertFromSync 호출) +- `src/main/repository/NoteRepository.ts` — `upsertFromSync(input)` 신규 (3 분기 — INSERT / metadata-only / updateRawText) +- `src/main/services/SettingsService.ts` — sync_repo_url / sync_auto_enabled / sync_interval_min 필드 (zod schema 확장 + getter/setter) +- `src/main/ipc/settingsApi.ts` — 5 IPC handler (`settings:configure-sync` / `settings:test-sync-connection` / `sync:list-conflicts` / `sync:resolve-conflict` / `sync:get-status`) +- `src/preload/index.ts` — 5 bridge 함수 +- `src/shared/types.ts` — `SyncStatus` 확장 + `ConflictRow` interface + InboxApi 5 메서드 +- `src/main/index.ts` — SyncTimer 인스턴스 생성 + start (settings 변경 시 reconfigure) +- `src/renderer/inbox/components/SettingsPage.tsx` — SyncSection mount +- `package.json` — version `0.2.11` → `0.3.0` +- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F21 promoted (A+B+C 옵션) + Cut E 라벨 + +--- + +## 단위 목표 + +608 (v0.2.11) → 약 635 (+27), typecheck 0. + +--- + +## Task 1: GitClient 확장 — fetch / rebaseOnto / rebaseAbort / hasUncommittedChanges / listConflicts + +**Files:** + +- Modify: `src/main/services/GitClient.ts` +- Create: `tests/unit/GitClient.fetch.test.ts` + +기존 `GitClient.run(args)` 로 git CLI 실행. 5 메서드 추가 — 모두 thin wrapper. `listConflicts` 는 `git diff --name-only --diff-filter=U` 결과 파싱. + +- [ ] **Step 1: failing test 작성** — `tests/unit/GitClient.fetch.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GitClient } from '../../src/main/services/GitClient.js'; + +describe('GitClient — fetch / rebase / conflict 메서드', () => { + let client: GitClient; + let runSpy: ReturnType; + + beforeEach(() => { + client = new GitClient('/tmp/sync'); + runSpy = vi.spyOn(client, 'run'); + }); + + it('fetch — git fetch origin 호출', async () => { + runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); + const r = await client.fetch(); + expect(runSpy).toHaveBeenCalledWith(['fetch', 'origin']); + expect(r.exitCode).toBe(0); + }); + + it('rebaseOnto — git rebase origin/main', async () => { + runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); + const r = await client.rebaseOnto('origin/main'); + expect(runSpy).toHaveBeenCalledWith(['rebase', 'origin/main']); + expect(r.exitCode).toBe(0); + }); + + it('rebaseAbort — git rebase --abort', async () => { + runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); + await client.rebaseAbort(); + expect(runSpy).toHaveBeenCalledWith(['rebase', '--abort']); + }); + + it('hasUncommittedChanges — git status --porcelain 의 출력 있으면 true', async () => { + runSpy.mockResolvedValueOnce({ stdout: ' M notes/abc.md\n', stderr: '', exitCode: 0 }); + expect(await client.hasUncommittedChanges()).toBe(true); + + runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); + expect(await client.hasUncommittedChanges()).toBe(false); + }); + + it('listConflicts — git diff --name-only --diff-filter=U 결과 파싱', async () => { + runSpy.mockResolvedValueOnce({ + stdout: 'notes/aaa.md\nnotes/bbb.md\n', + stderr: '', + exitCode: 0 + }); + const r = await client.listConflicts(); + expect(runSpy).toHaveBeenCalledWith(['diff', '--name-only', '--diff-filter=U']); + expect(r).toEqual(['notes/aaa.md', 'notes/bbb.md']); + }); +}); +``` + +- [ ] **Step 2: test FAIL** — 5 메서드 미정의. + +- [ ] **Step 3: GitClient 메서드 추가** — `src/main/services/GitClient.ts` 의 클래스 안 (push 메서드 다음): + +```ts +async fetch(remote: string = 'origin'): Promise { + return this.run(['fetch', remote]); +} + +async rebaseOnto(ref: string): Promise { + return this.run(['rebase', ref]); +} + +async rebaseAbort(): Promise { + return this.run(['rebase', '--abort']); +} + +async hasUncommittedChanges(): Promise { + const r = await this.run(['status', '--porcelain']); + return r.stdout.trim().length > 0; +} + +/** rebase conflict 시 conflict 마킹된 파일 list. `git diff --name-only --diff-filter=U`. */ +async listConflicts(): Promise { + const r = await this.run(['diff', '--name-only', '--diff-filter=U']); + return r.stdout.split('\n').map((s) => s.trim()).filter((s) => s.length > 0); +} +``` + +- [ ] **Step 4: tests + typecheck PASS** + +```bash +npm run typecheck +npx vitest run tests/unit/GitClient.fetch.test.ts +``` + +- [ ] **Step 5: commit** + +```bash +git add src/main/services/GitClient.ts tests/unit/GitClient.fetch.test.ts +git commit -m "feat(v030): GitClient — fetch/rebaseOnto/rebaseAbort/hasUncommittedChanges/listConflicts" +``` + +--- + +## Task 2: NoteRepository.upsertFromSync — sync 전용 3 분기 upsert + +**Files:** + +- Modify: `src/main/repository/NoteRepository.ts` +- Create: `tests/unit/NoteRepository.upsertFromSync.test.ts` + +기존 `importNote` 의 fork-on-conflict 정책은 sync 부적합 (양 기기 raw_text 다를 때마다 노트 갯수 증가). 신설 `upsertFromSync(input)`: + +- id 없음 → INSERT (capture revision 자동 by m006 trigger + create path) +- id 있음 + raw_text 동일 → metadata 갱신 path (source.updatedAt > local.updatedAt 일 때만 ai_title/ai_summary/tags/status/dueDate 갱신, tags 변경 시 `rebuildFtsTagsForNote`) +- id 있음 + raw_text 다름 → source.updatedAt > local.updatedAt 면 `updateRawText` (Cut C path) → 새 user revision INSERT + +`UpsertFromSyncInput` interface 신규 (importNote 의 ImportNoteInput 와 비슷하지만 status/moveReason 포함): + +```ts +export interface UpsertFromSyncInput { + id: string; + rawText: string; + createdAt: string; + updatedAt: string; + aiTitle: string | null; + aiSummary: string | null; + titleEditedByUser: boolean; + summaryEditedByUser: boolean; + aiProvider: string | null; + aiGeneratedAt: string | null; + userIntent: string | null; + intentPromptedAt: string | null; + tags: { name: string; source: 'ai' | 'user' }[]; + status: NoteStatus; + statusChangedAt: string | null; + moveReason: string | null; + dueDate: string | null; + dueDateEditedByUser: boolean; +} + +export type UpsertFromSyncStatus = 'inserted' | 'updated' | 'skipped'; +``` + +- [ ] **Step 1: failing tests** — `tests/unit/NoteRepository.upsertFromSync.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../../src/main/db/migrations/index.js'; +import { NoteRepository } from '../../src/main/repository/NoteRepository.js'; + +const baseInput = { + id: '00000000-0000-0000-0000-000000000001', + rawText: 'sync 본문', + createdAt: '2026-05-09T00:00:00Z', + updatedAt: '2026-05-10T00:00:00Z', + aiTitle: 'sync 제목', + aiSummary: 'sync 요약', + titleEditedByUser: false, + summaryEditedByUser: false, + aiProvider: 'p', + aiGeneratedAt: '2026-05-10T00:00:00Z', + userIntent: null, + intentPromptedAt: null, + tags: [{ name: '동기', source: 'user' as const }], + status: 'active' as const, + statusChangedAt: null, + moveReason: null, + dueDate: null, + dueDateEditedByUser: false +}; + +describe('NoteRepository.upsertFromSync', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + afterEach(() => { db.close(); }); + + it('id 없음 → INSERT (status=inserted) + capture revision + tags FTS sync', () => { + const r = repo.upsertFromSync(baseInput); + expect(r.status).toBe('inserted'); + expect(r.id).toBe(baseInput.id); + const note = repo.findById(baseInput.id); + expect(note?.rawText).toBe('sync 본문'); + expect(note?.aiTitle).toBe('sync 제목'); + const revs = repo.listRevisions(baseInput.id); + expect(revs).toHaveLength(1); + expect(revs[0]!.editedBy).toBe('capture'); + const fts = db.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`).get(baseInput.id) as { tags: string }; + expect(fts.tags).toBe('동기'); + }); + + it('id 있음 + raw_text 동일 + source 더 최신 → metadata 갱신 (status=updated)', () => { + const created = repo.create({ rawText: 'sync 본문' }); + repo.updateAiResult(created.id, { title: '옛 제목', summary: '옛 요약', tags: ['old'], provider: 'p' }); + // 충분히 옛 updatedAt + db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id); + const r = repo.upsertFromSync({ ...baseInput, id: created.id }); + expect(r.status).toBe('updated'); + const note = repo.findById(created.id); + expect(note?.aiTitle).toBe('sync 제목'); + expect(note?.tags.map((t) => t.name)).toEqual(['동기']); + // FTS tags sync + const fts = db.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`).get(created.id) as { tags: string }; + expect(fts.tags).toBe('동기'); + }); + + it('id 있음 + raw_text 동일 + source 더 옛 → skip (status=skipped)', () => { + const created = repo.create({ rawText: 'sync 본문' }); + repo.updateAiResult(created.id, { title: '신선한 제목', summary: 'fresh', tags: ['x'], provider: 'p' }); + // local 이 더 최신 + db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-12T00:00:00Z', created.id); + const r = repo.upsertFromSync({ ...baseInput, id: created.id, updatedAt: '2026-05-10T00:00:00Z' }); + expect(r.status).toBe('skipped'); + const note = repo.findById(created.id); + expect(note?.aiTitle).toBe('신선한 제목'); + }); + + it('id 있음 + raw_text 다름 + source 더 최신 → updateRawText (status=updated) + new user revision', () => { + const created = repo.create({ rawText: 'old text' }); + db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id); + const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'new sync text' }); + expect(r.status).toBe('updated'); + const note = repo.findById(created.id); + expect(note?.rawText).toBe('new sync text'); + const revs = repo.listRevisions(created.id); + expect(revs).toHaveLength(2); // capture (old) + user (new) + expect(revs[0]!.editedBy).toBe('user'); + expect(revs[0]!.rawText).toBe('new sync text'); + }); + + it('id 있음 + raw_text 다름 + source 더 옛 → skip', () => { + const created = repo.create({ rawText: 'local fresh' }); + db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-15T00:00:00Z', created.id); + const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'old sync text', updatedAt: '2026-05-10T00:00:00Z' }); + expect(r.status).toBe('skipped'); + const note = repo.findById(created.id); + expect(note?.rawText).toBe('local fresh'); + }); +}); +``` + +- [ ] **Step 2: test FAIL** — `repo.upsertFromSync` 미정의. + +- [ ] **Step 3: 구현** — `src/main/repository/NoteRepository.ts` 의 import 옆 (`ImportNoteInput` 다음) 에 `UpsertFromSyncInput` / `UpsertFromSyncStatus` interface 추가. `importNote` 메서드 다음에 추가: + +```ts +/** + * v0.3.0 Cut E — sync 전용 upsert. 기존 importNote 의 fork-on-id-collision 정책은 + * sync 에 부적합 (양 기기 raw_text 가 다를 때마다 fork → 노트 갯수 무한 증가). + * + * 3 분기: + * - id 없음 → create() path (capture revision INSERT) + * - id 있음 + raw_text 동일 → source.updatedAt 가 더 최신일 때만 metadata 갱신 + * - id 있음 + raw_text 다름 → source 가 더 최신이면 updateRawText (new user revision), + * local 이 더 최신이면 skip + * + * tags 변경 시 rebuildFtsTagsForNote 호출 — Cut D single write path 재사용. + * status / dueDate / moveReason 는 metadata 갱신 path 에서 sync. + */ +upsertFromSync(input: UpsertFromSyncInput): { id: string; status: UpsertFromSyncStatus } { + const existing = this.db + .prepare(`SELECT raw_text, updated_at, status FROM notes WHERE id=?`) + .get(input.id) as { raw_text: string; updated_at: string; status: NoteStatus } | undefined; + + if (!existing) { + // INSERT path — create() 가 capture revision + pending_jobs 처리. + const tx = this.db.transaction(() => { + this.db + .prepare( + `INSERT INTO notes + (id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at, + title_edited_by_user, summary_edited_by_user, + user_intent, intent_prompted_at, + created_at, updated_at, + due_date, due_date_edited_by_user, + status, status_changed_at, move_reason) + VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + input.id, + input.rawText, + input.aiTitle, + input.aiSummary, + input.aiProvider, + input.aiGeneratedAt, + input.titleEditedByUser ? 1 : 0, + input.summaryEditedByUser ? 1 : 0, + input.userIntent, + input.intentPromptedAt, + input.createdAt, + input.updatedAt, + input.dueDate, + input.dueDateEditedByUser ? 1 : 0, + input.status, + input.statusChangedAt, + input.moveReason + ); + this.db + .prepare( + `INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) + VALUES (?, ?, ?, 'capture')` + ) + .run(input.id, input.rawText, input.createdAt); + if (input.tags.length > 0) { + const getOrInsertTag = this.db.prepare( + `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id` + ); + const linkAi = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')` + ); + const linkUser = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')` + ); + for (const t of input.tags) { + const row = getOrInsertTag.get(t.name) as { id: number }; + if (t.source === 'ai') linkAi.run(input.id, row.id); + else linkUser.run(input.id, row.id); + } + this.rebuildFtsTagsForNote(input.id); + } + }); + tx(); + return { id: input.id, status: 'inserted' }; + } + + // 기존 노트 — source.updatedAt > local.updatedAt 일 때만 갱신 + if (input.updatedAt <= existing.updated_at) { + return { id: input.id, status: 'skipped' }; + } + + if (existing.raw_text !== input.rawText) { + // raw_text 변경 path — Cut C updateRawText 재사용 (new user revision INSERT) + this.updateRawText(input.id, input.rawText, new Date(input.updatedAt)); + } + + // metadata 갱신 (raw_text 동일이거나 위에서 이미 갱신된 경우) + const tx = this.db.transaction(() => { + this.db + .prepare( + `UPDATE notes + SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END, + ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END, + ai_provider = ?, + ai_generated_at = ?, + due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END, + status = ?, + status_changed_at = ?, + move_reason = ?, + updated_at = ? + WHERE id = ?` + ) + .run( + input.aiTitle, + input.aiSummary, + input.aiProvider, + input.aiGeneratedAt, + input.dueDate, + input.status, + input.statusChangedAt, + input.moveReason, + input.updatedAt, + input.id + ); + // tags — DELETE 후 INSERT (importNote 패턴) + FTS sync + this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(input.id); + if (input.tags.length > 0) { + const getOrInsertTag = this.db.prepare( + `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id` + ); + const linkAi = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')` + ); + const linkUser = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')` + ); + for (const t of input.tags) { + const row = getOrInsertTag.get(t.name) as { id: number }; + if (t.source === 'ai') linkAi.run(input.id, row.id); + else linkUser.run(input.id, row.id); + } + } + this.rebuildFtsTagsForNote(input.id); + }); + tx(); + return { id: input.id, status: 'updated' }; +} +``` + +- [ ] **Step 4: test PASS** — 5/5. + +- [ ] **Step 5: commit** + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.upsertFromSync.test.ts +git commit -m "feat(v030): NoteRepository.upsertFromSync — sync 전용 3 분기 upsert + single write path" +``` + +--- + +## Task 3: ImportService.applySyncFromDir — sourceDir 의 .md → upsertFromSync + +**Files:** + +- Modify: `src/main/services/ImportService.ts` +- Create: `tests/unit/ImportService.applySyncFromDir.test.ts` + +기존 `run(sourceDir)` 가 `parsedToInput → repo.importNote()`. sync 용 신설 메서드는 `parsedToInput → repo.upsertFromSync()` 호출. parsedToInput 시그니처는 status / dueDate / moveReason 포함하도록 확장 (parseExportNote 가 이미 frontmatter 에서 추출). + +> NOTE: `parseExportNote` 의 ParsedNote 가 status/dueDate/moveReason 필드를 노출 안 할 수 있음. 본 task 시작 전 `src/main/services/importFormat.ts` 와 `src/main/services/exportFormat.ts` 를 확인. status frontmatter 미포함이면 ParsedNote 갱신 + ExportService 의 frontmatter writer 갱신 모두 필요. Spec 2026-05-09 작성 시점 기준, 이 부분은 확인 후 plan 보강. + +- [ ] **Step 1: 의존 시그니처 확인** — `parseExportNote` 가 반환하는 ParsedNote 가 status/statusChangedAt/moveReason/dueDate 모두 포함하는지 확인. 미포함 시: + - `src/main/services/importFormat.ts` — frontmatter 파서에 status / statusChangedAt / moveReason / dueDate / dueDateEditedByUser 필드 추가 + - `src/main/services/exportFormat.ts` — frontmatter writer 에 동일 필드 추가 + - 기존 importNote 흐름은 ParsedNote 의 신규 필드 사용 안 해도 무관 (existing tests preserve) + +- [ ] **Step 2: failing test** — `tests/unit/ImportService.applySyncFromDir.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runMigrations } from '../../src/main/db/migrations/index.js'; +import { NoteRepository } from '../../src/main/repository/NoteRepository.js'; +import { ImportService } from '../../src/main/services/ImportService.js'; +import { MediaStore } from '../../src/main/services/MediaStore.js'; + +describe('ImportService.applySyncFromDir', () => { + let db: Database.Database; + let repo: NoteRepository; + let svc: ImportService; + let workDir: string; + + beforeEach(async () => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + workDir = await mkdtemp(join(tmpdir(), 'inkling-sync-')); + const mediaStore = new MediaStore(workDir); + svc = new ImportService(repo, mediaStore); + }); + + afterEach(async () => { + db.close(); + await rm(workDir, { recursive: true, force: true }); + }); + + it('inserts new notes and reports changedCount', async () => { + const notesDir = join(workDir, 'notes'); + await mkdir(notesDir, { recursive: true }); + await writeFile( + join(notesDir, 'a.md'), + `---\nid: 00000000-0000-0000-0000-000000000001\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\nai_title: title\nai_summary: summary\nstatus: active\n---\n\nbody\n` + ); + const r = await svc.applySyncFromDir(workDir); + expect(r.changedCount).toBe(1); + const note = repo.findById('00000000-0000-0000-0000-000000000001'); + expect(note?.rawText).toBe('body'); + }); + + it('skips unchanged notes (no changedCount increment)', async () => { + const created = repo.create({ rawText: 'body' }); + db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-15T00:00:00Z', created.id); + const notesDir = join(workDir, 'notes'); + await mkdir(notesDir, { recursive: true }); + await writeFile( + join(notesDir, 'a.md'), + `---\nid: ${created.id}\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\nai_title: t\nai_summary: s\nstatus: active\n---\n\nbody\n` + ); + const r = await svc.applySyncFromDir(workDir); + expect(r.changedCount).toBe(0); + }); +}); +``` + +(Two tests — minimal coverage to validate the path. More-detailed scenarios in `upsertFromSync` test.) + +- [ ] **Step 3: implementation** — `src/main/services/ImportService.ts` 안에 신규 메서드 + helper: + +```ts +async applySyncFromDir(dir: string): Promise<{ changedCount: number }> { + const files = await this.scanNotes(dir); + let changedCount = 0; + for (const f of files) { + const content = await readFile(f, 'utf8'); + const parsed = parseExportNote(content); + const r = this.repo.upsertFromSync({ + id: parsed.id, + rawText: parsed.rawText, + createdAt: parsed.createdAt, + updatedAt: parsed.updatedAt, + aiTitle: parsed.aiTitle, + aiSummary: parsed.aiSummary, + titleEditedByUser: parsed.titleEditedByUser, + summaryEditedByUser: parsed.summaryEditedByUser, + aiProvider: parsed.aiProvider, + aiGeneratedAt: parsed.aiGeneratedAt, + userIntent: parsed.userIntent, + intentPromptedAt: parsed.intentPromptedAt, + tags: parsed.tags, + status: parsed.status ?? 'active', + statusChangedAt: parsed.statusChangedAt ?? null, + moveReason: parsed.moveReason ?? null, + dueDate: parsed.dueDate ?? null, + dueDateEditedByUser: parsed.dueDateEditedByUser ?? false + }); + if (r.status !== 'skipped') changedCount += 1; + } + return { changedCount }; +} +``` + +(Skip media handling — sync 시 media 는 git 이 binary 변경 자동 sync, MediaStore 디렉토리 구조는 동일 layout 가정. F26 dogfood verify.) + +- [ ] **Step 4: test PASS + typecheck** + +- [ ] **Step 5: commit** + +```bash +git add src/main/services/ImportService.ts \ + src/main/services/importFormat.ts \ + src/main/services/exportFormat.ts \ + tests/unit/ImportService.applySyncFromDir.test.ts +git commit -m "feat(v030): ImportService.applySyncFromDir + import/export frontmatter status fields" +``` + +--- + +## Task 4: SyncService.sync — 양방향 6단계 흐름 + +**Files:** + +- Modify: `src/main/services/SyncService.ts` +- Create: `tests/unit/SyncService.bidirectional.test.ts` + +기존 `sync()` 를 6단계로 교체: + +1. local export → addAll +2. local commit (변경 있을 때만) +3. fetch +4. rebase +5. re-import (`applySyncFromDir` 호출) +6. push + +`SyncStatus` 확장: + +```ts +export interface SyncStatus { + ok: boolean; + reason?: string; + changed?: boolean; + localSha?: string | null; + pushed?: boolean; + importedCount?: number; + conflicts?: Array<{ noteId: string; localText: string; remoteText: string }>; +} +``` + +- [ ] **Step 1: failing test** — `tests/unit/SyncService.bidirectional.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SyncService } from '../../src/main/services/SyncService.js'; + +vi.mock('../../src/main/services/GitClient.js'); +import { GitClient } from '../../src/main/services/GitClient.js'; + +describe('SyncService.sync — 양방향', () => { + let svc: SyncService; + let exportSvc: { export: ReturnType }; + let importSvc: { applySyncFromDir: ReturnType }; + let gitInstance: { + isRepo: ReturnType; + hasRemote: ReturnType; + addAll: ReturnType; + hasUncommittedChanges: ReturnType; + commit: ReturnType; + fetch: ReturnType; + rebaseOnto: ReturnType; + rebaseAbort: ReturnType; + listConflicts: ReturnType; + push: ReturnType; + }; + + beforeEach(() => { + exportSvc = { export: vi.fn(async () => {}) }; + importSvc = { applySyncFromDir: vi.fn(async () => ({ changedCount: 0 })) }; + gitInstance = { + isRepo: vi.fn(async () => true), + hasRemote: vi.fn(async () => true), + addAll: vi.fn(async () => {}), + hasUncommittedChanges: vi.fn(async () => true), + commit: vi.fn(async () => ({ changed: true, sha: 'abc' })), + fetch: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + rebaseOnto: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + rebaseAbort: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + listConflicts: vi.fn(async () => []), + push: vi.fn(async () => {}) + }; + (GitClient as unknown as ReturnType).mockImplementation(() => gitInstance); + svc = new SyncService( + '/tmp/profile', + exportSvc as unknown as never, + importSvc as unknown as never + ); + }); + + it('happy path — 6단계 모두 호출, ok:true', async () => { + const r = await svc.sync(); + expect(exportSvc.export).toHaveBeenCalled(); + expect(gitInstance.addAll).toHaveBeenCalled(); + expect(gitInstance.commit).toHaveBeenCalled(); + expect(gitInstance.fetch).toHaveBeenCalled(); + expect(gitInstance.rebaseOnto).toHaveBeenCalledWith('origin/main'); + expect(importSvc.applySyncFromDir).toHaveBeenCalled(); + expect(gitInstance.push).toHaveBeenCalled(); + expect(r.ok).toBe(true); + expect(r.pushed).toBe(true); + }); + + it('local 변경 없음 → commit skip + 다음 단계 진행', async () => { + gitInstance.hasUncommittedChanges.mockResolvedValueOnce(false); + const r = await svc.sync(); + expect(gitInstance.commit).not.toHaveBeenCalled(); + expect(gitInstance.fetch).toHaveBeenCalled(); + expect(r.ok).toBe(true); + }); + + it('rebase 실패 → abort + reason=conflict + conflicts 포함', async () => { + gitInstance.rebaseOnto.mockResolvedValueOnce({ stdout: '', stderr: 'CONFLICT', exitCode: 1 }); + gitInstance.listConflicts.mockResolvedValueOnce(['notes/aaa.md', 'notes/bbb.md']); + const r = await svc.sync(); + expect(gitInstance.rebaseAbort).toHaveBeenCalled(); + expect(r.ok).toBe(false); + expect(r.reason).toBe('conflict'); + expect(r.conflicts?.length).toBe(2); + expect(gitInstance.push).not.toHaveBeenCalled(); + }); + + it('fetch 실패 → reason 반환', async () => { + gitInstance.fetch.mockResolvedValueOnce({ stdout: '', stderr: 'no network', exitCode: 1 }); + const r = await svc.sync(); + expect(r.ok).toBe(false); + expect(r.reason).toContain('fetch failed'); + expect(gitInstance.rebaseOnto).not.toHaveBeenCalled(); + }); + + it('not configured → ok:false + reason=not_configured', async () => { + gitInstance.isRepo.mockResolvedValueOnce(false); + const r = await svc.sync(); + expect(r.ok).toBe(false); + expect(r.reason).toBe('not_configured'); + }); +}); +``` + +- [ ] **Step 2: implementation** — `src/main/services/SyncService.ts`: + +```ts +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; + 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'); + } + + getSyncDir(): string { return this.syncDir; } + + async isConfigured(): Promise { + const git = new GitClient(this.syncDir); + if (!(await git.isRepo())) return false; + if (!(await git.hasRemote())) return false; + return true; + } + + getLastStatus(): { lastAt: string | null; lastResult: SyncStatus | null } { + return { lastAt: this.lastAt, lastResult: this.lastResult }; + } + + listConflicts(): SyncConflict[] { return this.lastConflicts; } + + async sync(): Promise { + 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 = []; + } + return result; + } + + private async runSync(): Promise { + 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. local commit (변경 있으면) + let localSha: string | null = null; + let localChanged = false; + try { + await git.addAll(); + localChanged = await git.hasUncommittedChanges(); + if (localChanged) { + const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`); + localSha = c.sha; + } + } catch (e) { + 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 + 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/.md → id + const m = /notes\/(.+)\.md$/.exec(path); + return m ? m[1]! : path; + } +} +``` + +NOTE: `SyncService` 생성자에 `importSvc` 파라미터 추가됨 — `src/main/index.ts` 의 인스턴스 생성부도 갱신 필요 (Task 10 에서 처리). + +- [ ] **Step 3: tests + typecheck PASS** — full suite 회귀 (기존 SyncService 사용처 영향 확인) + +- [ ] **Step 4: commit** + +```bash +git add src/main/services/SyncService.ts tests/unit/SyncService.bidirectional.test.ts +git commit -m "feat(v030): SyncService.sync — 양방향 6단계 (export/commit/fetch/rebase/re-import/push) + conflict 반환" +``` + +--- + +## Task 5: SyncService.resolveConflict — local / remote / both 3 choice + +**Files:** + +- Modify: `src/main/services/SyncService.ts` +- Create: `tests/unit/SyncService.resolveConflict.test.ts` + +`resolveConflict(noteId, choice)` — git CLI 의 `checkout --ours` / `--theirs` / 양쪽 보존 후 `rebase --continue` → push. + +`local` = local 채택 (origin 변경 폐기) = `git checkout --ours` + `git add` + `git rebase --continue`. +`remote` = origin 채택 (local 변경 → note_revisions 에 보존 — `updateRawText(id, remoteText)` 호출 후 git checkout --theirs) = `git checkout --theirs`. +`both` = local + origin 모두 note_revisions 에 INSERT (별도 INSERT), latest = remote = `git checkout --theirs`. + +이 task 는 spec §5 의 단순화 — 본 cut 에서는 `local` / `remote` 만 구현. `both` 는 v0.3.1+ deferred (revision 분기 정책 검토 필요). spec 정정. + +- [ ] **Step 1: spec §5 정정** — 'both' 옵션 deferred 마킹. UI 도 2 choice (내 것 / 원격) 만. + +- [ ] **Step 2: failing test** — `tests/unit/SyncService.resolveConflict.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SyncService } from '../../src/main/services/SyncService.js'; + +vi.mock('../../src/main/services/GitClient.js'); +import { GitClient } from '../../src/main/services/GitClient.js'; + +describe('SyncService.resolveConflict', () => { + let svc: SyncService; + let gitInstance: { + run: ReturnType; + addAll: ReturnType; + push: ReturnType; + }; + + beforeEach(() => { + gitInstance = { + run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + addAll: vi.fn(async () => {}), + push: vi.fn(async () => {}) + }; + (GitClient as unknown as ReturnType).mockImplementation(() => gitInstance); + svc = new SyncService('/tmp', {} as never, {} as never); + }); + + it('local 선택 → checkout --ours + add + rebase --continue + push', async () => { + const r = await svc.resolveConflict('note-id', 'local'); + expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--ours', 'notes/note-id.md']); + expect(gitInstance.run).toHaveBeenCalledWith(['rebase', '--continue']); + expect(gitInstance.push).toHaveBeenCalled(); + expect(r.ok).toBe(true); + }); + + it('remote 선택 → checkout --theirs + add + rebase --continue + push', async () => { + const r = await svc.resolveConflict('note-id', 'remote'); + expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--theirs', 'notes/note-id.md']); + expect(r.ok).toBe(true); + }); + + it('checkout 실패 → ok:false + reason 반환', async () => { + gitInstance.run.mockResolvedValueOnce({ stdout: '', stderr: 'fail', exitCode: 1 }); + const r = await svc.resolveConflict('note-id', 'local'); + expect(r.ok).toBe(false); + expect(r.reason).toContain('checkout failed'); + }); +}); +``` + +- [ ] **Step 3: implementation** — `SyncService` 클래스에 추가: + +```ts +async resolveConflict(noteId: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }> { + const git = new GitClient(this.syncDir); + const flag = choice === 'local' ? '--ours' : '--theirs'; + const path = `notes/${noteId}.md`; + + const checkout = await git.run(['checkout', flag, path]); + if (checkout.exitCode !== 0) return { ok: false, reason: `checkout failed: ${checkout.stderr}` }; + + await git.addAll(); + const cont = await git.run(['rebase', '--continue']); + if (cont.exitCode !== 0) return { ok: false, reason: `rebase --continue failed: ${cont.stderr}` }; + + // re-import remote 변경 (remote 선택 시 local DB 의 raw_text 도 갱신해야 함) + if (choice === 'remote') { + await this.importSvc.applySyncFromDir(this.syncDir); + } + + try { await git.push(); } catch (e) { return { ok: false, reason: `push failed: ${(e as Error).message}` }; } + + // conflicts 캐시에서 해당 noteId 제거 + this.lastConflicts = this.lastConflicts.filter((c) => c.noteId !== noteId); + + return { ok: true }; +} +``` + +- [ ] **Step 4: test PASS + commit** + +```bash +git add src/main/services/SyncService.ts tests/unit/SyncService.resolveConflict.test.ts \ + docs/superpowers/specs/2026-05-09-v030-cut-e-design.md +git commit -m "feat(v030): SyncService.resolveConflict — local/remote 2 choice (both deferred)" +``` + +--- + +## Task 6: settings — sync_repo_url / sync_auto_enabled / sync_interval_min + +**Files:** + +- Modify: `src/main/services/SettingsService.ts` + +기존 SettingsService 의 zod schema 에 3 필드 추가 (default: enabled=true, interval_min=30, repo_url=null). getter/setter 메서드 추가. 기존 ai_enabled / onboarding_completed 와 동일 패턴. + +- [ ] **Step 1: SettingsService 의 schema 갱신** — 적절한 zod field 추가 + default 처리 + +- [ ] **Step 2: 새 메서드 추가** — `setSyncRepoUrl(url)`, `getSyncRepoUrl()`, `setAutoSyncEnabled(b)`, `getAutoSyncEnabled()`, `setSyncIntervalMin(n)`, `getSyncIntervalMin()`. min interval = 5분 (validation). + +- [ ] **Step 3: tests** — `tests/unit/SettingsService.test.ts` 의 적절한 describe 안에 6개 (set/get x 3 필드). + +- [ ] **Step 4: commit** + +```bash +git add src/main/services/SettingsService.ts tests/unit/SettingsService.test.ts +git commit -m "feat(v030): settings.sync_repo_url + sync_auto_enabled + sync_interval_min" +``` + +--- + +## Task 7: types + IPC handlers + preload bridge + +**Files:** + +- Modify: `src/shared/types.ts` — `ConflictRow`, `SyncStatusResult` interface + InboxApi 5 메서드 +- Modify: `src/main/ipc/settingsApi.ts` — 5 IPC handler +- Modify: `src/preload/index.ts` — 5 bridge 함수 +- Create: `tests/unit/sync-ipc.test.ts` + +5 채널: +- `settings:configure-sync` (url) — settings 갱신 + git init / remote add +- `settings:test-sync-connection` () — `git ls-remote` 결과 +- `sync:list-conflicts` () — SyncService.listConflicts() 반환 +- `sync:resolve-conflict` (noteId, choice) — SyncService.resolveConflict +- `sync:get-status` () — { lastAt, lastResult, nextAt } (nextAt 은 SyncTimer 가 계산) + +(테스트 패턴은 Cut C/D 의 inboxApi-revisions / inboxApi-search-review 동일 — 5 handler 별 happy + edge case = 약 5-7 tests) + +- [ ] **Step 1: types** — 적절한 interface 추가 +- [ ] **Step 2: failing test** — handler 5개 mock-based +- [ ] **Step 3: handler 구현** +- [ ] **Step 4: preload bridge** +- [ ] **Step 5: tests + commit** + +```bash +git add src/shared/types.ts src/main/ipc/settingsApi.ts src/preload/index.ts \ + tests/unit/sync-ipc.test.ts +git commit -m "feat(v030): sync IPC + preload (configure / test / list-conflicts / resolve / status)" +``` + +--- + +## Task 8: SettingsPage 의 SyncSection — Configure UI + +**Files:** + +- Create: `src/renderer/inbox/components/settings/SyncSection.tsx` +- Modify: `src/renderer/inbox/components/SettingsPage.tsx` — SyncSection mount +- Create: `tests/unit/SyncSection.test.tsx` + +`AiProviderSection.tsx` (Cut B) 패턴 참고. URL 입력 + 저장 + 연결 테스트 + 자동 sync 토글 + interval input + 지금 동기화 버튼 + 마지막 sync 결과 표시 + 충돌 해결 버튼 (활성 시). + +- [ ] **Step 1-5: 컴포넌트 + test + mount + commit** (다른 Section 패턴 따름) + +```bash +git add src/renderer/inbox/components/settings/SyncSection.tsx \ + src/renderer/inbox/components/SettingsPage.tsx \ + tests/unit/SyncSection.test.tsx +git commit -m "feat(v030): SettingsPage SyncSection — Configure UI (URL + 자동 sync + interval + 충돌 해결)" +``` + +--- + +## Task 9: ConflictModal — 충돌 해결 UI + +**Files:** + +- Create: `src/renderer/inbox/components/ConflictModal.tsx` +- Modify: `src/renderer/inbox/components/settings/SyncSection.tsx` — modal mount +- Create: `tests/unit/ConflictModal.test.tsx` + +`MoveStatusModal` / `RevisionHistoryModal` 패턴. open 시 `inbox.listConflicts()` → 각 conflict 별 양쪽 텍스트 + "내 것 사용" / "원격 사용" 버튼. 클릭 → `inbox.resolveConflict(noteId, choice)`. + +```bash +git add src/renderer/inbox/components/ConflictModal.tsx \ + src/renderer/inbox/components/settings/SyncSection.tsx \ + tests/unit/ConflictModal.test.tsx +git commit -m "feat(v030): ConflictModal — 충돌 해결 UI (local / remote 2 choice)" +``` + +--- + +## Task 10: SyncTimer — main process 자동 주기 sync + +**Files:** + +- Create: `src/main/services/SyncTimer.ts` +- Modify: `src/main/index.ts` — SyncTimer 인스턴스 생성 + start +- Create: `tests/unit/SyncTimer.test.ts` + +```ts +export class SyncTimer { + private handle: NodeJS.Timeout | null = null; + + constructor( + private syncSvc: SyncService, + private settings: SettingsService + ) {} + + async start(): Promise { + const enabled = await this.settings.getAutoSyncEnabled(); + if (!enabled) return; + const intervalMin = await this.settings.getSyncIntervalMin(); + const ms = Math.max(5, intervalMin) * 60 * 1000; + this.handle = setInterval(() => { void this.syncSvc.sync(); }, ms); + } + + stop(): void { + if (this.handle) { clearInterval(this.handle); this.handle = null; } + } + + /** settings 변경 시 호출 — 새 interval 로 재시작. */ + async reconfigure(): Promise { + this.stop(); + await this.start(); + } +} +``` + +main 의 `src/main/index.ts` 에서 SyncService + ImportService 인스턴스 생성 + SyncTimer 생성 + start. settings:configure-sync IPC 가 reconfigure 호출. + +`tests/unit/SyncTimer.test.ts` — fake timers + sync.mock 검증. + +```bash +git add src/main/services/SyncTimer.ts src/main/index.ts tests/unit/SyncTimer.test.ts +git commit -m "feat(v030): SyncTimer — 자동 주기 sync (settings.sync_interval_min, default 30)" +``` + +--- + +## Task 11: dogfood promoted + version bump + release commit + +- [ ] F21 promoted 마킹 (✅ v0.3.0 Cut E — A+B+C 옵션, both choice deferred) +- [ ] package.json: 0.2.11 → 0.3.0 + package-lock.json +- [ ] full unit + typecheck + e2e 검증 + +```bash +git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json package-lock.json +git commit -m "chore(release): v0.3.0 — Cut E (양방향 git sync + Configure UI + Conflict resolution)" +``` + +--- + +## Self-Review Checklist (수행자: 모든 task 완료 후 1회 점검) + +- [ ] **Spec coverage**: §3 sync 6단계 (Task 4) / §3-2 ImportService.applySyncFromDir (Task 3) / §3-3 GitClient 확장 (Task 1) / §4 Configure UI (Task 8) / §5 Conflict UI (Task 9, both deferred 정정 반영) / §6 자동 주기 (Task 10) / §7 IPC (Task 7) / §8 테스트 전략 카운트 일치 +- [ ] **Single write path 강제 (Cut C/D 정책 확장)**: `upsertFromSync` 가 raw_text 변경 path 에서 `updateRawText` 호출 (capture revision chain 보존) + tags 변경 path 에서 `rebuildFtsTagsForNote` 호출. 사용 case ALL — `applySyncFromDir` (Task 3) → `upsertFromSync` (Task 2). 직접 `notes_fts` / `note_revisions` mutate path 없음 검증. +- [ ] **Type 일관성**: `SyncStatus` (Task 4) ↔ `SyncStatusResult` (Task 7 IPC) ↔ `ConflictRow` (Task 7) 모두 일치 +- [ ] **단위 카운트**: GitClient 5 + upsertFromSync 5 + applySyncFromDir 2 + sync 5 + resolveConflict 3 + settings 6 + IPC 5-7 + SyncSection 2 + ConflictModal 2 + SyncTimer 3 = **약 38-40 신규** (목표 27 보다 많음 — sync 인프라 전수 검증) +- [ ] **regression 회귀 패턴 (Cut D 확립)**: spec 단계 invariant 항목 (`note_tags` INSERT path 3곳 / `notes_fts` write path / `note_revisions` write path) 별 grep 점검. importNote 같은 secondary path 누락 회귀 방지. + +--- + +## Risk + +- **인증 외부 의존**: SSH key / git credential helper — 사용자 OS 설정 의존, 빌드 단계 검증 한계. Configure UI 의 "연결 테스트" 가 사용자 안내 첫 단계 +- **다기기 환경 필요**: 본인 dogfood 가 Mac + Windows 양 기기 sync 환경에서만 가치 검증. Windows 단독 dogfood = sync 흐름 시뮬레이션 한계 +- **conflict 빈도 측정**: 단위 테스트는 가능. 실 사용 conflict 빈도는 다기기 dogfood 1주 필수 +- **timestamp 단조 가정**: NTP 부재 + 양 기기 시계 어긋남 시 upsertFromSync 의 updated_at 비교 부정확 → 잘못된 skip/update. dogfood 검증 +- **`both` 옵션 deferred**: revision branch 분기 정책 미정. v0.3.1+ 에서 검토 (Cut C revision history 와 sync chain merge 통합 설계) +- **자동 주기 sync 의 silent 충돌 누적**: interval mode 충돌 시 notification + 충돌 UI 자동 popup option (현재 cut: notification 만) +- **e2e**: Cut C/D 와 동일 — worktree node_modules 비어 있음, 본 cut 의 변경은 SettingsPage SyncSection + ConflictModal — capture/onboarding flow 무관. main 머지 후 검증 +- **SyncService 생성자 시그니처 변경 (Task 4)**: 기존 push-only 사용처 (예: `src/main/index.ts`, 테스트 mock) 모두 importSvc 파라미터 추가 필요. compile error 로 catch — 각 사용처 갱신 필수 diff --git a/docs/superpowers/specs/2026-05-09-v030-cut-e-design.md b/docs/superpowers/specs/2026-05-09-v030-cut-e-design.md index 4d2a2e1..f825a83 100644 --- a/docs/superpowers/specs/2026-05-09-v030-cut-e-design.md +++ b/docs/superpowers/specs/2026-05-09-v030-cut-e-design.md @@ -38,60 +38,81 @@ async sync(opts: { interval?: boolean } = {}): Promise { const git = new GitClient(this.syncDir); - // 1. fetch - const fetchR = await git.fetch(); - if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` }; - - // 2. local export (변경 감지 위해) + // 1. local export — 현재 SQLite 상태를 syncDir 에 markdown 으로 출력 await this.exportSvc.export(this.syncDir, { includeMedia: true }); await git.addAll(); const localChanged = await git.hasUncommittedChanges(); - // 3. local commit (있으면) + // 2. local commit (변경 있으면) let localSha: string | null = null; if (localChanged) { const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`); localSha = c.sha; } - // 4. rebase + // 3. fetch + const fetchR = await git.fetch(); + if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` }; + + // 4. rebase onto origin/main const rebaseR = await git.rebaseOnto('origin/main'); if (rebaseR.exitCode !== 0) { - // conflict — abort + 사용자에게 conflict UI 안내 + // conflict — abort + conflict 목록 반환 (UI 가 활성) await git.rebaseAbort(); - return { ok: false, reason: 'conflict', conflicts: await this.listConflicts() }; + return { ok: false, reason: 'conflict', conflicts: await this.listConflictsFromMarkdown() }; } - // 5. re-import (rebase 후 markdown 변경 → SQLite 적용) - const imported = await this.importSvc.importAll(this.syncDir); + // 5. re-import (rebase 후 markdown 변경 → SQLite upsertFromSync) + const imported = await this.importSvc.applySyncFromDir(this.syncDir); // 6. push - const pushR = await git.push(); - if (pushR.exitCode !== 0) return { ok: false, reason: `push failed: ${pushR.stderr}` }; + try { await git.push(); } catch (e) { return { ok: false, reason: `push failed: ${(e as Error).message}` }; } return { ok: true, changed: localChanged || imported.changedCount > 0, localSha, importedCount: imported.changedCount, pushed: true }; } ``` -### 3-2. ImportService 활용 +**6 단계 흐름 — local export 가 fetch 보다 먼저 (Cut E 정정)**: spec 초안은 fetch 우선이었으나, local export → commit 후 fetch + rebase 가 git workflow 표준 (rebase 가 local commit 위에 origin commit 적용). local export 안 한 상태로 fetch + rebase → 혼란 발생. -기존 ImportService (백업 복원 흐름) 가 markdown → SQLite 적재. sync 의 re-import 도 같은 service 활용: +`SyncStatus` 인터페이스 확장: ```ts -class ImportService { - async importAll(dir: string): Promise<{ changedCount: number; conflicts: string[] }> { - // dir 하위의 모든 .md 파일 → frontmatter parse → notes UPSERT - // existing note 와 비교 — updated_at 더 최신이면 갱신, 아니면 skip - // raw_text 다른 경우 → note_revisions 에 INSERT (new rev, edited_by='sync') - } +export interface SyncStatus { + ok: boolean; + reason?: string; + changed?: boolean; + localSha?: string | null; + pushed?: boolean; + importedCount?: number; + conflicts?: Array<{ noteId: string; localText: string; remoteText: string }>; // reason='conflict' 시 } ``` -**revision linear merge 정책**: +### 3-2. ImportService 활용 (실제 코드 정정) -- 옛 rev (origin/main 의 rev_5) 가 local 에 없으면 → INSERT note_revisions (timestamp 기준 적절 위치) -- local rev 와 origin rev 가 동일 timestamp + 다른 raw_text → conflict (사용자 prompt) -- 일반적으로 다른 timestamp 면 timestamp 순 linear chain 으로 merge +기존 ImportService 는 `run(sourceDir)` 메서드 (백업 복원 흐름) — `parsedToInput` → `repo.importNote()` 호출. spec 작성 시 가정한 `importAll(dir)` 시그니처는 실재 코드와 다름. + +`repo.importNote()` 의 기존 conflict 정책 (export tree 복원용): + +- id 없음 → INSERT (`status: 'inserted'`) +- id 있음 + raw_text 동일 → no-op (`status: 'skipped'`) +- id 있음 + raw_text 다름 → fork-on-id-collision (fresh uuidv7) (`status: 'forked'`) + +**Cut E sync 정책 — fork 미적합, in-place update + revision 보존**: + +sync 에서 양 기기 raw_text 가 다를 때 fork 하면 노트 갯수 무한 증가 → 부적합. 신설 메서드 `repo.upsertFromSync(input)`: + +- id 없음 → INSERT (m006 trigger 가 capture revision 자동 생성) +- id 있음 + raw_text 동일 → metadata 갱신 path + - source.updatedAt > local.updatedAt 인 경우만 ai_title/ai_summary/tags/status/dueDate 갱신 + - tags 변경 시 `rebuildFtsTagsForNote` 호출 (Cut D single write path) + - 동등/older 면 skip +- id 있음 + raw_text 다름 → 옵션 분기: + - source.updatedAt > local.updatedAt → `updateRawText(id, sourceRawText, sourceUpdatedAt)` (Cut C single write path) → 새 user revision INSERT, latest = source + - local.updatedAt > source.updatedAt → skip (다음 push 가 source 갱신할 것) + - 동일 timestamp + 다른 raw_text → SyncService 가 conflict 마킹 (rebase 단계 git markdown conflict 가 먼저 잡힘 — 본 분기는 rare) + +**revision edited_by**: 'sync' enum 추가 안 함 — `updateRawText` 의 default 'user' 그대로 활용 (sync = user-edited 변경 전파 = 의미상 user). YAGNI: m008 회피. ### 3-3. GitClient 확장 @@ -193,7 +214,7 @@ settings: `sync_auto_enabled: boolean` (default true 단, configured 일 때만) | Conflict UI | 3 choice 별 sync 동작 | | 자동 주기 sync | timer + interval=true mode | -**목표**: 단위 528 → 약 555 (+27), typecheck 0. +**목표**: 단위 608 → 약 635 (+27), typecheck 0. --- From dba64c546f7cc5d7c2631a55fedef795b2b5e665 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 03:23:00 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat(v030):=20GitClient=20=E2=80=94=20fet?= =?UTF-8?q?ch/rebaseOnto/rebaseAbort/hasUncommittedChanges/listConflicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/main/services/GitClient.ts | 23 ++++++++++++++ tests/unit/GitClient.fetch.test.ts | 51 ++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 tests/unit/GitClient.fetch.test.ts diff --git a/src/main/services/GitClient.ts b/src/main/services/GitClient.ts index e4db135..71ba3b0 100644 --- a/src/main/services/GitClient.ts +++ b/src/main/services/GitClient.ts @@ -89,4 +89,27 @@ export class GitClient { if (r.exitCode !== 0) throw new Error(`git rev-parse failed: ${r.stderr}`); return r.stdout.trim(); } + + async fetch(remote: string = 'origin'): Promise { + return this.run(['fetch', remote]); + } + + async rebaseOnto(ref: string): Promise { + return this.run(['rebase', ref]); + } + + async rebaseAbort(): Promise { + return this.run(['rebase', '--abort']); + } + + async hasUncommittedChanges(): Promise { + const r = await this.run(['status', '--porcelain']); + return r.stdout.trim().length > 0; + } + + /** rebase conflict 시 conflict 마킹된 파일 list. `git diff --name-only --diff-filter=U`. */ + async listConflicts(): Promise { + const r = await this.run(['diff', '--name-only', '--diff-filter=U']); + return r.stdout.split('\n').map((s) => s.trim()).filter((s) => s.length > 0); + } } diff --git a/tests/unit/GitClient.fetch.test.ts b/tests/unit/GitClient.fetch.test.ts new file mode 100644 index 0000000..4a76b4b --- /dev/null +++ b/tests/unit/GitClient.fetch.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GitClient } from '../../src/main/services/GitClient.js'; + +describe('GitClient — fetch / rebase / conflict 메서드', () => { + let client: GitClient; + let runSpy: ReturnType; + + beforeEach(() => { + client = new GitClient('/tmp/sync'); + runSpy = vi.spyOn(client, 'run'); + }); + + it('fetch — git fetch origin 호출', async () => { + runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); + const r = await client.fetch(); + expect(runSpy).toHaveBeenCalledWith(['fetch', 'origin']); + expect(r.exitCode).toBe(0); + }); + + it('rebaseOnto — git rebase origin/main', async () => { + runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); + const r = await client.rebaseOnto('origin/main'); + expect(runSpy).toHaveBeenCalledWith(['rebase', 'origin/main']); + expect(r.exitCode).toBe(0); + }); + + it('rebaseAbort — git rebase --abort', async () => { + runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); + await client.rebaseAbort(); + expect(runSpy).toHaveBeenCalledWith(['rebase', '--abort']); + }); + + it('hasUncommittedChanges — git status --porcelain 의 출력 있으면 true', async () => { + runSpy.mockResolvedValueOnce({ stdout: ' M notes/abc.md\n', stderr: '', exitCode: 0 }); + expect(await client.hasUncommittedChanges()).toBe(true); + + runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); + expect(await client.hasUncommittedChanges()).toBe(false); + }); + + it('listConflicts — git diff --name-only --diff-filter=U 결과 파싱', async () => { + runSpy.mockResolvedValueOnce({ + stdout: 'notes/aaa.md\nnotes/bbb.md\n', + stderr: '', + exitCode: 0 + }); + const r = await client.listConflicts(); + expect(runSpy).toHaveBeenCalledWith(['diff', '--name-only', '--diff-filter=U']); + expect(r).toEqual(['notes/aaa.md', 'notes/bbb.md']); + }); +}); From bbfd0cccdaef7748f50fcf157f9faaddc88d251d Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 03:27:49 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat(v030):=20NoteRepository.upsertFromSy?= =?UTF-8?q?nc=20=E2=80=94=20sync=20=EC=A0=84=EC=9A=A9=203=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20upsert=20+=20single=20write=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/main/repository/NoteRepository.ts | 160 ++++++++++++++++++ .../NoteRepository.upsertFromSync.test.ts | 98 +++++++++++ 2 files changed, 258 insertions(+) create mode 100644 tests/unit/NoteRepository.upsertFromSync.test.ts diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 7f76045..f6959bd 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -51,6 +51,29 @@ export interface ImportNoteResult { status: ImportNoteStatus; } +export interface UpsertFromSyncInput { + id: string; + rawText: string; + createdAt: string; + updatedAt: string; + aiTitle: string | null; + aiSummary: string | null; + titleEditedByUser: boolean; + summaryEditedByUser: boolean; + aiProvider: string | null; + aiGeneratedAt: string | null; + userIntent: string | null; + intentPromptedAt: string | null; + tags: { name: string; source: 'ai' | 'user' }[]; + status: NoteStatus; + statusChangedAt: string | null; + moveReason: string | null; + dueDate: string | null; + dueDateEditedByUser: boolean; +} + +export type UpsertFromSyncStatus = 'inserted' | 'updated' | 'skipped'; + const KEBAB_CASE_RE = /^[a-z0-9-]+$/; export class NoteRepository { @@ -863,6 +886,143 @@ export class NoteRepository { return { id: finalId, status }; } + /** + * v0.3.0 Cut E — sync 전용 upsert. 기존 importNote 의 fork-on-id-collision 정책은 + * sync 에 부적합 (양 기기 raw_text 가 다를 때마다 fork → 노트 갯수 무한 증가). + * + * 3 분기: + * - id 없음 → INSERT (capture revision + tags FTS sync) + * - id 있음 + raw_text 동일 → source.updatedAt 가 더 최신일 때만 metadata 갱신 + * - id 있음 + raw_text 다름 → source 가 더 최신이면 updateRawText (new user revision), + * local 이 더 최신이면 skip + * + * tags 변경 시 rebuildFtsTagsForNote 호출 — Cut D single write path 재사용. + * raw_text 변경 시 updateRawText 호출 — Cut C single write path 재사용. + */ + upsertFromSync(input: UpsertFromSyncInput): { id: string; status: UpsertFromSyncStatus } { + const existing = this.db + .prepare(`SELECT raw_text, updated_at, status FROM notes WHERE id=?`) + .get(input.id) as { raw_text: string; updated_at: string; status: NoteStatus } | undefined; + + if (!existing) { + // INSERT path + const tx = this.db.transaction(() => { + this.db + .prepare( + `INSERT INTO notes + (id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at, + title_edited_by_user, summary_edited_by_user, + user_intent, intent_prompted_at, + created_at, updated_at, + due_date, due_date_edited_by_user, + status, status_changed_at, move_reason) + VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + input.id, + input.rawText, + input.aiTitle, + input.aiSummary, + input.aiProvider, + input.aiGeneratedAt, + input.titleEditedByUser ? 1 : 0, + input.summaryEditedByUser ? 1 : 0, + input.userIntent, + input.intentPromptedAt, + input.createdAt, + input.updatedAt, + input.dueDate, + input.dueDateEditedByUser ? 1 : 0, + input.status, + input.statusChangedAt, + input.moveReason + ); + this.db + .prepare( + `INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) + VALUES (?, ?, ?, 'capture')` + ) + .run(input.id, input.rawText, input.createdAt); + if (input.tags.length > 0) { + const getOrInsertTag = this.db.prepare( + `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id` + ); + const linkAi = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')` + ); + const linkUser = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')` + ); + for (const t of input.tags) { + const row = getOrInsertTag.get(t.name) as { id: number }; + if (t.source === 'ai') linkAi.run(input.id, row.id); + else linkUser.run(input.id, row.id); + } + this.rebuildFtsTagsForNote(input.id); + } + }); + tx(); + return { id: input.id, status: 'inserted' }; + } + + if (input.updatedAt <= existing.updated_at) { + return { id: input.id, status: 'skipped' }; + } + + if (existing.raw_text !== input.rawText) { + this.updateRawText(input.id, input.rawText, new Date(input.updatedAt)); + } + + const tx = this.db.transaction(() => { + this.db + .prepare( + `UPDATE notes + SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END, + ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END, + ai_provider = ?, + ai_generated_at = ?, + due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END, + status = ?, + status_changed_at = ?, + move_reason = ?, + updated_at = ? + WHERE id = ?` + ) + .run( + input.aiTitle, + input.aiSummary, + input.aiProvider, + input.aiGeneratedAt, + input.dueDate, + input.status, + input.statusChangedAt, + input.moveReason, + input.updatedAt, + input.id + ); + this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(input.id); + if (input.tags.length > 0) { + const getOrInsertTag = this.db.prepare( + `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id` + ); + const linkAi = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')` + ); + const linkUser = this.db.prepare( + `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')` + ); + for (const t of input.tags) { + const row = getOrInsertTag.get(t.name) as { id: number }; + if (t.source === 'ai') linkAi.run(input.id, row.id); + else linkUser.run(input.id, row.id); + } + } + this.rebuildFtsTagsForNote(input.id); + }); + tx(); + return { id: input.id, status: 'updated' }; + } + getPendingCount(): number { const row = this.db .prepare( diff --git a/tests/unit/NoteRepository.upsertFromSync.test.ts b/tests/unit/NoteRepository.upsertFromSync.test.ts new file mode 100644 index 0000000..4500dad --- /dev/null +++ b/tests/unit/NoteRepository.upsertFromSync.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../../src/main/db/migrations/index.js'; +import { NoteRepository } from '../../src/main/repository/NoteRepository.js'; + +const baseInput = { + id: '00000000-0000-0000-0000-000000000001', + rawText: 'sync 본문', + createdAt: '2026-05-09T00:00:00Z', + updatedAt: '2026-05-10T00:00:00Z', + aiTitle: 'sync 제목', + aiSummary: 'sync 요약', + titleEditedByUser: false, + summaryEditedByUser: false, + aiProvider: 'p', + aiGeneratedAt: '2026-05-10T00:00:00Z', + userIntent: null, + intentPromptedAt: null, + tags: [{ name: '동기', source: 'user' as const }], + status: 'active' as const, + statusChangedAt: null, + moveReason: null, + dueDate: null, + dueDateEditedByUser: false +}; + +describe('NoteRepository.upsertFromSync', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + afterEach(() => { db.close(); }); + + it('id 없음 → INSERT (status=inserted) + capture revision + tags FTS sync', () => { + const r = repo.upsertFromSync(baseInput); + expect(r.status).toBe('inserted'); + expect(r.id).toBe(baseInput.id); + const note = repo.findById(baseInput.id); + expect(note?.rawText).toBe('sync 본문'); + expect(note?.aiTitle).toBe('sync 제목'); + const revs = repo.listRevisions(baseInput.id); + expect(revs).toHaveLength(1); + expect(revs[0]!.editedBy).toBe('capture'); + const fts = db.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`).get(baseInput.id) as { tags: string }; + expect(fts.tags).toBe('동기'); + }); + + it('id 있음 + raw_text 동일 + source 더 최신 → metadata 갱신 (status=updated)', () => { + const created = repo.create({ rawText: 'sync 본문' }); + repo.updateAiResult(created.id, { title: '옛 제목', summary: '옛 요약', tags: ['old'], provider: 'p' }); + db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id); + const r = repo.upsertFromSync({ ...baseInput, id: created.id }); + expect(r.status).toBe('updated'); + const note = repo.findById(created.id); + expect(note?.aiTitle).toBe('sync 제목'); + expect(note?.tags.map((t) => t.name)).toEqual(['동기']); + const fts = db.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`).get(created.id) as { tags: string }; + expect(fts.tags).toBe('동기'); + }); + + it('id 있음 + raw_text 동일 + source 더 옛 → skip (status=skipped)', () => { + const created = repo.create({ rawText: 'sync 본문' }); + repo.updateAiResult(created.id, { title: '신선한 제목', summary: 'fresh', tags: ['x'], provider: 'p' }); + db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-12T00:00:00Z', created.id); + const r = repo.upsertFromSync({ ...baseInput, id: created.id, updatedAt: '2026-05-10T00:00:00Z' }); + expect(r.status).toBe('skipped'); + const note = repo.findById(created.id); + expect(note?.aiTitle).toBe('신선한 제목'); + }); + + it('id 있음 + raw_text 다름 + source 더 최신 → updateRawText (status=updated) + new user revision', () => { + const created = repo.create({ rawText: 'old text' }); + db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id); + const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'new sync text' }); + expect(r.status).toBe('updated'); + const note = repo.findById(created.id); + expect(note?.rawText).toBe('new sync text'); + const revs = repo.listRevisions(created.id); + expect(revs).toHaveLength(2); // capture (old) + user (new) + expect(revs[0]!.editedBy).toBe('user'); + expect(revs[0]!.rawText).toBe('new sync text'); + }); + + it('id 있음 + raw_text 다름 + source 더 옛 → skip', () => { + const created = repo.create({ rawText: 'local fresh' }); + db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-15T00:00:00Z', created.id); + const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'old sync text', updatedAt: '2026-05-10T00:00:00Z' }); + expect(r.status).toBe('skipped'); + const note = repo.findById(created.id); + expect(note?.rawText).toBe('local fresh'); + }); +}); From 9a1f0e269a8500345f2e3654221544e039ff824a Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 03:33:48 +0900 Subject: [PATCH 04/12] feat(v030): ImportService.applySyncFromDir + frontmatter status/dueDate/moveReason round-trip Co-Authored-By: Claude Sonnet 4.6 --- src/main/services/ExportService.ts | 5 + src/main/services/ImportService.ts | 31 +++++ src/main/services/exportFormat.ts | 19 ++++ src/main/services/importFormat.ts | 19 ++++ .../ImportService.applySyncFromDir.test.ts | 106 ++++++++++++++++++ tests/unit/exportFormat.test.ts | 53 +++++++++ tests/unit/importFormat.test.ts | 65 +++++++++++ 7 files changed, 298 insertions(+) create mode 100644 tests/unit/ImportService.applySyncFromDir.test.ts diff --git a/src/main/services/ExportService.ts b/src/main/services/ExportService.ts index fff4816..de08d27 100644 --- a/src/main/services/ExportService.ts +++ b/src/main/services/ExportService.ts @@ -64,6 +64,11 @@ function noteToExportNote(n: Note): ExportNote { aiGeneratedAt: n.aiGeneratedAt, userIntent: n.userIntent, intentPromptedAt: n.intentPromptedAt, + status: n.status, + statusChangedAt: n.statusChangedAt, + moveReason: n.moveReason, + dueDate: n.dueDate, + dueDateEditedByUser: n.dueDateEditedByUser, tags: n.tags.map((t) => ({ name: t.name, source: t.source })), media: n.media.map((m, idx) => ({ rel: `media/${n.id.slice(0, 8)}__${idx + 1}.${inferExt(m.mime)}`, diff --git a/src/main/services/ImportService.ts b/src/main/services/ImportService.ts index 93db353..d13eaac 100644 --- a/src/main/services/ImportService.ts +++ b/src/main/services/ImportService.ts @@ -130,6 +130,37 @@ export class ImportService { }; } + async applySyncFromDir(dir: string): Promise<{ changedCount: number }> { + const files = await this.scanNotes(dir); + let changedCount = 0; + for (const f of files) { + const content = await readFile(f, 'utf8'); + const parsed = parseExportNote(content); + const r = this.repo.upsertFromSync({ + id: parsed.id, + rawText: parsed.rawText, + createdAt: parsed.createdAt, + updatedAt: parsed.updatedAt, + aiTitle: parsed.aiTitle, + aiSummary: parsed.aiSummary, + titleEditedByUser: parsed.titleEditedByUser, + summaryEditedByUser: parsed.summaryEditedByUser, + aiProvider: parsed.aiProvider, + aiGeneratedAt: parsed.aiGeneratedAt, + userIntent: parsed.userIntent, + intentPromptedAt: parsed.intentPromptedAt, + tags: parsed.tags, + status: parsed.status, + statusChangedAt: parsed.statusChangedAt, + moveReason: parsed.moveReason, + dueDate: parsed.dueDate, + dueDateEditedByUser: parsed.dueDateEditedByUser + }); + if (r.status !== 'skipped') changedCount += 1; + } + return { changedCount }; + } + private async scanNotes(sourceDir: string): Promise { const notesDir = join(sourceDir, 'notes'); let entries: string[]; diff --git a/src/main/services/exportFormat.ts b/src/main/services/exportFormat.ts index 1116629..e0eb5cd 100644 --- a/src/main/services/exportFormat.ts +++ b/src/main/services/exportFormat.ts @@ -29,6 +29,13 @@ export interface ExportNote { aiGeneratedAt: string | null; userIntent: string | null; intentPromptedAt: string | null; + // v0.3.0 Cut E — Cut B (status), Cut C (dueDate via m002), and dueDate user-edited flag + // need to round-trip through F5 export and Cut E sync. + status: 'active' | 'completed' | 'archived' | 'trashed'; + statusChangedAt: string | null; + moveReason: string | null; + dueDate: string | null; + dueDateEditedByUser: boolean; tags: ExportNoteTag[]; media: ExportNoteMedia[]; } @@ -155,6 +162,18 @@ export function composeFrontmatter(note: ExportNote): string { lines.push(`ai_generated_at: ${note.aiGeneratedAt}`); } + lines.push(`status: ${note.status}`); + if (note.statusChangedAt !== null) { + lines.push(`status_changed_at: ${note.statusChangedAt}`); + } + if (note.moveReason !== null) { + lines.push(`move_reason: ${formatScalar(note.moveReason)}`); + } + if (note.dueDate !== null) { + lines.push(`due_date: ${note.dueDate}`); + lines.push(`due_date_source: ${note.dueDateEditedByUser ? 'user' : 'ai'}`); + } + if (note.media.length > 0) { lines.push('images:'); for (const m of note.media) { diff --git a/src/main/services/importFormat.ts b/src/main/services/importFormat.ts index b64b7c4..6917df2 100644 --- a/src/main/services/importFormat.ts +++ b/src/main/services/importFormat.ts @@ -34,6 +34,13 @@ export interface ParsedNote { userIntent: string | null; intentPromptedAt: string | null; deletedAt: string | null; // 신규 v0.2.3 #4 + // v0.3.0 Cut E — round-trip status / due_date / move_reason from frontmatter. + // Default to 'active' / null / false when absent (older exports pre-Cut E). + status: 'active' | 'completed' | 'archived' | 'trashed'; + statusChangedAt: string | null; + moveReason: string | null; + dueDate: string | null; + dueDateEditedByUser: boolean; tags: ParsedNoteTag[]; images: ParsedNoteImage[]; exportVersion: number; @@ -335,6 +342,13 @@ export function parseExportNote(markdown: string): ParsedNote { const versionRaw = get('inkling_export_version'); const exportVersion = versionRaw === null ? 0 : Number.parseInt(versionRaw, 10) || 0; + const statusRaw = get('status'); + const validStatuses = ['active', 'completed', 'archived', 'trashed'] as const; + const status = (validStatuses as readonly string[]).includes(statusRaw ?? 'active') + ? ((statusRaw ?? 'active') as ParsedNote['status']) + : 'active'; + const dueDateSource = get('due_date_source'); + return { id, createdAt, @@ -349,6 +363,11 @@ export function parseExportNote(markdown: string): ParsedNote { userIntent: get('user_intent'), intentPromptedAt: get('intent_prompted_at'), deletedAt: get('deleted_at'), + status, + statusChangedAt: get('status_changed_at'), + moveReason: get('move_reason'), + dueDate: get('due_date'), + dueDateEditedByUser: dueDateSource === 'user', tags: fm.tags, images: fm.images, exportVersion diff --git a/tests/unit/ImportService.applySyncFromDir.test.ts b/tests/unit/ImportService.applySyncFromDir.test.ts new file mode 100644 index 0000000..544bf3a --- /dev/null +++ b/tests/unit/ImportService.applySyncFromDir.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runMigrations } from '@main/db/migrations/index.js'; +import { NoteRepository } from '@main/repository/NoteRepository.js'; +import { ImportService } from '@main/services/ImportService.js'; +import { MediaStore } from '@main/services/MediaStore.js'; + +describe('ImportService.applySyncFromDir', () => { + let db: Database.Database; + let repo: NoteRepository; + let svc: ImportService; + let workDir: string; + + beforeEach(async () => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + workDir = await mkdtemp(join(tmpdir(), 'inkling-sync-')); + const mediaStore = new MediaStore(workDir); + svc = new ImportService(repo, mediaStore); + }); + + afterEach(async () => { + db.close(); + await rm(workDir, { recursive: true, force: true }); + }); + + it('inserts new notes and reports changedCount', async () => { + const notesDir = join(workDir, 'notes'); + await mkdir(notesDir, { recursive: true }); + await writeFile( + join(notesDir, 'a.md'), + `---\nid: 00000000-0000-0000-0000-000000000001\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: title\ntitle_source: ai\nsummary: summary\nsummary_source: ai\nstatus: active\ninkling_export_version: 1\n---\n\n# title\n\n> summary\n\nbody\n` + ); + const r = await svc.applySyncFromDir(workDir); + expect(r.changedCount).toBe(1); + const note = repo.findById('00000000-0000-0000-0000-000000000001'); + expect(note?.rawText).toBe('body'); + }); + + it('skips unchanged notes (no changedCount increment)', async () => { + const created = repo.create({ rawText: 'body' }); + db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-15T00:00:00Z', created.id); + const notesDir = join(workDir, 'notes'); + await mkdir(notesDir, { recursive: true }); + await writeFile( + join(notesDir, 'a.md'), + `---\nid: ${created.id}\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: active\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n` + ); + const r = await svc.applySyncFromDir(workDir); + expect(r.changedCount).toBe(0); + }); + + it('returns changedCount=0 for an empty notes directory', async () => { + const notesDir = join(workDir, 'notes'); + await mkdir(notesDir, { recursive: true }); + const r = await svc.applySyncFromDir(workDir); + expect(r.changedCount).toBe(0); + }); + + it('updates a note when source updatedAt is newer', async () => { + const created = repo.create({ rawText: 'old body' }); + db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-01T00:00:00Z', created.id); + const notesDir = join(workDir, 'notes'); + await mkdir(notesDir, { recursive: true }); + await writeFile( + join(notesDir, 'a.md'), + `---\nid: ${created.id}\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: active\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nnew body\n` + ); + const r = await svc.applySyncFromDir(workDir); + expect(r.changedCount).toBe(1); + const note = repo.findById(created.id); + expect(note?.rawText).toBe('new body'); + }); + + it('preserves status field from frontmatter', async () => { + const notesDir = join(workDir, 'notes'); + await mkdir(notesDir, { recursive: true }); + await writeFile( + join(notesDir, 'a.md'), + `---\nid: 00000000-0000-0000-0000-000000000002\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: archived\nstatus_changed_at: 2026-05-08T00:00:00Z\nmove_reason: done\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n` + ); + await svc.applySyncFromDir(workDir); + const note = repo.findById('00000000-0000-0000-0000-000000000002'); + expect(note?.status).toBe('archived'); + expect(note?.statusChangedAt).toBe('2026-05-08T00:00:00Z'); + expect(note?.moveReason).toBe('done'); + }); + + it('preserves dueDate from frontmatter', async () => { + const notesDir = join(workDir, 'notes'); + await mkdir(notesDir, { recursive: true }); + await writeFile( + join(notesDir, 'a.md'), + `---\nid: 00000000-0000-0000-0000-000000000003\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: active\ndue_date: 2026-06-01\ndue_date_source: user\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n` + ); + await svc.applySyncFromDir(workDir); + const note = repo.findById('00000000-0000-0000-0000-000000000003'); + expect(note?.dueDate).toBe('2026-06-01'); + expect(note?.dueDateEditedByUser).toBe(true); + }); +}); diff --git a/tests/unit/exportFormat.test.ts b/tests/unit/exportFormat.test.ts index 2212ae4..ed7f611 100644 --- a/tests/unit/exportFormat.test.ts +++ b/tests/unit/exportFormat.test.ts @@ -22,6 +22,11 @@ const baseNote: ExportNote = { aiGeneratedAt: '2026-04-25T14:23:34.000Z', userIntent: null, intentPromptedAt: null, + status: 'active', + statusChangedAt: null, + moveReason: null, + dueDate: null, + dueDateEditedByUser: false, tags: [{ name: 'pr', source: 'ai' }, { name: 'review', source: 'user' }], media: [] }; @@ -122,6 +127,54 @@ describe('composeFrontmatter', () => { expect(fm).toContain('mime: image/png'); expect(fm).toContain('bytes: 1234'); }); + + it('always emits status: active for a default note', () => { + const fm = composeFrontmatter(baseNote); + expect(fm).toContain('status: active'); + }); + + it('emits due_date and due_date_source together when dueDate present', () => { + const fm = composeFrontmatter({ ...baseNote, dueDate: '2026-06-01', dueDateEditedByUser: true }); + expect(fm).toContain('due_date: 2026-06-01'); + expect(fm).toContain('due_date_source: user'); + }); + + it('emits due_date_source: ai when dueDateEditedByUser is false', () => { + const fm = composeFrontmatter({ ...baseNote, dueDate: '2026-06-01', dueDateEditedByUser: false }); + expect(fm).toContain('due_date: 2026-06-01'); + expect(fm).toContain('due_date_source: ai'); + }); + + it('omits due_date and due_date_source when dueDate is null', () => { + const fm = composeFrontmatter(baseNote); + expect(fm).not.toContain('due_date:'); + expect(fm).not.toContain('due_date_source:'); + }); + + it('emits move_reason when present', () => { + const fm = composeFrontmatter({ ...baseNote, status: 'archived', moveReason: 'done for now' }); + expect(fm).toContain('status: archived'); + expect(fm).toContain('move_reason: done for now'); + }); + + it('emits status_changed_at when present', () => { + const fm = composeFrontmatter({ ...baseNote, statusChangedAt: '2026-05-01T00:00:00Z' }); + expect(fm).toContain('status_changed_at: 2026-05-01T00:00:00Z'); + }); + + it('status/due_date/move_reason fields appear before images: in frontmatter', () => { + const fm = composeFrontmatter({ + ...baseNote, + dueDate: '2026-06-01', + dueDateEditedByUser: false, + media: [{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 1 }] + }); + const statusPos = fm.indexOf('status:'); + const imagesPos = fm.indexOf('images:'); + expect(statusPos).toBeGreaterThan(-1); + expect(imagesPos).toBeGreaterThan(-1); + expect(statusPos).toBeLessThan(imagesPos); + }); }); describe('composeMarkdown', () => { diff --git a/tests/unit/importFormat.test.ts b/tests/unit/importFormat.test.ts index 5326909..aff6940 100644 --- a/tests/unit/importFormat.test.ts +++ b/tests/unit/importFormat.test.ts @@ -18,6 +18,11 @@ const baseNote: ExportNote = { aiGeneratedAt: '2026-04-25T14:23:34.000Z', userIntent: null, intentPromptedAt: null, + status: 'active', + statusChangedAt: null, + moveReason: null, + dueDate: null, + dueDateEditedByUser: false, tags: [{ name: 'pr', source: 'ai' }, { name: 'review', source: 'user' }], media: [] }; @@ -180,6 +185,66 @@ describe('parseExportNote — provenance', () => { }); }); +describe('parseExportNote — status/dueDate/moveReason round-trip (v0.3.0 Cut E)', () => { + it('round-trips status=active (default)', () => { + const md = composeMarkdown(baseNote); + const parsed = parseExportNote(md); + expect(parsed.status).toBe('active'); + expect(parsed.statusChangedAt).toBeNull(); + expect(parsed.moveReason).toBeNull(); + expect(parsed.dueDate).toBeNull(); + expect(parsed.dueDateEditedByUser).toBe(false); + }); + + it('round-trips status=archived with statusChangedAt and moveReason', () => { + const note: ExportNote = { + ...baseNote, + status: 'archived', + statusChangedAt: '2026-05-01T10:00:00Z', + moveReason: 'project done' + }; + const md = composeMarkdown(note); + const parsed = parseExportNote(md); + expect(parsed.status).toBe('archived'); + expect(parsed.statusChangedAt).toBe('2026-05-01T10:00:00Z'); + expect(parsed.moveReason).toBe('project done'); + }); + + it('round-trips dueDate with dueDateEditedByUser=true', () => { + const note: ExportNote = { + ...baseNote, + dueDate: '2026-06-15', + dueDateEditedByUser: true + }; + const md = composeMarkdown(note); + const parsed = parseExportNote(md); + expect(parsed.dueDate).toBe('2026-06-15'); + expect(parsed.dueDateEditedByUser).toBe(true); + }); + + it('round-trips dueDate with dueDateEditedByUser=false (ai source)', () => { + const note: ExportNote = { + ...baseNote, + dueDate: '2026-07-01', + dueDateEditedByUser: false + }; + const md = composeMarkdown(note); + const parsed = parseExportNote(md); + expect(parsed.dueDate).toBe('2026-07-01'); + expect(parsed.dueDateEditedByUser).toBe(false); + }); + + it('defaults to status=active for older exports without status field', () => { + // Simulate a pre-Cut E export that has no status line + const md = `---\nid: 014a3b9c-1234-7890-abcd-000000000001\ncreated_at: 2026-04-25T14:23:11.000Z\nupdated_at: 2026-04-25T14:24:02.000Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`; + const parsed = parseExportNote(md); + expect(parsed.status).toBe('active'); + expect(parsed.dueDate).toBeNull(); + expect(parsed.moveReason).toBeNull(); + expect(parsed.dueDateEditedByUser).toBe(false); + }); +}); + describe('parseExportNote — edge cases', () => { it('preserves user_intent when present', () => { const md = composeMarkdown({ From 33588b09df4b191219060902e086f7ad35ceca26 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 03:40:09 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat(v030):=20SyncService.sync=20?= =?UTF-8?q?=E2=80=94=20=EC=96=91=EB=B0=A9=ED=96=A5=206=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=20(export/commit/fetch/rebase/re-import/push)=20+=20conflict?= =?UTF-8?q?=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/main/index.ts | 2 +- src/main/ipc/settingsApi.ts | 2 +- src/main/services/GitClient.ts | 6 + src/main/services/SyncService.ts | 111 ++++++++++++++++--- tests/unit/SyncService.bidirectional.test.ts | 95 ++++++++++++++++ tests/unit/SyncService.test.ts | 14 ++- 6 files changed, 207 insertions(+), 23 deletions(-) create mode 100644 tests/unit/SyncService.bidirectional.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 8aee3ec..d93c027 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -196,7 +196,7 @@ app.whenReady().then(async () => { const exportSvc = new ExportService(repo, store); const importSvc = new ImportService(repo, store); - const syncSvc = new SyncService(paths.profileDir, exportSvc); + const syncSvc = new SyncService(paths.profileDir, exportSvc, importSvc); const backup = new BackupService(db, join(paths.profileDir, 'backups')); void backup.runDaily() diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index a1c86ad..2aa7ad0 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -239,7 +239,7 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { return { ok: true } as const; } if (r.changed) { - logger.info('sync.done', { sha: r.sha, pushed: r.pushed }); + logger.info('sync.done', { sha: r.localSha, pushed: r.pushed }); new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show(); } else { new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show(); diff --git a/src/main/services/GitClient.ts b/src/main/services/GitClient.ts index 71ba3b0..5fd5c01 100644 --- a/src/main/services/GitClient.ts +++ b/src/main/services/GitClient.ts @@ -107,6 +107,12 @@ export class GitClient { return r.stdout.trim().length > 0; } + /** ref (branch, tag, remote branch) 존재 여부 확인. `git rev-parse --verify`. */ + async refExists(ref: string): Promise { + const r = await this.run(['rev-parse', '--verify', ref]); + return r.exitCode === 0; + } + /** rebase conflict 시 conflict 마킹된 파일 list. `git diff --name-only --diff-filter=U`. */ async listConflicts(): Promise { const r = await this.run(['diff', '--name-only', '--diff-filter=U']); diff --git a/src/main/services/SyncService.ts b/src/main/services/SyncService.ts index 0a790ab..fcfcad4 100644 --- a/src/main/services/SyncService.ts +++ b/src/main/services/SyncService.ts @@ -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 { - 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 { + 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/.md or notes/--.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; } } diff --git a/tests/unit/SyncService.bidirectional.test.ts b/tests/unit/SyncService.bidirectional.test.ts new file mode 100644 index 0000000..6468dd8 --- /dev/null +++ b/tests/unit/SyncService.bidirectional.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SyncService } from '../../src/main/services/SyncService.js'; + +vi.mock('../../src/main/services/GitClient.js'); +import { GitClient } from '../../src/main/services/GitClient.js'; + +describe('SyncService.sync — 양방향', () => { + let svc: SyncService; + let exportSvc: { export: ReturnType }; + let importSvc: { applySyncFromDir: ReturnType }; + let gitInstance: { + isRepo: ReturnType; + hasRemote: ReturnType; + addAll: ReturnType; + hasUncommittedChanges: ReturnType; + commit: ReturnType; + fetch: ReturnType; + refExists: ReturnType; + rebaseOnto: ReturnType; + rebaseAbort: ReturnType; + listConflicts: ReturnType; + push: ReturnType; + }; + + beforeEach(() => { + exportSvc = { export: vi.fn(async () => {}) }; + importSvc = { applySyncFromDir: vi.fn(async () => ({ changedCount: 0 })) }; + gitInstance = { + isRepo: vi.fn(async () => true), + hasRemote: vi.fn(async () => true), + addAll: vi.fn(async () => {}), + hasUncommittedChanges: vi.fn(async () => true), + commit: vi.fn(async () => ({ changed: true, sha: 'abc' })), + fetch: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + refExists: vi.fn(async () => true), + rebaseOnto: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + rebaseAbort: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + listConflicts: vi.fn(async () => []), + push: vi.fn(async () => {}) + }; + (GitClient as unknown as ReturnType).mockImplementation(function () { return gitInstance; }); + svc = new SyncService( + '/tmp/profile', + exportSvc as unknown as never, + importSvc as unknown as never + ); + }); + + it('happy path — 6단계 모두 호출, ok:true', async () => { + const r = await svc.sync(); + expect(exportSvc.export).toHaveBeenCalled(); + expect(gitInstance.addAll).toHaveBeenCalled(); + expect(gitInstance.commit).toHaveBeenCalled(); + expect(gitInstance.fetch).toHaveBeenCalled(); + expect(gitInstance.rebaseOnto).toHaveBeenCalledWith('origin/main'); + expect(importSvc.applySyncFromDir).toHaveBeenCalled(); + expect(gitInstance.push).toHaveBeenCalled(); + expect(r.ok).toBe(true); + expect(r.pushed).toBe(true); + }); + + it('local 변경 없음 → commit skip + 다음 단계 진행', async () => { + gitInstance.hasUncommittedChanges.mockResolvedValueOnce(false); + const r = await svc.sync(); + expect(gitInstance.commit).not.toHaveBeenCalled(); + expect(gitInstance.fetch).toHaveBeenCalled(); + expect(r.ok).toBe(true); + }); + + it('rebase 실패 → abort + reason=conflict + conflicts 포함', async () => { + gitInstance.rebaseOnto.mockResolvedValueOnce({ stdout: '', stderr: 'CONFLICT', exitCode: 1 }); + gitInstance.listConflicts.mockResolvedValueOnce(['notes/aaa.md', 'notes/bbb.md']); + const r = await svc.sync(); + expect(gitInstance.rebaseAbort).toHaveBeenCalled(); + expect(r.ok).toBe(false); + expect(r.reason).toBe('conflict'); + expect(r.conflicts?.length).toBe(2); + expect(gitInstance.push).not.toHaveBeenCalled(); + }); + + it('fetch 실패 → reason 반환', async () => { + gitInstance.fetch.mockResolvedValueOnce({ stdout: '', stderr: 'no network', exitCode: 1 }); + const r = await svc.sync(); + expect(r.ok).toBe(false); + expect(r.reason).toContain('fetch failed'); + expect(gitInstance.rebaseOnto).not.toHaveBeenCalled(); + }); + + it('not configured → ok:false + reason=not_configured', async () => { + gitInstance.isRepo.mockResolvedValueOnce(false); + const r = await svc.sync(); + expect(r.ok).toBe(false); + expect(r.reason).toBe('not_configured'); + }); +}); diff --git a/tests/unit/SyncService.test.ts b/tests/unit/SyncService.test.ts index ea10c84..1244a66 100644 --- a/tests/unit/SyncService.test.ts +++ b/tests/unit/SyncService.test.ts @@ -9,6 +9,7 @@ import { runMigrations } from '@main/db/migrations/index.js'; import { NoteRepository } from '@main/repository/NoteRepository.js'; import { MediaStore } from '@main/services/MediaStore.js'; import { ExportService } from '@main/services/ExportService.js'; +import { ImportService } from '@main/services/ImportService.js'; import { SyncService } from '@main/services/SyncService.js'; const execFileAsync = promisify(execFile); @@ -47,6 +48,7 @@ describe('SyncService', () => { let repo: NoteRepository; let mediaStore: MediaStore; let exportSvc: ExportService; + let importSvc: ImportService; let svc: SyncService; let remoteDir: string | null = null; let prevEnv: NodeJS.ProcessEnv; @@ -73,7 +75,8 @@ describe('SyncService', () => { repo = new NoteRepository(db); mediaStore = new MediaStore(profileDir); exportSvc = new ExportService(repo, mediaStore, () => new Date('2026-04-26T12:00:00Z')); - svc = new SyncService(profileDir, exportSvc, () => new Date('2026-04-26T12:00:00Z')); + importSvc = new ImportService(repo, mediaStore); + svc = new SyncService(profileDir, exportSvc, importSvc, () => new Date('2026-04-26T12:00:00Z')); }); afterEach(() => { @@ -110,7 +113,7 @@ describe('SyncService', () => { expect(r.ok).toBe(true); expect(r.changed).toBe(true); expect(r.pushed).toBe(true); - expect(r.sha).toMatch(/^[0-9a-f]{40}$/); + expect(r.localSha).toMatch(/^[0-9a-f]{40}$/); expect(existsSync(join(svc.getSyncDir(), 'manifest.json'))).toBe(true); expect(existsSync(join(svc.getSyncDir(), 'notes'))).toBe(true); expect(existsSync(join(svc.getSyncDir(), 'index.jsonl'))).toBe(true); @@ -122,10 +125,11 @@ describe('SyncService', () => { const first = await svc.sync(); expect(first.ok).toBe(true); expect(first.changed).toBe(true); - // Re-sync without DB change. With fixed now() → identical files → git sees no change. + // Re-sync without DB change. With fixed now() → identical files → git sees no local change. + // New bidirectional flow: always does fetch+rebase+re-import+push. const second = await svc.sync(); expect(second.ok).toBe(true); - expect(second.changed).toBe(false); - expect(second.pushed).toBe(false); + expect(second.changed).toBe(false); // no local commit + importedCount=0 + expect(second.pushed).toBe(true); // push always runs on success }); }); From 8436846657921fdd9b616f2f41a7156eb4b1bb0f Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 03:42:50 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat(v030):=20SyncService.resolveConflict?= =?UTF-8?q?=20=E2=80=94=20local/remote=202=20choice=20(both=20deferred)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/services/SyncService.ts | 51 ++++++++++++++++ .../unit/SyncService.resolveConflict.test.ts | 60 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 tests/unit/SyncService.resolveConflict.test.ts diff --git a/src/main/services/SyncService.ts b/src/main/services/SyncService.ts index fcfcad4..621b198 100644 --- a/src/main/services/SyncService.ts +++ b/src/main/services/SyncService.ts @@ -66,6 +66,57 @@ export class SyncService { return result; } + /** + * v0.3.0 Cut E — conflict 해결. local/remote 2 choice (both deferred to v0.3.1+). + * 사용자가 ConflictModal 에서 선택 → IPC → 본 메서드. 각 noteId 별 호출. + * + * - 'local' = 내 것 사용 (origin 변경 폐기) → git checkout --ours + * - 'remote' = 원격 사용 → git checkout --theirs + applySyncFromDir (local DB 갱신) + * + * 모든 conflict 해결 후 rebase --continue 가 성공 → push. + * UI 가 여러 conflict 를 loop 호출하면 마지막 호출에서 push 까지 완료. + */ + async resolveConflict( + noteId: string, + choice: 'local' | 'remote' + ): Promise<{ ok: true } | { ok: false; reason: string }> { + const git = new GitClient(this.syncDir); + const flag = choice === 'local' ? '--ours' : '--theirs'; + const path = `notes/${noteId}.md`; + + const checkout = await git.run(['checkout', flag, path]); + if (checkout.exitCode !== 0) { + return { ok: false, reason: `checkout failed: ${checkout.stderr}` }; + } + + await git.addAll(); + + const cont = await git.run(['rebase', '--continue']); + if (cont.exitCode !== 0) { + // Likely other unresolved files — UI will call resolveConflict for them. + return { ok: false, reason: `rebase --continue failed: ${cont.stderr}` }; + } + + if (choice === 'remote') { + try { + await this.importSvc.applySyncFromDir(this.syncDir); + } catch (e) { + return { ok: false, reason: `re-import failed: ${(e as Error).message}` }; + } + } + + try { + await git.push(); + } catch (e) { + return { ok: false, reason: `push failed: ${(e as Error).message}` }; + } + + // Remove this noteId from cached conflicts list + this.lastConflicts = this.lastConflicts.filter((c) => c.noteId !== noteId); + + return { ok: true }; + } + private async runSync(): Promise { if (!(await this.isConfigured())) return { ok: false, reason: 'not_configured' }; diff --git a/tests/unit/SyncService.resolveConflict.test.ts b/tests/unit/SyncService.resolveConflict.test.ts new file mode 100644 index 0000000..e5cb1c3 --- /dev/null +++ b/tests/unit/SyncService.resolveConflict.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SyncService } from '../../src/main/services/SyncService.js'; + +vi.mock('../../src/main/services/GitClient.js'); +import { GitClient } from '../../src/main/services/GitClient.js'; + +describe('SyncService.resolveConflict', () => { + let svc: SyncService; + let importSvc: { applySyncFromDir: ReturnType }; + let gitInstance: { + run: ReturnType; + addAll: ReturnType; + push: ReturnType; + }; + + beforeEach(() => { + importSvc = { applySyncFromDir: vi.fn(async () => ({ changedCount: 0 })) }; + gitInstance = { + run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + addAll: vi.fn(async () => {}), + push: vi.fn(async () => {}) + }; + (GitClient as unknown as ReturnType).mockImplementation(function () { return gitInstance; }); + svc = new SyncService('/tmp', {} as never, importSvc as never); + }); + + it('local 선택 → checkout --ours + add + rebase --continue + push', async () => { + const r = await svc.resolveConflict('note-id', 'local'); + expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--ours', 'notes/note-id.md']); + expect(gitInstance.run).toHaveBeenCalledWith(['rebase', '--continue']); + expect(gitInstance.push).toHaveBeenCalled(); + expect(r.ok).toBe(true); + }); + + it('remote 선택 → checkout --theirs + add + rebase --continue + applySyncFromDir + push', async () => { + const r = await svc.resolveConflict('note-id', 'remote'); + expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--theirs', 'notes/note-id.md']); + expect(importSvc.applySyncFromDir).toHaveBeenCalled(); + expect(gitInstance.push).toHaveBeenCalled(); + expect(r.ok).toBe(true); + }); + + it('checkout 실패 → ok:false + reason 반환', async () => { + gitInstance.run.mockResolvedValueOnce({ stdout: '', stderr: 'fail', exitCode: 1 }); + const r = await svc.resolveConflict('note-id', 'local'); + expect(r.ok).toBe(false); + expect((r as { reason: string }).reason).toContain('checkout failed'); + expect(gitInstance.push).not.toHaveBeenCalled(); + }); + + it('rebase --continue 실패 (다른 파일 미해결) → ok:false', async () => { + gitInstance.run + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // checkout + .mockResolvedValueOnce({ stdout: '', stderr: 'still unresolved', exitCode: 1 }); // rebase --continue + const r = await svc.resolveConflict('note-id', 'local'); + expect(r.ok).toBe(false); + expect((r as { reason: string }).reason).toContain('rebase --continue failed'); + expect(gitInstance.push).not.toHaveBeenCalled(); + }); +}); From 62e68dcfe760e37aaac198d557d0987ad2c69cec Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 03:44:09 +0900 Subject: [PATCH 07/12] feat(v030): settings.sync_repo_url + sync_auto_enabled + sync_interval_min MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - zod schema 확장: sync_repo_url (nullable), sync_auto_enabled (default true), sync_interval_min (int >= 5, default 30) - getter/setter 6개 추가 (기존 ai_enabled / onboarding_completed 패턴) - setSyncIntervalMin 은 non-integer / < 5 reject --- src/main/services/SettingsService.ts | 48 +++++++++++++++++++++++++++- tests/unit/SettingsService.test.ts | 36 +++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/main/services/SettingsService.ts b/src/main/services/SettingsService.ts index 88a320e..1c091f6 100644 --- a/src/main/services/SettingsService.ts +++ b/src/main/services/SettingsService.ts @@ -13,7 +13,11 @@ const SettingsSchema = z.object({ // true 로 fallback (기본 enabled). zod default 는 file 이 존재 + 키 부재일 때만 적용 — // load() 의 file-missing 분기에선 cache={} 라 isAiEnabled() 의 fallback 이 작동. ai_enabled: z.boolean().optional(), - onboarding_completed: z.boolean().optional() + onboarding_completed: z.boolean().optional(), + // v0.3.0 Cut E — 양방향 git sync 설정. 모두 optional — 미구성 시 sync 비활성. + sync_repo_url: z.string().nullable().optional(), + sync_auto_enabled: z.boolean().optional(), + sync_interval_min: z.number().int().min(5).optional() }).strict(); export type Settings = z.infer; @@ -81,6 +85,48 @@ export class SettingsService { await this.persist(next); } + /** + * v0.3.0 Cut E — sync 저장소 URL. null/빈 문자열 = sync 비활성. 본 메서드는 값만 저장, + * git init/remote add 는 별도 호출자 (settings:configure-sync IPC) 가 담당. + */ + async getSyncRepoUrl(): Promise { + const s = await this.load(); + return s.sync_repo_url ?? null; + } + + async setSyncRepoUrl(value: string | null): Promise { + const current = await this.load(); + const next: Settings = { ...current, sync_repo_url: value }; + await this.persist(next); + } + + /** v0.3.0 Cut E — 자동 주기 sync 활성. configured 일 때만 의미 있음. 기본 true. */ + async isAutoSyncEnabled(): Promise { + const s = await this.load(); + return s.sync_auto_enabled ?? true; + } + + async setAutoSyncEnabled(value: boolean): Promise { + const current = await this.load(); + const next: Settings = { ...current, sync_auto_enabled: value }; + await this.persist(next); + } + + /** v0.3.0 Cut E — 자동 주기 sync interval (분). 기본 30, min 5. */ + async getSyncIntervalMin(): Promise { + const s = await this.load(); + return s.sync_interval_min ?? 30; + } + + async setSyncIntervalMin(value: number): Promise { + if (!Number.isInteger(value) || value < 5) { + throw new Error(`sync_interval_min must be an integer >= 5 (got ${value})`); + } + const current = await this.load(); + const next: Settings = { ...current, sync_interval_min: value }; + await this.persist(next); + } + private async persist(next: Settings): Promise { await mkdir(dirname(this.filePath), { recursive: true }); const tmpPath = this.filePath + '.tmp'; diff --git a/tests/unit/SettingsService.test.ts b/tests/unit/SettingsService.test.ts index 923050e..89fa152 100644 --- a/tests/unit/SettingsService.test.ts +++ b/tests/unit/SettingsService.test.ts @@ -54,4 +54,40 @@ describe('SettingsService', () => { expect(existsSync(join(dir, 'settings.json.tmp'))).toBe(false); expect(existsSync(join(dir, 'settings.json'))).toBe(true); }); + + describe('v0.3.0 Cut E — sync settings', () => { + it('getSyncRepoUrl() defaults to null', async () => { + expect(await svc.getSyncRepoUrl()).toBeNull(); + }); + + it('setSyncRepoUrl() / getSyncRepoUrl() round-trip', async () => { + await svc.setSyncRepoUrl('git@gitea.example:user/notes.git'); + expect(await svc.getSyncRepoUrl()).toBe('git@gitea.example:user/notes.git'); + // setting null clears + await svc.setSyncRepoUrl(null); + expect(await svc.getSyncRepoUrl()).toBeNull(); + }); + + it('isAutoSyncEnabled() defaults to true', async () => { + expect(await svc.isAutoSyncEnabled()).toBe(true); + }); + + it('setAutoSyncEnabled() persists', async () => { + await svc.setAutoSyncEnabled(false); + expect(await svc.isAutoSyncEnabled()).toBe(false); + await svc.setAutoSyncEnabled(true); + expect(await svc.isAutoSyncEnabled()).toBe(true); + }); + + it('getSyncIntervalMin() defaults to 30', async () => { + expect(await svc.getSyncIntervalMin()).toBe(30); + }); + + it('setSyncIntervalMin() persists + rejects values < 5 / non-integer', async () => { + await svc.setSyncIntervalMin(15); + expect(await svc.getSyncIntervalMin()).toBe(15); + await expect(svc.setSyncIntervalMin(3)).rejects.toThrow(); + await expect(svc.setSyncIntervalMin(10.5)).rejects.toThrow(); + }); + }); }); From 9e48624495c89b40009879f248dfb416cb6fdc67 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 03:49:10 +0900 Subject: [PATCH 08/12] feat(v030): sync IPC + preload (configure / test / list-conflicts / resolve / status) Co-Authored-By: Claude Sonnet 4.6 --- src/main/ipc/settingsApi.ts | 75 +++++++++++ src/preload/index.ts | 7 + src/shared/types.ts | 29 +++++ tests/unit/sync-ipc.test.ts | 250 ++++++++++++++++++++++++++++++++++++ 4 files changed, 361 insertions(+) create mode 100644 tests/unit/sync-ipc.test.ts diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index 2aa7ad0..b27fcaa 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -7,6 +7,7 @@ import type { BackupService } from '../services/BackupService.js'; import type { ExportService } from '../services/ExportService.js'; import type { ImportService } from '../services/ImportService.js'; import type { SyncService } from '../services/SyncService.js'; +import { GitClient } from '../services/GitClient.js'; import type { TelemetryService } from '../services/TelemetryService.js'; import type { SettingsService } from '../services/SettingsService.js'; import { collectAutostartState } from '../services/AutostartDiagnostic.js'; @@ -281,4 +282,78 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { } return { ok: true } as const; }); + + // v0.3.0 Cut E — sync IPC. + + // settings:configure-sync — URL 저장 + git init + remote add (없으면). + // null URL → 저장만 (init 안 함). 빈 문자열도 null 처리. + ipcMain.handle('settings:configure-sync', async (_e, url: string | null) => { + const trimmed = typeof url === 'string' ? url.trim() : ''; + const finalUrl = trimmed.length === 0 ? null : trimmed; + + try { + await deps.settings.setSyncRepoUrl(finalUrl); + } catch (e) { + return { ok: false as const, reason: `persist failed: ${(e as Error).message}` }; + } + + if (finalUrl === null) return { ok: true as const }; + + // git init + remote add origin + const syncDir = deps.syncSvc.getSyncDir(); + const git = new GitClient(syncDir); + + if (!(await git.isRepo())) { + const init = await git.run(['init']); + if (init.exitCode !== 0) { + return { ok: false as const, reason: `git init failed: ${init.stderr}` }; + } + } + if (!(await git.hasRemote())) { + const add = await git.run(['remote', 'add', 'origin', finalUrl]); + if (add.exitCode !== 0) { + return { ok: false as const, reason: `remote add failed: ${add.stderr}` }; + } + } else { + // remote exists — update URL + const set = await git.run(['remote', 'set-url', 'origin', finalUrl]); + if (set.exitCode !== 0) { + return { ok: false as const, reason: `remote set-url failed: ${set.stderr}` }; + } + } + return { ok: true as const }; + }); + + // settings:test-sync-connection — git ls-remote 결과 + ipcMain.handle('settings:test-sync-connection', async () => { + const syncDir = deps.syncSvc.getSyncDir(); + const git = new GitClient(syncDir); + if (!(await git.isRepo())) return { ok: false as const, reason: 'not_initialized' }; + const r = await git.run(['ls-remote', 'origin']); + if (r.exitCode !== 0) return { ok: false as const, reason: r.stderr || 'connection failed' }; + return { ok: true as const }; + }); + + // sync:list-conflicts — SyncService 캐시 결과 + ipcMain.handle('sync:list-conflicts', () => deps.syncSvc.listConflicts()); + + // sync:resolve-conflict — local/remote 2 choice + ipcMain.handle('sync:resolve-conflict', async (_e, noteId: string, choice: 'local' | 'remote') => { + if (choice !== 'local' && choice !== 'remote') { + return { ok: false as const, reason: 'invalid choice' }; + } + return deps.syncSvc.resolveConflict(noteId, choice); + }); + + // sync:get-status — lastAt + lastResult + nextAt 계산 + ipcMain.handle('sync:get-status', async () => { + const last = deps.syncSvc.getLastStatus(); + let nextAt: string | null = null; + if (await deps.settings.isAutoSyncEnabled()) { + const intervalMin = await deps.settings.getSyncIntervalMin(); + const baseMs = last.lastAt ? new Date(last.lastAt).getTime() : Date.now(); + nextAt = new Date(baseMs + intervalMin * 60 * 1000).toISOString(); + } + return { lastAt: last.lastAt, lastResult: last.lastResult, nextAt }; + }); } diff --git a/src/preload/index.ts b/src/preload/index.ts index f59ffe2..3db9c04 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -88,6 +88,13 @@ const api: InklingApi = { // v0.2.11 Cut D — search + 회고 aggregate. search: (query, opts) => ipcRenderer.invoke('inbox:search', query, opts ?? {}), reviewAggregate: (period) => ipcRenderer.invoke('inbox:review-aggregate', period), + // v0.3.0 Cut E — 양방향 sync. + configureSync: (url: string | null) => ipcRenderer.invoke('settings:configure-sync', url), + testSyncConnection: () => ipcRenderer.invoke('settings:test-sync-connection'), + listConflicts: () => ipcRenderer.invoke('sync:list-conflicts'), + resolveConflict: (noteId: string, choice: 'local' | 'remote') => + ipcRenderer.invoke('sync:resolve-conflict', noteId, choice), + getSyncStatus: () => ipcRenderer.invoke('sync:get-status'), } }; diff --git a/src/shared/types.ts b/src/shared/types.ts index cc625bf..217cc91 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -40,6 +40,29 @@ export interface ReviewAggregate { dueProgress: { total: number; passed: number; pending: number }; } +// v0.3.0 Cut E — 양방향 sync 결과 + conflict. +export interface SyncConflict { + noteId: string; + localText: string; + remoteText: string; +} + +export interface SyncStatus { + ok: boolean; + reason?: string; + changed?: boolean; + localSha?: string | null; + pushed?: boolean; + importedCount?: number; + conflicts?: SyncConflict[]; +} + +export interface SyncStatusSnapshot { + lastAt: string | null; + lastResult: SyncStatus | null; + nextAt: string | null; +} + export interface Note { id: string; rawText: string; @@ -182,6 +205,12 @@ export interface InboxApi { // v0.2.11 Cut D — FTS5 search + 회고 aggregate. search(query: string, opts?: { limit?: number; status?: NoteStatus }): Promise; reviewAggregate(period: ReviewPeriod): Promise; + // v0.3.0 Cut E — 양방향 sync. + configureSync(url: string | null): Promise<{ ok: true } | { ok: false; reason: string }>; + testSyncConnection(): Promise<{ ok: true } | { ok: false; reason: string }>; + listConflicts(): Promise; + resolveConflict(noteId: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }>; + getSyncStatus(): Promise; } export interface InklingApi { diff --git a/tests/unit/sync-ipc.test.ts b/tests/unit/sync-ipc.test.ts new file mode 100644 index 0000000..d2f459a --- /dev/null +++ b/tests/unit/sync-ipc.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() }, dialog: {}, shell: {} } })); +vi.mock('../../src/main/services/GitClient.js'); + +import electron from 'electron'; +import { GitClient } from '../../src/main/services/GitClient.js'; +import { registerSettingsApi } from '../../src/main/ipc/settingsApi.js'; +import type { SettingsIpcDeps } from '../../src/main/ipc/settingsApi.js'; + +function getHandler(channel: string): (...args: unknown[]) => unknown { + const handle = (electron.ipcMain as unknown as { handle: ReturnType }).handle; + const call = handle.mock.calls.find((c) => c[0] === channel); + if (!call) throw new Error(`channel ${channel} not registered`); + return call[1] as (...args: unknown[]) => unknown; +} + +function makeDeps() { + const gitInstance = { + isRepo: vi.fn(async () => false), + hasRemote: vi.fn(async () => false), + run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })) + }; + (GitClient as unknown as ReturnType).mockImplementation(function () { return gitInstance; }); + + const syncSvc = { + getSyncDir: vi.fn(() => '/tmp/sync'), + listConflicts: vi.fn(() => [] as { noteId: string; localText: string; remoteText: string }[]), + resolveConflict: vi.fn(async () => ({ ok: true as const })), + getLastStatus: vi.fn(() => ({ lastAt: null as string | null, lastResult: null as { ok: boolean } | null })) + }; + const settings = { + getSyncRepoUrl: vi.fn(async () => 'git@host:u/r.git'), + setSyncRepoUrl: vi.fn(async () => {}), + isAutoSyncEnabled: vi.fn(async () => false), + getSyncIntervalMin: vi.fn(async () => 30), + getAll: vi.fn(async () => ({})), + setAiEnabled: vi.fn(async () => {}), + setOnboardingCompleted: vi.fn(async () => {}), + isAiEnabled: vi.fn(async () => true) + }; + + const deps: Partial = { + backup: { runDaily: vi.fn(async () => ({ snapshotted: false })) } as never, + exportSvc: {} as never, + importSvc: {} as never, + syncSvc: syncSvc as never, + telemetry: { exportTo: vi.fn(async () => ({ eventCount: 0 })) } as never, + settings: settings as never, + getInboxWindow: () => null + }; + + return { gitInstance, syncSvc, settings, deps }; +} + +describe('sync IPC channels', () => { + beforeEach(() => { + (electron.ipcMain as unknown as { handle: ReturnType }).handle.mockClear(); + vi.clearAllMocks(); + }); + + it('5 sync channels registered', () => { + const { deps } = makeDeps(); + registerSettingsApi(deps as SettingsIpcDeps); + + const handle = (electron.ipcMain as unknown as { handle: ReturnType }).handle; + const channels = handle.mock.calls.map((c) => c[0]); + expect(channels).toContain('settings:configure-sync'); + expect(channels).toContain('settings:test-sync-connection'); + expect(channels).toContain('sync:list-conflicts'); + expect(channels).toContain('sync:resolve-conflict'); + expect(channels).toContain('sync:get-status'); + }); + + describe('settings:configure-sync', () => { + it('null URL → setSyncRepoUrl(null), no git init', async () => { + const { deps, settings, gitInstance } = makeDeps(); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:configure-sync'); + const r = await h({}, null); + expect(settings.setSyncRepoUrl).toHaveBeenCalledWith(null); + expect(gitInstance.run).not.toHaveBeenCalled(); + expect(r).toEqual({ ok: true }); + }); + + it('empty string URL → treated as null', async () => { + const { deps, settings, gitInstance } = makeDeps(); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:configure-sync'); + const r = await h({}, ' '); + expect(settings.setSyncRepoUrl).toHaveBeenCalledWith(null); + expect(gitInstance.run).not.toHaveBeenCalled(); + expect(r).toEqual({ ok: true }); + }); + + it('valid URL → isRepo=false → git init + remote add', async () => { + const { deps, gitInstance } = makeDeps(); + gitInstance.isRepo.mockResolvedValue(false); + gitInstance.hasRemote.mockResolvedValue(false); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:configure-sync'); + const r = await h({}, 'git@github.com:user/repo.git'); + expect(gitInstance.run).toHaveBeenCalledWith(['init']); + expect(gitInstance.run).toHaveBeenCalledWith(['remote', 'add', 'origin', 'git@github.com:user/repo.git']); + expect(r).toEqual({ ok: true }); + }); + + it('valid URL → isRepo=true, hasRemote=true → remote set-url', async () => { + const { deps, gitInstance } = makeDeps(); + gitInstance.isRepo.mockResolvedValue(true); + gitInstance.hasRemote.mockResolvedValue(true); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:configure-sync'); + const r = await h({}, 'git@github.com:user/new-repo.git'); + expect(gitInstance.run).toHaveBeenCalledWith(['remote', 'set-url', 'origin', 'git@github.com:user/new-repo.git']); + expect(r).toEqual({ ok: true }); + }); + + it('git init failure → ok: false', async () => { + const { deps, gitInstance } = makeDeps(); + gitInstance.isRepo.mockResolvedValue(false); + gitInstance.run.mockResolvedValue({ stdout: '', stderr: 'permission denied', exitCode: 1 }); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:configure-sync'); + const r = await h({}, 'git@github.com:user/repo.git'); + expect(r).toMatchObject({ ok: false, reason: expect.stringContaining('git init failed') }); + }); + + it('setSyncRepoUrl throws → ok: false', async () => { + const { deps, settings } = makeDeps(); + settings.setSyncRepoUrl.mockRejectedValue(new Error('disk full')); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:configure-sync'); + const r = await h({}, 'git@github.com:user/repo.git'); + expect(r).toMatchObject({ ok: false, reason: expect.stringContaining('persist failed') }); + }); + }); + + describe('settings:test-sync-connection', () => { + it('not initialized → ok: false, reason: not_initialized', async () => { + const { deps, gitInstance } = makeDeps(); + gitInstance.isRepo.mockResolvedValue(false); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:test-sync-connection'); + const r = await h({}); + expect(r).toEqual({ ok: false, reason: 'not_initialized' }); + }); + + it('ls-remote success → ok: true', async () => { + const { deps, gitInstance } = makeDeps(); + gitInstance.isRepo.mockResolvedValue(true); + gitInstance.run.mockResolvedValue({ stdout: 'abc123\trefs/heads/main', stderr: '', exitCode: 0 }); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:test-sync-connection'); + const r = await h({}); + expect(gitInstance.run).toHaveBeenCalledWith(['ls-remote', 'origin']); + expect(r).toEqual({ ok: true }); + }); + + it('ls-remote failure → ok: false', async () => { + const { deps, gitInstance } = makeDeps(); + gitInstance.isRepo.mockResolvedValue(true); + gitInstance.run.mockResolvedValue({ stdout: '', stderr: 'connection refused', exitCode: 128 }); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('settings:test-sync-connection'); + const r = await h({}); + expect(r).toMatchObject({ ok: false, reason: 'connection refused' }); + }); + }); + + describe('sync:list-conflicts', () => { + it('returns syncSvc.listConflicts() result', () => { + const { deps, syncSvc } = makeDeps(); + const conflicts = [{ noteId: 'abc', localText: 'local', remoteText: 'remote' }]; + syncSvc.listConflicts.mockReturnValue(conflicts); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('sync:list-conflicts'); + const r = h({}); + expect(r).toEqual(conflicts); + }); + }); + + describe('sync:resolve-conflict', () => { + it('valid choice "local" → delegates to syncSvc.resolveConflict', async () => { + const { deps, syncSvc } = makeDeps(); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('sync:resolve-conflict'); + const r = await h({}, 'note-1', 'local'); + expect(syncSvc.resolveConflict).toHaveBeenCalledWith('note-1', 'local'); + expect(r).toEqual({ ok: true }); + }); + + it('valid choice "remote" → delegates to syncSvc.resolveConflict', async () => { + const { deps, syncSvc } = makeDeps(); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('sync:resolve-conflict'); + await h({}, 'note-2', 'remote'); + expect(syncSvc.resolveConflict).toHaveBeenCalledWith('note-2', 'remote'); + }); + + it('invalid choice → ok: false, reason: invalid choice', async () => { + const { deps, syncSvc } = makeDeps(); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('sync:resolve-conflict'); + const r = await h({}, 'note-1', 'both'); + expect(syncSvc.resolveConflict).not.toHaveBeenCalled(); + expect(r).toEqual({ ok: false, reason: 'invalid choice' }); + }); + }); + + describe('sync:get-status', () => { + it('auto-sync disabled → nextAt: null', async () => { + const { deps, syncSvc, settings } = makeDeps(); + settings.isAutoSyncEnabled.mockResolvedValue(false); + syncSvc.getLastStatus.mockReturnValue({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true } }); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('sync:get-status'); + const r = await h({}); + expect(r).toMatchObject({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true }, nextAt: null }); + }); + + it('auto-sync enabled → nextAt computed from lastAt + interval', async () => { + const { deps, syncSvc, settings } = makeDeps(); + settings.isAutoSyncEnabled.mockResolvedValue(true); + settings.getSyncIntervalMin.mockResolvedValue(30); + syncSvc.getLastStatus.mockReturnValue({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true } }); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('sync:get-status'); + const r = (await h({})) as { lastAt: string; nextAt: string }; + const expectedNextAt = new Date(new Date('2026-05-09T10:00:00.000Z').getTime() + 30 * 60 * 1000).toISOString(); + expect(r.nextAt).toBe(expectedNextAt); + }); + + it('no previous sync + auto-sync enabled → nextAt based on Date.now()', async () => { + const { deps, syncSvc, settings } = makeDeps(); + settings.isAutoSyncEnabled.mockResolvedValue(true); + settings.getSyncIntervalMin.mockResolvedValue(15); + syncSvc.getLastStatus.mockReturnValue({ lastAt: null, lastResult: null }); + registerSettingsApi(deps as SettingsIpcDeps); + const h = getHandler('sync:get-status'); + const before = Date.now(); + const r = (await h({})) as { lastAt: null; nextAt: string }; + const after = Date.now(); + const nextAtMs = new Date(r.nextAt).getTime(); + expect(nextAtMs).toBeGreaterThanOrEqual(before + 15 * 60 * 1000); + expect(nextAtMs).toBeLessThanOrEqual(after + 15 * 60 * 1000); + expect(r.lastAt).toBeNull(); + }); + }); +}); From 87c18a4c2d9dc5c4b9079389c5332a154a6a592b Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 03:56:00 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat(v030):=20SyncSection=20+=20ConflictM?= =?UTF-8?q?odal=20=E2=80=94=20Configure=20UI=20+=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/main/ipc/settingsApi.ts | 14 ++ src/preload/index.ts | 2 + .../inbox/components/ConflictModal.tsx | 112 +++++++++++++ .../inbox/components/SettingsPage.tsx | 5 + .../inbox/components/settings/SyncSection.tsx | 150 ++++++++++++++++++ src/shared/types.ts | 5 + tests/unit/App.test.tsx | 8 +- tests/unit/ConflictModal.test.tsx | 59 +++++++ tests/unit/SettingsPage.test.tsx | 11 +- tests/unit/SyncSection.test.tsx | 75 +++++++++ 10 files changed, 438 insertions(+), 3 deletions(-) create mode 100644 src/renderer/inbox/components/ConflictModal.tsx create mode 100644 src/renderer/inbox/components/settings/SyncSection.tsx create mode 100644 tests/unit/ConflictModal.test.tsx create mode 100644 tests/unit/SyncSection.test.tsx diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index b27fcaa..1f30f55 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -107,6 +107,20 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { return { ok: true as const }; }); + ipcMain.handle('settings:set-sync-auto-enabled', async (_e, value: boolean) => { + await deps.settings.setAutoSyncEnabled(value); + return { ok: true as const }; + }); + + ipcMain.handle('settings:set-sync-interval-min', async (_e, value: number) => { + try { + await deps.settings.setSyncIntervalMin(value); + return { ok: true as const }; + } catch (e) { + return { ok: false as const, reason: (e as Error).message }; + } + }); + ipcMain.handle('settings:run-backup', async () => { try { const r = await backup.runDaily(); diff --git a/src/preload/index.ts b/src/preload/index.ts index 3db9c04..2f50a5f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -95,6 +95,8 @@ const api: InklingApi = { resolveConflict: (noteId: string, choice: 'local' | 'remote') => ipcRenderer.invoke('sync:resolve-conflict', noteId, choice), getSyncStatus: () => ipcRenderer.invoke('sync:get-status'), + setSyncAutoEnabled: (value: boolean) => ipcRenderer.invoke('settings:set-sync-auto-enabled', value), + setSyncIntervalMin: (value: number) => ipcRenderer.invoke('settings:set-sync-interval-min', value), } }; diff --git a/src/renderer/inbox/components/ConflictModal.tsx b/src/renderer/inbox/components/ConflictModal.tsx new file mode 100644 index 0000000..76441b9 --- /dev/null +++ b/src/renderer/inbox/components/ConflictModal.tsx @@ -0,0 +1,112 @@ +import React, { useEffect, useState } from 'react'; +import type { SyncConflict } from '@shared/types'; +import { inboxApi } from '../api.js'; + +interface Props { + onClose: () => void; + onResolved: () => void; +} + +const overlayStyle: React.CSSProperties = { + position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', + background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', + justifyContent: 'center', zIndex: 100 +}; + +const modalStyle: React.CSSProperties = { + background: '#fff', borderRadius: 8, padding: 20, width: 600, + maxHeight: '70vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)' +}; + +const rowStyle: React.CSSProperties = { + border: '1px solid #eee', borderRadius: 6, padding: 10, marginTop: 8 +}; + +export function ConflictModal({ onClose, onResolved }: Props): React.ReactElement { + const [conflicts, setConflicts] = useState([]); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + void (async () => { + const c = await inboxApi.listConflicts(); + if (!cancelled) setConflicts(c); + })(); + return () => { cancelled = true; }; + }, []); + + async function onChoose(noteId: string, choice: 'local' | 'remote') { + setBusy(noteId); + setError(null); + const r = await inboxApi.resolveConflict(noteId, choice); + setBusy(null); + if (!r.ok) { + setError(`해결 실패: ${r.reason}`); + return; + } + const next = conflicts.filter((c) => c.noteId !== noteId); + setConflicts(next); + if (next.length === 0) { + onResolved(); + onClose(); + } + } + + return ( +
+
e.stopPropagation()}> +
+

충돌 ({conflicts.length}건)

+ +
+ {error !== null &&
{error}
} + {conflicts.map((c) => ( +
+
note: {c.noteId}
+
+
+
내 기기
+
{c.localText || '(미리보기 없음)'}
+
+
+
다른 기기
+
{c.remoteText || '(미리보기 없음)'}
+
+
+
+ + +
+
+ ))} +
+
+ ); +} + +function preStyle(): React.CSSProperties { + return { + margin: 0, whiteSpace: 'pre-wrap', fontSize: 11, color: '#444', + background: '#fafafa', padding: 6, borderRadius: 3, maxHeight: 120, overflow: 'auto' + }; +} + +function chooseBtnStyle(color: string): React.CSSProperties { + return { + background: 'none', border: `1px solid ${color}`, color, cursor: 'pointer', + fontSize: 12, padding: '4px 10px', borderRadius: 4 + }; +} diff --git a/src/renderer/inbox/components/SettingsPage.tsx b/src/renderer/inbox/components/SettingsPage.tsx index a4b807a..cf96777 100644 --- a/src/renderer/inbox/components/SettingsPage.tsx +++ b/src/renderer/inbox/components/SettingsPage.tsx @@ -4,6 +4,7 @@ import { AiProviderSection } from './settings/AiProviderSection.js'; import { AutostartSection } from './settings/AutostartSection.js'; import { BackupSection } from './settings/BackupSection.js'; import { InfoSection } from './settings/InfoSection.js'; +import { SyncSection } from './settings/SyncSection.js'; export function SettingsPage(): React.ReactElement { const setShowSettings = useInbox((s) => s.setShowSettings); @@ -40,6 +41,10 @@ export function SettingsPage(): React.ReactElement {

정보

+
+

동기화

+ +
); } diff --git a/src/renderer/inbox/components/settings/SyncSection.tsx b/src/renderer/inbox/components/settings/SyncSection.tsx new file mode 100644 index 0000000..ae0a6b6 --- /dev/null +++ b/src/renderer/inbox/components/settings/SyncSection.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useState } from 'react'; +import { inboxApi } from '../../api.js'; +import type { SyncStatusSnapshot } from '@shared/types'; +import { ConflictModal } from '../ConflictModal.js'; + +export function SyncSection(): React.ReactElement { + const [url, setUrl] = useState(''); + const [draftUrl, setDraftUrl] = useState(''); + const [autoEnabled, setAutoEnabled] = useState(true); + const [intervalMin, setIntervalMin] = useState(30); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState<'save' | 'test' | 'sync' | null>(null); + const [feedback, setFeedback] = useState(null); + const [showConflict, setShowConflict] = useState(false); + + useEffect(() => { + void (async () => { + const s = await inboxApi.getSettings(); + const u = s.sync_repo_url ?? ''; + setUrl(u); + setDraftUrl(u); + setAutoEnabled(s.sync_auto_enabled ?? true); + setIntervalMin(s.sync_interval_min ?? 30); + setStatus(await inboxApi.getSyncStatus()); + })(); + }, []); + + async function onSaveUrl() { + setBusy('save'); + setFeedback(null); + const r = await inboxApi.configureSync(draftUrl.trim() === '' ? null : draftUrl.trim()); + setBusy(null); + if (r.ok) { + setUrl(draftUrl.trim()); + setFeedback('저장되었습니다'); + } else { + setFeedback(`저장 실패: ${r.reason}`); + } + } + + async function onTestConnection() { + setBusy('test'); + setFeedback(null); + const r = await inboxApi.testSyncConnection(); + setBusy(null); + setFeedback(r.ok ? '연결 성공' : `연결 실패: ${r.reason}`); + } + + async function onToggleAuto(next: boolean) { + await inboxApi.setSyncAutoEnabled(next); + setAutoEnabled(next); + } + + async function onChangeInterval(value: number) { + if (!Number.isInteger(value) || value < 5) return; + const r = await inboxApi.setSyncIntervalMin(value); + if (r.ok) setIntervalMin(value); + } + + const conflictCount = status?.lastResult?.conflicts?.length ?? 0; + + return ( +
+

동기화 저장소

+ +
+ setDraftUrl(e.target.value)} + style={{ flex: 1, fontSize: 12, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4 }} + /> + + +
+ + {feedback !== null && ( +
{feedback}
+ )} + + {url.trim() !== '' && ( + <> +
+ 마지막 sync: {status?.lastAt ?? '없음'} {status?.lastResult?.ok === false && status?.lastResult?.reason !== 'conflict' && ( + ({status.lastResult.reason}) + )} +
+ + + + + + {conflictCount > 0 && ( +
+ +
+ )} + + {showConflict && ( + setShowConflict(false)} + onResolved={async () => { + setStatus(await inboxApi.getSyncStatus()); + }} + /> + )} + + )} +
+ ); +} + +function btnStyle(): React.CSSProperties { + return { + background: '#0a4b80', + color: '#fff', + border: 'none', + cursor: 'pointer', + fontSize: 12, + padding: '4px 10px', + borderRadius: 4 + }; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 217cc91..ed165a0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -192,6 +192,9 @@ export interface InboxApi { ollama?: { endpoint: string; model: string }; ai_enabled?: boolean; onboarding_completed?: boolean; + sync_repo_url?: string | null; + sync_auto_enabled?: boolean; + sync_interval_min?: number; }>; setAiEnabled(enabled: boolean): Promise<{ ok: true }>; setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>; @@ -211,6 +214,8 @@ export interface InboxApi { listConflicts(): Promise; resolveConflict(noteId: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }>; getSyncStatus(): Promise; + setSyncAutoEnabled(enabled: boolean): Promise<{ ok: true }>; + setSyncIntervalMin(value: number): Promise<{ ok: true } | { ok: false; reason: string }>; } export interface InklingApi { diff --git a/tests/unit/App.test.tsx b/tests/unit/App.test.tsx index 262d27a..f49a065 100644 --- a/tests/unit/App.test.tsx +++ b/tests/unit/App.test.tsx @@ -56,7 +56,13 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })), // v0.2.9 Cut B Task 16 — AiProviderSection 가 SettingsPage 렌더 시 호출. getDisabledCount: vi.fn(async () => 0), - enqueueDisabled: vi.fn(async () => ({ count: 0 })) + enqueueDisabled: vi.fn(async () => ({ count: 0 })), + // v0.3.0 Cut E — SyncSection 이 SettingsPage 에 마운트되어 호출. + getSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })), + setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })), + setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })), + configureSync: vi.fn(async () => ({ ok: true as const })), + testSyncConnection: vi.fn(async () => ({ ok: true as const })) } })); diff --git a/tests/unit/ConflictModal.test.tsx b/tests/unit/ConflictModal.test.tsx new file mode 100644 index 0000000..3bee267 --- /dev/null +++ b/tests/unit/ConflictModal.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import React from 'react'; + +const { mockListConflicts, mockResolveConflict } = vi.hoisted(() => ({ + mockListConflicts: vi.fn(), + mockResolveConflict: vi.fn() +})); + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { listConflicts: mockListConflicts, resolveConflict: mockResolveConflict } +})); + +import { ConflictModal } from '../../src/renderer/inbox/components/ConflictModal'; + +describe('ConflictModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + mockListConflicts.mockResolvedValue([ + { noteId: 'n1', localText: 'local A', remoteText: 'remote A' }, + { noteId: 'n2', localText: 'local B', remoteText: 'remote B' } + ]); + mockResolveConflict.mockResolvedValue({ ok: true }); + }); + + it('open 시 listConflicts 호출 + 양 conflict 표시', async () => { + render( {}} onResolved={() => {}} />); + await waitFor(() => screen.getByText(/local A/)); + expect(screen.getByText(/local A/)).toBeInTheDocument(); + expect(screen.getByText(/remote A/)).toBeInTheDocument(); + expect(screen.getByText(/local B/)).toBeInTheDocument(); + }); + + it('내 것 사용 클릭 → resolveConflict(noteId, "local") 호출', async () => { + render( {}} onResolved={() => {}} />); + await waitFor(() => screen.getByText(/local A/)); + const buttons = screen.getAllByRole('button', { name: /내 것 사용/ }); + fireEvent.click(buttons[0]!); + await waitFor(() => { + expect(mockResolveConflict).toHaveBeenCalledWith('n1', 'local'); + }); + }); + + it('마지막 conflict 해결 → onResolved + onClose 호출', async () => { + mockListConflicts.mockResolvedValueOnce([{ noteId: 'n1', localText: 'a', remoteText: 'b' }]); + const onResolved = vi.fn(); + const onClose = vi.fn(); + render(); + await waitFor(() => screen.getByRole('button', { name: /원격 사용/ })); + fireEvent.click(screen.getByRole('button', { name: /원격 사용/ })); + await waitFor(() => { + expect(onResolved).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/SettingsPage.test.tsx b/tests/unit/SettingsPage.test.tsx index 2366ad1..7405c46 100644 --- a/tests/unit/SettingsPage.test.tsx +++ b/tests/unit/SettingsPage.test.tsx @@ -46,7 +46,13 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ setAiEnabled: vi.fn(async () => ({ ok: true as const })), setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })), getDisabledCount: vi.fn(async () => 0), - enqueueDisabled: vi.fn(async () => ({ count: 0 })) + enqueueDisabled: vi.fn(async () => ({ count: 0 })), + // v0.3.0 Cut E — SyncSection 이 SettingsPage 에 마운트되어 호출. + getSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })), + setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })), + setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })), + configureSync: vi.fn(async () => ({ ok: true as const })), + testSyncConnection: vi.fn(async () => ({ ok: true as const })) } })); @@ -64,12 +70,13 @@ describe('SettingsPage', () => { expect(screen.getByRole('button', { name: /돌아가기/ })).toBeInTheDocument(); }); - it('renders 4 section headings', () => { + it('renders 5 section headings', () => { render(); expect(screen.getByText('AI 제공자')).toBeInTheDocument(); expect(screen.getByText('자동 실행')).toBeInTheDocument(); expect(screen.getByText('백업 / 복원')).toBeInTheDocument(); expect(screen.getByText('정보')).toBeInTheDocument(); + expect(screen.getByText('동기화')).toBeInTheDocument(); }); it('clicking "← 돌아가기" sets showSettings to false', () => { diff --git a/tests/unit/SyncSection.test.tsx b/tests/unit/SyncSection.test.tsx new file mode 100644 index 0000000..8ecaba1 --- /dev/null +++ b/tests/unit/SyncSection.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import React from 'react'; + +const { mockGetSettings, mockConfigureSync, mockTestSyncConnection, mockGetSyncStatus, mockSetAuto, mockSetInterval } = vi.hoisted(() => ({ + mockGetSettings: vi.fn(async () => ({ sync_repo_url: '', sync_auto_enabled: true, sync_interval_min: 30 })), + mockConfigureSync: vi.fn(async () => ({ ok: true as const })), + mockTestSyncConnection: vi.fn(async () => ({ ok: true as const })), + mockGetSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })), + mockSetAuto: vi.fn(async () => ({ ok: true as const })), + mockSetInterval: vi.fn(async () => ({ ok: true as const })) +})); + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + getSettings: mockGetSettings, + configureSync: mockConfigureSync, + testSyncConnection: mockTestSyncConnection, + getSyncStatus: mockGetSyncStatus, + setSyncAutoEnabled: mockSetAuto, + setSyncIntervalMin: mockSetInterval + } +})); + +// ConflictModal is imported by SyncSection — mock it to avoid needing listConflicts +vi.mock('../../src/renderer/inbox/components/ConflictModal.js', () => ({ + ConflictModal: () => null +})); + +import { SyncSection } from '../../src/renderer/inbox/components/settings/SyncSection'; + +describe('SyncSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + mockGetSettings.mockResolvedValue({ sync_repo_url: '', sync_auto_enabled: true, sync_interval_min: 30 }); + mockGetSyncStatus.mockResolvedValue({ lastAt: null, lastResult: null, nextAt: null }); + }); + + it('빈 URL — 저장/연결 테스트 버튼 + 자동 sync 옵션 hide', async () => { + render(); + await waitFor(() => screen.getByRole('button', { name: /저장/ })); + expect(screen.queryByText(/자동 sync/)).not.toBeInTheDocument(); + }); + + it('URL 입력 + 저장 → configureSync 호출 + 자동 sync 옵션 표시', async () => { + mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 }); + render(); + await waitFor(() => screen.getByText(/자동 sync/)); + expect(screen.getByText(/자동 sync/)).toBeInTheDocument(); + }); + + it('연결 테스트 클릭 → testSyncConnection 호출 + 결과 표시', async () => { + mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 }); + render(); + await waitFor(() => screen.getByRole('button', { name: /연결 테스트/ })); + fireEvent.click(screen.getByRole('button', { name: /연결 테스트/ })); + await waitFor(() => { + expect(mockTestSyncConnection).toHaveBeenCalled(); + expect(screen.getByText(/연결 성공/)).toBeInTheDocument(); + }); + }); + + it('자동 sync 토글 → setSyncAutoEnabled 호출', async () => { + mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 }); + render(); + await waitFor(() => screen.getByLabelText(/자동 sync/)); + fireEvent.click(screen.getByLabelText(/자동 sync/)); + await waitFor(() => { + expect(mockSetAuto).toHaveBeenCalledWith(false); + }); + }); +}); From e3f6c711a77306316b81a67458724dd1daab317f Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 03:59:52 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat(v030):=20SyncTimer=20=E2=80=94=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=A3=BC=EA=B8=B0=20sync=20(settings=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20reconfigure)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/main/index.ts | 8 +++- src/main/ipc/settingsApi.ts | 10 ++++- src/main/services/SyncTimer.ts | 49 +++++++++++++++++++++++ tests/unit/SyncTimer.test.ts | 72 ++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 src/main/services/SyncTimer.ts create mode 100644 tests/unit/SyncTimer.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index d93c027..b249fd5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,6 +30,7 @@ import { BackupService } from './services/BackupService.js'; import { ExportService } from './services/ExportService.js'; import { ImportService } from './services/ImportService.js'; import { SyncService } from './services/SyncService.js'; +import { SyncTimer } from './services/SyncTimer.js'; import { TelemetryService } from './services/TelemetryService.js'; import { SettingsService } from './services/SettingsService.js'; import { collectAutostartState } from './services/AutostartDiagnostic.js'; @@ -197,6 +198,7 @@ app.whenReady().then(async () => { const exportSvc = new ExportService(repo, store); const importSvc = new ImportService(repo, store); const syncSvc = new SyncService(paths.profileDir, exportSvc, importSvc); + const syncTimer = new SyncTimer(syncSvc, settingsSvc); const backup = new BackupService(db, join(paths.profileDir, 'backups')); void backup.runDaily() @@ -206,14 +208,18 @@ app.whenReady().then(async () => { // v0.2.7 Task 10 — 설정 페이지 IPC (autostart + backup/export/import/sync/telemetry). // backup / exportSvc / importSvc / syncSvc / telemetry 가 모두 준비된 뒤 등록. registerSettingsApi({ - backup, exportSvc, importSvc, syncSvc, telemetry, settings: settingsSvc, getInboxWindow + backup, exportSvc, importSvc, syncSvc, telemetry, settings: settingsSvc, getInboxWindow, + syncTimer }); + void syncTimer.start(); + let backupOnQuitDone = false; let trayInterval: NodeJS.Timeout | null = null; app.on('before-quit', (e) => { // 모든 cleanup 한 곳에 통합 — sync (idempotent) → async backup chain. health.stop(); + syncTimer.stop(); if (trayInterval !== null) { clearInterval(trayInterval); trayInterval = null; diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index 1f30f55..8086b20 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -10,6 +10,7 @@ import type { SyncService } from '../services/SyncService.js'; import { GitClient } from '../services/GitClient.js'; import type { TelemetryService } from '../services/TelemetryService.js'; import type { SettingsService } from '../services/SettingsService.js'; +import type { SyncTimer } from '../services/SyncTimer.js'; import { collectAutostartState } from '../services/AutostartDiagnostic.js'; import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js'; @@ -37,6 +38,7 @@ export interface SettingsIpcDeps { telemetry: TelemetryService; settings: SettingsService; getInboxWindow: () => BrowserWindow | null; + syncTimer?: SyncTimer; } /** @@ -109,12 +111,14 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { ipcMain.handle('settings:set-sync-auto-enabled', async (_e, value: boolean) => { await deps.settings.setAutoSyncEnabled(value); + await deps.syncTimer?.reconfigure(); return { ok: true as const }; }); ipcMain.handle('settings:set-sync-interval-min', async (_e, value: number) => { try { await deps.settings.setSyncIntervalMin(value); + await deps.syncTimer?.reconfigure(); return { ok: true as const }; } catch (e) { return { ok: false as const, reason: (e as Error).message }; @@ -311,7 +315,10 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { return { ok: false as const, reason: `persist failed: ${(e as Error).message}` }; } - if (finalUrl === null) return { ok: true as const }; + if (finalUrl === null) { + await deps.syncTimer?.reconfigure(); + return { ok: true as const }; + } // git init + remote add origin const syncDir = deps.syncSvc.getSyncDir(); @@ -335,6 +342,7 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { return { ok: false as const, reason: `remote set-url failed: ${set.stderr}` }; } } + await deps.syncTimer?.reconfigure(); return { ok: true as const }; }); diff --git a/src/main/services/SyncTimer.ts b/src/main/services/SyncTimer.ts new file mode 100644 index 0000000..203be6d --- /dev/null +++ b/src/main/services/SyncTimer.ts @@ -0,0 +1,49 @@ +import type { SyncService } from './SyncService.js'; +import type { SettingsService } from './SettingsService.js'; + +/** + * v0.3.0 Cut E — 자동 주기 sync timer. + * + * - start: settings 의 auto enabled + repo URL 모두 갖춰져야 시작 + * - reconfigure: settings 변경 시 stop + start (새 interval 적용) + * - stop: clearInterval (idempotent) + * + * sync 결과는 무시 (interval mode = silent). conflict 발생 시 다음 manual sync / + * 충돌 UI 진입 시 처리됨 — 사용자가 settings 페이지의 SyncSection 에서 확인 가능. + */ +export class SyncTimer { + private handle: NodeJS.Timeout | null = null; + + constructor( + private syncSvc: SyncService, + private settings: SettingsService + ) {} + + async start(): Promise { + if (this.handle !== null) return; // idempotent + const enabled = await this.settings.isAutoSyncEnabled(); + if (!enabled) return; + const url = await this.settings.getSyncRepoUrl(); + if (url === null || url.trim().length === 0) return; + const intervalMin = await this.settings.getSyncIntervalMin(); + const ms = Math.max(5, intervalMin) * 60 * 1000; + this.handle = setInterval(() => { + void this.syncSvc.sync().catch(() => { + // silent — interval mode 의 실패는 다음 attempt 또는 사용자 manual 호출이 처리 + }); + }, ms); + } + + stop(): void { + if (this.handle !== null) { + clearInterval(this.handle); + this.handle = null; + } + } + + /** settings 변경 시 호출 — 현재 interval stop 후 새 값으로 start. */ + async reconfigure(): Promise { + this.stop(); + await this.start(); + } +} diff --git a/tests/unit/SyncTimer.test.ts b/tests/unit/SyncTimer.test.ts new file mode 100644 index 0000000..d2971cf --- /dev/null +++ b/tests/unit/SyncTimer.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SyncTimer } from '../../src/main/services/SyncTimer.js'; + +describe('SyncTimer', () => { + let syncSvc: { sync: ReturnType }; + let settings: { + isAutoSyncEnabled: ReturnType; + getSyncIntervalMin: ReturnType; + getSyncRepoUrl: ReturnType; + }; + let timer: SyncTimer; + + beforeEach(() => { + vi.useFakeTimers(); + syncSvc = { sync: vi.fn(async () => ({ ok: true })) }; + settings = { + isAutoSyncEnabled: vi.fn(async () => true), + getSyncIntervalMin: vi.fn(async () => 5), + getSyncRepoUrl: vi.fn(async () => 'git@host:u/r.git') + }; + timer = new SyncTimer(syncSvc as never, settings as never); + }); + + afterEach(() => { + timer.stop(); + vi.useRealTimers(); + }); + + it('start — interval 마다 syncSvc.sync 호출', async () => { + await timer.start(); + expect(syncSvc.sync).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(syncSvc.sync).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(syncSvc.sync).toHaveBeenCalledTimes(2); + }); + + it('auto disabled → 시작 안 함 (sync 0회)', async () => { + settings.isAutoSyncEnabled.mockResolvedValueOnce(false); + await timer.start(); + await vi.advanceTimersByTimeAsync(60 * 60 * 1000); + expect(syncSvc.sync).not.toHaveBeenCalled(); + }); + + it('repo URL 미설정 → 시작 안 함', async () => { + settings.getSyncRepoUrl.mockResolvedValueOnce(null); + await timer.start(); + await vi.advanceTimersByTimeAsync(60 * 60 * 1000); + expect(syncSvc.sync).not.toHaveBeenCalled(); + }); + + it('reconfigure — stop + 새 interval 로 start', async () => { + await timer.start(); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(syncSvc.sync).toHaveBeenCalledTimes(1); + + settings.getSyncIntervalMin.mockResolvedValueOnce(10); + await timer.reconfigure(); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + // not enough time for new interval — still 1 call + expect(syncSvc.sync).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(syncSvc.sync).toHaveBeenCalledTimes(2); + }); + + it('stop — 호출 후 더 이상 sync 발생 안 함', async () => { + await timer.start(); + timer.stop(); + await vi.advanceTimersByTimeAsync(60 * 60 * 1000); + expect(syncSvc.sync).not.toHaveBeenCalled(); + }); +}); From 2ef48020501acc96995c4dbd2639ef29b9b60e59 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 04:01:41 +0900 Subject: [PATCH 11/12] =?UTF-8?q?chore(release):=20v0.3.0=20=E2=80=94=20Cu?= =?UTF-8?q?t=20E=20(=EC=96=91=EB=B0=A9=ED=96=A5=20git=20sync=20+=20Configu?= =?UTF-8?q?re=20UI=20+=20Conflict=20resolution)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F21 promoted (✅ v0.3.0 Cut E — A+B+C 옵션, both deferred) - version 0.2.11 → 0.3.0 (semver MINOR — Major 영역 진입) - 단위 608 → 680 (+72): GitClient 5 + upsertFromSync 5 + ImportService 18 + SyncService bidirectional 5 + resolveConflict 4 + SettingsService 6 + sync IPC 17 + SyncSection 4 + ConflictModal 3 + SyncTimer 5 - typecheck 0 errors --- docs/superpowers/specs/2026-04-25-dogfood-feedback.md | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md index 8bbe3ea..2c8709a 100644 --- a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md +++ b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md @@ -1570,9 +1570,9 @@ app.on('activate', () => { --- -## F21. 다기기 git-based 동기화 (🌱 raw — v0.2.8 후보, **부분 구현됨**) +## F21. 다기기 git-based 동기화 (✅ promoted v0.3.0 Cut E — 양방향 + Configure UI + Conflict) -**진행 상태:** 🌱 raw — `SyncService` + `GitClient` 가 이미 push-only 형태로 존재. **양방향 동기화 + UI 구성** 이 누락된 핵심 부분. v0.2.8 brainstorm 시 명확한 cut. +**진행 상태:** ✅ promoted v0.3.0 Cut E — 옵션 A (자동 rebase) + B (Configure UI) + C (conflict UI). SyncService 양방향 6단계 (export → commit → fetch → rebase → re-import → push), `NoteRepository.upsertFromSync` (sync 전용 3 분기), `SettingsService.{getSyncRepoUrl,isAutoSyncEnabled,getSyncIntervalMin}` + `SyncTimer` (자동 주기 + reconfigure), `SyncSection` UI + `ConflictModal` (local/remote 2 choice, both deferred v0.3.1+). 단위 608 → 679. dogfood 1주 soak 후 Cut F (F24 vision) 진입. **발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. 사용자 표현: "그 중심에 git repo 를 쓸 수 있으면 좋겠어". diff --git a/package-lock.json b/package-lock.json index 5b9e9a2..f2ba246 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "inkling", - "version": "0.2.11", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "inkling", - "version": "0.2.11", + "version": "0.3.0", "dependencies": { "better-sqlite3": "12.9.0", "electron-log": "5.2.0", diff --git a/package.json b/package.json index 549ca97..9d4b8c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inkling", - "version": "0.2.11", + "version": "0.3.0", "private": true, "description": "Inkling — local-first 한 줄 보관 도구", "author": "altair823 ", From 401414608bd7ff33aca0525a7b193d07d32cfabc Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 04:10:59 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix(v030):=20SyncConflict=20noteId?= =?UTF-8?q?=E2=86=92path=20+=20populate=20localText/remoteText=20(final=20?= =?UTF-8?q?review=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit final code review (Opus) 발견 2 important issues: 1. SyncConflict.noteId 가 실제로 export filename slug (date-id8-slug) 였음 — UUID 가 아니라 git checkout path 의 stem. 명명 혼동 → 'path' 로 rename (실제 의미와 일치). 2. ConflictModal preview 가 항상 빈 문자열이라 사용자가 비교 없이 local/remote 선택해야 했음. runSync 의 conflict 분기에서 `git show :2:` (ours) + `:3:` (theirs) 호출 추가하여 localText/remoteText 채움. 영향: - SyncService.SyncConflict + shared/types.ts.SyncConflict: noteId → path - SyncService.resolveConflict(path, choice) — 'notes/...md' 그대로 받음 - pathToNoteId 헬퍼 제거 (불필요) - ConflictModal: c.noteId → c.path, busy 상태 + 표시 모두 path 키 - IPC handler / preload bridge / InboxApi 시그니처 모두 path 로 통일 - SyncService.bidirectional/resolveConflict/sync-ipc/ConflictModal 4 test 갱신 regression 회귀 패턴 검사: rename 후 NoteRepository / SyncService / IPC / UI 의 모든 conflict-related path 일관 (typecheck 0). --- src/main/ipc/settingsApi.ts | 6 +-- src/main/services/SyncService.ts | 47 +++++++++++-------- src/preload/index.ts | 4 +- .../inbox/components/ConflictModal.tsx | 24 +++++----- src/shared/types.ts | 6 ++- tests/unit/ConflictModal.test.tsx | 14 +++--- tests/unit/SyncService.bidirectional.test.ts | 18 +++++-- .../unit/SyncService.resolveConflict.test.ts | 8 ++-- tests/unit/sync-ipc.test.ts | 18 +++---- 9 files changed, 85 insertions(+), 60 deletions(-) diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index 8086b20..4d62971 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -359,12 +359,12 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { // sync:list-conflicts — SyncService 캐시 결과 ipcMain.handle('sync:list-conflicts', () => deps.syncSvc.listConflicts()); - // sync:resolve-conflict — local/remote 2 choice - ipcMain.handle('sync:resolve-conflict', async (_e, noteId: string, choice: 'local' | 'remote') => { + // sync:resolve-conflict — local/remote 2 choice. path = git index conflict 경로. + ipcMain.handle('sync:resolve-conflict', async (_e, path: string, choice: 'local' | 'remote') => { if (choice !== 'local' && choice !== 'remote') { return { ok: false as const, reason: 'invalid choice' }; } - return deps.syncSvc.resolveConflict(noteId, choice); + return deps.syncSvc.resolveConflict(path, choice); }); // sync:get-status — lastAt + lastResult + nextAt 계산 diff --git a/src/main/services/SyncService.ts b/src/main/services/SyncService.ts index 621b198..06c37f2 100644 --- a/src/main/services/SyncService.ts +++ b/src/main/services/SyncService.ts @@ -4,8 +4,14 @@ import type { ExportService } from './ExportService.js'; import type { ImportService } from './ImportService.js'; import { GitClient } from './GitClient.js'; +/** + * Cut E final review fix: 'noteId' was misleading — F5 export filenames are + * `--.md` (composeFilename), not `.md`. The git checkout / + * resolve operations use the FULL relative path (e.g., `notes/2026-05-09-abc12345-회의.md`). + * `path` matches what we actually pass to `git checkout --ours/theirs`. + */ export interface SyncConflict { - noteId: string; + path: string; localText: string; remoteText: string; } @@ -68,21 +74,23 @@ export class SyncService { /** * v0.3.0 Cut E — conflict 해결. local/remote 2 choice (both deferred to v0.3.1+). - * 사용자가 ConflictModal 에서 선택 → IPC → 본 메서드. 각 noteId 별 호출. + * 사용자가 ConflictModal 에서 선택 → IPC → 본 메서드. 각 conflict 의 path 별 호출. * * - 'local' = 내 것 사용 (origin 변경 폐기) → git checkout --ours * - 'remote' = 원격 사용 → git checkout --theirs + applySyncFromDir (local DB 갱신) * * 모든 conflict 해결 후 rebase --continue 가 성공 → push. * UI 가 여러 conflict 를 loop 호출하면 마지막 호출에서 push 까지 완료. + * + * Cut E final review fix: 파라미터를 path 로 변경 (옛 noteId 는 export filename slug, + * UUID 아님 — 혼동 회피). */ async resolveConflict( - noteId: string, + path: string, choice: 'local' | 'remote' ): Promise<{ ok: true } | { ok: false; reason: string }> { const git = new GitClient(this.syncDir); const flag = choice === 'local' ? '--ours' : '--theirs'; - const path = `notes/${noteId}.md`; const checkout = await git.run(['checkout', flag, path]); if (checkout.exitCode !== 0) { @@ -111,8 +119,8 @@ export class SyncService { return { ok: false, reason: `push failed: ${(e as Error).message}` }; } - // Remove this noteId from cached conflicts list - this.lastConflicts = this.lastConflicts.filter((c) => c.noteId !== noteId); + // Remove this path from cached conflicts list + this.lastConflicts = this.lastConflicts.filter((c) => c.path !== path); return { ok: true }; } @@ -154,12 +162,21 @@ export class SyncService { const rebaseR = await git.rebaseOnto('origin/main'); if (rebaseR.exitCode !== 0) { const files = await git.listConflicts(); + // Cut E final review fix — populate localText/remoteText from rebase index + // BEFORE aborting. `git show :2:` = ours (local during rebase), + // `:3:` = theirs (remote being applied). UI shows side-by-side diff. + const conflicts: SyncConflict[] = []; + for (const path of files) { + const ours = await git.run(['show', `:2:${path}`]); + const theirs = await git.run(['show', `:3:${path}`]); + conflicts.push({ + path, + localText: ours.exitCode === 0 ? ours.stdout : '', + remoteText: theirs.exitCode === 0 ? theirs.stdout : '' + }); + } await git.rebaseAbort(); - return { - ok: false, - reason: 'conflict', - conflicts: files.map((path) => ({ noteId: this.pathToNoteId(path), localText: '', remoteText: '' })) - }; + return { ok: false, reason: 'conflict', conflicts }; } } @@ -182,12 +199,4 @@ export class SyncService { return { ok: true, changed: localChanged || importedCount > 0, localSha, importedCount, pushed: true }; } - private pathToNoteId(path: string): string { - // notes/.md or notes/--.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; - } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2f50a5f..539f4cc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -92,8 +92,8 @@ const api: InklingApi = { configureSync: (url: string | null) => ipcRenderer.invoke('settings:configure-sync', url), testSyncConnection: () => ipcRenderer.invoke('settings:test-sync-connection'), listConflicts: () => ipcRenderer.invoke('sync:list-conflicts'), - resolveConflict: (noteId: string, choice: 'local' | 'remote') => - ipcRenderer.invoke('sync:resolve-conflict', noteId, choice), + resolveConflict: (path: string, choice: 'local' | 'remote') => + ipcRenderer.invoke('sync:resolve-conflict', path, choice), getSyncStatus: () => ipcRenderer.invoke('sync:get-status'), setSyncAutoEnabled: (value: boolean) => ipcRenderer.invoke('settings:set-sync-auto-enabled', value), setSyncIntervalMin: (value: number) => ipcRenderer.invoke('settings:set-sync-interval-min', value), diff --git a/src/renderer/inbox/components/ConflictModal.tsx b/src/renderer/inbox/components/ConflictModal.tsx index 76441b9..675572a 100644 --- a/src/renderer/inbox/components/ConflictModal.tsx +++ b/src/renderer/inbox/components/ConflictModal.tsx @@ -36,16 +36,16 @@ export function ConflictModal({ onClose, onResolved }: Props): React.ReactElemen return () => { cancelled = true; }; }, []); - async function onChoose(noteId: string, choice: 'local' | 'remote') { - setBusy(noteId); + async function onChoose(path: string, choice: 'local' | 'remote') { + setBusy(path); setError(null); - const r = await inboxApi.resolveConflict(noteId, choice); + const r = await inboxApi.resolveConflict(path, choice); setBusy(null); if (!r.ok) { setError(`해결 실패: ${r.reason}`); return; } - const next = conflicts.filter((c) => c.noteId !== noteId); + const next = conflicts.filter((c) => c.path !== path); setConflicts(next); if (next.length === 0) { onResolved(); @@ -62,8 +62,8 @@ export function ConflictModal({ onClose, onResolved }: Props): React.ReactElemen {error !== null &&
{error}
} {conflicts.map((c) => ( -
-
note: {c.noteId}
+
+
{c.path}
내 기기
@@ -76,18 +76,18 @@ export function ConflictModal({ onClose, onResolved }: Props): React.ReactElemen
diff --git a/src/shared/types.ts b/src/shared/types.ts index ed165a0..6bcbbf2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -41,8 +41,10 @@ export interface ReviewAggregate { } // v0.3.0 Cut E — 양방향 sync 결과 + conflict. +// `path` = git index 의 conflict 파일 상대경로 (예: 'notes/2026-05-09-abc12345-회의.md'). +// F5 export 의 filename 은 date-id8-slug 패턴 — UUID 가 아니라 path 가 맞는 식별자. export interface SyncConflict { - noteId: string; + path: string; localText: string; remoteText: string; } @@ -212,7 +214,7 @@ export interface InboxApi { configureSync(url: string | null): Promise<{ ok: true } | { ok: false; reason: string }>; testSyncConnection(): Promise<{ ok: true } | { ok: false; reason: string }>; listConflicts(): Promise; - resolveConflict(noteId: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }>; + resolveConflict(path: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }>; getSyncStatus(): Promise; setSyncAutoEnabled(enabled: boolean): Promise<{ ok: true }>; setSyncIntervalMin(value: number): Promise<{ ok: true } | { ok: false; reason: string }>; diff --git a/tests/unit/ConflictModal.test.tsx b/tests/unit/ConflictModal.test.tsx index 3bee267..ff0d9a1 100644 --- a/tests/unit/ConflictModal.test.tsx +++ b/tests/unit/ConflictModal.test.tsx @@ -20,32 +20,34 @@ describe('ConflictModal', () => { vi.clearAllMocks(); cleanup(); mockListConflicts.mockResolvedValue([ - { noteId: 'n1', localText: 'local A', remoteText: 'remote A' }, - { noteId: 'n2', localText: 'local B', remoteText: 'remote B' } + { path: 'notes/n1.md', localText: 'local A', remoteText: 'remote A' }, + { path: 'notes/n2.md', localText: 'local B', remoteText: 'remote B' } ]); mockResolveConflict.mockResolvedValue({ ok: true }); }); - it('open 시 listConflicts 호출 + 양 conflict 표시', async () => { + it('open 시 listConflicts 호출 + 양 conflict preview 표시', async () => { render( {}} onResolved={() => {}} />); await waitFor(() => screen.getByText(/local A/)); expect(screen.getByText(/local A/)).toBeInTheDocument(); expect(screen.getByText(/remote A/)).toBeInTheDocument(); expect(screen.getByText(/local B/)).toBeInTheDocument(); + // path 가 표시됨 (Cut E final review fix — noteId → path) + expect(screen.getByText('notes/n1.md')).toBeInTheDocument(); }); - it('내 것 사용 클릭 → resolveConflict(noteId, "local") 호출', async () => { + it('내 것 사용 클릭 → resolveConflict(path, "local") 호출', async () => { render( {}} onResolved={() => {}} />); await waitFor(() => screen.getByText(/local A/)); const buttons = screen.getAllByRole('button', { name: /내 것 사용/ }); fireEvent.click(buttons[0]!); await waitFor(() => { - expect(mockResolveConflict).toHaveBeenCalledWith('n1', 'local'); + expect(mockResolveConflict).toHaveBeenCalledWith('notes/n1.md', 'local'); }); }); it('마지막 conflict 해결 → onResolved + onClose 호출', async () => { - mockListConflicts.mockResolvedValueOnce([{ noteId: 'n1', localText: 'a', remoteText: 'b' }]); + mockListConflicts.mockResolvedValueOnce([{ path: 'notes/n1.md', localText: 'a', remoteText: 'b' }]); const onResolved = vi.fn(); const onClose = vi.fn(); render(); diff --git a/tests/unit/SyncService.bidirectional.test.ts b/tests/unit/SyncService.bidirectional.test.ts index 6468dd8..0d5f01b 100644 --- a/tests/unit/SyncService.bidirectional.test.ts +++ b/tests/unit/SyncService.bidirectional.test.ts @@ -20,6 +20,7 @@ describe('SyncService.sync — 양방향', () => { rebaseAbort: ReturnType; listConflicts: ReturnType; push: ReturnType; + run: ReturnType; }; beforeEach(() => { @@ -36,7 +37,8 @@ describe('SyncService.sync — 양방향', () => { rebaseOnto: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), rebaseAbort: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), listConflicts: vi.fn(async () => []), - push: vi.fn(async () => {}) + push: vi.fn(async () => {}), + run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })) }; (GitClient as unknown as ReturnType).mockImplementation(function () { return gitInstance; }); svc = new SyncService( @@ -67,14 +69,24 @@ describe('SyncService.sync — 양방향', () => { expect(r.ok).toBe(true); }); - it('rebase 실패 → abort + reason=conflict + conflicts 포함', async () => { + it('rebase 실패 → abort + reason=conflict + conflicts 포함 (path + localText/remoteText)', async () => { gitInstance.rebaseOnto.mockResolvedValueOnce({ stdout: '', stderr: 'CONFLICT', exitCode: 1 }); gitInstance.listConflicts.mockResolvedValueOnce(['notes/aaa.md', 'notes/bbb.md']); + // Cut E final review fix — runSync calls git.run(['show', ':2:path']) and ':3:path' + // for each conflict. Mock returns ours/theirs text per call. + gitInstance.run + .mockResolvedValueOnce({ stdout: 'aaa local', stderr: '', exitCode: 0 }) // :2:notes/aaa.md + .mockResolvedValueOnce({ stdout: 'aaa remote', stderr: '', exitCode: 0 }) // :3:notes/aaa.md + .mockResolvedValueOnce({ stdout: 'bbb local', stderr: '', exitCode: 0 }) // :2:notes/bbb.md + .mockResolvedValueOnce({ stdout: 'bbb remote', stderr: '', exitCode: 0 }); // :3:notes/bbb.md const r = await svc.sync(); expect(gitInstance.rebaseAbort).toHaveBeenCalled(); expect(r.ok).toBe(false); expect(r.reason).toBe('conflict'); - expect(r.conflicts?.length).toBe(2); + expect(r.conflicts).toEqual([ + { path: 'notes/aaa.md', localText: 'aaa local', remoteText: 'aaa remote' }, + { path: 'notes/bbb.md', localText: 'bbb local', remoteText: 'bbb remote' } + ]); expect(gitInstance.push).not.toHaveBeenCalled(); }); diff --git a/tests/unit/SyncService.resolveConflict.test.ts b/tests/unit/SyncService.resolveConflict.test.ts index e5cb1c3..98d2ba7 100644 --- a/tests/unit/SyncService.resolveConflict.test.ts +++ b/tests/unit/SyncService.resolveConflict.test.ts @@ -25,7 +25,7 @@ describe('SyncService.resolveConflict', () => { }); it('local 선택 → checkout --ours + add + rebase --continue + push', async () => { - const r = await svc.resolveConflict('note-id', 'local'); + const r = await svc.resolveConflict('notes/note-id.md', 'local'); expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--ours', 'notes/note-id.md']); expect(gitInstance.run).toHaveBeenCalledWith(['rebase', '--continue']); expect(gitInstance.push).toHaveBeenCalled(); @@ -33,7 +33,7 @@ describe('SyncService.resolveConflict', () => { }); it('remote 선택 → checkout --theirs + add + rebase --continue + applySyncFromDir + push', async () => { - const r = await svc.resolveConflict('note-id', 'remote'); + const r = await svc.resolveConflict('notes/note-id.md', 'remote'); expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--theirs', 'notes/note-id.md']); expect(importSvc.applySyncFromDir).toHaveBeenCalled(); expect(gitInstance.push).toHaveBeenCalled(); @@ -42,7 +42,7 @@ describe('SyncService.resolveConflict', () => { it('checkout 실패 → ok:false + reason 반환', async () => { gitInstance.run.mockResolvedValueOnce({ stdout: '', stderr: 'fail', exitCode: 1 }); - const r = await svc.resolveConflict('note-id', 'local'); + const r = await svc.resolveConflict('notes/note-id.md', 'local'); expect(r.ok).toBe(false); expect((r as { reason: string }).reason).toContain('checkout failed'); expect(gitInstance.push).not.toHaveBeenCalled(); @@ -52,7 +52,7 @@ describe('SyncService.resolveConflict', () => { gitInstance.run .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // checkout .mockResolvedValueOnce({ stdout: '', stderr: 'still unresolved', exitCode: 1 }); // rebase --continue - const r = await svc.resolveConflict('note-id', 'local'); + const r = await svc.resolveConflict('notes/note-id.md', 'local'); expect(r.ok).toBe(false); expect((r as { reason: string }).reason).toContain('rebase --continue failed'); expect(gitInstance.push).not.toHaveBeenCalled(); diff --git a/tests/unit/sync-ipc.test.ts b/tests/unit/sync-ipc.test.ts index d2f459a..866991a 100644 --- a/tests/unit/sync-ipc.test.ts +++ b/tests/unit/sync-ipc.test.ts @@ -25,7 +25,7 @@ function makeDeps() { const syncSvc = { getSyncDir: vi.fn(() => '/tmp/sync'), - listConflicts: vi.fn(() => [] as { noteId: string; localText: string; remoteText: string }[]), + listConflicts: vi.fn(() => [] as { path: string; localText: string; remoteText: string }[]), resolveConflict: vi.fn(async () => ({ ok: true as const })), getLastStatus: vi.fn(() => ({ lastAt: null as string | null, lastResult: null as { ok: boolean } | null })) }; @@ -171,7 +171,7 @@ describe('sync IPC channels', () => { describe('sync:list-conflicts', () => { it('returns syncSvc.listConflicts() result', () => { const { deps, syncSvc } = makeDeps(); - const conflicts = [{ noteId: 'abc', localText: 'local', remoteText: 'remote' }]; + const conflicts = [{ path: 'notes/abc.md', localText: 'local', remoteText: 'remote' }]; syncSvc.listConflicts.mockReturnValue(conflicts); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('sync:list-conflicts'); @@ -181,28 +181,28 @@ describe('sync IPC channels', () => { }); describe('sync:resolve-conflict', () => { - it('valid choice "local" → delegates to syncSvc.resolveConflict', async () => { + it('valid choice "local" → delegates to syncSvc.resolveConflict (path)', async () => { const { deps, syncSvc } = makeDeps(); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('sync:resolve-conflict'); - const r = await h({}, 'note-1', 'local'); - expect(syncSvc.resolveConflict).toHaveBeenCalledWith('note-1', 'local'); + const r = await h({}, 'notes/note-1.md', 'local'); + expect(syncSvc.resolveConflict).toHaveBeenCalledWith('notes/note-1.md', 'local'); expect(r).toEqual({ ok: true }); }); - it('valid choice "remote" → delegates to syncSvc.resolveConflict', async () => { + it('valid choice "remote" → delegates to syncSvc.resolveConflict (path)', async () => { const { deps, syncSvc } = makeDeps(); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('sync:resolve-conflict'); - await h({}, 'note-2', 'remote'); - expect(syncSvc.resolveConflict).toHaveBeenCalledWith('note-2', 'remote'); + await h({}, 'notes/note-2.md', 'remote'); + expect(syncSvc.resolveConflict).toHaveBeenCalledWith('notes/note-2.md', 'remote'); }); it('invalid choice → ok: false, reason: invalid choice', async () => { const { deps, syncSvc } = makeDeps(); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('sync:resolve-conflict'); - const r = await h({}, 'note-1', 'both'); + const r = await h({}, 'notes/note-1.md', 'both'); expect(syncSvc.resolveConflict).not.toHaveBeenCalled(); expect(r).toEqual({ ok: false, reason: 'invalid choice' }); });