docs(plan): F5 export 구현 계획 (4 tasks)
This commit is contained in:
353
docs/superpowers/plans/2026-04-26-f5-export.md
Normal file
353
docs/superpowers/plans/2026-04-26-f5-export.md
Normal file
@@ -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: ``
|
||||
|
||||
### `composeIndexJsonl(notes: ExportNote[], paths: string[]): string`
|
||||
One line per note (`'\n'`-separated, terminating newline):
|
||||
```json
|
||||
{"id":"...","path":"notes/...","created_at":"...","tags":["..."],"embedding_text":"<rawText>"}
|
||||
```
|
||||
|
||||
### `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<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
|
||||
|
||||
```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<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:
|
||||
|
||||
```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<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:
|
||||
|
||||
```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.
|
||||
Reference in New Issue
Block a user