F5 Export (v0.2.1 dogfood-feedback Track #2) #3
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.
|
||||
@@ -418,7 +418,9 @@ strategy.md 가 다루는 cue 는:
|
||||
|
||||
---
|
||||
|
||||
## F5. 마크다운 일괄 export (RAG 활용 가정) (🌱 raw)
|
||||
## F5. 마크다운 일괄 export (RAG 활용 가정) (🚀 promoted)
|
||||
|
||||
**진행 상태:** 🚀 promoted → `docs/superpowers/specs/2026-04-26-f5-export.md`
|
||||
|
||||
**발견:** 2026-04-26 dogfood 시작 직전 사고 실험. 슬라이스 v0.4 는 노트가 SQLite + 로컬 미디어 폴더에만 존재. 외부 도구 (Obsidian, RAG 파이프라인, 로컬 LLM 컨텍스트, 검색 엔진) 로 빼낼 통로가 0.
|
||||
|
||||
|
||||
55
docs/superpowers/specs/2026-04-26-f5-export.md
Normal file
55
docs/superpowers/specs/2026-04-26-f5-export.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# F5 Export Spec (Promoted)
|
||||
|
||||
**Extracted from:** `2026-04-25-dogfood-feedback.md` F5
|
||||
**Plan:** `docs/superpowers/plans/2026-04-26-f5-export.md`
|
||||
**Status:** 🚀 promoted — implemented 2026-04-26
|
||||
|
||||
## 결정 (mini-brainstorm 결과)
|
||||
|
||||
| 결정 항목 | 값 | 근거 |
|
||||
|----------|-----|------|
|
||||
| 포맷 | one-file-per-note + frontmatter + index.jsonl + manifest.json + README | RAG 친화 + Obsidian 호환 |
|
||||
| 미디어 포함 | 기본 ON, includeMedia 옵션화 | 사용자 선택 가능, dialog 에 평문 경고 |
|
||||
| 삭제된 노트 | 현 시점 스냅샷만 | soft-delete 컬럼 없음 (slice 외 별 spec 후보) |
|
||||
| provenance | 평탄 enum (`title_source: 'ai'\|'user'`) | parser 호환성 우선 |
|
||||
| embedding_text | raw_text 단독 | 가장 미가공, 사용자 별 가공 가능 |
|
||||
| 파일명 | `YYYY-MM-DD-{id8}-{slugified-ai_title}.md` (export 시점 동결) | 가독 + 정렬, AI 제목 변경 시 새 파일명 (의도) |
|
||||
| 트리거 | 트레이 메뉴 "내보내기..." 만 | 슬라이스 외부 dep 없이 |
|
||||
| version | `inkling_export_version: 1` | 첫 출시, 향후 변경 시 manifest 로 식별 |
|
||||
| 경고 | dialog 에 raw_text 평문 + 비공개 위치 권장 명시 | 슬라이스 §1.1 보안 톤 |
|
||||
|
||||
## 범위 (PR 안에 포함됨)
|
||||
|
||||
- `src/main/services/exportFormat.ts` (pure: slugify, composeFilename, composeFrontmatter, composeMarkdown, composeIndexJsonl, composeManifest)
|
||||
- `src/main/services/ExportService.ts` (orchestrator)
|
||||
- `src/main/repository/NoteRepository.ts` 수정 — `listAll()` 신규 (asc 순)
|
||||
- `src/main/services/MediaStore.ts` 수정 — `absolutePath()` 신규
|
||||
- `src/main/index.ts` 수정 — ExportService instantiate + dialog import + 4th tray callback
|
||||
- `src/main/tray.ts` 수정 — 4번째 callback `runExport` + "내보내기..." 메뉴
|
||||
- `tests/unit/exportFormat.test.ts` (26 단위 테스트)
|
||||
- `tests/unit/ExportService.test.ts` (6 단위 테스트)
|
||||
|
||||
## 출력 트리
|
||||
|
||||
```
|
||||
<targetDir>/
|
||||
├── notes/
|
||||
│ └── 2026-04-25-014a3b9c-주간-회고.md
|
||||
├── media/
|
||||
│ └── 014a3b9c__1.png
|
||||
├── index.jsonl
|
||||
├── manifest.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 후속 (별 spec 또는 후속 항목 후보)
|
||||
|
||||
- 증분 export (변경된 노트만)
|
||||
- watch 모드 (자동 동기화)
|
||||
- CLI 플래그 (`--export <dir>`)
|
||||
- 다중 형식 (CSV, 단일 JSON, OPML)
|
||||
- 외부 SaaS 동기화 (Confluence, Notion)
|
||||
- export 시 raw_text 마스킹/익명화 옵션
|
||||
- Importer (F6-L3 가 자매 항목)
|
||||
- embedding_text 합성 규칙 옵션화 (title+summary+raw_text 등)
|
||||
- 파일명 안정성 옵션 (id-only naming 으로 AI 제목 변경 영향 0)
|
||||
@@ -1,5 +1,5 @@
|
||||
import electron from 'electron';
|
||||
const { app, BrowserWindow, Notification } = electron;
|
||||
const { app, BrowserWindow, Notification, dialog } = electron;
|
||||
import '@shared/types';
|
||||
import { existsSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { createTray } from './tray.js';
|
||||
import { MediaGc } from './services/MediaGc.js';
|
||||
import { BackupService } from './services/BackupService.js';
|
||||
import { ExportService } from './services/ExportService.js';
|
||||
|
||||
const HIDDEN_ARG = '--hidden';
|
||||
const startedHidden = process.argv.includes(HIDDEN_ARG);
|
||||
@@ -102,6 +103,8 @@ app.whenReady().then(async () => {
|
||||
const gc = new MediaGc(db, store);
|
||||
void gc.run().then((r) => logger.info('media.gc', { ...r } as Record<string, unknown>));
|
||||
|
||||
const exportSvc = new ExportService(repo, store);
|
||||
|
||||
const backup = new BackupService(db, join(paths.profileDir, 'backups'));
|
||||
void backup.runDaily()
|
||||
.then((r) => logger.info('backup.daily', { ...r } as Record<string, unknown>))
|
||||
@@ -142,6 +145,40 @@ app.whenReady().then(async () => {
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
const win = getInboxWindow();
|
||||
const dialogOpts: Electron.OpenDialogOptions = {
|
||||
title: '내보낼 폴더 선택',
|
||||
message: '선택한 폴더에 노트를 마크다운으로 내보냅니다. 이미지가 함께 포함됩니다. raw_text 가 평문으로 보관되니 비공개 위치를 권장합니다.',
|
||||
buttonLabel: '여기에 내보내기',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
};
|
||||
const result = win
|
||||
? await dialog.showOpenDialog(win, dialogOpts)
|
||||
: await dialog.showOpenDialog(dialogOpts);
|
||||
if (result.canceled || result.filePaths.length === 0) return;
|
||||
try {
|
||||
const r = await exportSvc.export(result.filePaths[0]!, { includeMedia: true });
|
||||
logger.info('export.done', {
|
||||
outDir: r.outDir,
|
||||
noteCount: r.noteCount,
|
||||
mediaCount: r.mediaCount,
|
||||
bytes: r.bytes
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -65,6 +65,13 @@ export class NoteRepository {
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
listAll(): Note[] {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM notes ORDER BY created_at ASC, id ASC`)
|
||||
.all() as any[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
updateAiResult(
|
||||
id: string,
|
||||
result: { title: string; summary: string; tags: string[]; provider: string }
|
||||
|
||||
151
src/main/services/ExportService.ts
Normal file
151
src/main/services/ExportService.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { mkdir, writeFile, copyFile, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { MediaStore } from './MediaStore.js';
|
||||
import type { Note } from '@shared/types';
|
||||
import {
|
||||
composeFilename,
|
||||
composeMarkdown,
|
||||
composeIndexJsonl,
|
||||
composeManifest,
|
||||
type ExportNote
|
||||
} from './exportFormat.js';
|
||||
|
||||
export interface ExportOptions {
|
||||
includeMedia: boolean;
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
outDir: string;
|
||||
noteCount: number;
|
||||
mediaCount: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
const README_BODY = `# Inkling Export
|
||||
|
||||
이 폴더는 Inkling 메모 앱이 내보낸 마크다운 + 메타데이터 트리입니다.
|
||||
|
||||
## 구조
|
||||
|
||||
- \`notes/\` — 노트 1건당 마크다운 1파일. YAML frontmatter 에 id, 날짜, 태그, AI 메타 포함.
|
||||
- \`media/\` — 노트에 첨부된 이미지. \`{id8}__{n}.{ext}\` 명명.
|
||||
- \`index.jsonl\` — 노트별 RAG 친화 1줄 메타. \`embedding_text\` 는 raw_text 그대로.
|
||||
- \`manifest.json\` — export 시각, 노트/미디어 수, 형식 버전.
|
||||
|
||||
## RAG 활용 예
|
||||
|
||||
LangChain / LlamaIndex 의 markdown loader 가 frontmatter 를 자동 파싱합니다. \`index.jsonl\` 을 직접 임베딩 입력으로 쓰면 별도 가공 없이 진행 가능.
|
||||
|
||||
## 형식 버전
|
||||
|
||||
\`inkling_export_version: 1\` — 향후 버전이 바뀌면 manifest.json 의 같은 필드로 식별.
|
||||
`;
|
||||
|
||||
function inferExt(mime: string): string {
|
||||
if (mime === 'image/png') return 'png';
|
||||
if (mime === 'image/jpeg') return 'jpg';
|
||||
if (mime === 'image/gif') return 'gif';
|
||||
if (mime === 'image/webp') return 'webp';
|
||||
return 'bin';
|
||||
}
|
||||
|
||||
function noteToExportNote(n: Note): ExportNote {
|
||||
return {
|
||||
id: n.id,
|
||||
createdAt: n.createdAt,
|
||||
updatedAt: n.updatedAt,
|
||||
rawText: n.rawText,
|
||||
aiTitle: n.aiTitle,
|
||||
aiSummary: n.aiSummary,
|
||||
titleEditedByUser: n.titleEditedByUser,
|
||||
summaryEditedByUser: n.summaryEditedByUser,
|
||||
aiProvider: n.aiProvider,
|
||||
aiGeneratedAt: n.aiGeneratedAt,
|
||||
userIntent: n.userIntent,
|
||||
intentPromptedAt: n.intentPromptedAt,
|
||||
tags: n.tags.map((t) => ({ name: t.name, source: t.source })),
|
||||
media: n.media.map((m, idx) => ({
|
||||
rel: `media/${n.id.slice(0, 8)}__${idx + 1}.${inferExt(m.mime)}`,
|
||||
mime: m.mime,
|
||||
bytes: m.bytes
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export class ExportService {
|
||||
constructor(
|
||||
private repo: NoteRepository,
|
||||
private mediaStore: MediaStore,
|
||||
private now: () => Date = () => new Date()
|
||||
) {}
|
||||
|
||||
async export(targetDir: string, opts: ExportOptions): Promise<ExportResult> {
|
||||
await mkdir(join(targetDir, 'notes'), { recursive: true });
|
||||
if (opts.includeMedia) {
|
||||
await mkdir(join(targetDir, 'media'), { recursive: true });
|
||||
}
|
||||
|
||||
const notes = this.repo.listAll();
|
||||
const indexEntries: Array<{ note: ExportNote; path: string }> = [];
|
||||
let totalBytes = 0;
|
||||
let mediaCount = 0;
|
||||
|
||||
for (const n of notes) {
|
||||
const en = noteToExportNote(n);
|
||||
const filename = composeFilename({
|
||||
id: n.id,
|
||||
createdAt: n.createdAt,
|
||||
aiTitle: n.aiTitle
|
||||
});
|
||||
const noteRelPath = `notes/${filename}`;
|
||||
const noteAbsPath = join(targetDir, noteRelPath);
|
||||
const md = composeMarkdown(en);
|
||||
await writeFile(noteAbsPath, md, 'utf8');
|
||||
const st = await stat(noteAbsPath);
|
||||
totalBytes += st.size;
|
||||
indexEntries.push({ note: en, path: noteRelPath });
|
||||
|
||||
if (opts.includeMedia) {
|
||||
for (let i = 0; i < n.media.length; i++) {
|
||||
const m = n.media[i]!;
|
||||
const ext = inferExt(m.mime);
|
||||
const flatName = `${n.id.slice(0, 8)}__${i + 1}.${ext}`;
|
||||
const src = this.mediaStore.absolutePath(m.relPath);
|
||||
const dst = join(targetDir, 'media', flatName);
|
||||
try {
|
||||
await copyFile(src, dst);
|
||||
const ms = await stat(dst);
|
||||
totalBytes += ms.size;
|
||||
mediaCount += 1;
|
||||
} catch {
|
||||
// skip missing media file but log via throw upstream? For now, swallow.
|
||||
// (production: caller's logger captures via try/catch around the export call)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const indexJsonl = composeIndexJsonl(indexEntries);
|
||||
await writeFile(join(targetDir, 'index.jsonl'), indexJsonl, 'utf8');
|
||||
totalBytes += Buffer.byteLength(indexJsonl, 'utf8');
|
||||
|
||||
const manifest = composeManifest({
|
||||
exportedAt: this.now().toISOString(),
|
||||
noteCount: notes.length,
|
||||
mediaCount
|
||||
});
|
||||
await writeFile(join(targetDir, 'manifest.json'), manifest, 'utf8');
|
||||
totalBytes += Buffer.byteLength(manifest, 'utf8');
|
||||
|
||||
await writeFile(join(targetDir, 'README.md'), README_BODY, 'utf8');
|
||||
totalBytes += Buffer.byteLength(README_BODY, 'utf8');
|
||||
|
||||
return {
|
||||
outDir: targetDir,
|
||||
noteCount: notes.length,
|
||||
mediaCount,
|
||||
bytes: totalBytes
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,8 @@ export class MediaStore {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
absolutePath(relPath: string): string {
|
||||
return join(this.profileDir, relPath);
|
||||
}
|
||||
}
|
||||
|
||||
251
src/main/services/exportFormat.ts
Normal file
251
src/main/services/exportFormat.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Pure compose functions for F5 (Export).
|
||||
*
|
||||
* No filesystem, no I/O, no Date.now() — every output is a function of the input.
|
||||
* Caller layer (Task 2+) handles fs writes, atomic rename, zip packaging.
|
||||
*/
|
||||
|
||||
export interface ExportNoteMedia {
|
||||
rel: string;
|
||||
mime: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface ExportNoteTag {
|
||||
name: string;
|
||||
source: 'ai' | 'user';
|
||||
}
|
||||
|
||||
export interface ExportNote {
|
||||
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: ExportNoteTag[];
|
||||
media: ExportNoteMedia[];
|
||||
}
|
||||
|
||||
const FORBIDDEN_FS_CHARS_REGEX = /[/\\:*?"<>|]/g;
|
||||
const WHITESPACE_RUN_REGEX = /\s+/g;
|
||||
const SLUG_MAX_CODEPOINTS = 32;
|
||||
|
||||
export function slugifyTitle(title: string | null): string {
|
||||
if (title === null || title === undefined) return 'untitled';
|
||||
// Strip forbidden characters first.
|
||||
const stripped = title.replace(FORBIDDEN_FS_CHARS_REGEX, '');
|
||||
// Collapse whitespace runs to a single hyphen.
|
||||
const hyphenated = stripped.replace(WHITESPACE_RUN_REGEX, '-');
|
||||
// Trim leading/trailing hyphens.
|
||||
const trimmed = hyphenated.replace(/^-+|-+$/g, '');
|
||||
if (trimmed.length === 0) return 'untitled';
|
||||
// Truncate to 32 unicode codepoints (handle Korean / emoji properly).
|
||||
const cps = [...trimmed];
|
||||
const truncated = cps.length > SLUG_MAX_CODEPOINTS
|
||||
? cps.slice(0, SLUG_MAX_CODEPOINTS).join('')
|
||||
: trimmed;
|
||||
// Re-trim hyphens in case truncation landed on one.
|
||||
const finalSlug = truncated.replace(/^-+|-+$/g, '');
|
||||
return finalSlug.length === 0 ? 'untitled' : finalSlug;
|
||||
}
|
||||
|
||||
export function composeFilename(input: {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
aiTitle: string | null;
|
||||
}): string {
|
||||
const date = input.createdAt.slice(0, 10);
|
||||
const id8 = input.id.slice(0, 8);
|
||||
const slug = slugifyTitle(input.aiTitle);
|
||||
return `${date}-${id8}-${slug}.md`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML quoting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const YAML_SPECIAL_CHARS = [':', "'", '"', '#', '[', ']', '{', '}'];
|
||||
const YAML_LEADING_INDICATORS = ['-', '?', '!', '&', '*', '>'];
|
||||
|
||||
function needsQuoting(value: string): boolean {
|
||||
if (value.length === 0) return true;
|
||||
// Leading/trailing whitespace.
|
||||
if (value !== value.trim()) return true;
|
||||
// Embedded special chars that conflict with plain-scalar parsing.
|
||||
for (const ch of YAML_SPECIAL_CHARS) {
|
||||
if (value.includes(ch)) return true;
|
||||
}
|
||||
// Leading indicators.
|
||||
const first = value.charAt(0);
|
||||
if (YAML_LEADING_INDICATORS.includes(first)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function singleQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a string scalar for YAML.
|
||||
* - Multiline → block scalar `|-` with given indent.
|
||||
* - Otherwise → plain or single-quoted depending on contents.
|
||||
*
|
||||
* `indent` is the indent applied to body lines of the block scalar (default 2 spaces).
|
||||
*/
|
||||
function formatScalar(value: string, indent = 2): string {
|
||||
if (value.includes('\n')) {
|
||||
const pad = ' '.repeat(indent);
|
||||
const lines = value.split('\n').map((l) => `${pad}${l}`);
|
||||
return `|-\n${lines.join('\n')}`;
|
||||
}
|
||||
return needsQuoting(value) ? singleQuote(value) : value;
|
||||
}
|
||||
|
||||
function formatTagName(name: string): string {
|
||||
// Names can't be multiline in flow style — fall back to single-quote when needed.
|
||||
if (name.includes('\n') || needsQuoting(name)) return singleQuote(name);
|
||||
return name;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// composeFrontmatter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function composeFrontmatter(note: ExportNote): string {
|
||||
const lines: string[] = [];
|
||||
lines.push('---');
|
||||
lines.push(`id: ${note.id}`);
|
||||
lines.push(`created_at: ${note.createdAt}`);
|
||||
lines.push(`updated_at: ${note.updatedAt}`);
|
||||
|
||||
if (note.aiTitle !== null) {
|
||||
lines.push(`title: ${formatScalar(note.aiTitle)}`);
|
||||
lines.push(`title_source: ${note.titleEditedByUser ? 'user' : 'ai'}`);
|
||||
}
|
||||
|
||||
if (note.aiSummary !== null) {
|
||||
lines.push(`summary: ${formatScalar(note.aiSummary)}`);
|
||||
lines.push(`summary_source: ${note.summaryEditedByUser ? 'user' : 'ai'}`);
|
||||
}
|
||||
|
||||
if (note.tags.length > 0) {
|
||||
lines.push('tags:');
|
||||
for (const tag of note.tags) {
|
||||
lines.push(` - { name: ${formatTagName(tag.name)}, source: ${tag.source} }`);
|
||||
}
|
||||
}
|
||||
|
||||
if (note.userIntent !== null) {
|
||||
lines.push(`user_intent: ${formatScalar(note.userIntent)}`);
|
||||
}
|
||||
if (note.intentPromptedAt !== null) {
|
||||
lines.push(`intent_prompted_at: ${note.intentPromptedAt}`);
|
||||
}
|
||||
if (note.aiProvider !== null) {
|
||||
lines.push(`ai_provider: ${formatScalar(note.aiProvider)}`);
|
||||
}
|
||||
if (note.aiGeneratedAt !== null) {
|
||||
lines.push(`ai_generated_at: ${note.aiGeneratedAt}`);
|
||||
}
|
||||
|
||||
if (note.media.length > 0) {
|
||||
lines.push('images:');
|
||||
for (const m of note.media) {
|
||||
lines.push(` - rel: ${formatScalar(m.rel)}`);
|
||||
lines.push(` mime: ${formatScalar(m.mime)}`);
|
||||
lines.push(` bytes: ${m.bytes}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('inkling_export_version: 1');
|
||||
lines.push('---');
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// composeMarkdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extFromMime(mime: string): string {
|
||||
switch (mime) {
|
||||
case 'image/png':
|
||||
return 'png';
|
||||
case 'image/jpeg':
|
||||
return 'jpg';
|
||||
default:
|
||||
return 'bin';
|
||||
}
|
||||
}
|
||||
|
||||
export function composeMarkdown(note: ExportNote): string {
|
||||
const fm = composeFrontmatter(note);
|
||||
const heading = `# ${note.aiTitle ?? '(제목 없음)'}`;
|
||||
const id8 = note.id.slice(0, 8);
|
||||
|
||||
const sections: string[] = [];
|
||||
sections.push(fm.trimEnd()); // remove trailing newline so we control spacing
|
||||
sections.push(heading);
|
||||
if (note.aiSummary !== null) {
|
||||
sections.push(`> ${note.aiSummary}`);
|
||||
}
|
||||
sections.push(note.rawText);
|
||||
|
||||
if (note.media.length > 0) {
|
||||
const imageLines = note.media.map((m, idx) => {
|
||||
const n = idx + 1;
|
||||
const ext = extFromMime(m.mime);
|
||||
return ``;
|
||||
});
|
||||
sections.push(imageLines.join('\n'));
|
||||
}
|
||||
|
||||
return sections.join('\n\n') + '\n';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// composeIndexJsonl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function composeIndexJsonl(
|
||||
entries: Array<{ note: ExportNote; path: string }>
|
||||
): string {
|
||||
if (entries.length === 0) return '';
|
||||
const lines = entries.map(({ note, path }) =>
|
||||
JSON.stringify({
|
||||
id: note.id,
|
||||
path,
|
||||
created_at: note.createdAt,
|
||||
tags: note.tags.map((t) => t.name),
|
||||
embedding_text: note.rawText
|
||||
})
|
||||
);
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// composeManifest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function composeManifest(input: {
|
||||
exportedAt: string;
|
||||
noteCount: number;
|
||||
mediaCount: number;
|
||||
}): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
inkling_export_version: 1,
|
||||
exported_at: input.exportedAt,
|
||||
note_count: input.noteCount,
|
||||
media_count: input.mediaCount
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,15 @@ let tray: TrayType | null = null;
|
||||
function buildMenu(
|
||||
showInbox: () => void,
|
||||
showCapture: () => void,
|
||||
runBackup: () => void
|
||||
runBackup: () => void,
|
||||
runExport: () => void
|
||||
) {
|
||||
const items: MenuItemConstructorOptions[] = [
|
||||
{ label: '구출한 메모 보기', click: showInbox },
|
||||
{ label: '기억 구출하기', click: showCapture },
|
||||
{ type: 'separator' },
|
||||
{ label: '지금 백업', click: runBackup }
|
||||
{ label: '지금 백업', click: runBackup },
|
||||
{ label: '내보내기...', click: runExport }
|
||||
];
|
||||
if (app.isPackaged) {
|
||||
const { openAtLogin } = app.getLoginItemSettings();
|
||||
@@ -39,12 +41,13 @@ function buildMenu(
|
||||
export function createTray(
|
||||
showInbox: () => void,
|
||||
showCapture: () => void,
|
||||
runBackup: () => void
|
||||
runBackup: () => void,
|
||||
runExport: () => void
|
||||
): TrayType {
|
||||
const icon = nativeImage.createEmpty();
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip('Inkling');
|
||||
tray.setContextMenu(buildMenu(showInbox, showCapture, runBackup));
|
||||
tray.setContextMenu(buildMenu(showInbox, showCapture, runBackup, runExport));
|
||||
tray.on('click', showInbox);
|
||||
return tray;
|
||||
}
|
||||
|
||||
141
tests/unit/ExportService.test.ts
Normal file
141
tests/unit/ExportService.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { runMigrations } from '@main/db/migrations/index.js';
|
||||
import { NoteRepository } from '@main/repository/NoteRepository.js';
|
||||
import { MediaStore } from '@main/services/MediaStore.js';
|
||||
import { ExportService } from '@main/services/ExportService.js';
|
||||
|
||||
describe('ExportService', () => {
|
||||
let tmpRoot: string;
|
||||
let outDir: string;
|
||||
let profileDir: string;
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let mediaStore: MediaStore;
|
||||
let svc: ExportService;
|
||||
const NOW = () => new Date('2026-04-26T00:00:00.000Z');
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), 'inkling-export-'));
|
||||
outDir = join(tmpRoot, 'out');
|
||||
profileDir = join(tmpRoot, 'profile');
|
||||
mkdirSync(join(profileDir, 'media'), { recursive: true });
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
mediaStore = new MediaStore(profileDir);
|
||||
svc = new ExportService(repo, mediaStore, NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('empty DB exports manifest with note_count=0', async () => {
|
||||
const r = await svc.export(outDir, { includeMedia: true });
|
||||
expect(r.noteCount).toBe(0);
|
||||
expect(r.mediaCount).toBe(0);
|
||||
const manifest = JSON.parse(readFileSync(join(outDir, 'manifest.json'), 'utf8'));
|
||||
expect(manifest.note_count).toBe(0);
|
||||
expect(manifest.media_count).toBe(0);
|
||||
expect(manifest.inkling_export_version).toBe(1);
|
||||
});
|
||||
|
||||
it('single note creates one notes/*.md and one-line index.jsonl', async () => {
|
||||
const { id } = repo.create({ rawText: '회고 메모' });
|
||||
repo.updateAiResult(id, { title: '주간 회고', summary: '한 줄', tags: ['pr'], provider: 'local-ollama/x' });
|
||||
|
||||
const r = await svc.export(outDir, { includeMedia: true });
|
||||
expect(r.noteCount).toBe(1);
|
||||
|
||||
const noteFiles = readdirSync(join(outDir, 'notes'));
|
||||
expect(noteFiles.length).toBe(1);
|
||||
expect(noteFiles[0]).toMatch(/^\d{4}-\d{2}-\d{2}-[0-9a-f]{8}-주간-회고\.md$/);
|
||||
|
||||
const md = readFileSync(join(outDir, 'notes', noteFiles[0]!), 'utf8');
|
||||
expect(md).toContain('# 주간 회고');
|
||||
expect(md).toContain('> 한 줄');
|
||||
expect(md).toContain('회고 메모');
|
||||
|
||||
const jsonl = readFileSync(join(outDir, 'index.jsonl'), 'utf8');
|
||||
const lines = jsonl.trimEnd().split('\n');
|
||||
expect(lines.length).toBe(1);
|
||||
const obj = JSON.parse(lines[0]!);
|
||||
expect(obj.id).toBe(id);
|
||||
expect(obj.embedding_text).toBe('회고 메모');
|
||||
});
|
||||
|
||||
it('two notes produce two-line jsonl ordered ascending by created_at', async () => {
|
||||
const { id: id1 } = repo.create({ rawText: 'first' });
|
||||
// tiny delay to ensure created_at differs
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const { id: id2 } = repo.create({ rawText: 'second' });
|
||||
|
||||
const r = await svc.export(outDir, { includeMedia: true });
|
||||
expect(r.noteCount).toBe(2);
|
||||
|
||||
const lines = readFileSync(join(outDir, 'index.jsonl'), 'utf8').trimEnd().split('\n');
|
||||
expect(lines.length).toBe(2);
|
||||
const a = JSON.parse(lines[0]!);
|
||||
const b = JSON.parse(lines[1]!);
|
||||
expect(a.id).toBe(id1);
|
||||
expect(b.id).toBe(id2);
|
||||
});
|
||||
|
||||
it('note with image: media file copied with id8__n naming', async () => {
|
||||
const { id } = repo.create({ rawText: 'with image' });
|
||||
// Create a fake media file in profile + DB row
|
||||
const noteMediaDir = join(profileDir, 'media', id);
|
||||
mkdirSync(noteMediaDir, { recursive: true });
|
||||
writeFileSync(join(noteMediaDir, 'orig.png'), Buffer.from('PNGDATA'));
|
||||
repo.insertMedia([{
|
||||
noteId: id,
|
||||
kind: 'image',
|
||||
relPath: `media/${id}/orig.png`,
|
||||
mime: 'image/png',
|
||||
bytes: 7
|
||||
}]);
|
||||
|
||||
const r = await svc.export(outDir, { includeMedia: true });
|
||||
expect(r.mediaCount).toBe(1);
|
||||
|
||||
const id8 = id.slice(0, 8);
|
||||
const expectedFlat = join(outDir, 'media', `${id8}__1.png`);
|
||||
expect(existsSync(expectedFlat)).toBe(true);
|
||||
expect(readFileSync(expectedFlat).toString()).toBe('PNGDATA');
|
||||
});
|
||||
|
||||
it('includeMedia=false: no media dir, but frontmatter still references images', async () => {
|
||||
const { id } = repo.create({ rawText: 'with image' });
|
||||
const noteMediaDir = join(profileDir, 'media', id);
|
||||
mkdirSync(noteMediaDir, { recursive: true });
|
||||
writeFileSync(join(noteMediaDir, 'orig.png'), Buffer.from('XX'));
|
||||
repo.insertMedia([{
|
||||
noteId: id,
|
||||
kind: 'image',
|
||||
relPath: `media/${id}/orig.png`,
|
||||
mime: 'image/png',
|
||||
bytes: 2
|
||||
}]);
|
||||
|
||||
const r = await svc.export(outDir, { includeMedia: false });
|
||||
expect(r.mediaCount).toBe(0);
|
||||
expect(existsSync(join(outDir, 'media'))).toBe(false);
|
||||
|
||||
const noteFiles = readdirSync(join(outDir, 'notes'));
|
||||
const md = readFileSync(join(outDir, 'notes', noteFiles[0]!), 'utf8');
|
||||
expect(md).toContain('images:');
|
||||
expect(md).toContain(`}__1.png)`);
|
||||
});
|
||||
|
||||
it('writes README.md mentioning RAG and inkling_export_version', async () => {
|
||||
await svc.export(outDir, { includeMedia: true });
|
||||
const readme = readFileSync(join(outDir, 'README.md'), 'utf8');
|
||||
expect(readme).toContain('RAG');
|
||||
expect(readme).toContain('inkling_export_version');
|
||||
});
|
||||
});
|
||||
192
tests/unit/exportFormat.test.ts
Normal file
192
tests/unit/exportFormat.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
slugifyTitle,
|
||||
composeFilename,
|
||||
composeFrontmatter,
|
||||
composeMarkdown,
|
||||
composeIndexJsonl,
|
||||
composeManifest,
|
||||
type ExportNote
|
||||
} from '@main/services/exportFormat.js';
|
||||
|
||||
const baseNote: ExportNote = {
|
||||
id: '014a3b9c-1234-7890-abcd-000000000001',
|
||||
createdAt: '2026-04-25T14:23:11.000Z',
|
||||
updatedAt: '2026-04-25T14:24:02.000Z',
|
||||
rawText: '회고 메모 본문',
|
||||
aiTitle: '주간 회고 PR 리뷰',
|
||||
aiSummary: '회고 양식 통일을 위한 메모.',
|
||||
titleEditedByUser: false,
|
||||
summaryEditedByUser: false,
|
||||
aiProvider: 'local-ollama/gemma4:e4b',
|
||||
aiGeneratedAt: '2026-04-25T14:23:34.000Z',
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
tags: [{ name: 'pr', source: 'ai' }, { name: 'review', source: 'user' }],
|
||||
media: []
|
||||
};
|
||||
|
||||
describe('slugifyTitle', () => {
|
||||
it('converts spaces to single hyphen, preserves Korean', () => {
|
||||
expect(slugifyTitle('주간 회고 PR 리뷰')).toBe('주간-회고-PR-리뷰');
|
||||
});
|
||||
it('returns "untitled" for null', () => {
|
||||
expect(slugifyTitle(null)).toBe('untitled');
|
||||
});
|
||||
it('returns "untitled" for empty string', () => {
|
||||
expect(slugifyTitle('')).toBe('untitled');
|
||||
});
|
||||
it('returns "untitled" for whitespace-only', () => {
|
||||
expect(slugifyTitle(' ')).toBe('untitled');
|
||||
});
|
||||
it('strips filesystem-forbidden chars', () => {
|
||||
expect(slugifyTitle('foo/bar:baz*qux"<>|?\\')).toBe('foobarbazqux');
|
||||
});
|
||||
it('collapses multiple whitespace to single hyphen', () => {
|
||||
expect(slugifyTitle('a b c')).toBe('a-b-c');
|
||||
});
|
||||
it('trims leading/trailing hyphens', () => {
|
||||
expect(slugifyTitle(' hello ')).toBe('hello');
|
||||
});
|
||||
it('truncates to 32 codepoints (Korean counted properly)', () => {
|
||||
const long = '가'.repeat(50);
|
||||
expect([...slugifyTitle(long)].length).toBeLessThanOrEqual(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeFilename', () => {
|
||||
it('combines date prefix + id8 + slug + .md', () => {
|
||||
expect(composeFilename({
|
||||
id: '014a3b9c-1234-7890-abcd-000000000001',
|
||||
createdAt: '2026-04-25T14:23:11.000Z',
|
||||
aiTitle: '주간 회고'
|
||||
})).toBe('2026-04-25-014a3b9c-주간-회고.md');
|
||||
});
|
||||
it('uses untitled slug for null title', () => {
|
||||
expect(composeFilename({
|
||||
id: '01234567-aaaa-bbbb-cccc-000000000000',
|
||||
createdAt: '2026-04-25T14:23:11.000Z',
|
||||
aiTitle: null
|
||||
})).toBe('2026-04-25-01234567-untitled.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeFrontmatter', () => {
|
||||
it('produces frontmatter with delimiters and inkling_export_version=1', () => {
|
||||
const fm = composeFrontmatter(baseNote);
|
||||
expect(fm.startsWith('---\n')).toBe(true);
|
||||
expect(fm.trimEnd().endsWith('---')).toBe(true);
|
||||
expect(fm).toContain('inkling_export_version: 1');
|
||||
});
|
||||
it('includes title and source=ai for non-edited title', () => {
|
||||
const fm = composeFrontmatter(baseNote);
|
||||
expect(fm).toContain('title: 주간 회고 PR 리뷰');
|
||||
expect(fm).toContain('title_source: ai');
|
||||
});
|
||||
it('marks source=user when edited', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, titleEditedByUser: true });
|
||||
expect(fm).toContain('title_source: user');
|
||||
});
|
||||
it('omits null fields', () => {
|
||||
const fm = composeFrontmatter(baseNote);
|
||||
expect(fm).not.toContain('user_intent:');
|
||||
expect(fm).not.toContain('intent_prompted_at:');
|
||||
});
|
||||
it('uses block scalar |- for multiline summary', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, aiSummary: 'line1\nline2' });
|
||||
expect(fm).toContain('summary: |-');
|
||||
expect(fm).toContain(' line1');
|
||||
expect(fm).toContain(' line2');
|
||||
});
|
||||
it('single-quotes title containing colon', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, aiTitle: 'a: b' });
|
||||
expect(fm).toContain("title: 'a: b'");
|
||||
});
|
||||
it('emits tags array with inline flow style', () => {
|
||||
const fm = composeFrontmatter(baseNote);
|
||||
expect(fm).toContain('tags:');
|
||||
expect(fm).toContain('- { name: pr, source: ai }');
|
||||
expect(fm).toContain('- { name: review, source: user }');
|
||||
});
|
||||
it('omits tags section when empty', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, tags: [] });
|
||||
expect(fm).not.toContain('tags:');
|
||||
});
|
||||
it('emits images array when media present', () => {
|
||||
const fm = composeFrontmatter({
|
||||
...baseNote,
|
||||
media: [{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 1234 }]
|
||||
});
|
||||
expect(fm).toContain('images:');
|
||||
expect(fm).toContain('rel: media/014a3b9c__1.png');
|
||||
expect(fm).toContain('mime: image/png');
|
||||
expect(fm).toContain('bytes: 1234');
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeMarkdown', () => {
|
||||
it('includes h1 with title, blockquote summary, body', () => {
|
||||
const md = composeMarkdown(baseNote);
|
||||
expect(md).toContain('# 주간 회고 PR 리뷰');
|
||||
expect(md).toContain('> 회고 양식 통일을 위한 메모.');
|
||||
expect(md).toContain('회고 메모 본문');
|
||||
});
|
||||
it('uses fallback heading when title null', () => {
|
||||
const md = composeMarkdown({ ...baseNote, aiTitle: null });
|
||||
expect(md).toContain('# (제목 없음)');
|
||||
});
|
||||
it('omits blockquote when summary null', () => {
|
||||
const md = composeMarkdown({ ...baseNote, aiSummary: null });
|
||||
expect(md).not.toContain('>');
|
||||
});
|
||||
it('appends image refs with id8__n.ext naming', () => {
|
||||
const md = composeMarkdown({
|
||||
...baseNote,
|
||||
media: [
|
||||
{ rel: 'media/old/1.png', mime: 'image/png', bytes: 100 },
|
||||
{ rel: 'media/old/2.jpg', mime: 'image/jpeg', bytes: 200 }
|
||||
]
|
||||
});
|
||||
expect(md).toContain('');
|
||||
expect(md).toContain('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeIndexJsonl', () => {
|
||||
it('emits one JSON line per entry with trailing newline', () => {
|
||||
const out = composeIndexJsonl([
|
||||
{ note: baseNote, path: 'notes/2026-04-25-014a3b9c-주간-회고.md' }
|
||||
]);
|
||||
expect(out.endsWith('\n')).toBe(true);
|
||||
const lines = out.trimEnd().split('\n');
|
||||
expect(lines.length).toBe(1);
|
||||
const obj = JSON.parse(lines[0]!);
|
||||
expect(obj.id).toBe(baseNote.id);
|
||||
expect(obj.path).toBe('notes/2026-04-25-014a3b9c-주간-회고.md');
|
||||
expect(obj.tags).toEqual(['pr', 'review']);
|
||||
expect(obj.embedding_text).toBe('회고 메모 본문');
|
||||
});
|
||||
|
||||
it('emits two lines for two entries', () => {
|
||||
const out = composeIndexJsonl([
|
||||
{ note: baseNote, path: 'notes/a.md' },
|
||||
{ note: { ...baseNote, id: '02xxxxxx-...', rawText: 'b' }, path: 'notes/b.md' }
|
||||
]);
|
||||
expect(out.trimEnd().split('\n').length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeManifest', () => {
|
||||
it('emits pretty JSON with required fields', () => {
|
||||
const m = composeManifest({
|
||||
exportedAt: '2026-04-26T00:00:00.000Z',
|
||||
noteCount: 42,
|
||||
mediaCount: 17
|
||||
});
|
||||
const obj = JSON.parse(m);
|
||||
expect(obj.inkling_export_version).toBe(1);
|
||||
expect(obj.exported_at).toBe('2026-04-26T00:00:00.000Z');
|
||||
expect(obj.note_count).toBe(42);
|
||||
expect(obj.media_count).toBe(17);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user