docs(plan): F6-L3 import 구현 계획 (4 tasks)
This commit is contained in:
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.
|
||||
Reference in New Issue
Block a user