From c3b650058add826445b427e98eed8d0af14e26fc Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 10:36:17 +0900 Subject: [PATCH] =?UTF-8?q?docs(plan):=20F5=20export=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=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 --- .../superpowers/plans/2026-04-26-f5-export.md | 353 ++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-f5-export.md diff --git a/docs/superpowers/plans/2026-04-26-f5-export.md b/docs/superpowers/plans/2026-04-26-f5-export.md new file mode 100644 index 0000000..61c1672 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-f5-export.md @@ -0,0 +1,353 @@ +# 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: +```typescript +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): +```yaml +--- +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): +```json +{"id":"...","path":"notes/...","created_at":"...","tags":["..."],"embedding_text":""} +``` + +### `composeManifest({ exportedAt, noteCount, mediaCount, version, schemaHash }): string` +JSON object (pretty-printed): +```json +{ + "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 + +```typescript +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 { + // 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 + +```typescript +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` 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: + +```typescript +const exportSvc = new ExportService(repo, store); +``` + +Extend createTray call to add 4th callback: + +```typescript +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); + 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: + +```typescript +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.