From 9a1f0e269a8500345f2e3654221544e039ff824a Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 03:33:48 +0900 Subject: [PATCH] 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({