F6-L3 Import (v0.2.1 dogfood-feedback Track #3) #4
412
docs/superpowers/plans/2026-04-26-f6-l3-import.md
Normal file
412
docs/superpowers/plans/2026-04-26-f6-l3-import.md
Normal 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
|
||||
\n
|
||||
\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 `
|
||||
|
||||
- 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.
|
||||
@@ -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 완주") 자체가 위협받음.
|
||||
|
||||
|
||||
45
docs/superpowers/specs/2026-04-26-f6-l3-import.md
Normal file
45
docs/superpowers/specs/2026-04-26-f6-l3-import.md
Normal 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 트리 합치기)
|
||||
- 파일별 진행률 표시 (현재는 시작/완료만)
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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'`)
|
||||
|
||||
146
src/main/services/ImportService.ts
Normal file
146
src/main/services/ImportService.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
354
src/main/services/importFormat.ts
Normal file
354
src/main/services/importFormat.ts
Normal 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 `` 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(') 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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
235
tests/unit/ImportService.test.ts
Normal file
235
tests/unit/ImportService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
209
tests/unit/importFormat.test.ts
Normal file
209
tests/unit/importFormat.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user