From a5f23b925e0a400931266f41775876000ca8bae9 Mon Sep 17 00:00:00 2001 From: altair823 Date: Fri, 1 May 2026 21:23:23 +0900 Subject: [PATCH] feat(trash): ImportService deletedAt preservation + skip-merge policy (#4 v0.2.3) --- src/main/repository/NoteRepository.ts | 17 +++++- src/main/services/ImportService.ts | 3 +- src/main/services/importFormat.ts | 2 + tests/unit/ImportService.test.ts | 78 +++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 44dff17..c6ec4f8 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -28,6 +28,7 @@ export interface ImportNoteInput { userIntent: string | null; intentPromptedAt: string | null; tags: { name: string; source: 'ai' | 'user' }[]; + deletedAt?: string | null; } export type ImportNoteStatus = 'inserted' | 'skipped' | 'forked'; @@ -294,6 +295,17 @@ export class NoteRepository { let status: ImportNoteStatus = 'inserted'; if (existing !== null) { if (existing === input.rawText) { + // skip — 단, source 가 deletedAt IS NOT NULL 이고 dest 가 NULL 이면 dest 갱신 (삭제 보존) + if (input.deletedAt) { + const destRow = this.db + .prepare('SELECT deleted_at FROM notes WHERE id=?') + .get(input.id) as { deleted_at: string | null } | undefined; + if (destRow && destRow.deleted_at === null) { + this.db + .prepare('UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ?') + .run(input.deletedAt, input.deletedAt, input.id); + } + } return { id: input.id, status: 'skipped' }; } finalId = uuidv7(); @@ -305,8 +317,8 @@ export class NoteRepository { `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', ?, ?, ?, ?, ?, ?, ?, ?)` + user_intent, intent_prompted_at, deleted_at, created_at, updated_at) + VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( finalId, @@ -319,6 +331,7 @@ export class NoteRepository { input.summaryEditedByUser ? 1 : 0, input.userIntent, input.intentPromptedAt, + input.deletedAt ?? null, input.createdAt, input.updatedAt ); diff --git a/src/main/services/ImportService.ts b/src/main/services/ImportService.ts index 5fcb935..93db353 100644 --- a/src/main/services/ImportService.ts +++ b/src/main/services/ImportService.ts @@ -39,7 +39,8 @@ function parsedToInput(parsed: ParsedNote): ImportNoteInput { aiGeneratedAt: parsed.aiGeneratedAt, userIntent: parsed.userIntent, intentPromptedAt: parsed.intentPromptedAt, - tags: parsed.tags + tags: parsed.tags, + deletedAt: parsed.deletedAt }; } diff --git a/src/main/services/importFormat.ts b/src/main/services/importFormat.ts index 2420e47..b64b7c4 100644 --- a/src/main/services/importFormat.ts +++ b/src/main/services/importFormat.ts @@ -33,6 +33,7 @@ export interface ParsedNote { aiGeneratedAt: string | null; userIntent: string | null; intentPromptedAt: string | null; + deletedAt: string | null; // 신규 v0.2.3 #4 tags: ParsedNoteTag[]; images: ParsedNoteImage[]; exportVersion: number; @@ -347,6 +348,7 @@ export function parseExportNote(markdown: string): ParsedNote { aiGeneratedAt: get('ai_generated_at'), userIntent: get('user_intent'), intentPromptedAt: get('intent_prompted_at'), + deletedAt: get('deleted_at'), tags: fm.tags, images: fm.images, exportVersion diff --git a/tests/unit/ImportService.test.ts b/tests/unit/ImportService.test.ts index a88cee0..f401669 100644 --- a/tests/unit/ImportService.test.ts +++ b/tests/unit/ImportService.test.ts @@ -233,3 +233,81 @@ describe('ImportService', () => { expect(dbNote!.media[0]!.bytes).toBe(7); }); }); + +describe('ImportService — deletedAt preservation (v0.2.3 #4)', () => { + it('id-collide skip: source deleted_at IS NOT NULL → dest deleted_at 갱신', () => { + const db = new Database(':memory:'); + runMigrations(db); + const repo = new NoteRepository(db); + const { id } = repo.create({ rawText: 'identical' }); + const r = repo.importNote({ + id, rawText: 'identical', + createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z', + aiTitle: null, aiSummary: null, + titleEditedByUser: false, summaryEditedByUser: false, + aiProvider: null, aiGeneratedAt: null, + userIntent: null, intentPromptedAt: null, + tags: [], + deletedAt: '2026-05-01T12:00:00.000Z' + }); + expect(r.status).toBe('skipped'); + expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); + }); + + it('id-collide skip: source deleted_at NULL + dest IS NOT NULL → dest 유지', () => { + const db = new Database(':memory:'); + runMigrations(db); + const repo = new NoteRepository(db); + const { id } = repo.create({ rawText: 'identical' }); + repo.trash(id, '2026-05-01T00:00:00.000Z'); + repo.importNote({ + id, rawText: 'identical', + createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z', + aiTitle: null, aiSummary: null, + titleEditedByUser: false, summaryEditedByUser: false, + aiProvider: null, aiGeneratedAt: null, + userIntent: null, intentPromptedAt: null, + tags: [], + deletedAt: null + }); + expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T00:00:00.000Z'); + }); + + it('id-new insert: source deletedAt 보존', () => { + const db = new Database(':memory:'); + runMigrations(db); + const repo = new NoteRepository(db); + const r = repo.importNote({ + id: 'fresh-id', rawText: 'fresh', + createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z', + aiTitle: null, aiSummary: null, + titleEditedByUser: false, summaryEditedByUser: false, + aiProvider: null, aiGeneratedAt: null, + userIntent: null, intentPromptedAt: null, + tags: [], + deletedAt: '2026-05-01T12:00:00.000Z' + }); + expect(r.status).toBe('inserted'); + expect(repo.findById('fresh-id')!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); + }); + + it('id-collide forked: deletedAt 도 fork 노트에 보존', () => { + const db = new Database(':memory:'); + runMigrations(db); + const repo = new NoteRepository(db); + const { id } = repo.create({ rawText: 'original' }); + const r = repo.importNote({ + id, rawText: 'different', + createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z', + aiTitle: null, aiSummary: null, + titleEditedByUser: false, summaryEditedByUser: false, + aiProvider: null, aiGeneratedAt: null, + userIntent: null, intentPromptedAt: null, + tags: [], + deletedAt: '2026-05-01T12:00:00.000Z' + }); + expect(r.status).toBe('forked'); + expect(r.id).not.toBe(id); + expect(repo.findById(r.id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); + }); +});