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:
@@ -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'`)
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
235
tests/unit/ImportService.test.ts
Normal file
235
tests/unit/ImportService.test.ts
Normal file
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user