diff --git a/docs/superpowers/plans/2026-04-26-f6-l3-import.md b/docs/superpowers/plans/2026-04-26-f6-l3-import.md new file mode 100644 index 0000000..1edf2a7 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-f6-l3-import.md @@ -0,0 +1,412 @@ +# F6-L3 Import Implementation Plan + +**Goal:** Import an F5 export tree (`notes/*.md` + `media/*.{ext}` + optional `manifest.json`) back into Inkling DB. Handle id collisions safely (same body → skip, different body → new id to preserve raw_text invariant). + +**Architecture:** Pure parse layer (`importFormat.ts` reverses `exportFormat.ts`) + orchestrator (`ImportService.ts` reads files via `node:fs/promises`, writes via `NoteRepository` + `MediaStore`) + tray menu callback (preview dialog → confirm → import). + +**Tech Stack:** TypeScript, vitest, node:fs/promises. No new package deps. + +## File Structure + +**Create:** +- `src/main/services/importFormat.ts` — pure parse functions +- `src/main/services/ImportService.ts` — orchestrator +- `tests/unit/importFormat.test.ts` +- `tests/unit/ImportService.test.ts` + +**Modify:** +- `src/main/repository/NoteRepository.ts` — add `importNote()` + `findRawTextById()` +- `src/main/index.ts` — wire ImportService + extend tray callback (5th) +- `src/main/tray.ts` — accept 5th callback `runImport`, add "백업에서 복원..." menu +- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F6-L3 status update +- Create `docs/superpowers/specs/2026-04-26-f6-l3-import.md` + +**No schema changes. No new package dependencies.** + +## Conflict Policy + +| 상황 | 처리 | +|------|------| +| id 신규 | INSERT (그대로 적재) | +| id 매치 + raw_text 동일 | skip (counted as `unchanged`) | +| id 매치 + raw_text 상이 | 새 uuidv7 발급 후 INSERT (raw_text invariant 보호) | + +## Decisions + +| 결정 | 값 | +|------|-----| +| 파싱 범위 | F5 export 포맷만 (`inkling_export_version: 1` 가정) | +| raw_text 추출 | body 구조 기반 (h1 / blockquote / image ref 제거 후 잔여) | +| 미디어 복사 | 항상 (스킵 옵션 후속) | +| AI 메타 보존 | ai_title, ai_summary, ai_provider, ai_generated_at, edited flags 모두 복원 | +| 태그 | source 보존 (ai/user) | +| 트리거 | 트레이 메뉴 "백업에서 복원..." | + +## Task 1: Pure parse functions + +**Files:** +- `src/main/services/importFormat.ts` +- `tests/unit/importFormat.test.ts` + +### `parseExportNote(markdown: string): ParsedNote` + +Returns: +```typescript +export interface ParsedNote { + id: string; + createdAt: string; + updatedAt: string; + rawText: 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' }[]; + images: { rel: string; mime: string; bytes: number }[]; + exportVersion: number; +} +``` + +### Frontmatter parsing (subset YAML) + +Handle exactly the variants `composeFrontmatter` produces: +- Plain scalar: `key: value` +- Single-quoted: `key: 'value with '': escapes'` +- Block scalar `|-`: + ``` + key: |- + line1 + line2 + ``` + Body lines have 4-space indent, joined with `\n`. +- Inline flow tag: `- { name: foo, source: ai }` +- Numeric: `bytes: 1234` +- ISO timestamps stay as strings + +Frontmatter extraction: +1. Markdown must start with `---\n` +2. Read lines until next `---\n` (the closing delimiter) +3. Parse each top-level key: detect plain / quoted / block / list +4. Return key-value map + handle `tags:` and `images:` lists specially + +### Body parsing (raw_text extraction) + +After frontmatter, body looks like: +``` +\n +# {title or '(제목 없음)'}\n +\n +> {summary line 1}\n +> {summary line 2}\n +\n +{rawText}\n +\n +![](media/{id8}__1.{ext})\n +![](media/{id8}__2.{ext})\n +``` + +Algorithm: +1. Drop leading blank lines after frontmatter +2. Skip the h1 line (starts with `# `) +3. Drop blank lines +4. Skip blockquote lines (each starts with `> `) +5. Drop blank lines +6. Capture lines until first `![](media/` or end-of-string +7. Trim trailing blank lines +8. Result = rawText + +If no h1/blockquote/images present, body verbatim is rawText. + +### Tests (≥ 12) + +- Round-trip: compose then parse → deep equal +- Plain scalar +- Single-quoted with escapes +- Block scalar `|-` multi-line +- Tag list inline flow +- Image list with mime/bytes +- Body extraction with summary +- Body extraction without summary +- Body extraction without images +- Body extraction with rawText that contains `>` mid-line (but not at line start, so not blockquote) +- Missing version → exportVersion = 0 (or throw — pick one, document in test) +- Source field maps `ai`/`user` correctly to titleEditedByUser/summaryEditedByUser + +**Commit:** `feat(import): pure parser for F5 export format` + +## Task 2: ImportService + +**Files:** +- `src/main/services/ImportService.ts` +- `tests/unit/ImportService.test.ts` +- Modify `src/main/repository/NoteRepository.ts` + +### `NoteRepository.findRawTextById(id: string): string | null` + +```typescript +findRawTextById(id: string): string | null { + const row = this.db.prepare('SELECT raw_text FROM notes WHERE id=?').get(id) as + | { raw_text: string } + | undefined; + return row?.raw_text ?? null; +} +``` + +### `NoteRepository.importNote(input: ImportNoteInput): { id: string; status: 'inserted' | 'skipped' | 'forked' }` + +```typescript +export interface ImportNoteInput { + id: string; // proposed id (caller should re-roll if conflict) + 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' }[]; +} +``` + +```typescript +importNote(input: ImportNoteInput): { id: string; status: 'inserted' | 'skipped' | 'forked' } { + const existing = this.findRawTextById(input.id); + let finalId = input.id; + let status: 'inserted' | 'skipped' | 'forked' = 'inserted'; + if (existing !== null) { + if (existing === input.rawText) { + return { id: input.id, status: 'skipped' }; + } + finalId = uuidv7(); + status = 'forked'; + } + 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) + VALUES (?,?,?,?,'done',?,?,?,?,?,?,?,?)` + ).run( + finalId, 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 + ); + 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(finalId, row.id); + else linkUser.run(finalId, row.id); + } + } + }); + tx(); + return { id: finalId, status }; +} +``` + +Note: `ai_status` is set to `'done'` (not pending) since this is an imported note that already has AI fields. No pending_jobs entry created — caller can re-trigger AI later if desired. + +### ImportService + +```typescript +export interface ImportPlan { + total: number; + newCount: number; + unchangedCount: number; + forkedCount: number; + mediaCount: number; +} + +export interface ImportResult extends ImportPlan { + finalNoteIds: Map; // origId -> finalId +} + +export class ImportService { + constructor(private repo: NoteRepository, private mediaStore: MediaStore) {} + + async preview(sourceDir: string): Promise { + const files = await this.scanNotes(sourceDir); + const plan: ImportPlan = { total: 0, newCount: 0, unchangedCount: 0, forkedCount: 0, mediaCount: 0 }; + for (const f of files) { + const content = await readFile(f, 'utf8'); + const parsed = parseExportNote(content); + plan.total += 1; + const existing = this.repo.findRawTextById(parsed.id); + if (existing === null) plan.newCount += 1; + else if (existing === parsed.rawText) plan.unchangedCount += 1; + else plan.forkedCount += 1; + plan.mediaCount += parsed.images.length; + } + return plan; + } + + async run(sourceDir: string): Promise { + const files = await this.scanNotes(sourceDir); + const finalNoteIds = new Map(); + let newCount = 0, unchangedCount = 0, forkedCount = 0, mediaCount = 0; + for (const f of files) { + const content = await readFile(f, 'utf8'); + const parsed = parseExportNote(content); + const r = this.repo.importNote({ ...parsed }); // tags + ImportNoteInput shape + finalNoteIds.set(parsed.id, r.id); + if (r.status === 'inserted') newCount += 1; + else if (r.status === 'skipped') unchangedCount += 1; + else forkedCount += 1; + // Copy media + if (r.status !== 'skipped') { + for (let i = 0; i < parsed.images.length; i++) { + const img = parsed.images[i]!; + // Source rel = img.rel relative to sourceDir + const src = join(sourceDir, img.rel); + const ext = img.rel.split('.').pop() ?? 'bin'; + // MediaStore expects bytes; we'll call writeFile direct + // BUT respect MediaStore convention: media/{noteId}/{filename} + const noteMediaDir = join(this.mediaStore.absolutePath('media'), r.id); + await mkdir(noteMediaDir, { recursive: true }); + const dstFilename = `${i + 1}.${ext}`; + await copyFile(src, join(noteMediaDir, dstFilename)); + this.repo.insertMedia([{ + noteId: r.id, + kind: 'image', + relPath: `media/${r.id}/${dstFilename}`, + mime: img.mime, + bytes: img.bytes + }]); + mediaCount += 1; + } + } + } + return { total: files.length, newCount, unchangedCount, forkedCount, mediaCount, finalNoteIds }; + } + + private async scanNotes(sourceDir: string): Promise { + const notesDir = join(sourceDir, 'notes'); + const entries = await readdir(notesDir); + return entries.filter((e) => e.endsWith('.md')).map((e) => join(notesDir, e)); + } +} +``` + +### Tests (≥ 6) + +1. preview() of empty `notes/` dir → all zeros +2. preview() of single new note → newCount=1 +3. run() inserts new note + tags + media +4. run() with id collision + same body → status='skipped' +5. run() with id collision + different body → forked, new id +6. run() copies media file to profileDir + inserts media row + +**Commit:** `feat(import): ImportService with conflict policy + media copy` + +## Task 3: Wire into main + tray + +**Modify:** `src/main/index.ts`, `src/main/tray.ts` + +### tray.ts — 5th callback + +`createTray(showInbox, showCapture, runBackup, runExport, runImport)` + +Menu order: +1. 구출한 메모 보기 +2. 기억 구출하기 +3. ─ +4. 지금 백업 +5. 내보내기... +6. 백업에서 복원... +7. (packaged: 자동 실행 + sep | dev: sep) +8. 종료 + +### index.ts + +Add `ImportService` import + instantiate after ExportService: + +```typescript +const importSvc = new ImportService(repo, store); +``` + +5th callback (between existing 4th and closing paren): + +```typescript +async () => { + // runImport + const win = getInboxWindow(); + const dirOpts: Electron.OpenDialogOptions = { + title: '복원할 백업 폴더 선택', + message: 'F5 export 형식의 폴더를 선택하세요. notes/ 하위의 .md 파일이 적재됩니다.', + buttonLabel: '여기서 복원', + properties: ['openDirectory'] + }; + const dirResult = win + ? await dialog.showOpenDialog(win, dirOpts) + : await dialog.showOpenDialog(dirOpts); + if (dirResult.canceled || dirResult.filePaths.length === 0) return; + const sourceDir = dirResult.filePaths[0]!; + let plan; + try { + plan = await importSvc.preview(sourceDir); + } catch (e) { + logger.warn('import.preview.failed', { reason: String(e) }); + new Notification({ title: 'Inkling', body: '백업 폴더를 읽지 못했습니다.', silent: true }).show(); + return; + } + // Confirm dialog + const confirm = await dialog.showMessageBox(win ?? undefined as any, { + type: 'question', + buttons: ['복원', '취소'], + defaultId: 0, + cancelId: 1, + title: 'Inkling 복원', + message: `복원 미리보기`, + detail: `총 ${plan.total}개 노트\n · 신규 ${plan.newCount}개\n · 동일 (스킵) ${plan.unchangedCount}개\n · 충돌→새 id (${plan.forkedCount}개, raw_text 보존)\n\n이미지 ${plan.mediaCount}개 복사 예정.` + }); + if (confirm.response !== 0) return; + try { + const r = await importSvc.run(sourceDir); + logger.info('import.done', { total: r.total, new: r.newCount, unchanged: r.unchangedCount, forked: r.forkedCount, media: r.mediaCount }); + new Notification({ + title: 'Inkling', + body: `복원 완료 — 신규 ${r.newCount}개, 스킵 ${r.unchangedCount}개, 충돌 ${r.forkedCount}개`, + silent: true + }).show(); + } catch (e) { + logger.warn('import.run.failed', { reason: String(e) }); + new Notification({ title: 'Inkling', body: '복원을 완료하지 못했습니다.', silent: true }).show(); + } +} +``` + +**Commit:** `feat(import): wire ImportService — tray '백업에서 복원...' + preview dialog` + +## Task 4: Promote + +Create `docs/superpowers/specs/2026-04-26-f6-l3-import.md` with mini-brainstorm decisions table. + +Update `docs/superpowers/specs/2026-04-25-dogfood-feedback.md`: +- F6 진행 상태: L3 → 🚀 promoted (link) + +**Commit:** `docs(spec): promote F6-L3 import` + +## Verification + +Each task: typecheck 0 + tests pass. +End: 108 + ~20 (12 importFormat + 6 ImportService + 1-2 NoteRepository) ≈ 128/128.