From eca91a1e7cb20f77b362c3bc6d90a577320b47ea Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Fri, 15 May 2026 15:06:44 +0900
Subject: [PATCH] =?UTF-8?q?feat(notebook):=20m009=20sort=5Forder=20?=
=?UTF-8?q?=EC=BB=AC=EB=9F=BC=20+=20reorder=20=EB=A9=94=EC=84=9C=EB=93=9C?=
=?UTF-8?q?=20+=20IPC=20notebook:reorder?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- m009 마이그레이션: notebooks.sort_order INTEGER 컬럼 추가, 기존 rows created_at 순으로 backfill
- NotebookRepository.list ORDER BY sort_order ASC, name ASC 로 변경
- NotebookRepository.create 신규 노트북 sort_order = max+1 자동 할당
- NotebookRepository.reorder(id, direction) — swap transaction 으로 atomic 순서 변경
- IPC notebook:reorder 핸들러 등록, preload/shared types pass-through
- 테스트 45개 추가 (m009, reorder 케이스 4, list ORDER BY, IPC 핸들러 2)
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/main/db/migrations/index.ts | 3 +-
src/main/db/migrations/m009_notebook_order.ts | 17 +++++++
src/main/ipc/notebookApi.ts | 2 +
src/main/repository/NotebookRepository.ts | 25 +++++++++--
src/preload/index.ts | 3 +-
src/shared/types.ts | 1 +
tests/unit/NotebookRepository.test.ts | 45 +++++++++++++++++++
tests/unit/m009.test.ts | 28 ++++++++++++
tests/unit/migrations.test.ts | 4 +-
tests/unit/notebookApi.test.ts | 22 ++++++++-
10 files changed, 142 insertions(+), 8 deletions(-)
create mode 100644 src/main/db/migrations/m009_notebook_order.ts
create mode 100644 tests/unit/m009.test.ts
diff --git a/src/main/db/migrations/index.ts b/src/main/db/migrations/index.ts
index bfa01e2..6c14a1b 100644
--- a/src/main/db/migrations/index.ts
+++ b/src/main/db/migrations/index.ts
@@ -7,8 +7,9 @@ import * as m005 from './m005_ai_disabled.js';
import * as m006 from './m006_revisions.js';
import * as m007 from './m007_fts.js';
import * as m008 from './m008_notebooks.js';
+import * as m009 from './m009_notebook_order.js';
-const migrations = [m001, m002, m003, m004, m005, m006, m007, m008];
+const migrations = [m001, m002, m003, m004, m005, m006, m007, m008, m009];
export function latestVersion(): number {
return migrations[migrations.length - 1]!.version;
diff --git a/src/main/db/migrations/m009_notebook_order.ts b/src/main/db/migrations/m009_notebook_order.ts
new file mode 100644
index 0000000..ff3abfd
--- /dev/null
+++ b/src/main/db/migrations/m009_notebook_order.ts
@@ -0,0 +1,17 @@
+// v9: notebooks.sort_order 컬럼 추가 + 기존 notebooks 를 created_at 순서로 backfill.
+// NotebookList 사이드바 의 순서 변경 (T2/T3) 의 schema 기반.
+import type Database from 'better-sqlite3';
+
+export const version = 9;
+
+export function up(db: Database.Database): void {
+ db.exec(`
+ ALTER TABLE notebooks ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
+ CREATE INDEX idx_notebooks_sort_order ON notebooks(sort_order, created_at);
+ `);
+
+ // 기존 notebooks 를 created_at 순서로 0,1,2,... 채움
+ const rows = db.prepare(`SELECT id FROM notebooks ORDER BY created_at ASC, id ASC`).all() as Array<{ id: string }>;
+ const update = db.prepare(`UPDATE notebooks SET sort_order = ? WHERE id = ?`);
+ rows.forEach((r, idx) => update.run(idx, r.id));
+}
diff --git a/src/main/ipc/notebookApi.ts b/src/main/ipc/notebookApi.ts
index ff9c86c..fe77aaa 100644
--- a/src/main/ipc/notebookApi.ts
+++ b/src/main/ipc/notebookApi.ts
@@ -48,4 +48,6 @@ export function registerNotebookApi(deps: NotebookIpcDeps): void {
deps.repo.moveNote(noteId, notebookId);
return { ok: true as const };
});
+
+ ipcMain.handle('notebook:reorder', (_e, id: string, direction: 'up' | 'down') => deps.repo.reorder(id, direction));
}
diff --git a/src/main/repository/NotebookRepository.ts b/src/main/repository/NotebookRepository.ts
index eba56cd..d02e670 100644
--- a/src/main/repository/NotebookRepository.ts
+++ b/src/main/repository/NotebookRepository.ts
@@ -11,7 +11,7 @@ export class NotebookRepository {
(SELECT COUNT(*) FROM notes n
WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count
FROM notebooks nb
- ORDER BY nb.name ASC`
+ ORDER BY nb.sort_order ASC, nb.name ASC`
).all() as Array>;
return rows.map((r) => this.hydrate(r));
}
@@ -30,12 +30,31 @@ export class NotebookRepository {
create(input: { name: string; color?: string | null }): Notebook {
const id = uuidv7();
const now = new Date().toISOString();
+ const maxRow = this.db.prepare(`SELECT COALESCE(MAX(sort_order), -1) AS m FROM notebooks`).get() as { m: number };
+ const sortOrder = maxRow.m + 1;
this.db.prepare(
- `INSERT INTO notebooks(id, name, color, created_at, updated_at) VALUES(?,?,?,?,?)`
- ).run(id, input.name, input.color ?? null, now, now);
+ `INSERT INTO notebooks(id, name, color, created_at, updated_at, sort_order) VALUES(?,?,?,?,?,?)`
+ ).run(id, input.name, input.color ?? null, now, now, sortOrder);
return { id, name: input.name, color: input.color ?? null, createdAt: now, updatedAt: now, noteCount: 0 };
}
+ reorder(id: string, direction: 'up' | 'down'): { ok: boolean } {
+ const cur = this.db.prepare(`SELECT sort_order FROM notebooks WHERE id = ?`).get(id) as { sort_order: number } | undefined;
+ if (!cur) return { ok: false };
+ const neighbor = direction === 'up'
+ ? this.db.prepare(`SELECT id, sort_order FROM notebooks WHERE sort_order < ? ORDER BY sort_order DESC LIMIT 1`).get(cur.sort_order)
+ : this.db.prepare(`SELECT id, sort_order FROM notebooks WHERE sort_order > ? ORDER BY sort_order ASC LIMIT 1`).get(cur.sort_order);
+ if (!neighbor) return { ok: false }; // 이미 끝
+ const n = neighbor as { id: string; sort_order: number };
+ const now = new Date().toISOString();
+ const tx = this.db.transaction(() => {
+ this.db.prepare(`UPDATE notebooks SET sort_order = ?, updated_at = ? WHERE id = ?`).run(n.sort_order, now, id);
+ this.db.prepare(`UPDATE notebooks SET sort_order = ?, updated_at = ? WHERE id = ?`).run(cur.sort_order, now, n.id);
+ });
+ tx();
+ return { ok: true };
+ }
+
rename(id: string, name: string): void {
const now = new Date().toISOString();
this.db.prepare(`UPDATE notebooks SET name=?, updated_at=? WHERE id=?`).run(name, now, id);
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 1ba7c6f..6d0055c 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -122,7 +122,8 @@ const api: InklingApi = {
rename: (id: string, name: string) => ipcRenderer.invoke('notebook:rename', id, name),
setColor: (id: string, color: string | null) => ipcRenderer.invoke('notebook:set-color', id, color),
delete: (id: string) => ipcRenderer.invoke('notebook:delete', id),
- moveNote: (noteId: string, notebookId: string) => ipcRenderer.invoke('notebook:move-note', noteId, notebookId)
+ moveNote: (noteId: string, notebookId: string) => ipcRenderer.invoke('notebook:move-note', noteId, notebookId),
+ reorder: (id: string, direction: 'up' | 'down') => ipcRenderer.invoke('notebook:reorder', id, direction)
}
};
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 6e50f9f..12acfcc 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -274,6 +274,7 @@ export interface NotebookApi {
setColor(id: string, color: string | null): Promise<{ ok: true }>;
delete(id: string): Promise<{ ok: true } | { ok: false; reason: 'has_notes' | 'not_found' }>;
moveNote(noteId: string, notebookId: string): Promise<{ ok: true }>;
+ reorder(id: string, direction: 'up' | 'down'): Promise<{ ok: boolean }>;
}
export interface InklingApi {
diff --git a/tests/unit/NotebookRepository.test.ts b/tests/unit/NotebookRepository.test.ts
index 2d83c92..80e12ee 100644
--- a/tests/unit/NotebookRepository.test.ts
+++ b/tests/unit/NotebookRepository.test.ts
@@ -112,4 +112,49 @@ describe('NotebookRepository', () => {
it('findByName: 없으면 null', () => {
expect(repo.findByName('없음')).toBeNull();
});
+
+ it('list: sort_order ASC 순서로 반환', () => {
+ const a = repo.create({ name: 'A' }); // sort_order = 1 (기본=0)
+ const b = repo.create({ name: 'B' }); // sort_order = 2
+ const all = repo.list();
+ expect(all[0]!.name).toBe('기본');
+ expect(all[1]!.id).toBe(a.id);
+ expect(all[2]!.id).toBe(b.id);
+ });
+
+ it('reorder: B.up → B/기본/A 순서로 swap', () => {
+ const a = repo.create({ name: 'A' }); // sort_order=1
+ const b = repo.create({ name: 'B' }); // sort_order=2
+ // 초기: 기본(0), A(1), B(2)
+ const r = repo.reorder(b.id, 'up');
+ expect(r.ok).toBe(true);
+ const names = repo.list().map((n) => n.name);
+ expect(names).toEqual(['기본', 'B', 'A']);
+ });
+
+ it('reorder: 첫 번째 notebook up → ok:false', () => {
+ const defaultId = repo.list()[0]!.id;
+ const r = repo.reorder(defaultId, 'up');
+ expect(r.ok).toBe(false);
+ });
+
+ it('reorder: 마지막 notebook down → ok:false', () => {
+ const c = repo.create({ name: 'C' }); // sort_order=1
+ const r = repo.reorder(c.id, 'down');
+ expect(r.ok).toBe(false);
+ });
+
+ it('reorder: B.down → 기본/A/C/B 순서', () => {
+ const a = repo.create({ name: 'A' }); // sort_order=1
+ const b = repo.create({ name: 'B' }); // sort_order=2
+ const c = repo.create({ name: 'C' }); // sort_order=3
+ // 초기: 기본(0), A(1), B(2), C(3)
+ const r = repo.reorder(b.id, 'down');
+ expect(r.ok).toBe(true);
+ const names = repo.list().map((n) => n.name);
+ expect(names).toEqual(['기본', 'A', 'C', 'B']);
+ // a, c 순서 안 변함 확인
+ expect(names.indexOf('A')).toBeLessThan(names.indexOf('C'));
+ void a; void c;
+ });
});
diff --git a/tests/unit/m009.test.ts b/tests/unit/m009.test.ts
new file mode 100644
index 0000000..d1507d9
--- /dev/null
+++ b/tests/unit/m009.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import Database from 'better-sqlite3';
+import { runMigrations } from '../../src/main/db/migrations/index.js';
+
+describe('m009 notebook sort_order migration', () => {
+ let db: Database.Database;
+ beforeEach(() => { db = new Database(':memory:'); db.pragma('foreign_keys = ON'); });
+ afterEach(() => { db.close(); });
+
+ it('fresh DB: default notebook 의 sort_order = 0', () => {
+ runMigrations(db);
+ const r = db.prepare(`SELECT sort_order FROM notebooks`).get() as { sort_order: number };
+ expect(r.sort_order).toBe(0);
+ });
+
+ it('새 notebook insert 시 DEFAULT 0 (caller 가 max+1 책임)', () => {
+ runMigrations(db);
+ db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES('nb-x','회사','t','t')`).run();
+ const r = db.prepare(`SELECT sort_order FROM notebooks WHERE id='nb-x'`).get() as { sort_order: number };
+ expect(r.sort_order).toBe(0);
+ });
+
+ it('user_version reaches 9 after all migrations', () => {
+ runMigrations(db);
+ const r = db.prepare('PRAGMA user_version').get() as { user_version: number };
+ expect(r.user_version).toBe(9);
+ });
+});
diff --git a/tests/unit/migrations.test.ts b/tests/unit/migrations.test.ts
index dbf4a0b..77a4dc4 100644
--- a/tests/unit/migrations.test.ts
+++ b/tests/unit/migrations.test.ts
@@ -51,11 +51,11 @@ describe('migration v3 — soft delete columns', () => {
db.close();
});
- it('user_version reaches latest (8)', () => {
+ it('user_version reaches latest (9)', () => {
const db = new Database(':memory:');
runMigrations(db);
const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
- expect(row.user_version).toBe(8);
+ expect(row.user_version).toBe(9);
db.close();
});
diff --git a/tests/unit/notebookApi.test.ts b/tests/unit/notebookApi.test.ts
index a3685b8..535c891 100644
--- a/tests/unit/notebookApi.test.ts
+++ b/tests/unit/notebookApi.test.ts
@@ -22,7 +22,8 @@ function makeRepo() {
setColor: vi.fn(),
delete: vi.fn(() => ({ ok: true })),
moveNote: vi.fn(),
- findById: vi.fn(() => null)
+ findById: vi.fn(() => null),
+ reorder: vi.fn(() => ({ ok: true }))
};
}
@@ -96,4 +97,23 @@ describe('notebookApi IPC', () => {
expect(repo.setColor).toHaveBeenCalledWith('id1', '#fff');
expect(r).toEqual({ ok: true });
});
+
+ it('notebook:reorder — repo.reorder 호출 + ok:true 전달', async () => {
+ const repo = makeRepo();
+ repo.reorder.mockReturnValue({ ok: true } as never);
+ registerNotebookApi({ repo: repo as never });
+ const h = getHandler('notebook:reorder');
+ const r = await h({}, 'nb-1', 'up');
+ expect(repo.reorder).toHaveBeenCalledWith('nb-1', 'up');
+ expect(r).toEqual({ ok: true });
+ });
+
+ it('notebook:reorder — 첫 번째 항목 up 시 ok:false 전달', async () => {
+ const repo = makeRepo();
+ repo.reorder.mockReturnValue({ ok: false } as never);
+ registerNotebookApi({ repo: repo as never });
+ const h = getHandler('notebook:reorder');
+ const r = await h({}, 'nb-first', 'up');
+ expect(r).toEqual({ ok: false });
+ });
});