feat(notes): list/listByStatus/countByStatus/search 에 notebookId 옵션
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -140,23 +140,15 @@ export class NoteRepository {
|
|||||||
return this.hydrate(row);
|
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 limit = Math.max(1, Math.min(200, opts.limit));
|
||||||
const rows = opts.cursor
|
const params: unknown[] = [];
|
||||||
? (this.db
|
let sql = `SELECT * FROM notes WHERE deleted_at IS NULL`;
|
||||||
.prepare(
|
if (opts.notebookId) { sql += ` AND notebook_id = ?`; params.push(opts.notebookId); }
|
||||||
`SELECT * FROM notes
|
if (opts.cursor) { sql += ` AND created_at < ?`; params.push(opts.cursor); }
|
||||||
WHERE deleted_at IS NULL AND created_at < ?
|
sql += ` ORDER BY created_at DESC, id DESC LIMIT ?`;
|
||||||
ORDER BY created_at DESC, id DESC LIMIT ?`
|
params.push(limit);
|
||||||
)
|
const rows = this.db.prepare(sql).all(...params) as Record<string, unknown>[];
|
||||||
.all(opts.cursor, limit) as Record<string, unknown>[])
|
|
||||||
: (this.db
|
|
||||||
.prepare(
|
|
||||||
`SELECT * FROM notes
|
|
||||||
WHERE deleted_at IS NULL
|
|
||||||
ORDER BY created_at DESC, id DESC LIMIT ?`
|
|
||||||
)
|
|
||||||
.all(limit) as Record<string, unknown>[]);
|
|
||||||
return rows.map((r) => this.hydrate(r));
|
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 용.
|
* v0.2.9 Cut B Task 4 — status 별 row count. 4탭 헤더 badge 용.
|
||||||
* tags/media hydrate 없음 (cheap path, listByStatus 와 별도).
|
* tags/media hydrate 없음 (cheap path, listByStatus 와 별도).
|
||||||
|
* v0.4 — notebookId 옵션 추가. 미지정 시 전체 notebook.
|
||||||
*/
|
*/
|
||||||
countByStatus(status: NoteStatus): number {
|
countByStatus(status: NoteStatus, opts: { notebookId?: string } = {}): number {
|
||||||
const row = this.db
|
const params: unknown[] = [status];
|
||||||
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE status = ?`)
|
let sql = `SELECT COUNT(*) AS c FROM notes WHERE status = ?`;
|
||||||
.get(status) as { c: number };
|
if (opts.notebookId) { sql += ` AND notebook_id = ?`; params.push(opts.notebookId); }
|
||||||
return row.c;
|
const r = this.db.prepare(sql).get(...params) as { c: number };
|
||||||
|
return r.c;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v0.2.9 Cut B — status 별 노트 목록. status_changed_at DESC (최근 전이 우선),
|
* v0.2.9 Cut B — status 별 노트 목록. status_changed_at DESC (최근 전이 우선),
|
||||||
* NULL 은 created_at fallback. limit cap 200 (list/listTrashed 와 동일).
|
* 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 limit = Math.max(1, Math.min(200, opts.limit ?? 200));
|
||||||
const rows = this.db
|
const params: unknown[] = [status];
|
||||||
.prepare(
|
let sql = `SELECT * FROM notes WHERE status = ?`;
|
||||||
`SELECT * FROM notes
|
if (opts.notebookId) { sql += ` AND notebook_id = ?`; params.push(opts.notebookId); }
|
||||||
WHERE status = ?
|
sql += ` ORDER BY COALESCE(status_changed_at, created_at) DESC, id DESC LIMIT ?`;
|
||||||
ORDER BY COALESCE(status_changed_at, created_at) DESC, id DESC
|
params.push(limit);
|
||||||
LIMIT ?`
|
const rows = this.db.prepare(sql).all(...params) as Record<string, unknown>[];
|
||||||
)
|
|
||||||
.all(status, limit) as Record<string, unknown>[];
|
|
||||||
return rows.map((r) => this.hydrate(r));
|
return rows.map((r) => this.hydrate(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v0.2.11 Cut D — FTS5 검색. notes_fts MATCH + rank 정렬 + 기본 trashed 제외.
|
* v0.2.11 Cut D — FTS5 검색. notes_fts MATCH + rank 정렬 + 기본 trashed 제외.
|
||||||
* 빈/공백 query → []. multi-token 은 implicit AND. FTS5 special chars 는 sanitize.
|
* 빈/공백 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);
|
const sanitized = sanitizeFtsQuery(query);
|
||||||
if (sanitized.length === 0) return [];
|
if (sanitized.length === 0) return [];
|
||||||
const limit = Math.max(1, Math.min(200, opts.limit ?? 50));
|
const limit = Math.max(1, Math.min(200, opts.limit ?? 50));
|
||||||
const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`;
|
const statusClause = opts.status ? `AND n.status = ?` : `AND n.status != 'trashed'`;
|
||||||
|
const notebookClause = opts.notebookId ? `AND n.notebook_id = ?` : ``;
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT n.* FROM notes n
|
SELECT n.* FROM notes n
|
||||||
JOIN notes_fts f ON n.id = f.note_id
|
JOIN notes_fts f ON n.id = f.note_id
|
||||||
WHERE notes_fts MATCH ? ${statusClause}
|
WHERE notes_fts MATCH ? ${statusClause} ${notebookClause}
|
||||||
ORDER BY rank
|
ORDER BY rank
|
||||||
LIMIT ?
|
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<string, unknown>[];
|
const rows = this.db.prepare(sql).all(...args) as Record<string, unknown>[];
|
||||||
return rows.map((r) => this.hydrate(r));
|
return rows.map((r) => this.hydrate(r));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1255,3 +1255,49 @@ describe('NoteRepository.create with notebook', () => {
|
|||||||
expect(r?.notebookId).toBe(defaultId);
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user