diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index ed0c0a4..224fda0 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -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'`) diff --git a/src/main/services/ImportService.ts b/src/main/services/ImportService.ts new file mode 100644 index 0000000..5fcb935 --- /dev/null +++ b/src/main/services/ImportService.ts @@ -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; +} + +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 { + 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 { + const files = await this.scanNotes(sourceDir); + const finalNoteIds = new Map(); + 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 /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 { + 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)); + } +} diff --git a/tests/unit/ImportService.test.ts b/tests/unit/ImportService.test.ts new file mode 100644 index 0000000..a88cee0 --- /dev/null +++ b/tests/unit/ImportService.test.ts @@ -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 { + 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); + }); +});