docs(plan): F6-L3 import 구현 계획 (4 tasks)

This commit is contained in:
altair823
2026-04-26 10:50:01 +09:00
parent f4d78456ae
commit fe6bbd3104

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.