diff --git a/packages/core/src/entries.ts b/packages/core/src/entries.ts index ec9d8ab..1533a86 100644 --- a/packages/core/src/entries.ts +++ b/packages/core/src/entries.ts @@ -594,6 +594,28 @@ export async function quickAdd( // Reading: views and the library list // --------------------------------------------------------------------------------------------- +/** + * FROM/WHERE for this user's live entries on works below `e.work_id`, down to great-grandchildren + * (show → season → episode, series → volume → chapter). Portable: three fixed self-joins. + */ +const DESCENDANTS = sql`from entries c + inner join works cw on cw.id = c.work_id + left join works p1 on p1.id = cw.parent_id + left join works p2 on p2.id = p1.parent_id + where c.user_id = e.user_id and c.deleted_at is null + and (cw.parent_id = e.work_id or p1.parent_id = e.work_id or p2.parent_id = e.work_id)`; + +/** + * True when one of the work's ancestors (parent, grandparent, great-grandparent) carries a live entry + * of this user, i.e. the entry is rolled up under that ancestor's entry. + */ +const HAS_ENTRY_ANCESTOR = sql`exists (select 1 from works a1 + left join works a2 on a2.id = a1.parent_id + left join works a3 on a3.id = a2.parent_id + inner join entries pe on pe.user_id = e.user_id and pe.deleted_at is null + and (pe.work_id = a1.id or pe.work_id = a2.id or pe.work_id = a3.id) + where a1.id = w.parent_id)`; + function viewQuery(k: Executor, userId: string) { return k .selectFrom("entries as e") @@ -609,11 +631,9 @@ function viewQuery(k: Executor, userId: string) { sql`case when exists (select 1 from external_ids x where x.work_id = w.id) then 0 else 1 end`.as( "is_draft", ), - sql`(select count(*) from entries c inner join works cw on cw.id = c.work_id - where c.user_id = e.user_id and c.deleted_at is null and cw.parent_id = e.work_id)`.as("child_total"), - sql`(select count(*) from entries c inner join works cw on cw.id = c.work_id - where c.user_id = e.user_id and c.deleted_at is null and cw.parent_id = e.work_id - and c.status = 'completed')`.as("child_completed"), + sql`(select count(*) ${DESCENDANTS})`.as("child_total"), + sql`(select count(*) ${DESCENDANTS} and c.status = 'completed')`.as("child_completed"), + sql`lower(w.title)`.as("title_key"), ]) .where("e.user_id", "=", userId) .where("e.deleted_at", "is", null); @@ -629,6 +649,7 @@ type ViewRow = EntryRow & { is_draft: number | string; child_total: number | string; child_completed: number | string; + title_key: string; }; function toView(r: ViewRow): EntryView { @@ -664,7 +685,10 @@ export interface EntryFilter { tag?: string; draft?: boolean; q?: string; - /** Default `null`: top-level works only. A work id lists that work's children. */ + /** + * Default `null`: the top of the user's library, i.e. entries with no ancestor work (up to three + * levels) carrying a live entry of this user. A work id lists that work's direct children. + */ parentId?: string | null; sort?: "updated" | "title" | "rating"; limit?: number; @@ -674,7 +698,8 @@ export interface EntryFilter { const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`); /** - * The user's live entries, keyset-paginated. `cursor` is base64 JSON `{ sort, sortValue, id }`. + * The user's live entries, keyset-paginated. `cursor` is base64 JSON `{ sort, sortValue, id }`; for the + * title sort `sortValue` is the SQL `lower(w.title)` of the last row, so SQL and cursor always agree. * Sorts: `updated` (newest first), `title` (A–Z, case-insensitive), `rating` (highest first, unrated last). */ export async function listEntries( @@ -690,7 +715,7 @@ export async function listEntries( let q = viewQuery(db.k, userId); const parentId = filter.parentId === undefined ? null : filter.parentId; - q = parentId === null ? q.where("w.parent_id", "is", null) : q.where("w.parent_id", "=", parentId); + q = parentId === null ? q.where(sql`not ${HAS_ENTRY_ANCESTOR}`) : q.where("w.parent_id", "=", parentId); if (filter.mediaType) q = q.where(sql`coalesce(e.media_type_override, w.media_type)`, "=", filter.mediaType); if (filter.status) q = q.where("e.status", "=", filter.status); if (filter.tag) { @@ -743,7 +768,7 @@ export async function listEntries( let nextCursor: string | null = null; if (rows.length > take) { const last = page[page.length - 1]!; - const sortValue = sort === "updated" ? last.updated_at : sort === "title" ? last.w_title.toLowerCase() : last.rating; + const sortValue = sort === "updated" ? last.updated_at : sort === "title" ? last.title_key : last.rating; nextCursor = Buffer.from(JSON.stringify({ sort, sortValue, id: last.id })).toString("base64"); } return { items: page.map(toView), nextCursor }; diff --git a/packages/core/test/entries.test.ts b/packages/core/test/entries.test.ts index 2a547f0..55e429e 100644 --- a/packages/core/test/entries.test.ts +++ b/packages/core/test/entries.test.ts @@ -127,6 +127,56 @@ forEachDialect("entries", (getDb) => { const children = await listEntries(db, userId, { parentId: show }); expect(children.items).toHaveLength(3); expect(children.items.every((i) => i.work.mediaType === "episode")).toBe(true); + + // grandchildren: show → season (no entry) → episodes still roll up to the show + const show2 = await makeWork(db, { title: "The Expanse", mediaType: "show" }); + const show2Entry = await upsertEntry(db, userId, show2, {}, "user"); + const season = await makeWork(db, { title: "Season 1", mediaType: "season", parentId: show2 }); + for (const status of ["completed", "planned"] as const) { + const ep = await makeWork(db, { title: "Episode", mediaType: "episode", parentId: season }); + await upsertEntry(db, userId, ep, { status }, "user"); + } + const top2 = await listEntries(db, userId, {}); + expect(top2.items.map((i) => i.id).sort()).toEqual([showEntry.id, lone.id, show2Entry.id].sort()); + expect(top2.items.find((i) => i.id === show2Entry.id)!.childSummary).toEqual({ total: 2, completed: 1 }); + }); + + test("orphaned child entries are top level until an ancestor has an entry", async () => { + const db = getDb(); + const userId = await makeUser(db); + const show = await makeWork(db, { title: "Severance", mediaType: "show" }); + const season = await makeWork(db, { title: "Season 1", mediaType: "season", parentId: show }); + const ep = await makeWork(db, { title: "Good News About Hell", mediaType: "episode", parentId: season }); + const epEntry = await upsertEntry(db, userId, ep, { status: "completed" }, "user"); + expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]); + + // another user's show entry does not hide it + const bob = await makeUser(db, "bob"); + await upsertEntry(db, bob, show, {}, "user"); + expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]); + + const showEntry = await upsertEntry(db, userId, show, {}, "user"); + expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([showEntry.id]); + // a soft-deleted ancestor entry does not hide it + await softDeleteEntry(db, userId, showEntry.id, "user"); + expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]); + }); + + test("title pagination does not skip non-ASCII titles", async () => { + const db = getDb(); + const userId = await makeUser(db); + const titles = ["Éclair", "Ölund", "Zeta", "alpha", "日本", "éclair 2", "BETA"]; + for (const title of titles) await upsertEntry(db, userId, await makeWork(db, { title }), {}, "user"); + const seen: string[] = []; + let cursor: string | undefined; + for (let i = 0; i < 20; i++) { + const page = await listEntries(db, userId, { sort: "title", limit: 1, cursor }); + seen.push(...page.items.map((x) => x.work.title)); + if (!page.nextCursor) break; + cursor = page.nextCursor; + } + expect(seen).toHaveLength(titles.length); + expect([...seen].sort()).toEqual([...titles].sort()); }); test("listEntries filters, sorts and paginates", async () => {