reading: owner archive / restore / delete / merge for books (⋯ menu on cards, rows and detail pages), archived filter, tombstones so merged ids redirect and the ingest never recreates deleted works, books-quarantine.py for deleted files
All checks were successful
Deploy production / deploy (push) Successful in 1m11s

This commit is contained in:
2026-10-25 14:22:40 +00:00
parent 8a8ed1642e
commit 27987fb8e7
17 changed files with 27812 additions and 28 deletions

View File

@@ -71,6 +71,9 @@ export const bookWorks = booksSchema.table(
url: text('url'),
siteName: text('site_name'),
publishedAt: timestamptz('published_at'),
// Archived works stay in the database (and keep their files, notes and progress) but are
// hidden from everyone except the owner, who can restore them.
archivedAt: timestamptz('archived_at'),
...timestamps
},
(t) => [
@@ -78,6 +81,7 @@ export const bookWorks = booksSchema.table(
.on(t.url)
.where(sql`${t.url} is not null`),
index('works_kind_idx').on(t.kind),
index('works_archived_at_idx').on(t.archivedAt),
uniqueIndex('works_isbn13_key')
.on(t.isbn13)
.where(sql`${t.isbn13} is not null`),
@@ -222,3 +226,24 @@ export type BookNote = typeof bookNotes.$inferSelect;
export type BookSession = typeof bookSessions.$inferSelect;
export type BookKind = (typeof bookKind.enumValues)[number];
export type BookReadingStatus = (typeof bookReadingStatus.enumValues)[number];
// Tombstones for hard-deleted (or merged-away) works. Keeps what the ingest matches on so it
// never recreates them, the file paths left on disk (for books-quarantine.py), and, for a
// merge, the surviving work so the old URL can redirect there. `id` is the old works.id.
export const bookDeletedWorks = booksSchema.table(
'deleted_works',
{
id: integer('id').primaryKey(),
title: text('title').notNull(),
authors: text('authors').array().notNull().default([]),
isbn13: text('isbn13'),
annasMd5: text('annas_md5').array().notNull().default([]),
fileSha256: text('file_sha256').array().notNull().default([]),
filePaths: text('file_paths').array().notNull().default([]),
sourceRefs: jsonb('source_refs').$type<Record<string, unknown>>().notNull().default({}),
mergedInto: integer('merged_into').references(() => bookWorks.id, { onDelete: 'set null' }),
deletedAt: timestamptz('deleted_at').notNull().defaultNow(),
quarantinedAt: timestamptz('quarantined_at') // files moved to $BOOKS_ROOT/quarantine/deleted
},
(t) => [index('deleted_works_merged_into_idx').on(t.mergedInto)]
);