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'`)
|
||||
|
||||
Reference in New Issue
Block a user