diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 782bc86..6cdaa02 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -140,23 +140,15 @@ export class NoteRepository { return this.hydrate(row); } - list(opts: { limit: number; cursor?: string }): Note[] { + list(opts: { limit: number; cursor?: string; notebookId?: string }): Note[] { const limit = Math.max(1, Math.min(200, opts.limit)); - const rows = opts.cursor - ? (this.db - .prepare( - `SELECT * FROM notes - WHERE deleted_at IS NULL AND created_at < ? - ORDER BY created_at DESC, id DESC LIMIT ?` - ) - .all(opts.cursor, limit) as Record[]) - : (this.db - .prepare( - `SELECT * FROM notes - WHERE deleted_at IS NULL - ORDER BY created_at DESC, id DESC LIMIT ?` - ) - .all(limit) as Record[]); + const params: unknown[] = []; + let sql = `SELECT * FROM notes WHERE deleted_at IS NULL`; + if (opts.notebookId) { sql += ` AND notebook_id = ?`; params.push(opts.notebookId); } + if (opts.cursor) { sql += ` AND created_at < ?`; params.push(opts.cursor); } + sql += ` ORDER BY created_at DESC, id DESC LIMIT ?`; + params.push(limit); + const rows = this.db.prepare(sql).all(...params) as Record[]; return rows.map((r) => this.hydrate(r)); } @@ -678,48 +670,54 @@ export class NoteRepository { /** * v0.2.9 Cut B Task 4 — status 별 row count. 4탭 헤더 badge 용. * tags/media hydrate 없음 (cheap path, listByStatus 와 별도). + * v0.4 — notebookId 옵션 추가. 미지정 시 전체 notebook. */ - countByStatus(status: NoteStatus): number { - const row = this.db - .prepare(`SELECT COUNT(*) AS c FROM notes WHERE status = ?`) - .get(status) as { c: number }; - return row.c; + countByStatus(status: NoteStatus, opts: { notebookId?: string } = {}): number { + const params: unknown[] = [status]; + let sql = `SELECT COUNT(*) AS c FROM notes WHERE status = ?`; + if (opts.notebookId) { sql += ` AND notebook_id = ?`; params.push(opts.notebookId); } + const r = this.db.prepare(sql).get(...params) as { c: number }; + return r.c; } /** * v0.2.9 Cut B — status 별 노트 목록. status_changed_at DESC (최근 전이 우선), * NULL 은 created_at fallback. limit cap 200 (list/listTrashed 와 동일). + * v0.4 — notebookId 옵션 추가. 미지정 시 전체 notebook. */ - listByStatus(status: NoteStatus, opts: { limit?: number } = {}): Note[] { + listByStatus(status: NoteStatus, opts: { limit?: number; notebookId?: string } = {}): Note[] { const limit = Math.max(1, Math.min(200, opts.limit ?? 200)); - const rows = this.db - .prepare( - `SELECT * FROM notes - WHERE status = ? - ORDER BY COALESCE(status_changed_at, created_at) DESC, id DESC - LIMIT ?` - ) - .all(status, limit) as Record[]; + const params: unknown[] = [status]; + let sql = `SELECT * FROM notes WHERE status = ?`; + if (opts.notebookId) { sql += ` AND notebook_id = ?`; params.push(opts.notebookId); } + sql += ` ORDER BY COALESCE(status_changed_at, created_at) DESC, id DESC LIMIT ?`; + params.push(limit); + const rows = this.db.prepare(sql).all(...params) as Record[]; return rows.map((r) => this.hydrate(r)); } /** * v0.2.11 Cut D — FTS5 검색. notes_fts MATCH + rank 정렬 + 기본 trashed 제외. * 빈/공백 query → []. multi-token 은 implicit AND. FTS5 special chars 는 sanitize. + * v0.4 — notebookId 옵션 추가. 미지정 시 전체 notebook. */ - search(query: string, opts: { limit?: number; status?: NoteStatus } = {}): Note[] { + search(query: string, opts: { limit?: number; status?: NoteStatus; notebookId?: string } = {}): Note[] { const sanitized = sanitizeFtsQuery(query); if (sanitized.length === 0) return []; const limit = Math.max(1, Math.min(200, opts.limit ?? 50)); const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`; + const notebookClause = opts.notebookId ? `AND n.notebook_id = ?` : ``; const sql = ` SELECT n.* FROM notes n JOIN notes_fts f ON n.id = f.note_id - WHERE notes_fts MATCH ? ${statusClause} + WHERE notes_fts MATCH ? ${statusClause} ${notebookClause} ORDER BY rank LIMIT ? `; - const args: unknown[] = opts.status ? [sanitized, opts.status, limit] : [sanitized, limit]; + const args: unknown[] = [sanitized]; + if (opts.status) args.push(opts.status); + if (opts.notebookId) args.push(opts.notebookId); + args.push(limit); const rows = this.db.prepare(sql).all(...args) as Record[]; return rows.map((r) => this.hydrate(r)); } diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index f5289a6..f172096 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -1255,3 +1255,49 @@ describe('NoteRepository.create with notebook', () => { expect(r?.notebookId).toBe(defaultId); }); }); + +describe('NoteRepository.list / countByStatus with notebookId', () => { + let db: Database.Database; + let repo: NoteRepository; + let nbA: string, nbB: string; + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NoteRepository(db); + nbA = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id; + nbB = 'nb-b'; + db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES(?,?,?,?)`).run(nbB,'회사','2099-01-01','2099-01-01'); + }); + + it('list 가 notebookId 필터로 노트 분리', () => { + repo.create({ rawText: 'in-default' }); + repo.create({ rawText: 'in-B', notebookId: nbB }); + expect(repo.list({ limit: 10, notebookId: nbA }).map((n) => n.rawText)).toEqual(['in-default']); + expect(repo.list({ limit: 10, notebookId: nbB }).map((n) => n.rawText)).toEqual(['in-B']); + }); + + it('list 의 notebookId 미지정 시 모든 notebook 의 노트', () => { + repo.create({ rawText: 'in-default' }); + repo.create({ rawText: 'in-B', notebookId: nbB }); + expect(repo.list({ limit: 10 })).toHaveLength(2); + }); + + it('countByStatus(notebookId) — 각 notebook 의 active 갯수', () => { + repo.create({ rawText: 'a1' }); + repo.create({ rawText: 'a2' }); + repo.create({ rawText: 'b1', notebookId: nbB }); + expect(repo.countByStatus('active', { notebookId: nbA })).toBe(2); + expect(repo.countByStatus('active', { notebookId: nbB })).toBe(1); + expect(repo.countByStatus('active')).toBe(3); // 옵션 미지정 시 전체 + }); + + it('listByStatus(notebookId) — 같은 status 라도 notebook 별 분리', () => { + const { id: a1 } = repo.create({ rawText: 'a1' }); + const { id: b1 } = repo.create({ rawText: 'b1', notebookId: nbB }); + repo.setStatus(a1, 'completed', null); + repo.setStatus(b1, 'completed', null); + expect(repo.listByStatus('completed', { notebookId: nbA }).map((n) => n.id)).toEqual([a1]); + expect(repo.listByStatus('completed', { notebookId: nbB }).map((n) => n.id)).toEqual([b1]); + }); +});