feat(import): ImportService with conflict policy + media copy
Three-state outcome per note: inserted (new id), skipped (id+rawText
match), forked (id match but rawText differs → new uuidv7 to preserve
raw_text invariant from slice §1.1). Media files copied into
MediaStore convention <profileDir>/media/{noteId}/{n}.{ext} with
new media DB rows.
NoteRepository.importNote handles full provenance: ai_status='done',
ai_provider, ai_generated_at, edited flags, intent fields, tags
with source preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
146
src/main/services/ImportService.ts
Normal file
146
src/main/services/ImportService.ts
Normal file
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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<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;
|
||||
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 <profileDir>/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<string[]> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user