Files
inkling/docs/superpowers/plans/2026-04-26-f6-l3-import.md
2026-04-26 10:50:01 +09:00

14 KiB

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:

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

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' }

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' }[];
}
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

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:

const importSvc = new ImportService(repo, store);

5th callback (between existing 4th and closing paren):

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.