14 KiB
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 functionssrc/main/services/ImportService.ts— orchestratortests/unit/importFormat.test.tstests/unit/ImportService.test.ts
Modify:
src/main/repository/NoteRepository.ts— addimportNote()+findRawTextById()src/main/index.ts— wire ImportService + extend tray callback (5th)src/main/tray.ts— accept 5th callbackrunImport, add "백업에서 복원..." menudocs/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.tstests/unit/importFormat.test.ts
parseExportNote(markdown: string): ParsedNote
Returns:
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
|-:Body lines have 4-space indent, joined withkey: |- line1 line2\n. - Inline flow tag:
- { name: foo, source: ai } - Numeric:
bytes: 1234 - ISO timestamps stay as strings
Frontmatter extraction:
- Markdown must start with
---\n - Read lines until next
---\n(the closing delimiter) - Parse each top-level key: detect plain / quoted / block / list
- Return key-value map + handle
tags:andimages: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:
- Drop leading blank lines after frontmatter
- Skip the h1 line (starts with
#) - Drop blank lines
- Skip blockquote lines (each starts with
>) - Drop blank lines
- 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/usercorrectly to titleEditedByUser/summaryEditedByUser
Commit: feat(import): pure parser for F5 export format
Task 2: ImportService
Files:
src/main/services/ImportService.tstests/unit/ImportService.test.ts- Modify
src/main/repository/NoteRepository.ts
NoteRepository.findRawTextById(id: string): string | null
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' }
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' }[];
}
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
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)
- preview() of empty
notes/dir → all zeros - preview() of single new note → newCount=1
- run() inserts new note + tags + media
- run() with id collision + same body → status='skipped'
- run() with id collision + different body → forked, new id
- 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:
- 구출한 메모 보기
- 기억 구출하기
- ─
- 지금 백업
- 내보내기...
- 백업에서 복원...
- (packaged: 자동 실행 + sep | dev: sep)
- 종료
index.ts
Add ImportService import + instantiate after ExportService:
const importSvc = new ImportService(repo, store);
5th callback (between existing 4th and closing paren):
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.