148 lines
4.4 KiB
TypeScript
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));
|
|
}
|
|
}
|