# 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<string, string>; // origId -> finalId
}

export class ImportService {
  constructor(private repo: NoteRepository, private mediaStore: MediaStore) {}

  async preview(sourceDir: string): Promise<ImportPlan> {
    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<ImportResult> {
    const files = await this.scanNotes(sourceDir);
    const finalNoteIds = new Map<string, string>();
    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<string[]> {
    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.
