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 }); + }); });