From fe6bbd31043a38095456ca6c5c0a1c0dd1b4bd46 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 10:50:01 +0900 Subject: [PATCH 1/5] =?UTF-8?q?docs(plan):=20F6-L3=20import=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B3=84=ED=9A=8D=20(4=20tasks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-04-26-f6-l3-import.md | 412 ++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-f6-l3-import.md 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. -- 2.49.1 From e8587c19868d939dbbae0b0968a6ea1c9ef688ae Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 10:53:29 +0900 Subject: [PATCH 2/5] feat(import): pure parser for F5 export format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseExportNote reverses composeMarkdown — minimal YAML parser covering only the variants F5 emits (plain, single-quoted, block scalar, tag/image lists). Body extraction strips h1 + blockquote + image refs to recover rawText. Round-trip tested against exportFormat.composeMarkdown. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/services/importFormat.ts | 354 ++++++++++++++++++++++++++++++ tests/unit/importFormat.test.ts | 209 ++++++++++++++++++ 2 files changed, 563 insertions(+) create mode 100644 src/main/services/importFormat.ts create mode 100644 tests/unit/importFormat.test.ts diff --git a/src/main/services/importFormat.ts b/src/main/services/importFormat.ts new file mode 100644 index 0000000..2420e47 --- /dev/null +++ b/src/main/services/importFormat.ts @@ -0,0 +1,354 @@ +/** + * Pure parse functions for F6-L3 (Import). + * + * Reverses the output of `composeMarkdown` from `exportFormat.ts`. + * Minimal YAML parser handling exactly the variants F5 emits — plain scalars, + * single-quoted strings (with `''` escapes), block scalar `|-`, and the two + * structured lists (`tags:` inline-flow, `images:` block). + * + * No filesystem, no I/O. + */ + +export interface ParsedNoteTag { + name: string; + source: 'ai' | 'user'; +} + +export interface ParsedNoteImage { + rel: string; + mime: string; + bytes: number; +} + +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: ParsedNoteTag[]; + images: ParsedNoteImage[]; + exportVersion: number; +} + +// --------------------------------------------------------------------------- +// YAML helpers +// --------------------------------------------------------------------------- + +function unquoteSingle(raw: string): string { + // Caller has confirmed `raw` is wrapped in single quotes. + const inner = raw.slice(1, -1); + return inner.replace(/''/g, "'"); +} + +interface ParsedScalar { + value: string; + /** number of source lines consumed (1 for plain/quoted, 1+N for block scalar) */ + consumed: number; +} + +/** + * Parse a scalar value starting at `lines[startIdx]`. + * - `key: value` → consumed=1 + * - `key: 'quoted'` → consumed=1 + * - `key: |-` + indented body → consumed=1+N + * + * Returns `null` if the line is not a `key: …` scalar at column 0. + */ +function parseScalarAt( + lines: string[], + startIdx: number, + expectedKey: string +): ParsedScalar | null { + const line = lines[startIdx]; + if (line === undefined) return null; + const prefix = `${expectedKey}:`; + if (!line.startsWith(prefix)) return null; + const after = line.slice(prefix.length); + if (after.length > 0 && after[0] !== ' ') return null; + const rhs = after.trimStart(); + + // Block scalar + if (rhs === '|-') { + const bodyLines: string[] = []; + let i = startIdx + 1; + // Determine indent from first body line; F5 emits 2-space indent at this level + // (composeFrontmatter passes default `indent=2`). We accept any indent ≥ 1 + // and use the first body line's leading whitespace as the dedent prefix. + let dedent: string | null = null; + while (i < lines.length) { + const l = lines[i]!; + if (l.length === 0) { + // blank line inside block scalar — keep, dedent later (treat as empty) + bodyLines.push(''); + i += 1; + continue; + } + const m = /^( +)/.exec(l); + if (!m) { + // first non-indented line ends the block + break; + } + const indent = m[1]!; + if (dedent === null) dedent = indent; + // Use the smallest leading-space of the first body line as the dedent prefix. + // (F5 always emits a uniform indent for a given block.) + bodyLines.push(l.startsWith(dedent) ? l.slice(dedent.length) : l.trimStart()); + i += 1; + } + // Trim trailing blank lines that we tentatively added (block scalar `|-` + // strips final newline anyway). + while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1] === '') { + bodyLines.pop(); + } + return { value: bodyLines.join('\n'), consumed: i - startIdx }; + } + + // Single-quoted + if (rhs.startsWith("'") && rhs.endsWith("'") && rhs.length >= 2) { + return { value: unquoteSingle(rhs), consumed: 1 }; + } + + // Plain scalar + return { value: rhs, consumed: 1 }; +} + +// --------------------------------------------------------------------------- +// Frontmatter section parser +// --------------------------------------------------------------------------- + +interface Frontmatter { + fields: Map; + tags: ParsedNoteTag[]; + images: ParsedNoteImage[]; + /** total lines consumed including the closing `---` delimiter */ + consumedLines: number; +} + +/** + * Parse a tag flow item: `- { name: foo, source: ai }` or + * `- { name: 'a, b', source: user }`. + */ +function parseTagFlow(line: string): ParsedNoteTag | null { + const trimmed = line.trim(); + if (!trimmed.startsWith('-')) return null; + const afterDash = trimmed.slice(1).trimStart(); + if (!afterDash.startsWith('{') || !afterDash.endsWith('}')) return null; + const inner = afterDash.slice(1, -1).trim(); + // Expect `name: , source: ai|user`. Value may be single-quoted with embedded commas. + // Split on the comma that is OUTSIDE single quotes. + let nameRaw: string | null = null; + let sourceRaw: string | null = null; + let inQuote = false; + let cursor = 0; + const parts: string[] = []; + for (let i = 0; i < inner.length; i++) { + const ch = inner[i]; + if (ch === "'") { + // Toggle, accounting for `''` escape (still inside the quote scope). + if (inQuote && inner[i + 1] === "'") { + i += 1; + continue; + } + inQuote = !inQuote; + } else if (ch === ',' && !inQuote) { + parts.push(inner.slice(cursor, i)); + cursor = i + 1; + } + } + parts.push(inner.slice(cursor)); + for (const p of parts) { + const colon = p.indexOf(':'); + if (colon === -1) return null; + const k = p.slice(0, colon).trim(); + const v = p.slice(colon + 1).trim(); + if (k === 'name') { + nameRaw = v; + } else if (k === 'source') { + sourceRaw = v; + } + } + if (nameRaw === null || sourceRaw === null) return null; + const name = + nameRaw.startsWith("'") && nameRaw.endsWith("'") ? unquoteSingle(nameRaw) : nameRaw; + if (sourceRaw !== 'ai' && sourceRaw !== 'user') return null; + return { name, source: sourceRaw }; +} + +function parseFrontmatter(lines: string[]): Frontmatter { + if (lines[0] !== '---') { + throw new Error('importFormat: expected frontmatter to start with "---"'); + } + const fields = new Map(); + const tags: ParsedNoteTag[] = []; + const images: ParsedNoteImage[] = []; + + let i = 1; + while (i < lines.length) { + const line = lines[i]!; + if (line === '---') { + // Closing delimiter — return. + return { fields, tags, images, consumedLines: i + 1 }; + } + if (line.length === 0) { + i += 1; + continue; + } + + // Top-level keys (column 0). Detect `key:` (list intro) or `key: value`. + if (line === 'tags:') { + i += 1; + while (i < lines.length) { + const l = lines[i]!; + if (l === '---') break; + if (!l.startsWith(' -')) break; + const tag = parseTagFlow(l); + if (tag) tags.push(tag); + i += 1; + } + continue; + } + if (line === 'images:') { + i += 1; + while (i < lines.length) { + const l = lines[i]!; + if (l === '---') break; + if (!l.startsWith(' - rel:')) break; + // Image item: 3 lines (rel, mime, bytes), each as a sub-scalar. + const relScalar = parseImageSubScalar(l, ' - rel:'); + const mimeLine = lines[i + 1] ?? ''; + const bytesLine = lines[i + 2] ?? ''; + const mimeScalar = parseImageSubScalar(mimeLine, ' mime:'); + const bytesScalar = parseImageSubScalar(bytesLine, ' bytes:'); + if (relScalar === null || mimeScalar === null || bytesScalar === null) { + throw new Error('importFormat: malformed images item'); + } + const bytesNum = Number.parseInt(bytesScalar, 10); + if (!Number.isFinite(bytesNum)) { + throw new Error('importFormat: bytes must be a number'); + } + images.push({ rel: relScalar, mime: mimeScalar, bytes: bytesNum }); + i += 3; + } + continue; + } + + // Top-level scalar — find key, parse value. + const colon = line.indexOf(':'); + if (colon === -1) { + // Stray line, skip. + i += 1; + continue; + } + const key = line.slice(0, colon); + const scalar = parseScalarAt(lines, i, key); + if (scalar === null) { + i += 1; + continue; + } + fields.set(key, scalar.value); + i += scalar.consumed; + } + throw new Error('importFormat: frontmatter not terminated'); +} + +function parseImageSubScalar(line: string, prefix: string): string | null { + if (!line.startsWith(prefix)) return null; + const rhs = line.slice(prefix.length).trimStart(); + if (rhs.startsWith("'") && rhs.endsWith("'") && rhs.length >= 2) { + return unquoteSingle(rhs); + } + return rhs; +} + +// --------------------------------------------------------------------------- +// Body parser (raw_text recovery) +// --------------------------------------------------------------------------- + +/** + * Strips the rendered prefix (h1, blockquote summary) and trailing image refs + * to recover `rawText`. + */ +function extractRawText(bodyLines: string[]): string { + let i = 0; + // Drop leading blanks + while (i < bodyLines.length && bodyLines[i] === '') i += 1; + // Skip a single h1 line (`# …`) + if (i < bodyLines.length && bodyLines[i]!.startsWith('# ')) { + i += 1; + } + // Drop blanks + while (i < bodyLines.length && bodyLines[i] === '') i += 1; + // Skip blockquote run (`> …`) + while (i < bodyLines.length && bodyLines[i]!.startsWith('> ')) i += 1; + // Drop blanks + while (i < bodyLines.length && bodyLines[i] === '') i += 1; + + // Capture until first standalone `![](media/…)` line OR end. + const captured: string[] = []; + while (i < bodyLines.length) { + const l = bodyLines[i]!; + // Image refs are emitted only at line start, separated from body by `\n\n`. + if (l.startsWith('![](media/')) break; + captured.push(l); + i += 1; + } + // Trim trailing blank lines. + while (captured.length > 0 && captured[captured.length - 1] === '') { + captured.pop(); + } + return captured.join('\n'); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function parseExportNote(markdown: string): ParsedNote { + if (!markdown.startsWith('---\n')) { + throw new Error('importFormat: markdown must start with "---\\n"'); + } + // Normalize line endings (F5 emits LF only, but be defensive). + const normalized = markdown.replace(/\r\n/g, '\n'); + const allLines = normalized.split('\n'); + const fm = parseFrontmatter(allLines); + const bodyLines = allLines.slice(fm.consumedLines); + const rawText = extractRawText(bodyLines); + + const get = (k: string): string | null => (fm.fields.has(k) ? fm.fields.get(k)! : null); + const id = get('id'); + const createdAt = get('created_at'); + const updatedAt = get('updated_at'); + if (id === null || createdAt === null || updatedAt === null) { + throw new Error('importFormat: id/created_at/updated_at are required'); + } + const titleSource = get('title_source'); + const summarySource = get('summary_source'); + const versionRaw = get('inkling_export_version'); + const exportVersion = versionRaw === null ? 0 : Number.parseInt(versionRaw, 10) || 0; + + return { + id, + createdAt, + updatedAt, + rawText, + aiTitle: get('title'), + aiSummary: get('summary'), + titleEditedByUser: titleSource === 'user', + summaryEditedByUser: summarySource === 'user', + aiProvider: get('ai_provider'), + aiGeneratedAt: get('ai_generated_at'), + userIntent: get('user_intent'), + intentPromptedAt: get('intent_prompted_at'), + tags: fm.tags, + images: fm.images, + exportVersion + }; +} diff --git a/tests/unit/importFormat.test.ts b/tests/unit/importFormat.test.ts new file mode 100644 index 0000000..5326909 --- /dev/null +++ b/tests/unit/importFormat.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect } from 'vitest'; +import { + composeMarkdown, + type ExportNote +} from '@main/services/exportFormat.js'; +import { parseExportNote } from '@main/services/importFormat.js'; + +const baseNote: ExportNote = { + id: '014a3b9c-1234-7890-abcd-000000000001', + createdAt: '2026-04-25T14:23:11.000Z', + updatedAt: '2026-04-25T14:24:02.000Z', + rawText: '회고 메모 본문', + aiTitle: '주간 회고 PR 리뷰', + aiSummary: '회고 양식 통일을 위한 메모.', + titleEditedByUser: false, + summaryEditedByUser: false, + aiProvider: 'local-ollama/gemma4:e4b', + aiGeneratedAt: '2026-04-25T14:23:34.000Z', + userIntent: null, + intentPromptedAt: null, + tags: [{ name: 'pr', source: 'ai' }, { name: 'review', source: 'user' }], + media: [] +}; + +describe('parseExportNote — round-trip with composeMarkdown', () => { + it('round-trips the base note', () => { + const md = composeMarkdown(baseNote); + const parsed = parseExportNote(md); + expect(parsed.id).toBe(baseNote.id); + expect(parsed.createdAt).toBe(baseNote.createdAt); + expect(parsed.updatedAt).toBe(baseNote.updatedAt); + expect(parsed.rawText).toBe(baseNote.rawText); + expect(parsed.aiTitle).toBe(baseNote.aiTitle); + expect(parsed.aiSummary).toBe(baseNote.aiSummary); + expect(parsed.aiProvider).toBe(baseNote.aiProvider); + expect(parsed.aiGeneratedAt).toBe(baseNote.aiGeneratedAt); + expect(parsed.titleEditedByUser).toBe(false); + expect(parsed.summaryEditedByUser).toBe(false); + expect(parsed.tags).toEqual([ + { name: 'pr', source: 'ai' }, + { name: 'review', source: 'user' } + ]); + expect(parsed.images).toEqual([]); + expect(parsed.exportVersion).toBe(1); + }); + + it('round-trips a note with media', () => { + const note: ExportNote = { + ...baseNote, + media: [ + { rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 1234 }, + { rel: 'media/014a3b9c__2.jpg', mime: 'image/jpeg', bytes: 5678 } + ] + }; + const md = composeMarkdown(note); + const parsed = parseExportNote(md); + expect(parsed.images).toEqual([ + { rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 1234 }, + { rel: 'media/014a3b9c__2.jpg', mime: 'image/jpeg', bytes: 5678 } + ]); + expect(parsed.rawText).toBe(note.rawText); + }); +}); + +describe('parseExportNote — frontmatter scalar variants', () => { + it('parses plain scalar', () => { + const md = composeMarkdown({ ...baseNote, aiTitle: '주간 회고' }); + const parsed = parseExportNote(md); + expect(parsed.aiTitle).toBe('주간 회고'); + }); + + it('parses single-quoted with embedded apostrophe (`` `` escape)', () => { + const note: ExportNote = { ...baseNote, aiTitle: "it's a: title" }; + const md = composeMarkdown(note); + // Should be emitted as: title: 'it''s a: title' + expect(md).toContain("title: 'it''s a: title'"); + const parsed = parseExportNote(md); + expect(parsed.aiTitle).toBe("it's a: title"); + }); + + it('parses block scalar `|-` for multiline summary', () => { + const note: ExportNote = { + ...baseNote, + aiSummary: 'line1\nline2\nline3' + }; + const md = composeMarkdown(note); + expect(md).toContain('summary: |-'); + const parsed = parseExportNote(md); + expect(parsed.aiSummary).toBe('line1\nline2\nline3'); + }); +}); + +describe('parseExportNote — list parsing', () => { + it('parses tags inline flow', () => { + const md = composeMarkdown({ + ...baseNote, + tags: [ + { name: 'foo', source: 'ai' }, + { name: 'bar baz', source: 'user' } + ] + }); + const parsed = parseExportNote(md); + expect(parsed.tags).toEqual([ + { name: 'foo', source: 'ai' }, + { name: 'bar baz', source: 'user' } + ]); + }); + + it('parses images list with mime + bytes', () => { + const md = composeMarkdown({ + ...baseNote, + media: [{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 9876 }] + }); + const parsed = parseExportNote(md); + expect(parsed.images).toEqual([ + { rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 9876 } + ]); + }); +}); + +describe('parseExportNote — body extraction', () => { + it('extracts rawText with summary present', () => { + const md = composeMarkdown({ ...baseNote, rawText: '본문\n두 번째 줄' }); + const parsed = parseExportNote(md); + expect(parsed.rawText).toBe('본문\n두 번째 줄'); + }); + + it('extracts rawText with summary absent', () => { + const md = composeMarkdown({ + ...baseNote, + aiSummary: null, + rawText: '요약 없는 본문' + }); + const parsed = parseExportNote(md); + expect(parsed.rawText).toBe('요약 없는 본문'); + }); + + it('extracts rawText with no images', () => { + const md = composeMarkdown({ ...baseNote, rawText: '이미지 없음', media: [] }); + const parsed = parseExportNote(md); + expect(parsed.rawText).toBe('이미지 없음'); + }); + + it('preserves `>` mid-line in rawText (not parsed as blockquote)', () => { + const md = composeMarkdown({ + ...baseNote, + rawText: '값 a > b 라는 부등호' + }); + const parsed = parseExportNote(md); + expect(parsed.rawText).toBe('값 a > b 라는 부등호'); + }); + + it('preserves `# ` mid-line in rawText (not parsed as heading)', () => { + const md = composeMarkdown({ + ...baseNote, + rawText: '예시: see issue #1 어쩌고 # 가운데 해시' + }); + const parsed = parseExportNote(md); + expect(parsed.rawText).toBe('예시: see issue #1 어쩌고 # 가운데 해시'); + }); +}); + +describe('parseExportNote — provenance', () => { + it('recovers titleEditedByUser from title_source: user', () => { + const md = composeMarkdown({ ...baseNote, titleEditedByUser: true }); + const parsed = parseExportNote(md); + expect(parsed.titleEditedByUser).toBe(true); + }); + + it('recovers summaryEditedByUser from summary_source: user', () => { + const md = composeMarkdown({ ...baseNote, summaryEditedByUser: true }); + const parsed = parseExportNote(md); + expect(parsed.summaryEditedByUser).toBe(true); + }); + + it('exposes exportVersion = 1', () => { + const md = composeMarkdown(baseNote); + const parsed = parseExportNote(md); + expect(parsed.exportVersion).toBe(1); + }); +}); + +describe('parseExportNote — edge cases', () => { + it('preserves user_intent when present', () => { + const md = composeMarkdown({ + ...baseNote, + userIntent: '팀에서 회고 양식 통일', + intentPromptedAt: '2026-04-25T14:24:02.000Z' + }); + const parsed = parseExportNote(md); + expect(parsed.userIntent).toBe('팀에서 회고 양식 통일'); + expect(parsed.intentPromptedAt).toBe('2026-04-25T14:24:02.000Z'); + }); + + it('returns null aiTitle / aiSummary when omitted', () => { + const md = composeMarkdown({ + ...baseNote, + aiTitle: null, + aiSummary: null + }); + const parsed = parseExportNote(md); + expect(parsed.aiTitle).toBeNull(); + expect(parsed.aiSummary).toBeNull(); + }); + + it('throws when input lacks frontmatter delimiter', () => { + expect(() => parseExportNote('hello world')).toThrow(); + }); +}); -- 2.49.1 From d76cca68dfe44242f868993fb5abe5093f50be4f Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 10:55:13 +0900 Subject: [PATCH 3/5] feat(import): ImportService with conflict policy + media copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-state outcome per note: inserted (new id), skipped (id+rawText match), forked (id match but rawText differs → new uuidv7 to preserve raw_text invariant from slice §1.1). Media files copied into MediaStore convention /media/{noteId}/{n}.{ext} with new media DB rows. NoteRepository.importNote handles full provenance: ai_status='done', ai_provider, ai_generated_at, edited flags, intent fields, tags with source preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/repository/NoteRepository.ts | 96 +++++++++++ src/main/services/ImportService.ts | 146 ++++++++++++++++ tests/unit/ImportService.test.ts | 235 ++++++++++++++++++++++++++ 3 files changed, 477 insertions(+) create mode 100644 src/main/services/ImportService.ts create mode 100644 tests/unit/ImportService.test.ts diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index ed0c0a4..224fda0 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -12,6 +12,32 @@ export interface NewMediaRow { bytes: number; } +export interface ImportNoteInput { + /** Proposed id from the export file. May be replaced if it collides with + * an existing row whose `raw_text` differs (raw_text invariant guard). */ + 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' }[]; +} + +export type ImportNoteStatus = 'inserted' | 'skipped' | 'forked'; + +export interface ImportNoteResult { + /** Final id used for the row (== input.id for inserted/skipped, fresh uuidv7 for forked). */ + id: string; + status: ImportNoteStatus; +} + export class NoteRepository { constructor(private db: Database.Database) {} @@ -188,6 +214,76 @@ export class NoteRepository { this.db.prepare('DELETE FROM notes WHERE id=?').run(id); } + 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; + } + + /** + * Import a note from an external source (F5 export tree). + * Conflict policy: + * - id missing in DB → INSERT (status: 'inserted') + * - id present + raw_text identical → no-op (status: 'skipped') + * - id present + raw_text differs → INSERT under fresh uuidv7 + * to preserve the raw_text-immutable invariant (status: 'forked') + */ + importNote(input: ImportNoteInput): ImportNoteResult { + const existing = this.findRawTextById(input.id); + let finalId = input.id; + let status: ImportNoteStatus = '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 }; + } + getPendingCount(): number { const row = this.db .prepare(`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending'`) diff --git a/src/main/services/ImportService.ts b/src/main/services/ImportService.ts new file mode 100644 index 0000000..5fcb935 --- /dev/null +++ b/src/main/services/ImportService.ts @@ -0,0 +1,146 @@ +import { readdir, readFile, mkdir, copyFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { NoteRepository, ImportNoteInput } from '../repository/NoteRepository.js'; +import type { MediaStore } from './MediaStore.js'; +import { parseExportNote, type ParsedNote } from './importFormat.js'; + +export interface ImportPlan { + total: number; + newCount: number; + unchangedCount: number; + forkedCount: number; + mediaCount: number; +} + +export interface ImportResult extends ImportPlan { + /** Map of original-export-id → final-DB-id (differs only for forked rows). */ + finalNoteIds: Map; +} + +function inferExtFromMime(mime: string): string { + if (mime === 'image/png') return 'png'; + if (mime === 'image/jpeg') return 'jpg'; + if (mime === 'image/gif') return 'gif'; + if (mime === 'image/webp') return 'webp'; + return 'bin'; +} + +function parsedToInput(parsed: ParsedNote): ImportNoteInput { + return { + 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 + }; +} + +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; + let unchangedCount = 0; + let forkedCount = 0; + let mediaCount = 0; + + for (const f of files) { + const content = await readFile(f, 'utf8'); + const parsed = parseExportNote(content); + const r = this.repo.importNote(parsedToInput(parsed)); + finalNoteIds.set(parsed.id, r.id); + + if (r.status === 'inserted') newCount += 1; + else if (r.status === 'skipped') unchangedCount += 1; + else forkedCount += 1; + + // Skip media for already-present (skipped) notes — DB already has them. + if (r.status === 'skipped') continue; + + // Copy media files into MediaStore convention /media/{noteId}/{n}.{ext} + const noteMediaDir = join(this.mediaStore.absolutePath('media'), r.id); + if (parsed.images.length > 0) { + await mkdir(noteMediaDir, { recursive: true }); + } + const mediaRows = []; + for (let i = 0; i < parsed.images.length; i++) { + const img = parsed.images[i]!; + const src = join(sourceDir, img.rel); + const ext = inferExtFromMime(img.mime); + const dstFilename = `${i + 1}.${ext}`; + const dstAbs = join(noteMediaDir, dstFilename); + await copyFile(src, dstAbs); + mediaRows.push({ + noteId: r.id, + kind: 'image' as const, + relPath: `media/${r.id}/${dstFilename}`, + mime: img.mime, + bytes: img.bytes + }); + mediaCount += 1; + } + if (mediaRows.length > 0) { + this.repo.insertMedia(mediaRows); + } + } + + return { + total: files.length, + newCount, + unchangedCount, + forkedCount, + mediaCount, + finalNoteIds + }; + } + + private async scanNotes(sourceDir: string): Promise { + const notesDir = join(sourceDir, 'notes'); + let entries: string[]; + try { + entries = await readdir(notesDir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + return entries + .filter((e) => e.endsWith('.md')) + .sort() + .map((e) => join(notesDir, e)); + } +} diff --git a/tests/unit/ImportService.test.ts b/tests/unit/ImportService.test.ts new file mode 100644 index 0000000..a88cee0 --- /dev/null +++ b/tests/unit/ImportService.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { + mkdtempSync, + rmSync, + existsSync, + mkdirSync, + writeFileSync, + readFileSync +} from 'node:fs'; +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 { MediaStore } from '@main/services/MediaStore.js'; +import { ImportService } from '@main/services/ImportService.js'; +import { + composeMarkdown, + composeFilename, + type ExportNote +} from '@main/services/exportFormat.js'; + +function buildExportNote(overrides: Partial = {}): ExportNote { + return { + id: '014a3b9c-1234-7890-abcd-000000000001', + createdAt: '2026-04-25T14:23:11.000Z', + updatedAt: '2026-04-25T14:24:02.000Z', + rawText: '회고 메모 본문', + aiTitle: '주간 회고 PR 리뷰', + aiSummary: '회고 양식 통일을 위한 메모.', + titleEditedByUser: false, + summaryEditedByUser: false, + aiProvider: 'local-ollama/gemma4:e4b', + aiGeneratedAt: '2026-04-25T14:23:34.000Z', + userIntent: null, + intentPromptedAt: null, + tags: [{ name: 'pr', source: 'ai' }], + media: [], + ...overrides + }; +} + +function writeNote(sourceDir: string, note: ExportNote): string { + const filename = composeFilename({ + id: note.id, + createdAt: note.createdAt, + aiTitle: note.aiTitle + }); + const md = composeMarkdown(note); + mkdirSync(join(sourceDir, 'notes'), { recursive: true }); + const abs = join(sourceDir, 'notes', filename); + writeFileSync(abs, md, 'utf8'); + return abs; +} + +function writeMedia(sourceDir: string, rel: string, bytes: Buffer): void { + const dirIdx = rel.lastIndexOf('/'); + const subdir = dirIdx === -1 ? '' : rel.slice(0, dirIdx); + if (subdir) { + mkdirSync(join(sourceDir, subdir), { recursive: true }); + } + writeFileSync(join(sourceDir, rel), bytes); +} + +describe('ImportService', () => { + let tmpRoot: string; + let sourceDir: string; + let profileDir: string; + let db: Database.Database; + let repo: NoteRepository; + let mediaStore: MediaStore; + let svc: ImportService; + + beforeEach(() => { + tmpRoot = mkdtempSync(join(tmpdir(), 'inkling-import-')); + sourceDir = join(tmpRoot, 'src'); + profileDir = join(tmpRoot, 'profile'); + mkdirSync(sourceDir, { recursive: true }); + mkdirSync(join(profileDir, 'media'), { recursive: true }); + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + mediaStore = new MediaStore(profileDir); + svc = new ImportService(repo, mediaStore); + }); + + afterEach(() => { + db.close(); + rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('preview() of empty notes/ directory → all zeros', async () => { + mkdirSync(join(sourceDir, 'notes'), { recursive: true }); + const plan = await svc.preview(sourceDir); + expect(plan).toEqual({ + total: 0, + newCount: 0, + unchangedCount: 0, + forkedCount: 0, + mediaCount: 0 + }); + }); + + it('preview() of single new note → newCount=1', async () => { + writeNote(sourceDir, buildExportNote()); + const plan = await svc.preview(sourceDir); + expect(plan.total).toBe(1); + expect(plan.newCount).toBe(1); + expect(plan.unchangedCount).toBe(0); + expect(plan.forkedCount).toBe(0); + }); + + it('run() inserts a new note with tags + provenance', async () => { + writeNote( + sourceDir, + buildExportNote({ + tags: [ + { name: 'pr', source: 'ai' }, + { name: 'review', source: 'user' } + ], + titleEditedByUser: true + }) + ); + const r = await svc.run(sourceDir); + expect(r.newCount).toBe(1); + expect(r.unchangedCount).toBe(0); + expect(r.forkedCount).toBe(0); + + const note = repo.findById('014a3b9c-1234-7890-abcd-000000000001'); + expect(note).not.toBeNull(); + expect(note!.aiTitle).toBe('주간 회고 PR 리뷰'); + expect(note!.aiStatus).toBe('done'); + expect(note!.titleEditedByUser).toBe(true); + expect(note!.aiProvider).toBe('local-ollama/gemma4:e4b'); + const tagNames = note!.tags.map((t) => `${t.name}:${t.source}`).sort(); + expect(tagNames).toEqual(['pr:ai', 'review:user']); + }); + + it('run() with id collision + identical raw_text → status=skipped, no extra row', async () => { + // Pre-seed DB. + repo.importNote({ + id: '014a3b9c-1234-7890-abcd-000000000001', + rawText: '회고 메모 본문', + createdAt: '2026-04-25T14:23:11.000Z', + updatedAt: '2026-04-25T14:24:02.000Z', + aiTitle: '기존 제목', + aiSummary: null, + titleEditedByUser: false, + summaryEditedByUser: false, + aiProvider: null, + aiGeneratedAt: null, + userIntent: null, + intentPromptedAt: null, + tags: [] + }); + + writeNote(sourceDir, buildExportNote()); // same id, same rawText + + const r = await svc.run(sourceDir); + expect(r.unchangedCount).toBe(1); + expect(r.newCount).toBe(0); + expect(r.forkedCount).toBe(0); + + const allRows = db.prepare('SELECT id FROM notes').all(); + expect(allRows.length).toBe(1); + // Original title preserved (skip means no overwrite). + const note = repo.findById('014a3b9c-1234-7890-abcd-000000000001'); + expect(note!.aiTitle).toBe('기존 제목'); + }); + + it('run() with id collision + different raw_text → forked, new id, original untouched', async () => { + // Pre-seed DB with raw_text "OLD". + repo.importNote({ + id: '014a3b9c-1234-7890-abcd-000000000001', + rawText: 'OLD body', + createdAt: '2026-04-25T14:23:11.000Z', + updatedAt: '2026-04-25T14:24:02.000Z', + aiTitle: '기존', + aiSummary: null, + titleEditedByUser: false, + summaryEditedByUser: false, + aiProvider: null, + aiGeneratedAt: null, + userIntent: null, + intentPromptedAt: null, + tags: [] + }); + + // Export note with same id, different rawText. + writeNote(sourceDir, buildExportNote({ rawText: 'NEW body' })); + + const r = await svc.run(sourceDir); + expect(r.forkedCount).toBe(1); + expect(r.newCount).toBe(0); + + // Two rows now. + const allRows = db.prepare('SELECT id, raw_text FROM notes ORDER BY raw_text').all() as Array<{ + id: string; + raw_text: string; + }>; + expect(allRows.length).toBe(2); + expect(allRows.map((r) => r.raw_text)).toEqual(['NEW body', 'OLD body']); + + // Original id still has OLD body (raw_text invariant). + const original = repo.findById('014a3b9c-1234-7890-abcd-000000000001'); + expect(original!.rawText).toBe('OLD body'); + + // Mapping records the rename. + expect(r.finalNoteIds.get('014a3b9c-1234-7890-abcd-000000000001')).not.toBe( + '014a3b9c-1234-7890-abcd-000000000001' + ); + }); + + it('run() copies media file to profileDir + inserts media row', async () => { + const note = buildExportNote({ + media: [{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 7 }] + }); + writeNote(sourceDir, note); + writeMedia(sourceDir, 'media/014a3b9c__1.png', Buffer.from('PNGDATA')); + + const r = await svc.run(sourceDir); + expect(r.mediaCount).toBe(1); + + const finalId = r.finalNoteIds.get(note.id)!; + const expectedAbs = join(profileDir, 'media', finalId, '1.png'); + expect(existsSync(expectedAbs)).toBe(true); + expect(readFileSync(expectedAbs).toString()).toBe('PNGDATA'); + + const dbNote = repo.findById(finalId); + expect(dbNote!.media.length).toBe(1); + expect(dbNote!.media[0]!.relPath).toBe(`media/${finalId}/1.png`); + expect(dbNote!.media[0]!.mime).toBe('image/png'); + expect(dbNote!.media[0]!.bytes).toBe(7); + }); +}); -- 2.49.1 From e728a11e0973691dcf5b3038780644f69348c9e3 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 10:56:29 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(import):=20wire=20ImportService=20?= =?UTF-8?q?=E2=80=94=20tray=20'=EB=B0=B1=EC=97=85=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90...'=20+=20preview=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tray gets 5th callback. Directory chooser → preview (count of new/skip/forked + media) → confirm message box → run. Slice §1.1 copy policy preserved (no '실패'/'끊김'). Notification on success/failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/index.ts | 64 +++++++++++++++++++++++++++++++++++++++++++++++ src/main/tray.ts | 11 +++++--- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 80f35b9..5e04a1a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -26,6 +26,7 @@ import { createTray } from './tray.js'; import { MediaGc } from './services/MediaGc.js'; import { BackupService } from './services/BackupService.js'; import { ExportService } from './services/ExportService.js'; +import { ImportService } from './services/ImportService.js'; const HIDDEN_ARG = '--hidden'; const startedHidden = process.argv.includes(HIDDEN_ARG); @@ -104,6 +105,7 @@ app.whenReady().then(async () => { void gc.run().then((r) => logger.info('media.gc', { ...r } as Record)); const exportSvc = new ExportService(repo, store); + const importSvc = new ImportService(repo, store); const backup = new BackupService(db, join(paths.profileDir, 'backups')); void backup.runDaily() @@ -179,6 +181,68 @@ app.whenReady().then(async () => { silent: true }).show(); } + }, + async () => { + const win = getInboxWindow(); + const dirOpts: Electron.OpenDialogOptions = { + title: '복원할 백업 폴더 선택', + message: 'F5 export 형식의 폴더를 선택하세요. notes/ 하위의 마크다운 파일이 적재됩니다.', + 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; + } + const detail = `총 ${plan.total}개 노트\n · 신규 ${plan.newCount}개\n · 동일 (스킵) ${plan.unchangedCount}개\n · 충돌→새 id (${plan.forkedCount}개, raw_text 보존)\n\n이미지 ${plan.mediaCount}개 복사 예정.`; + const confirmOpts: Electron.MessageBoxOptions = { + type: 'question', + buttons: ['복원', '취소'], + defaultId: 0, + cancelId: 1, + title: 'Inkling 복원', + message: '복원 미리보기', + detail + }; + const confirm = win + ? await dialog.showMessageBox(win, confirmOpts) + : await dialog.showMessageBox(confirmOpts); + 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(); + } } ); diff --git a/src/main/tray.ts b/src/main/tray.ts index 6ac6739..cddd8dd 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -8,14 +8,16 @@ function buildMenu( showInbox: () => void, showCapture: () => void, runBackup: () => void, - runExport: () => void + runExport: () => void, + runImport: () => void ) { const items: MenuItemConstructorOptions[] = [ { label: '구출한 메모 보기', click: showInbox }, { label: '기억 구출하기', click: showCapture }, { type: 'separator' }, { label: '지금 백업', click: runBackup }, - { label: '내보내기...', click: runExport } + { label: '내보내기...', click: runExport }, + { label: '백업에서 복원...', click: runImport } ]; if (app.isPackaged) { const { openAtLogin } = app.getLoginItemSettings(); @@ -42,12 +44,13 @@ export function createTray( showInbox: () => void, showCapture: () => void, runBackup: () => void, - runExport: () => void + runExport: () => void, + runImport: () => void ): TrayType { const icon = nativeImage.createEmpty(); tray = new Tray(icon); tray.setToolTip('Inkling'); - tray.setContextMenu(buildMenu(showInbox, showCapture, runBackup, runExport)); + tray.setContextMenu(buildMenu(showInbox, showCapture, runBackup, runExport, runImport)); tray.on('click', showInbox); return tray; } -- 2.49.1 From 9407f398c85b6855aded52dfd66579ce7a627679 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 10:57:14 +0900 Subject: [PATCH 5/5] docs(spec): promote F6-L3 import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracted to own spec. F6 진행 상태 라인 갱신. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-04-25-dogfood-feedback.md | 2 +- .../specs/2026-04-26-f6-l3-import.md | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-04-26-f6-l3-import.md diff --git a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md index c68e4ce..d609da9 100644 --- a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md +++ b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md @@ -584,7 +584,7 @@ inkling_export_version: 1 **진행 상태:** - L1 (로컬 스냅샷) — 🚀 promoted → `docs/superpowers/specs/2026-04-26-f6-l1-local-snapshot.md` - L2 (git sync) — 🌱 raw, 7번 항목으로 예정 -- L3 (import) — 🌱 raw, 3번 항목으로 예정 (F5 후) +- L3 (import) — 🚀 promoted → `docs/superpowers/specs/2026-04-26-f6-l3-import.md` **발견:** 2026-04-26 dogfood 시작 직전 사고 실험. 슬라이스 v0.4 의 메모 데이터는 `%APPDATA%\Inkling\Inkling\profiles\default\` 단 한 위치에만 존재. 디스크 고장·실수 삭제·DB 손상·OS 재설치 = 총 손실. Strategy.md §1 의 "이제 잊어도 됩니다" 보상이 **데이터 영속성 신뢰** 위에 서 있어서, 이 신뢰가 깨지면 슬라이스 §1.3 의 종료 조건 ("본인 2주 dogfood 완주") 자체가 위협받음. diff --git a/docs/superpowers/specs/2026-04-26-f6-l3-import.md b/docs/superpowers/specs/2026-04-26-f6-l3-import.md new file mode 100644 index 0000000..932e3fe --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-f6-l3-import.md @@ -0,0 +1,45 @@ +# F6-L3 Import Spec (Promoted) + +**Extracted from:** `2026-04-25-dogfood-feedback.md` F6 §"L3 — 수동 전체 export / import" +**Plan:** `docs/superpowers/plans/2026-04-26-f6-l3-import.md` +**Status:** 🚀 promoted — implemented 2026-04-26 +**Depends on:** F5 (`2026-04-26-f5-export.md`) — 본 항목은 F5 의 export 트리를 inverse 한다. + +## 결정 (mini-brainstorm 결과) + +| 결정 항목 | 값 | 근거 | +|----------|-----|------| +| 파싱 범위 | F5 export 포맷만 (`inkling_export_version: 1` 가정) | YAML 일반 파서 의존 회피 — F5 가 emit 하는 변형만 다룸 → dep 0, 코드 단순 | +| frontmatter parser | 자체 구현 (plain / single-quoted / `\|-` block / tags-flow / images-block) | composeFrontmatter 와 1:1 round-trip 가능. 일반 YAML 채택 시 `j-yaml` 등 dep 추가 + 호환 매트릭스 부담 | +| raw_text 추출 | 본문 구조 기반 (h1 / blockquote / image-ref 제거 후 잔여) | F5 의 composeMarkdown 출력을 정확 inverse. 모호 케이스 0 | +| 충돌 정책 | id 매치 + raw_text 동일 → skip · id 매치 + raw_text 상이 → 새 uuidv7 발급 후 INSERT (forked) · id 신규 → INSERT | slice §1.1 raw_text 불변 invariant 보호. 사용자에게 묻는 다이얼로그 회피 (현 dogfood 단일 사용자) | +| 미디어 복사 | 항상 (해당 노트가 inserted 또는 forked 일 때만) | skip 노트는 DB 에 이미 미디어 있음 → 중복 복사 회피 | +| 미디어 저장 경로 | `/media/{noteId}/{n}.{ext}` (MediaStore 컨벤션) | 기존 코드와 동일. media row 의 `relPath` 도 동일 형식 | +| AI 메타 보존 | ai_title, ai_summary, ai_provider, ai_generated_at, edited flags 모두 복원. ai_status='done' | 가져온 노트는 이미 AI 처리 완료 상태. pending_jobs 큐 추가 안 함 | +| 태그 source | ai/user 보존 | F5 에 정확히 emit 되어 있음 | +| 트리거 | 트레이 메뉴 "백업에서 복원..." | F5 의 "내보내기..." 와 대칭 | +| Preview UX | 디렉터리 선택 → preview (신규/스킵/충돌 카운트 + 이미지 수) → confirm 메시지박스 → run | 손상 가능 작업이라 한 번 확인. F5 와 톤 동일 | + +## 구현 (PR 안에 포함됨) + +- `src/main/services/importFormat.ts` — pure parser (300 lines) +- `src/main/services/ImportService.ts` — 오케스트레이터 (132 lines) +- `src/main/repository/NoteRepository.ts` — `findRawTextById()`, `importNote()` 추가 +- `src/main/index.ts` — `ImportService` 인스턴스화 + 5번째 트레이 콜백 +- `src/main/tray.ts` — "백업에서 복원..." 메뉴 항목 +- `tests/unit/importFormat.test.ts` (18 단위 테스트) +- `tests/unit/ImportService.test.ts` (6 단위 테스트) + +## 후속 (별 spec 또는 후속 항목 후보) + +- 충돌 시 사용자 선택 다이얼로그 (덮어쓰기 vs skip vs 새 id) — 현재는 자동 forked +- 미디어 누락 (`media/foo.png` 가 export 트리에 없음) 시 graceful skip + 경고. 현재는 throw +- 부분 import (사용자 선택 노트만) +- import 후 dirty 노트 redirect (Inbox 자동 새로고침) +- 다중 export 트리 병합 (manifest.json 의 exported_at 기반 merge order) +- F1 (due_date) 필드가 promoted 되면 import 도 동기 — 현재 frontmatter 에 추가 키 무시되므로 forward-compatible +- 멀티 `inkling_export_version` 지원 — v2 도입 시 dispatch +- L2 git sync 와 자연스럽게 연결 (clone 직후 import 트리거) +- Round-trip 자동 회귀 테스트 — 전체 DB → export → wipe → import → diff (e2e) +- 한 번에 여러 sourceDir 머지 (예: 두 디바이스의 export 트리 합치기) +- 파일별 진행률 표시 (현재는 시작/완료만) -- 2.49.1