import { sql } from 'drizzle-orm'; import { bigint, check, index, integer, jsonb, primaryKey, real, text, uniqueIndex } from 'drizzle-orm/pg-core'; import { booksSchema } from './schemas'; import { id, timestamps, timestamptz } from './helpers'; // Book tracker + reader. A work is one book (any edition we care about); a work can // exist without files (placeholder for a book Stan wants but doesn't have yet). // Files live on stan-vm under $BOOKS_ROOT/library (target /srv/books/library); covers under $BOOKS_ROOT/covers. export const bookMetadataStatus = booksSchema.enum('metadata_status', [ 'pending', // not looked up yet 'matched', // confident match (ISBN or exact title+author) 'low_confidence', // fuzzy match, needs a human look 'not_found', 'manual' // edited by hand; enrichment never overwrites it ]); export const bookWorks = booksSchema.table( 'works', { id: id(), title: text('title').notNull(), subtitle: text('subtitle'), authors: text('authors').array().notNull().default([]), isbn10: text('isbn10'), isbn13: text('isbn13'), publisher: text('publisher'), year: integer('year'), language: text('language'), // ISO 639-1 where known, e.g. 'en', 'nl' pageCount: integer('page_count'), description: text('description'), tags: text('tags').array().notNull().default([]), coverUrl: text('cover_url'), // remote source of the cover coverPath: text('cover_path'), // local file, relative to /srv/books/covers // External ids openlibraryWorkId: text('openlibrary_work_id'), // e.g. OL45883W openlibraryEditionId: text('openlibrary_edition_id'), // e.g. OL7353617M googleBooksId: text('google_books_id'), annasMd5: text('annas_md5').array().notNull().default([]), // Anna's Archive / libgen file md5s sourceRefs: jsonb('source_refs').$type>().notNull().default({}), metadataStatus: bookMetadataStatus('metadata_status').notNull().default('pending'), metadataSource: text('metadata_source'), // 'openlibrary' | 'googlebooks' | 'libgen' | 'manual' metadataFetchedAt: timestamptz('metadata_fetched_at'), notes: text('notes'), ...timestamps }, (t) => [ uniqueIndex('works_isbn13_key') .on(t.isbn13) .where(sql`${t.isbn13} is not null`), index('works_title_idx').on(sql`lower(${t.title})`), check('works_year_check', sql`${t.year} is null or ${t.year} between 0 and 2100`) ] ); export const bookFiles = booksSchema.table( 'files', { id: id(), workId: integer('work_id') .notNull() .references(() => bookWorks.id, { onDelete: 'cascade' }), path: text('path').notNull(), // relative to /srv/books/library format: text('format').notNull(), // epub | pdf | mobi | azw3 | djvu sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(), sha256: text('sha256').notNull(), md5: text('md5'), source: text('source'), // 'gdrive' | 'textbooks-repo' | 'annas' | 'upload' ...timestamps }, (t) => [ uniqueIndex('files_path_key').on(t.path), uniqueIndex('files_sha256_key').on(t.sha256), index('files_work_id_idx').on(t.workId) ] ); export const bookShelves = booksSchema.table( 'shelves', { id: id(), slug: text('slug').notNull(), name: text('name').notNull(), sortOrder: integer('sort_order').notNull().default(0), ...timestamps }, (t) => [uniqueIndex('shelves_slug_key').on(t.slug)] ); export const bookShelfWorks = booksSchema.table( 'shelf_works', { shelfId: integer('shelf_id') .notNull() .references(() => bookShelves.id, { onDelete: 'cascade' }), workId: integer('work_id') .notNull() .references(() => bookWorks.id, { onDelete: 'cascade' }), sortOrder: integer('sort_order').notNull().default(0), addedAt: timestamptz('added_at').notNull().defaultNow() }, (t) => [ primaryKey({ columns: [t.shelfId, t.workId] }), index('shelf_works_work_id_idx').on(t.workId) ] ); export const bookProgress = booksSchema.table( 'progress', { id: id(), workId: integer('work_id') .notNull() .references(() => bookWorks.id, { onDelete: 'cascade' }), fileId: integer('file_id').references(() => bookFiles.id, { onDelete: 'set null' }), locator: text('locator'), // EPUB CFI or 'page:123' for PDFs page: integer('page'), percent: real('percent').notNull().default(0), startedAt: timestamptz('started_at'), finishedAt: timestamptz('finished_at'), ...timestamps }, (t) => [ uniqueIndex('progress_work_id_key').on(t.workId), check('progress_percent_check', sql`${t.percent} between 0 and 100`) ] ); export const bookNotes = booksSchema.table( 'notes', { id: id(), workId: integer('work_id') .notNull() .references(() => bookWorks.id, { onDelete: 'cascade' }), fileId: integer('file_id').references(() => bookFiles.id, { onDelete: 'set null' }), locator: text('locator'), page: integer('page'), highlight: text('highlight'), // the selected text, if any note: text('note'), color: text('color'), ...timestamps }, (t) => [index('notes_work_id_idx').on(t.workId)] ); export type BookWork = typeof bookWorks.$inferSelect; export type NewBookWork = typeof bookWorks.$inferInsert; export type BookFile = typeof bookFiles.$inferSelect; export type BookShelf = typeof bookShelves.$inferSelect; export type BookProgress = typeof bookProgress.$inferSelect; export type BookNote = typeof bookNotes.$inferSelect;