F6-L3 Import (v0.2.1 dogfood-feedback Track #3) #4

Merged
altair823 merged 5 commits from feat/f6-l3-import into main 2026-04-26 01:58:55 +00:00
10 changed files with 1569 additions and 5 deletions

View File

@@ -0,0 +1,412 @@
# F6-L3 Import Implementation Plan
**Goal:** Import an F5 export tree (`notes/*.md` + `media/*.{ext}` + optional `manifest.json`) back into Inkling DB. Handle id collisions safely (same body → skip, different body → new id to preserve raw_text invariant).
**Architecture:** Pure parse layer (`importFormat.ts` reverses `exportFormat.ts`) + orchestrator (`ImportService.ts` reads files via `node:fs/promises`, writes via `NoteRepository` + `MediaStore`) + tray menu callback (preview dialog → confirm → import).
**Tech Stack:** TypeScript, vitest, node:fs/promises. No new package deps.
## File Structure
**Create:**
- `src/main/services/importFormat.ts` — pure parse functions
- `src/main/services/ImportService.ts` — orchestrator
- `tests/unit/importFormat.test.ts`
- `tests/unit/ImportService.test.ts`
**Modify:**
- `src/main/repository/NoteRepository.ts` — add `importNote()` + `findRawTextById()`
- `src/main/index.ts` — wire ImportService + extend tray callback (5th)
- `src/main/tray.ts` — accept 5th callback `runImport`, add "백업에서 복원..." menu
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F6-L3 status update
- Create `docs/superpowers/specs/2026-04-26-f6-l3-import.md`
**No schema changes. No new package dependencies.**
## Conflict Policy
| 상황 | 처리 |
|------|------|
| id 신규 | INSERT (그대로 적재) |
| id 매치 + raw_text 동일 | skip (counted as `unchanged`) |
| id 매치 + raw_text 상이 | 새 uuidv7 발급 후 INSERT (raw_text invariant 보호) |
## Decisions
| 결정 | 값 |
|------|-----|
| 파싱 범위 | F5 export 포맷만 (`inkling_export_version: 1` 가정) |
| raw_text 추출 | body 구조 기반 (h1 / blockquote / image ref 제거 후 잔여) |
| 미디어 복사 | 항상 (스킵 옵션 후속) |
| AI 메타 보존 | ai_title, ai_summary, ai_provider, ai_generated_at, edited flags 모두 복원 |
| 태그 | source 보존 (ai/user) |
| 트리거 | 트레이 메뉴 "백업에서 복원..." |
## Task 1: Pure parse functions
**Files:**
- `src/main/services/importFormat.ts`
- `tests/unit/importFormat.test.ts`
### `parseExportNote(markdown: string): ParsedNote`
Returns:
```typescript
export interface ParsedNote {
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: { name: string; source: 'ai' | 'user' }[];
images: { rel: string; mime: string; bytes: number }[];
exportVersion: number;
}
```
### Frontmatter parsing (subset YAML)
Handle exactly the variants `composeFrontmatter` produces:
- Plain scalar: `key: value`
- Single-quoted: `key: 'value with '': escapes'`
- Block scalar `|-`:
```
key: |-
line1
line2
```
Body lines have 4-space indent, joined with `\n`.
- Inline flow tag: `- { name: foo, source: ai }`
- Numeric: `bytes: 1234`
- ISO timestamps stay as strings
Frontmatter extraction:
1. Markdown must start with `---\n`
2. Read lines until next `---\n` (the closing delimiter)
3. Parse each top-level key: detect plain / quoted / block / list
4. Return key-value map + handle `tags:` and `images:` lists specially
### Body parsing (raw_text extraction)
After frontmatter, body looks like:
```
\n
# {title or '(제목 없음)'}\n
\n
> {summary line 1}\n
> {summary line 2}\n
\n
{rawText}\n
\n
![](media/{id8}__1.{ext})\n
![](media/{id8}__2.{ext})\n
```
Algorithm:
1. Drop leading blank lines after frontmatter
2. Skip the h1 line (starts with `# `)
3. Drop blank lines
4. Skip blockquote lines (each starts with `> `)
5. Drop blank lines
6. Capture lines until first `![](media/` or end-of-string
7. Trim trailing blank lines
8. Result = rawText
If no h1/blockquote/images present, body verbatim is rawText.
### Tests (≥ 12)
- Round-trip: compose then parse → deep equal
- Plain scalar
- Single-quoted with escapes
- Block scalar `|-` multi-line
- Tag list inline flow
- Image list with mime/bytes
- Body extraction with summary
- Body extraction without summary
- Body extraction without images
- Body extraction with rawText that contains `>` mid-line (but not at line start, so not blockquote)
- Missing version → exportVersion = 0 (or throw — pick one, document in test)
- Source field maps `ai`/`user` correctly to titleEditedByUser/summaryEditedByUser
**Commit:** `feat(import): pure parser for F5 export format`
## Task 2: ImportService
**Files:**
- `src/main/services/ImportService.ts`
- `tests/unit/ImportService.test.ts`
- Modify `src/main/repository/NoteRepository.ts`
### `NoteRepository.findRawTextById(id: string): string | null`
```typescript
findRawTextById(id: string): string | null {
const row = this.db.prepare('SELECT raw_text FROM notes WHERE id=?').get(id) as
| { raw_text: string }
| undefined;
return row?.raw_text ?? null;
}
```
### `NoteRepository.importNote(input: ImportNoteInput): { id: string; status: 'inserted' | 'skipped' | 'forked' }`
```typescript
export interface ImportNoteInput {
id: string; // proposed id (caller should re-roll if conflict)
rawText: string;
createdAt: string;
updatedAt: 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' }[];
}
```
```typescript
importNote(input: ImportNoteInput): { id: string; status: 'inserted' | 'skipped' | 'forked' } {
const existing = this.findRawTextById(input.id);
let finalId = input.id;
let status: 'inserted' | 'skipped' | 'forked' = 'inserted';
if (existing !== null) {
if (existing === input.rawText) {
return { id: input.id, status: 'skipped' };
}
finalId = uuidv7();
status = 'forked';
}
const tx = this.db.transaction(() => {
this.db.prepare(
`INSERT INTO notes
(id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
title_edited_by_user, summary_edited_by_user,
user_intent, intent_prompted_at, created_at, updated_at)
VALUES (?,?,?,?,'done',?,?,?,?,?,?,?,?)`
).run(
finalId, input.rawText, input.aiTitle, input.aiSummary,
input.aiProvider, input.aiGeneratedAt,
input.titleEditedByUser ? 1 : 0, input.summaryEditedByUser ? 1 : 0,
input.userIntent, input.intentPromptedAt,
input.createdAt, input.updatedAt
);
if (input.tags.length > 0) {
const getOrInsertTag = this.db.prepare(
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
);
const linkAi = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
);
const linkUser = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
);
for (const t of input.tags) {
const row = getOrInsertTag.get(t.name) as { id: number };
if (t.source === 'ai') linkAi.run(finalId, row.id);
else linkUser.run(finalId, row.id);
}
}
});
tx();
return { id: finalId, status };
}
```
Note: `ai_status` is set to `'done'` (not pending) since this is an imported note that already has AI fields. No pending_jobs entry created — caller can re-trigger AI later if desired.
### ImportService
```typescript
export interface ImportPlan {
total: number;
newCount: number;
unchangedCount: number;
forkedCount: number;
mediaCount: number;
}
export interface ImportResult extends ImportPlan {
finalNoteIds: Map<string, string>; // origId -> finalId
}
export class ImportService {
constructor(private repo: NoteRepository, private mediaStore: MediaStore) {}
async preview(sourceDir: string): Promise<ImportPlan> {
const files = await this.scanNotes(sourceDir);
const plan: ImportPlan = { total: 0, newCount: 0, unchangedCount: 0, forkedCount: 0, mediaCount: 0 };
for (const f of files) {
const content = await readFile(f, 'utf8');
const parsed = parseExportNote(content);
plan.total += 1;
const existing = this.repo.findRawTextById(parsed.id);
if (existing === null) plan.newCount += 1;
else if (existing === parsed.rawText) plan.unchangedCount += 1;
else plan.forkedCount += 1;
plan.mediaCount += parsed.images.length;
}
return plan;
}
async run(sourceDir: string): Promise<ImportResult> {
const files = await this.scanNotes(sourceDir);
const finalNoteIds = new Map<string, string>();
let newCount = 0, unchangedCount = 0, forkedCount = 0, mediaCount = 0;
for (const f of files) {
const content = await readFile(f, 'utf8');
const parsed = parseExportNote(content);
const r = this.repo.importNote({ ...parsed }); // tags + ImportNoteInput shape
finalNoteIds.set(parsed.id, r.id);
if (r.status === 'inserted') newCount += 1;
else if (r.status === 'skipped') unchangedCount += 1;
else forkedCount += 1;
// Copy media
if (r.status !== 'skipped') {
for (let i = 0; i < parsed.images.length; i++) {
const img = parsed.images[i]!;
// Source rel = img.rel relative to sourceDir
const src = join(sourceDir, img.rel);
const ext = img.rel.split('.').pop() ?? 'bin';
// MediaStore expects bytes; we'll call writeFile direct
// BUT respect MediaStore convention: media/{noteId}/{filename}
const noteMediaDir = join(this.mediaStore.absolutePath('media'), r.id);
await mkdir(noteMediaDir, { recursive: true });
const dstFilename = `${i + 1}.${ext}`;
await copyFile(src, join(noteMediaDir, dstFilename));
this.repo.insertMedia([{
noteId: r.id,
kind: 'image',
relPath: `media/${r.id}/${dstFilename}`,
mime: img.mime,
bytes: img.bytes
}]);
mediaCount += 1;
}
}
}
return { total: files.length, newCount, unchangedCount, forkedCount, mediaCount, finalNoteIds };
}
private async scanNotes(sourceDir: string): Promise<string[]> {
const notesDir = join(sourceDir, 'notes');
const entries = await readdir(notesDir);
return entries.filter((e) => e.endsWith('.md')).map((e) => join(notesDir, e));
}
}
```
### Tests (≥ 6)
1. preview() of empty `notes/` dir → all zeros
2. preview() of single new note → newCount=1
3. run() inserts new note + tags + media
4. run() with id collision + same body → status='skipped'
5. run() with id collision + different body → forked, new id
6. run() copies media file to profileDir + inserts media row
**Commit:** `feat(import): ImportService with conflict policy + media copy`
## Task 3: Wire into main + tray
**Modify:** `src/main/index.ts`, `src/main/tray.ts`
### tray.ts — 5th callback
`createTray(showInbox, showCapture, runBackup, runExport, runImport)`
Menu order:
1. 구출한 메모 보기
2. 기억 구출하기
3. ─
4. 지금 백업
5. 내보내기...
6. 백업에서 복원...
7. (packaged: 자동 실행 + sep | dev: sep)
8. 종료
### index.ts
Add `ImportService` import + instantiate after ExportService:
```typescript
const importSvc = new ImportService(repo, store);
```
5th callback (between existing 4th and closing paren):
```typescript
async () => {
// runImport
const win = getInboxWindow();
const dirOpts: Electron.OpenDialogOptions = {
title: '복원할 백업 폴더 선택',
message: 'F5 export 형식의 폴더를 선택하세요. notes/ 하위의 .md 파일이 적재됩니다.',
buttonLabel: '여기서 복원',
properties: ['openDirectory']
};
const dirResult = win
? await dialog.showOpenDialog(win, dirOpts)
: await dialog.showOpenDialog(dirOpts);
if (dirResult.canceled || dirResult.filePaths.length === 0) return;
const sourceDir = dirResult.filePaths[0]!;
let plan;
try {
plan = await importSvc.preview(sourceDir);
} catch (e) {
logger.warn('import.preview.failed', { reason: String(e) });
new Notification({ title: 'Inkling', body: '백업 폴더를 읽지 못했습니다.', silent: true }).show();
return;
}
// Confirm dialog
const confirm = await dialog.showMessageBox(win ?? undefined as any, {
type: 'question',
buttons: ['복원', '취소'],
defaultId: 0,
cancelId: 1,
title: 'Inkling 복원',
message: `복원 미리보기`,
detail: `총 ${plan.total}개 노트\n · 신규 ${plan.newCount}개\n · 동일 (스킵) ${plan.unchangedCount}개\n · 충돌→새 id (${plan.forkedCount}개, raw_text 보존)\n\n이미지 ${plan.mediaCount}개 복사 예정.`
});
if (confirm.response !== 0) return;
try {
const r = await importSvc.run(sourceDir);
logger.info('import.done', { total: r.total, new: r.newCount, unchanged: r.unchangedCount, forked: r.forkedCount, media: r.mediaCount });
new Notification({
title: 'Inkling',
body: `복원 완료 — 신규 ${r.newCount}개, 스킵 ${r.unchangedCount}개, 충돌 ${r.forkedCount}개`,
silent: true
}).show();
} catch (e) {
logger.warn('import.run.failed', { reason: String(e) });
new Notification({ title: 'Inkling', body: '복원을 완료하지 못했습니다.', silent: true }).show();
}
}
```
**Commit:** `feat(import): wire ImportService — tray '백업에서 복원...' + preview dialog`
## Task 4: Promote
Create `docs/superpowers/specs/2026-04-26-f6-l3-import.md` with mini-brainstorm decisions table.
Update `docs/superpowers/specs/2026-04-25-dogfood-feedback.md`:
- F6 진행 상태: L3 → 🚀 promoted (link)
**Commit:** `docs(spec): promote F6-L3 import`
## Verification
Each task: typecheck 0 + tests pass.
End: 108 + ~20 (12 importFormat + 6 ImportService + 1-2 NoteRepository) ≈ 128/128.

View File

@@ -584,7 +584,7 @@ inkling_export_version: 1
**진행 상태:**
- L1 (로컬 스냅샷) — 🚀 promoted → `docs/superpowers/specs/2026-04-26-f6-l1-local-snapshot.md`
- L2 (git sync) — 🌱 raw, 7번 항목으로 예정
- L3 (import) — 🌱 raw, 3번 항목으로 예정 (F5 후)
- L3 (import) — 🚀 promoted → `docs/superpowers/specs/2026-04-26-f6-l3-import.md`
**발견:** 2026-04-26 dogfood 시작 직전 사고 실험. 슬라이스 v0.4 의 메모 데이터는 `%APPDATA%\Inkling\Inkling\profiles\default\` 단 한 위치에만 존재. 디스크 고장·실수 삭제·DB 손상·OS 재설치 = 총 손실. Strategy.md §1 의 "이제 잊어도 됩니다" 보상이 **데이터 영속성 신뢰** 위에 서 있어서, 이 신뢰가 깨지면 슬라이스 §1.3 의 종료 조건 ("본인 2주 dogfood 완주") 자체가 위협받음.

View File

@@ -0,0 +1,45 @@
# F6-L3 Import Spec (Promoted)
**Extracted from:** `2026-04-25-dogfood-feedback.md` F6 §"L3 — 수동 전체 export / import"
**Plan:** `docs/superpowers/plans/2026-04-26-f6-l3-import.md`
**Status:** 🚀 promoted — implemented 2026-04-26
**Depends on:** F5 (`2026-04-26-f5-export.md`) — 본 항목은 F5 의 export 트리를 inverse 한다.
## 결정 (mini-brainstorm 결과)
| 결정 항목 | 값 | 근거 |
|----------|-----|------|
| 파싱 범위 | F5 export 포맷만 (`inkling_export_version: 1` 가정) | YAML 일반 파서 의존 회피 — F5 가 emit 하는 변형만 다룸 → dep 0, 코드 단순 |
| frontmatter parser | 자체 구현 (plain / single-quoted / `\|-` block / tags-flow / images-block) | composeFrontmatter 와 1:1 round-trip 가능. 일반 YAML 채택 시 `j-yaml` 등 dep 추가 + 호환 매트릭스 부담 |
| raw_text 추출 | 본문 구조 기반 (h1 / blockquote / image-ref 제거 후 잔여) | F5 의 composeMarkdown 출력을 정확 inverse. 모호 케이스 0 |
| 충돌 정책 | id 매치 + raw_text 동일 → skip · id 매치 + raw_text 상이 → 새 uuidv7 발급 후 INSERT (forked) · id 신규 → INSERT | slice §1.1 raw_text 불변 invariant 보호. 사용자에게 묻는 다이얼로그 회피 (현 dogfood 단일 사용자) |
| 미디어 복사 | 항상 (해당 노트가 inserted 또는 forked 일 때만) | skip 노트는 DB 에 이미 미디어 있음 → 중복 복사 회피 |
| 미디어 저장 경로 | `<profileDir>/media/{noteId}/{n}.{ext}` (MediaStore 컨벤션) | 기존 코드와 동일. media row 의 `relPath` 도 동일 형식 |
| AI 메타 보존 | ai_title, ai_summary, ai_provider, ai_generated_at, edited flags 모두 복원. ai_status='done' | 가져온 노트는 이미 AI 처리 완료 상태. pending_jobs 큐 추가 안 함 |
| 태그 source | ai/user 보존 | F5 에 정확히 emit 되어 있음 |
| 트리거 | 트레이 메뉴 "백업에서 복원..." | F5 의 "내보내기..." 와 대칭 |
| Preview UX | 디렉터리 선택 → preview (신규/스킵/충돌 카운트 + 이미지 수) → confirm 메시지박스 → run | 손상 가능 작업이라 한 번 확인. F5 와 톤 동일 |
## 구현 (PR 안에 포함됨)
- `src/main/services/importFormat.ts` — pure parser (300 lines)
- `src/main/services/ImportService.ts` — 오케스트레이터 (132 lines)
- `src/main/repository/NoteRepository.ts``findRawTextById()`, `importNote()` 추가
- `src/main/index.ts``ImportService` 인스턴스화 + 5번째 트레이 콜백
- `src/main/tray.ts` — "백업에서 복원..." 메뉴 항목
- `tests/unit/importFormat.test.ts` (18 단위 테스트)
- `tests/unit/ImportService.test.ts` (6 단위 테스트)
## 후속 (별 spec 또는 후속 항목 후보)
- 충돌 시 사용자 선택 다이얼로그 (덮어쓰기 vs skip vs 새 id) — 현재는 자동 forked
- 미디어 누락 (`media/foo.png` 가 export 트리에 없음) 시 graceful skip + 경고. 현재는 throw
- 부분 import (사용자 선택 노트만)
- import 후 dirty 노트 redirect (Inbox 자동 새로고침)
- 다중 export 트리 병합 (manifest.json 의 exported_at 기반 merge order)
- F1 (due_date) 필드가 promoted 되면 import 도 동기 — 현재 frontmatter 에 추가 키 무시되므로 forward-compatible
- 멀티 `inkling_export_version` 지원 — v2 도입 시 dispatch
- L2 git sync 와 자연스럽게 연결 (clone 직후 import 트리거)
- Round-trip 자동 회귀 테스트 — 전체 DB → export → wipe → import → diff (e2e)
- 한 번에 여러 sourceDir 머지 (예: 두 디바이스의 export 트리 합치기)
- 파일별 진행률 표시 (현재는 시작/완료만)

View File

@@ -26,6 +26,7 @@ import { createTray } from './tray.js';
import { MediaGc } from './services/MediaGc.js';
import { BackupService } from './services/BackupService.js';
import { ExportService } from './services/ExportService.js';
import { ImportService } from './services/ImportService.js';
const HIDDEN_ARG = '--hidden';
const startedHidden = process.argv.includes(HIDDEN_ARG);
@@ -104,6 +105,7 @@ app.whenReady().then(async () => {
void gc.run().then((r) => logger.info('media.gc', { ...r } as Record<string, unknown>));
const exportSvc = new ExportService(repo, store);
const importSvc = new ImportService(repo, store);
const backup = new BackupService(db, join(paths.profileDir, 'backups'));
void backup.runDaily()
@@ -179,6 +181,68 @@ app.whenReady().then(async () => {
silent: true
}).show();
}
},
async () => {
const win = getInboxWindow();
const dirOpts: Electron.OpenDialogOptions = {
title: '복원할 백업 폴더 선택',
message: 'F5 export 형식의 폴더를 선택하세요. notes/ 하위의 마크다운 파일이 적재됩니다.',
buttonLabel: '여기서 복원',
properties: ['openDirectory']
};
const dirResult = win
? await dialog.showOpenDialog(win, dirOpts)
: await dialog.showOpenDialog(dirOpts);
if (dirResult.canceled || dirResult.filePaths.length === 0) return;
const sourceDir = dirResult.filePaths[0]!;
let plan;
try {
plan = await importSvc.preview(sourceDir);
} catch (e) {
logger.warn('import.preview.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '백업 폴더를 읽지 못했습니다.',
silent: true
}).show();
return;
}
const detail = `${plan.total}개 노트\n · 신규 ${plan.newCount}\n · 동일 (스킵) ${plan.unchangedCount}\n · 충돌→새 id (${plan.forkedCount}개, raw_text 보존)\n\n이미지 ${plan.mediaCount}개 복사 예정.`;
const confirmOpts: Electron.MessageBoxOptions = {
type: 'question',
buttons: ['복원', '취소'],
defaultId: 0,
cancelId: 1,
title: 'Inkling 복원',
message: '복원 미리보기',
detail
};
const confirm = win
? await dialog.showMessageBox(win, confirmOpts)
: await dialog.showMessageBox(confirmOpts);
if (confirm.response !== 0) return;
try {
const r = await importSvc.run(sourceDir);
logger.info('import.done', {
total: r.total,
new: r.newCount,
unchanged: r.unchangedCount,
forked: r.forkedCount,
media: r.mediaCount
});
new Notification({
title: 'Inkling',
body: `복원 완료 — 신규 ${r.newCount}개, 스킵 ${r.unchangedCount}개, 충돌 ${r.forkedCount}`,
silent: true
}).show();
} catch (e) {
logger.warn('import.run.failed', { reason: String(e) });
new Notification({
title: 'Inkling',
body: '복원을 완료하지 못했습니다.',
silent: true
}).show();
}
}
);

View File

@@ -12,6 +12,32 @@ export interface NewMediaRow {
bytes: number;
}
export interface ImportNoteInput {
/** Proposed id from the export file. May be replaced if it collides with
* an existing row whose `raw_text` differs (raw_text invariant guard). */
id: string;
rawText: string;
createdAt: string;
updatedAt: 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' }[];
}
export type ImportNoteStatus = 'inserted' | 'skipped' | 'forked';
export interface ImportNoteResult {
/** Final id used for the row (== input.id for inserted/skipped, fresh uuidv7 for forked). */
id: string;
status: ImportNoteStatus;
}
export class NoteRepository {
constructor(private db: Database.Database) {}
@@ -188,6 +214,76 @@ export class NoteRepository {
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
}
findRawTextById(id: string): string | null {
const row = this.db.prepare('SELECT raw_text FROM notes WHERE id=?').get(id) as
| { raw_text: string }
| undefined;
return row?.raw_text ?? null;
}
/**
* Import a note from an external source (F5 export tree).
* Conflict policy:
* - id missing in DB → INSERT (status: 'inserted')
* - id present + raw_text identical → no-op (status: 'skipped')
* - id present + raw_text differs → INSERT under fresh uuidv7
* to preserve the raw_text-immutable invariant (status: 'forked')
*/
importNote(input: ImportNoteInput): ImportNoteResult {
const existing = this.findRawTextById(input.id);
let finalId = input.id;
let status: ImportNoteStatus = 'inserted';
if (existing !== null) {
if (existing === input.rawText) {
return { id: input.id, status: 'skipped' };
}
finalId = uuidv7();
status = 'forked';
}
const tx = this.db.transaction(() => {
this.db
.prepare(
`INSERT INTO notes
(id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
title_edited_by_user, summary_edited_by_user,
user_intent, intent_prompted_at, created_at, updated_at)
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
finalId,
input.rawText,
input.aiTitle,
input.aiSummary,
input.aiProvider,
input.aiGeneratedAt,
input.titleEditedByUser ? 1 : 0,
input.summaryEditedByUser ? 1 : 0,
input.userIntent,
input.intentPromptedAt,
input.createdAt,
input.updatedAt
);
if (input.tags.length > 0) {
const getOrInsertTag = this.db.prepare(
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
);
const linkAi = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
);
const linkUser = this.db.prepare(
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
);
for (const t of input.tags) {
const row = getOrInsertTag.get(t.name) as { id: number };
if (t.source === 'ai') linkAi.run(finalId, row.id);
else linkUser.run(finalId, row.id);
}
}
});
tx();
return { id: finalId, status };
}
getPendingCount(): number {
const row = this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending'`)

View File

@@ -0,0 +1,146 @@
import { readdir, readFile, mkdir, copyFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { NoteRepository, ImportNoteInput } from '../repository/NoteRepository.js';
import type { MediaStore } from './MediaStore.js';
import { parseExportNote, type ParsedNote } from './importFormat.js';
export interface ImportPlan {
total: number;
newCount: number;
unchangedCount: number;
forkedCount: number;
mediaCount: number;
}
export interface ImportResult extends ImportPlan {
/** Map of original-export-id → final-DB-id (differs only for forked rows). */
finalNoteIds: Map<string, string>;
}
function inferExtFromMime(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 parsedToInput(parsed: ParsedNote): ImportNoteInput {
return {
id: parsed.id,
rawText: parsed.rawText,
createdAt: parsed.createdAt,
updatedAt: parsed.updatedAt,
aiTitle: parsed.aiTitle,
aiSummary: parsed.aiSummary,
titleEditedByUser: parsed.titleEditedByUser,
summaryEditedByUser: parsed.summaryEditedByUser,
aiProvider: parsed.aiProvider,
aiGeneratedAt: parsed.aiGeneratedAt,
userIntent: parsed.userIntent,
intentPromptedAt: parsed.intentPromptedAt,
tags: parsed.tags
};
}
export class ImportService {
constructor(
private repo: NoteRepository,
private mediaStore: MediaStore
) {}
async preview(sourceDir: string): Promise<ImportPlan> {
const files = await this.scanNotes(sourceDir);
const plan: ImportPlan = {
total: 0,
newCount: 0,
unchangedCount: 0,
forkedCount: 0,
mediaCount: 0
};
for (const f of files) {
const content = await readFile(f, 'utf8');
const parsed = parseExportNote(content);
plan.total += 1;
const existing = this.repo.findRawTextById(parsed.id);
if (existing === null) plan.newCount += 1;
else if (existing === parsed.rawText) plan.unchangedCount += 1;
else plan.forkedCount += 1;
plan.mediaCount += parsed.images.length;
}
return plan;
}
async run(sourceDir: string): Promise<ImportResult> {
const files = await this.scanNotes(sourceDir);
const finalNoteIds = new Map<string, string>();
let newCount = 0;
let unchangedCount = 0;
let forkedCount = 0;
let mediaCount = 0;
for (const f of files) {
const content = await readFile(f, 'utf8');
const parsed = parseExportNote(content);
const r = this.repo.importNote(parsedToInput(parsed));
finalNoteIds.set(parsed.id, r.id);
if (r.status === 'inserted') newCount += 1;
else if (r.status === 'skipped') unchangedCount += 1;
else forkedCount += 1;
// Skip media for already-present (skipped) notes — DB already has them.
if (r.status === 'skipped') continue;
// Copy media files into MediaStore convention <profileDir>/media/{noteId}/{n}.{ext}
const noteMediaDir = join(this.mediaStore.absolutePath('media'), r.id);
if (parsed.images.length > 0) {
await mkdir(noteMediaDir, { recursive: true });
}
const mediaRows = [];
for (let i = 0; i < parsed.images.length; i++) {
const img = parsed.images[i]!;
const src = join(sourceDir, img.rel);
const ext = inferExtFromMime(img.mime);
const dstFilename = `${i + 1}.${ext}`;
const dstAbs = join(noteMediaDir, dstFilename);
await copyFile(src, dstAbs);
mediaRows.push({
noteId: r.id,
kind: 'image' as const,
relPath: `media/${r.id}/${dstFilename}`,
mime: img.mime,
bytes: img.bytes
});
mediaCount += 1;
}
if (mediaRows.length > 0) {
this.repo.insertMedia(mediaRows);
}
}
return {
total: files.length,
newCount,
unchangedCount,
forkedCount,
mediaCount,
finalNoteIds
};
}
private async scanNotes(sourceDir: string): Promise<string[]> {
const notesDir = join(sourceDir, 'notes');
let entries: string[];
try {
entries = await readdir(notesDir);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
return entries
.filter((e) => e.endsWith('.md'))
.sort()
.map((e) => join(notesDir, e));
}
}

View File

@@ -0,0 +1,354 @@
/**
* Pure parse functions for F6-L3 (Import).
*
* Reverses the output of `composeMarkdown` from `exportFormat.ts`.
* Minimal YAML parser handling exactly the variants F5 emits — plain scalars,
* single-quoted strings (with `''` escapes), block scalar `|-`, and the two
* structured lists (`tags:` inline-flow, `images:` block).
*
* No filesystem, no I/O.
*/
export interface ParsedNoteTag {
name: string;
source: 'ai' | 'user';
}
export interface ParsedNoteImage {
rel: string;
mime: string;
bytes: number;
}
export interface ParsedNote {
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: ParsedNoteTag[];
images: ParsedNoteImage[];
exportVersion: number;
}
// ---------------------------------------------------------------------------
// YAML helpers
// ---------------------------------------------------------------------------
function unquoteSingle(raw: string): string {
// Caller has confirmed `raw` is wrapped in single quotes.
const inner = raw.slice(1, -1);
return inner.replace(/''/g, "'");
}
interface ParsedScalar {
value: string;
/** number of source lines consumed (1 for plain/quoted, 1+N for block scalar) */
consumed: number;
}
/**
* Parse a scalar value starting at `lines[startIdx]`.
* - `key: value` → consumed=1
* - `key: 'quoted'` → consumed=1
* - `key: |-` + indented body → consumed=1+N
*
* Returns `null` if the line is not a `key: …` scalar at column 0.
*/
function parseScalarAt(
lines: string[],
startIdx: number,
expectedKey: string
): ParsedScalar | null {
const line = lines[startIdx];
if (line === undefined) return null;
const prefix = `${expectedKey}:`;
if (!line.startsWith(prefix)) return null;
const after = line.slice(prefix.length);
if (after.length > 0 && after[0] !== ' ') return null;
const rhs = after.trimStart();
// Block scalar
if (rhs === '|-') {
const bodyLines: string[] = [];
let i = startIdx + 1;
// Determine indent from first body line; F5 emits 2-space indent at this level
// (composeFrontmatter passes default `indent=2`). We accept any indent ≥ 1
// and use the first body line's leading whitespace as the dedent prefix.
let dedent: string | null = null;
while (i < lines.length) {
const l = lines[i]!;
if (l.length === 0) {
// blank line inside block scalar — keep, dedent later (treat as empty)
bodyLines.push('');
i += 1;
continue;
}
const m = /^( +)/.exec(l);
if (!m) {
// first non-indented line ends the block
break;
}
const indent = m[1]!;
if (dedent === null) dedent = indent;
// Use the smallest leading-space of the first body line as the dedent prefix.
// (F5 always emits a uniform indent for a given block.)
bodyLines.push(l.startsWith(dedent) ? l.slice(dedent.length) : l.trimStart());
i += 1;
}
// Trim trailing blank lines that we tentatively added (block scalar `|-`
// strips final newline anyway).
while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1] === '') {
bodyLines.pop();
}
return { value: bodyLines.join('\n'), consumed: i - startIdx };
}
// Single-quoted
if (rhs.startsWith("'") && rhs.endsWith("'") && rhs.length >= 2) {
return { value: unquoteSingle(rhs), consumed: 1 };
}
// Plain scalar
return { value: rhs, consumed: 1 };
}
// ---------------------------------------------------------------------------
// Frontmatter section parser
// ---------------------------------------------------------------------------
interface Frontmatter {
fields: Map<string, string>;
tags: ParsedNoteTag[];
images: ParsedNoteImage[];
/** total lines consumed including the closing `---` delimiter */
consumedLines: number;
}
/**
* Parse a tag flow item: `- { name: foo, source: ai }` or
* `- { name: 'a, b', source: user }`.
*/
function parseTagFlow(line: string): ParsedNoteTag | null {
const trimmed = line.trim();
if (!trimmed.startsWith('-')) return null;
const afterDash = trimmed.slice(1).trimStart();
if (!afterDash.startsWith('{') || !afterDash.endsWith('}')) return null;
const inner = afterDash.slice(1, -1).trim();
// Expect `name: <value>, source: ai|user`. Value may be single-quoted with embedded commas.
// Split on the comma that is OUTSIDE single quotes.
let nameRaw: string | null = null;
let sourceRaw: string | null = null;
let inQuote = false;
let cursor = 0;
const parts: string[] = [];
for (let i = 0; i < inner.length; i++) {
const ch = inner[i];
if (ch === "'") {
// Toggle, accounting for `''` escape (still inside the quote scope).
if (inQuote && inner[i + 1] === "'") {
i += 1;
continue;
}
inQuote = !inQuote;
} else if (ch === ',' && !inQuote) {
parts.push(inner.slice(cursor, i));
cursor = i + 1;
}
}
parts.push(inner.slice(cursor));
for (const p of parts) {
const colon = p.indexOf(':');
if (colon === -1) return null;
const k = p.slice(0, colon).trim();
const v = p.slice(colon + 1).trim();
if (k === 'name') {
nameRaw = v;
} else if (k === 'source') {
sourceRaw = v;
}
}
if (nameRaw === null || sourceRaw === null) return null;
const name =
nameRaw.startsWith("'") && nameRaw.endsWith("'") ? unquoteSingle(nameRaw) : nameRaw;
if (sourceRaw !== 'ai' && sourceRaw !== 'user') return null;
return { name, source: sourceRaw };
}
function parseFrontmatter(lines: string[]): Frontmatter {
if (lines[0] !== '---') {
throw new Error('importFormat: expected frontmatter to start with "---"');
}
const fields = new Map<string, string>();
const tags: ParsedNoteTag[] = [];
const images: ParsedNoteImage[] = [];
let i = 1;
while (i < lines.length) {
const line = lines[i]!;
if (line === '---') {
// Closing delimiter — return.
return { fields, tags, images, consumedLines: i + 1 };
}
if (line.length === 0) {
i += 1;
continue;
}
// Top-level keys (column 0). Detect `key:` (list intro) or `key: value`.
if (line === 'tags:') {
i += 1;
while (i < lines.length) {
const l = lines[i]!;
if (l === '---') break;
if (!l.startsWith(' -')) break;
const tag = parseTagFlow(l);
if (tag) tags.push(tag);
i += 1;
}
continue;
}
if (line === 'images:') {
i += 1;
while (i < lines.length) {
const l = lines[i]!;
if (l === '---') break;
if (!l.startsWith(' - rel:')) break;
// Image item: 3 lines (rel, mime, bytes), each as a sub-scalar.
const relScalar = parseImageSubScalar(l, ' - rel:');
const mimeLine = lines[i + 1] ?? '';
const bytesLine = lines[i + 2] ?? '';
const mimeScalar = parseImageSubScalar(mimeLine, ' mime:');
const bytesScalar = parseImageSubScalar(bytesLine, ' bytes:');
if (relScalar === null || mimeScalar === null || bytesScalar === null) {
throw new Error('importFormat: malformed images item');
}
const bytesNum = Number.parseInt(bytesScalar, 10);
if (!Number.isFinite(bytesNum)) {
throw new Error('importFormat: bytes must be a number');
}
images.push({ rel: relScalar, mime: mimeScalar, bytes: bytesNum });
i += 3;
}
continue;
}
// Top-level scalar — find key, parse value.
const colon = line.indexOf(':');
if (colon === -1) {
// Stray line, skip.
i += 1;
continue;
}
const key = line.slice(0, colon);
const scalar = parseScalarAt(lines, i, key);
if (scalar === null) {
i += 1;
continue;
}
fields.set(key, scalar.value);
i += scalar.consumed;
}
throw new Error('importFormat: frontmatter not terminated');
}
function parseImageSubScalar(line: string, prefix: string): string | null {
if (!line.startsWith(prefix)) return null;
const rhs = line.slice(prefix.length).trimStart();
if (rhs.startsWith("'") && rhs.endsWith("'") && rhs.length >= 2) {
return unquoteSingle(rhs);
}
return rhs;
}
// ---------------------------------------------------------------------------
// Body parser (raw_text recovery)
// ---------------------------------------------------------------------------
/**
* Strips the rendered prefix (h1, blockquote summary) and trailing image refs
* to recover `rawText`.
*/
function extractRawText(bodyLines: string[]): string {
let i = 0;
// Drop leading blanks
while (i < bodyLines.length && bodyLines[i] === '') i += 1;
// Skip a single h1 line (`# …`)
if (i < bodyLines.length && bodyLines[i]!.startsWith('# ')) {
i += 1;
}
// Drop blanks
while (i < bodyLines.length && bodyLines[i] === '') i += 1;
// Skip blockquote run (`> …`)
while (i < bodyLines.length && bodyLines[i]!.startsWith('> ')) i += 1;
// Drop blanks
while (i < bodyLines.length && bodyLines[i] === '') i += 1;
// Capture until first standalone `![](media/…)` line OR end.
const captured: string[] = [];
while (i < bodyLines.length) {
const l = bodyLines[i]!;
// Image refs are emitted only at line start, separated from body by `\n\n`.
if (l.startsWith('![](media/')) break;
captured.push(l);
i += 1;
}
// Trim trailing blank lines.
while (captured.length > 0 && captured[captured.length - 1] === '') {
captured.pop();
}
return captured.join('\n');
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function parseExportNote(markdown: string): ParsedNote {
if (!markdown.startsWith('---\n')) {
throw new Error('importFormat: markdown must start with "---\\n"');
}
// Normalize line endings (F5 emits LF only, but be defensive).
const normalized = markdown.replace(/\r\n/g, '\n');
const allLines = normalized.split('\n');
const fm = parseFrontmatter(allLines);
const bodyLines = allLines.slice(fm.consumedLines);
const rawText = extractRawText(bodyLines);
const get = (k: string): string | null => (fm.fields.has(k) ? fm.fields.get(k)! : null);
const id = get('id');
const createdAt = get('created_at');
const updatedAt = get('updated_at');
if (id === null || createdAt === null || updatedAt === null) {
throw new Error('importFormat: id/created_at/updated_at are required');
}
const titleSource = get('title_source');
const summarySource = get('summary_source');
const versionRaw = get('inkling_export_version');
const exportVersion = versionRaw === null ? 0 : Number.parseInt(versionRaw, 10) || 0;
return {
id,
createdAt,
updatedAt,
rawText,
aiTitle: get('title'),
aiSummary: get('summary'),
titleEditedByUser: titleSource === 'user',
summaryEditedByUser: summarySource === 'user',
aiProvider: get('ai_provider'),
aiGeneratedAt: get('ai_generated_at'),
userIntent: get('user_intent'),
intentPromptedAt: get('intent_prompted_at'),
tags: fm.tags,
images: fm.images,
exportVersion
};
}

View File

@@ -8,14 +8,16 @@ function buildMenu(
showInbox: () => void,
showCapture: () => void,
runBackup: () => void,
runExport: () => void
runExport: () => void,
runImport: () => void
) {
const items: MenuItemConstructorOptions[] = [
{ label: '구출한 메모 보기', click: showInbox },
{ label: '기억 구출하기', click: showCapture },
{ type: 'separator' },
{ label: '지금 백업', click: runBackup },
{ label: '내보내기...', click: runExport }
{ label: '내보내기...', click: runExport },
{ label: '백업에서 복원...', click: runImport }
];
if (app.isPackaged) {
const { openAtLogin } = app.getLoginItemSettings();
@@ -42,12 +44,13 @@ export function createTray(
showInbox: () => void,
showCapture: () => void,
runBackup: () => void,
runExport: () => void
runExport: () => void,
runImport: () => void
): TrayType {
const icon = nativeImage.createEmpty();
tray = new Tray(icon);
tray.setToolTip('Inkling');
tray.setContextMenu(buildMenu(showInbox, showCapture, runBackup, runExport));
tray.setContextMenu(buildMenu(showInbox, showCapture, runBackup, runExport, runImport));
tray.on('click', showInbox);
return tray;
}

View File

@@ -0,0 +1,235 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import {
mkdtempSync,
rmSync,
existsSync,
mkdirSync,
writeFileSync,
readFileSync
} 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 { ImportService } from '@main/services/ImportService.js';
import {
composeMarkdown,
composeFilename,
type ExportNote
} from '@main/services/exportFormat.js';
function buildExportNote(overrides: Partial<ExportNote> = {}): ExportNote {
return {
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' }],
media: [],
...overrides
};
}
function writeNote(sourceDir: string, note: ExportNote): string {
const filename = composeFilename({
id: note.id,
createdAt: note.createdAt,
aiTitle: note.aiTitle
});
const md = composeMarkdown(note);
mkdirSync(join(sourceDir, 'notes'), { recursive: true });
const abs = join(sourceDir, 'notes', filename);
writeFileSync(abs, md, 'utf8');
return abs;
}
function writeMedia(sourceDir: string, rel: string, bytes: Buffer): void {
const dirIdx = rel.lastIndexOf('/');
const subdir = dirIdx === -1 ? '' : rel.slice(0, dirIdx);
if (subdir) {
mkdirSync(join(sourceDir, subdir), { recursive: true });
}
writeFileSync(join(sourceDir, rel), bytes);
}
describe('ImportService', () => {
let tmpRoot: string;
let sourceDir: string;
let profileDir: string;
let db: Database.Database;
let repo: NoteRepository;
let mediaStore: MediaStore;
let svc: ImportService;
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'inkling-import-'));
sourceDir = join(tmpRoot, 'src');
profileDir = join(tmpRoot, 'profile');
mkdirSync(sourceDir, { recursive: true });
mkdirSync(join(profileDir, 'media'), { recursive: true });
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
mediaStore = new MediaStore(profileDir);
svc = new ImportService(repo, mediaStore);
});
afterEach(() => {
db.close();
rmSync(tmpRoot, { recursive: true, force: true });
});
it('preview() of empty notes/ directory → all zeros', async () => {
mkdirSync(join(sourceDir, 'notes'), { recursive: true });
const plan = await svc.preview(sourceDir);
expect(plan).toEqual({
total: 0,
newCount: 0,
unchangedCount: 0,
forkedCount: 0,
mediaCount: 0
});
});
it('preview() of single new note → newCount=1', async () => {
writeNote(sourceDir, buildExportNote());
const plan = await svc.preview(sourceDir);
expect(plan.total).toBe(1);
expect(plan.newCount).toBe(1);
expect(plan.unchangedCount).toBe(0);
expect(plan.forkedCount).toBe(0);
});
it('run() inserts a new note with tags + provenance', async () => {
writeNote(
sourceDir,
buildExportNote({
tags: [
{ name: 'pr', source: 'ai' },
{ name: 'review', source: 'user' }
],
titleEditedByUser: true
})
);
const r = await svc.run(sourceDir);
expect(r.newCount).toBe(1);
expect(r.unchangedCount).toBe(0);
expect(r.forkedCount).toBe(0);
const note = repo.findById('014a3b9c-1234-7890-abcd-000000000001');
expect(note).not.toBeNull();
expect(note!.aiTitle).toBe('주간 회고 PR 리뷰');
expect(note!.aiStatus).toBe('done');
expect(note!.titleEditedByUser).toBe(true);
expect(note!.aiProvider).toBe('local-ollama/gemma4:e4b');
const tagNames = note!.tags.map((t) => `${t.name}:${t.source}`).sort();
expect(tagNames).toEqual(['pr:ai', 'review:user']);
});
it('run() with id collision + identical raw_text → status=skipped, no extra row', async () => {
// Pre-seed DB.
repo.importNote({
id: '014a3b9c-1234-7890-abcd-000000000001',
rawText: '회고 메모 본문',
createdAt: '2026-04-25T14:23:11.000Z',
updatedAt: '2026-04-25T14:24:02.000Z',
aiTitle: '기존 제목',
aiSummary: null,
titleEditedByUser: false,
summaryEditedByUser: false,
aiProvider: null,
aiGeneratedAt: null,
userIntent: null,
intentPromptedAt: null,
tags: []
});
writeNote(sourceDir, buildExportNote()); // same id, same rawText
const r = await svc.run(sourceDir);
expect(r.unchangedCount).toBe(1);
expect(r.newCount).toBe(0);
expect(r.forkedCount).toBe(0);
const allRows = db.prepare('SELECT id FROM notes').all();
expect(allRows.length).toBe(1);
// Original title preserved (skip means no overwrite).
const note = repo.findById('014a3b9c-1234-7890-abcd-000000000001');
expect(note!.aiTitle).toBe('기존 제목');
});
it('run() with id collision + different raw_text → forked, new id, original untouched', async () => {
// Pre-seed DB with raw_text "OLD".
repo.importNote({
id: '014a3b9c-1234-7890-abcd-000000000001',
rawText: 'OLD body',
createdAt: '2026-04-25T14:23:11.000Z',
updatedAt: '2026-04-25T14:24:02.000Z',
aiTitle: '기존',
aiSummary: null,
titleEditedByUser: false,
summaryEditedByUser: false,
aiProvider: null,
aiGeneratedAt: null,
userIntent: null,
intentPromptedAt: null,
tags: []
});
// Export note with same id, different rawText.
writeNote(sourceDir, buildExportNote({ rawText: 'NEW body' }));
const r = await svc.run(sourceDir);
expect(r.forkedCount).toBe(1);
expect(r.newCount).toBe(0);
// Two rows now.
const allRows = db.prepare('SELECT id, raw_text FROM notes ORDER BY raw_text').all() as Array<{
id: string;
raw_text: string;
}>;
expect(allRows.length).toBe(2);
expect(allRows.map((r) => r.raw_text)).toEqual(['NEW body', 'OLD body']);
// Original id still has OLD body (raw_text invariant).
const original = repo.findById('014a3b9c-1234-7890-abcd-000000000001');
expect(original!.rawText).toBe('OLD body');
// Mapping records the rename.
expect(r.finalNoteIds.get('014a3b9c-1234-7890-abcd-000000000001')).not.toBe(
'014a3b9c-1234-7890-abcd-000000000001'
);
});
it('run() copies media file to profileDir + inserts media row', async () => {
const note = buildExportNote({
media: [{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 7 }]
});
writeNote(sourceDir, note);
writeMedia(sourceDir, 'media/014a3b9c__1.png', Buffer.from('PNGDATA'));
const r = await svc.run(sourceDir);
expect(r.mediaCount).toBe(1);
const finalId = r.finalNoteIds.get(note.id)!;
const expectedAbs = join(profileDir, 'media', finalId, '1.png');
expect(existsSync(expectedAbs)).toBe(true);
expect(readFileSync(expectedAbs).toString()).toBe('PNGDATA');
const dbNote = repo.findById(finalId);
expect(dbNote!.media.length).toBe(1);
expect(dbNote!.media[0]!.relPath).toBe(`media/${finalId}/1.png`);
expect(dbNote!.media[0]!.mime).toBe('image/png');
expect(dbNote!.media[0]!.bytes).toBe(7);
});
});

View File

@@ -0,0 +1,209 @@
import { describe, it, expect } from 'vitest';
import {
composeMarkdown,
type ExportNote
} from '@main/services/exportFormat.js';
import { parseExportNote } from '@main/services/importFormat.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('parseExportNote — round-trip with composeMarkdown', () => {
it('round-trips the base note', () => {
const md = composeMarkdown(baseNote);
const parsed = parseExportNote(md);
expect(parsed.id).toBe(baseNote.id);
expect(parsed.createdAt).toBe(baseNote.createdAt);
expect(parsed.updatedAt).toBe(baseNote.updatedAt);
expect(parsed.rawText).toBe(baseNote.rawText);
expect(parsed.aiTitle).toBe(baseNote.aiTitle);
expect(parsed.aiSummary).toBe(baseNote.aiSummary);
expect(parsed.aiProvider).toBe(baseNote.aiProvider);
expect(parsed.aiGeneratedAt).toBe(baseNote.aiGeneratedAt);
expect(parsed.titleEditedByUser).toBe(false);
expect(parsed.summaryEditedByUser).toBe(false);
expect(parsed.tags).toEqual([
{ name: 'pr', source: 'ai' },
{ name: 'review', source: 'user' }
]);
expect(parsed.images).toEqual([]);
expect(parsed.exportVersion).toBe(1);
});
it('round-trips a note with media', () => {
const note: ExportNote = {
...baseNote,
media: [
{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 1234 },
{ rel: 'media/014a3b9c__2.jpg', mime: 'image/jpeg', bytes: 5678 }
]
};
const md = composeMarkdown(note);
const parsed = parseExportNote(md);
expect(parsed.images).toEqual([
{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 1234 },
{ rel: 'media/014a3b9c__2.jpg', mime: 'image/jpeg', bytes: 5678 }
]);
expect(parsed.rawText).toBe(note.rawText);
});
});
describe('parseExportNote — frontmatter scalar variants', () => {
it('parses plain scalar', () => {
const md = composeMarkdown({ ...baseNote, aiTitle: '주간 회고' });
const parsed = parseExportNote(md);
expect(parsed.aiTitle).toBe('주간 회고');
});
it('parses single-quoted with embedded apostrophe (`` `` escape)', () => {
const note: ExportNote = { ...baseNote, aiTitle: "it's a: title" };
const md = composeMarkdown(note);
// Should be emitted as: title: 'it''s a: title'
expect(md).toContain("title: 'it''s a: title'");
const parsed = parseExportNote(md);
expect(parsed.aiTitle).toBe("it's a: title");
});
it('parses block scalar `|-` for multiline summary', () => {
const note: ExportNote = {
...baseNote,
aiSummary: 'line1\nline2\nline3'
};
const md = composeMarkdown(note);
expect(md).toContain('summary: |-');
const parsed = parseExportNote(md);
expect(parsed.aiSummary).toBe('line1\nline2\nline3');
});
});
describe('parseExportNote — list parsing', () => {
it('parses tags inline flow', () => {
const md = composeMarkdown({
...baseNote,
tags: [
{ name: 'foo', source: 'ai' },
{ name: 'bar baz', source: 'user' }
]
});
const parsed = parseExportNote(md);
expect(parsed.tags).toEqual([
{ name: 'foo', source: 'ai' },
{ name: 'bar baz', source: 'user' }
]);
});
it('parses images list with mime + bytes', () => {
const md = composeMarkdown({
...baseNote,
media: [{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 9876 }]
});
const parsed = parseExportNote(md);
expect(parsed.images).toEqual([
{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 9876 }
]);
});
});
describe('parseExportNote — body extraction', () => {
it('extracts rawText with summary present', () => {
const md = composeMarkdown({ ...baseNote, rawText: '본문\n두 번째 줄' });
const parsed = parseExportNote(md);
expect(parsed.rawText).toBe('본문\n두 번째 줄');
});
it('extracts rawText with summary absent', () => {
const md = composeMarkdown({
...baseNote,
aiSummary: null,
rawText: '요약 없는 본문'
});
const parsed = parseExportNote(md);
expect(parsed.rawText).toBe('요약 없는 본문');
});
it('extracts rawText with no images', () => {
const md = composeMarkdown({ ...baseNote, rawText: '이미지 없음', media: [] });
const parsed = parseExportNote(md);
expect(parsed.rawText).toBe('이미지 없음');
});
it('preserves `>` mid-line in rawText (not parsed as blockquote)', () => {
const md = composeMarkdown({
...baseNote,
rawText: '값 a > b 라는 부등호'
});
const parsed = parseExportNote(md);
expect(parsed.rawText).toBe('값 a > b 라는 부등호');
});
it('preserves `# ` mid-line in rawText (not parsed as heading)', () => {
const md = composeMarkdown({
...baseNote,
rawText: '예시: see issue #1 어쩌고 # 가운데 해시'
});
const parsed = parseExportNote(md);
expect(parsed.rawText).toBe('예시: see issue #1 어쩌고 # 가운데 해시');
});
});
describe('parseExportNote — provenance', () => {
it('recovers titleEditedByUser from title_source: user', () => {
const md = composeMarkdown({ ...baseNote, titleEditedByUser: true });
const parsed = parseExportNote(md);
expect(parsed.titleEditedByUser).toBe(true);
});
it('recovers summaryEditedByUser from summary_source: user', () => {
const md = composeMarkdown({ ...baseNote, summaryEditedByUser: true });
const parsed = parseExportNote(md);
expect(parsed.summaryEditedByUser).toBe(true);
});
it('exposes exportVersion = 1', () => {
const md = composeMarkdown(baseNote);
const parsed = parseExportNote(md);
expect(parsed.exportVersion).toBe(1);
});
});
describe('parseExportNote — edge cases', () => {
it('preserves user_intent when present', () => {
const md = composeMarkdown({
...baseNote,
userIntent: '팀에서 회고 양식 통일',
intentPromptedAt: '2026-04-25T14:24:02.000Z'
});
const parsed = parseExportNote(md);
expect(parsed.userIntent).toBe('팀에서 회고 양식 통일');
expect(parsed.intentPromptedAt).toBe('2026-04-25T14:24:02.000Z');
});
it('returns null aiTitle / aiSummary when omitted', () => {
const md = composeMarkdown({
...baseNote,
aiTitle: null,
aiSummary: null
});
const parsed = parseExportNote(md);
expect(parsed.aiTitle).toBeNull();
expect(parsed.aiSummary).toBeNull();
});
it('throws when input lacks frontmatter delimiter', () => {
expect(() => parseExportNote('hello world')).toThrow();
});
});