Files
inkling/docs/superpowers/plans/2026-04-26-f5-export.md
2026-04-26 10:36:17 +09:00

11 KiB

F5 Export Implementation Plan

Goal: Export all notes to a directory tree of frontmatter.md files + index.jsonl + manifest.json + bundled media. Triggered from tray "내보내기..." menu via Electron directory dialog.

Architecture: Pure compose layer (exportFormat.ts — slugify, frontmatter, markdown, jsonl, manifest) + orchestrator (ExportService.ts reads from NoteRepository + MediaStore, writes via node:fs/promises) + IPC (exportApi.ts) + tray menu (4th callback).

Tech Stack: TypeScript, vitest, node:fs/promises. No new package deps.


File Structure

Create:

  • src/main/services/exportFormat.ts — pure compose functions
  • src/main/services/ExportService.ts — orchestrator
  • src/main/ipc/exportApi.ts — IPC handler with dialog
  • tests/unit/exportFormat.test.ts
  • tests/unit/ExportService.test.ts

Modify:

  • src/main/index.ts — wire ExportService + register exportApi + extend tray callback
  • src/main/tray.ts — accept 4th runExport callback, add "내보내기..." menu item
  • docs/superpowers/specs/2026-04-25-dogfood-feedback.md — F5 status 🌱🚀
  • Create docs/superpowers/specs/2026-04-26-f5-export.md — promoted spec

No schema changes. No new package dependencies.


Task 1: Pure compose functions

Files:

  • Create src/main/services/exportFormat.ts
  • Create tests/unit/exportFormat.test.ts

Implement and test in TDD:

slugifyTitle(title: string | null): string

  • Null/empty → 'untitled'
  • Strip filesystem-forbidden chars: /\\:*?"<>|
  • Collapse whitespace to single hyphen
  • Trim hyphens at both ends
  • Truncate to 32 chars
  • Preserve Korean and other unicode

composeFilename({ id, createdAt, aiTitle }): string

  • Format: YYYY-MM-DD-{id8}-{slug}.md
  • YYYY-MM-DD from createdAt ISO string (UTC date — match index sort)
  • id8 = first 8 chars of UUIDv7 (preserves time order)
  • slug from slugifyTitle(aiTitle)
  • E.g., 2026-04-25-014a3b9c-주간회고-PR-리뷰.md

composeFrontmatter(note: ExportNote): string

Returns YAML frontmatter block (between --- lines).

ExportNote interface fields:

interface ExportNote {
  id: string;
  createdAt: string;       // ISO
  updatedAt: string;       // ISO
  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' }[];
  media: { rel: string; mime: string; bytes: number }[];
}

YAML output (omit empty fields like null intent):

---
id: 014a3b9c-...
created_at: 2026-04-25T14:23:11.000Z
updated_at: 2026-04-25T14:24:02.000Z
title: 주간 회고 PR 리뷰
title_source: ai          # or 'user' if titleEditedByUser
summary: |-
  Multi-line
  summary text
summary_source: ai
tags:
  - { name: pr, source: ai }
  - { name: review, source: user }
user_intent: 팀에서 회고 양식 통일
intent_prompted_at: 2026-04-25T14:24:02.000Z
ai_provider: local-ollama/gemma4:e4b
ai_generated_at: 2026-04-25T14:23:34.000Z
images:
  - rel: media/014a3b9c__1.png
    mime: image/png
    bytes: 152834
inkling_export_version: 1
---

Quoting rules:

  • title/summary/user_intent: if contains :, \n, ', ", #, [, ], {, } → use |- block scalar; else plain
  • tags: inline flow style { name: ..., source: ... }
  • Always include inkling_export_version: 1
  • Omit fields where value is null/undefined (not null: lines)

composeMarkdown(note: ExportNote): string

  • Frontmatter (from above)
  • Title heading: # {title} (or # (제목 없음) if null)
  • Summary blockquote: > {summary} (omit if null)
  • Empty line
  • raw_text body (verbatim — not escaped)
  • For each image: ![](media/{id8}__{n}.{ext})

composeIndexJsonl(notes: ExportNote[], paths: string[]): string

One line per note ('\n'-separated, terminating newline):

{"id":"...","path":"notes/...","created_at":"...","tags":["..."],"embedding_text":"<rawText>"}

composeManifest({ exportedAt, noteCount, mediaCount, version, schemaHash }): string

JSON object (pretty-printed):

{
  "inkling_export_version": 1,
  "exported_at": "2026-04-26T...",
  "note_count": 42,
  "media_count": 17,
  "schema_hash": "sha256:..."
}

schema_hash = sha256 of the migration files content (or simpler: "v1" for now)

Test cases (≥ 8)

  1. slugifyTitle('주간 회고 PR 리뷰')'주간-회고-PR-리뷰'
  2. slugifyTitle(null)'untitled'
  3. slugifyTitle('foo/bar:baz')'foobarbaz' (forbidden chars stripped)
  4. slugifyTitle(' '.repeat(50))'untitled' (empty after strip)
  5. slugifyTitle('a'.repeat(50)) → 32-char truncation
  6. composeFilename({ id:'014a3b9c-1234-7890-...', createdAt:'2026-04-25T14:23:00Z', aiTitle:'주간 회고' })'2026-04-25-014a3b9c-주간-회고.md'
  7. composeFrontmatter produces parseable YAML for normal note
  8. composeFrontmatter uses block scalar for multiline summary
  9. composeMarkdown includes h1 + summary + body + image refs
  10. composeIndexJsonl produces 1 line per note + trailing newline
  11. composeManifest produces valid JSON with all required fields

Step structure:

  • Write all tests
  • Run, expect fail
  • Implement all functions
  • Run, expect pass
  • typecheck pass
  • commit: feat(export): pure frontmatter + slug + markdown + jsonl + manifest composers

Task 2: ExportService + tests

Files:

  • Create src/main/services/ExportService.ts
  • Create tests/unit/ExportService.test.ts

ExportService class

export interface ExportOptions {
  includeMedia: boolean;
}

export interface ExportResult {
  outDir: string;
  noteCount: number;
  mediaCount: number;
  bytes: number;
}

export class ExportService {
  constructor(
    private repo: NoteRepository,
    private mediaStore: MediaStore,
    private now: () => Date = () => new Date()
  ) {}

  async export(targetDir: string, opts: ExportOptions): Promise<ExportResult> {
    // 1. mkdir targetDir/notes, targetDir/media (if opts.includeMedia)
    // 2. List all notes (use repo.list with large limit, or new repo.listAll())
    // 3. For each note:
    //    a. Build ExportNote (resolve media paths via mediaStore.fileForId — or just rel)
    //    b. composeMarkdown → write to notes/{filename}
    //    c. If includeMedia: copy each media file to media/{id8}__{n}.{ext}
    // 4. composeIndexJsonl → write index.jsonl
    // 5. composeManifest → write manifest.json
    // 6. Write README.md (static text)
    // 7. Return totals
  }
}

NoteRepository.listAll()

Add a new method that returns ALL notes (not paginated). Keep existing list({limit, cursor}) untouched. New method joins tags + media in a single query/function.

Test cases (≥ 6)

  1. Empty DB exports manifest with note_count=0
  2. Single note export: notes/, manifest, index.jsonl all present
  3. Two notes: index.jsonl has 2 lines
  4. Note with image: media file copied with renamed {id8}__1.png
  5. opts.includeMedia=false: no media/ dir created, frontmatter still references images
  6. README.md contains usage hint mentioning RAG

Use mkdtempSync + :memory: DB pattern matching BackupService.test.ts.

Step structure:

  • Write tests + tdd
  • Implement
  • Run all + typecheck
  • commit: feat(export): ExportService writing frontmatter tree + media + manifest

Task 3: IPC + tray wiring

Files:

  • Create src/main/ipc/exportApi.ts
  • Modify src/main/index.ts — wire ExportService, registerExportApi, extend tray
  • Modify src/main/tray.ts — 4th callback

exportApi.ts

import electron from 'electron';
const { ipcMain, dialog } = electron;
import type { ExportService } from '../services/ExportService.js';

export function registerExportApi(svc: ExportService): void {
  ipcMain.handle('export:run', async () => {
    // Show directory chooser
    const win = ...; // pass through if needed
    const result = await dialog.showOpenDialog({
      title: '내보낼 폴더 선택',
      properties: ['openDirectory', 'createDirectory']
    });
    if (result.canceled || result.filePaths.length === 0) {
      return { canceled: true };
    }
    const r = await svc.export(result.filePaths[0]!, { includeMedia: true });
    return { canceled: false, ...r };
  });
}

But for the tray menu use case (no IPC needed since main calls it directly), provide a separate runExportFromTray(svc): Promise<void> helper.

Actually, since tray menu is in main process, no IPC needed. The tray callback in main/index.ts can call dialog and svc.export directly. Skip exportApi.ts entirely for now (deferred until renderer needs it).

main/index.ts

After BackupService instantiation:

const exportSvc = new ExportService(repo, store);

Extend createTray call to add 4th callback:

createTray(
  () => createInboxWindow(),
  () => showQuickCapture(),
  async () => { /* runBackup — existing */ },
  async () => {
    // runExport
    const win = getInboxWindow();
    const opts: Electron.OpenDialogOptions = {
      title: '내보낼 폴더 선택',
      properties: ['openDirectory', 'createDirectory']
    };
    const result = win
      ? await dialog.showOpenDialog(win, opts)
      : await dialog.showOpenDialog(opts);
    if (result.canceled || result.filePaths.length === 0) return;
    try {
      const r = await exportSvc.export(result.filePaths[0]!, { includeMedia: true });
      logger.info('export.done', { ...r } as Record<string, unknown>);
      new Notification({
        title: 'Inkling',
        body: `내보내기 완료 — ${r.noteCount}개 노트, ${r.mediaCount}개 이미지`,
        silent: true
      }).show();
    } catch (e) {
      logger.warn('export.failed', { reason: String(e) });
      new Notification({
        title: 'Inkling',
        body: '내보내기를 완료하지 못했습니다.',
        silent: true
      }).show();
    }
  }
);

Add dialog to electron imports at top.

tray.ts

Add 4th param runExport: () => void. New menu item between "지금 백업" and packaged-only autostart:

items.push({ label: '내보내기...', click: runExport });

Verification

  • npm run typecheck 0 errors
  • npm test 76+ → 90+ pass
  • npm run test:e2e 1/1 (smoke test ignores tray menu)
  • commit: feat(export): wire ExportService — tray menu, dialog, notification

Task 4: Promote F5 in dogfood-feedback

Files:

  • Create docs/superpowers/specs/2026-04-26-f5-export.md
  • Modify docs/superpowers/specs/2026-04-25-dogfood-feedback.md — F5 header to 🚀 promoted with link

Final extracted spec content — record decisions made.

commit: docs(spec): promote F5 export


Self-Review Notes

Spec coverage (against F5 dogfood-feedback section):

  • frontmatter format
  • index.jsonl
  • manifest.json
  • media bundled
  • tray trigger
  • unit tests
  • Out: CLI, watch, multi-format

Type consistency:

  • ExportNote defined in Task 1, consumed by Task 2
  • ExportResult defined in Task 2, consumed by Task 3
  • No name drift

Concurrency: Export is single-shot, user-triggered. No marker-style serialization needed (different from backup). If user clicks "내보내기..." while one is running, Electron dialog modal blocks re-trigger naturally.