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, deletedAt: parsed.deletedAt }; } 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)); } }