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 functionssrc/main/services/ExportService.ts— orchestratorsrc/main/ipc/exportApi.ts— IPC handler with dialogtests/unit/exportFormat.test.tstests/unit/ExportService.test.ts
Modify:
src/main/index.ts— wireExportService+ registerexportApi+ extend tray callbacksrc/main/tray.ts— accept 4thrunExportcallback, add "내보내기..." menu itemdocs/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-DDfromcreatedAtISO string (UTC date — match index sort)id8= first 8 chars of UUIDv7 (preserves time order)slugfromslugifyTitle(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:

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)
slugifyTitle('주간 회고 PR 리뷰')→'주간-회고-PR-리뷰'slugifyTitle(null)→'untitled'slugifyTitle('foo/bar:baz')→'foobarbaz'(forbidden chars stripped)slugifyTitle(' '.repeat(50))→'untitled'(empty after strip)slugifyTitle('a'.repeat(50))→ 32-char truncationcomposeFilename({ id:'014a3b9c-1234-7890-...', createdAt:'2026-04-25T14:23:00Z', aiTitle:'주간 회고' })→'2026-04-25-014a3b9c-주간-회고.md'composeFrontmatterproduces parseable YAML for normal notecomposeFrontmatteruses block scalar for multiline summarycomposeMarkdownincludes h1 + summary + body + image refscomposeIndexJsonlproduces 1 line per note + trailing newlinecomposeManifestproduces 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)
- Empty DB exports manifest with note_count=0
- Single note export: notes/, manifest, index.jsonl all present
- Two notes: index.jsonl has 2 lines
- Note with image: media file copied with renamed
{id8}__1.png - opts.includeMedia=false: no media/ dir created, frontmatter still references images
- 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 typecheck0 errorsnpm test76+ → 90+ passnpm run test:e2e1/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:
ExportNotedefined in Task 1, consumed by Task 2ExportResultdefined 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.