Files
inkling/src/main/services/ImportService.ts

148 lines
4.4 KiB
TypeScript

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