mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
c239cfa2dd
Sort groups by reclaimable space, pre-pick a keeper with a rule that states its reasoning, and expand any row into a field-by-field comparison that shows only what differs. Persist "not duplicates" so a judgement survives a rescan, and review every copy before deleting. Reading progress no longer renders 0%.
163 lines
6.2 KiB
TypeScript
163 lines
6.2 KiB
TypeScript
import {
|
|
bigint,
|
|
check,
|
|
index,
|
|
integer,
|
|
pgTable,
|
|
primaryKey,
|
|
real,
|
|
serial,
|
|
smallint,
|
|
text,
|
|
timestamp,
|
|
uniqueIndex,
|
|
varchar,
|
|
} from 'drizzle-orm/pg-core';
|
|
import { sql } from 'drizzle-orm';
|
|
|
|
import { users } from './auth';
|
|
import { books } from './books';
|
|
|
|
export const bookDuplicateScans = pgTable(
|
|
'book_duplicate_scans',
|
|
{
|
|
id: serial('id').primaryKey(),
|
|
userId: integer('user_id')
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
libraryIds: integer('library_ids').array().notNull(),
|
|
requestedLibraryId: integer('requested_library_id'),
|
|
similarityPercent: integer('similarity_percent').notNull(),
|
|
status: varchar('status', { length: 20 }).notNull().default('queued'),
|
|
processedBooks: integer('processed_books').notNull().default(0),
|
|
totalBooks: integer('total_books'),
|
|
totalGroups: integer('total_groups'),
|
|
totalExtraCopies: integer('total_extra_copies'),
|
|
totalReclaimableBytes: bigint('total_reclaimable_bytes', { mode: 'number' }),
|
|
errorCode: varchar('error_code', { length: 50 }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
|
},
|
|
(t) => [
|
|
index('book_duplicate_scans_user_created_idx').on(t.userId, sql`${t.createdAt} desc`),
|
|
index('book_duplicate_scans_status_idx').on(t.status),
|
|
check('book_duplicate_scans_similarity_chk', sql`${t.similarityPercent} between 70 and 100`),
|
|
check('book_duplicate_scans_status_chk', sql`${t.status} in ('queued', 'running', 'completed', 'failed')`),
|
|
],
|
|
);
|
|
|
|
export const bookDuplicateScanKeys = pgTable(
|
|
'book_duplicate_scan_keys',
|
|
{
|
|
scanId: integer('scan_id')
|
|
.notNull()
|
|
.references(() => bookDuplicateScans.id, { onDelete: 'cascade' }),
|
|
bookId: integer('book_id')
|
|
.notNull()
|
|
.references(() => books.id, { onDelete: 'cascade' }),
|
|
kind: varchar('kind', { length: 20 }).notNull(),
|
|
value: text('value').notNull(),
|
|
},
|
|
(t) => [
|
|
primaryKey({ columns: [t.scanId, t.bookId, t.kind, t.value] }),
|
|
index('book_duplicate_scan_keys_lookup_idx').on(t.scanId, t.kind, t.value, t.bookId),
|
|
check('book_duplicate_scan_keys_kind_chk', sql`${t.kind} in ('file_hash', 'isbn', 'exact_metadata')`),
|
|
],
|
|
);
|
|
|
|
export const bookDuplicateGroups = pgTable(
|
|
'book_duplicate_groups',
|
|
{
|
|
id: serial('id').primaryKey(),
|
|
scanId: integer('scan_id')
|
|
.notNull()
|
|
.references(() => bookDuplicateScans.id, { onDelete: 'cascade' }),
|
|
rootBookId: integer('root_book_id')
|
|
.notNull()
|
|
.references(() => books.id, { onDelete: 'cascade' }),
|
|
reasons: text('reasons').array().notNull(),
|
|
maxTitleSimilarity: real('max_title_similarity'),
|
|
memberCount: integer('member_count').notNull(),
|
|
confidence: smallint('confidence').notNull().default(1),
|
|
memberBytesTotal: bigint('member_bytes_total', { mode: 'number' }).notNull().default(0),
|
|
memberBytesMax: bigint('member_bytes_max', { mode: 'number' }).notNull().default(0),
|
|
},
|
|
(t) => [
|
|
uniqueIndex('book_duplicate_groups_scan_root_uidx').on(t.scanId, t.rootBookId),
|
|
index('book_duplicate_groups_scan_id_idx').on(t.scanId, t.id),
|
|
index('book_duplicate_groups_scan_member_count_idx').on(t.scanId, t.memberCount),
|
|
index('book_duplicate_groups_scan_reclaimable_idx').on(t.scanId, sql`(${t.memberBytesTotal} - ${t.memberBytesMax}) desc`),
|
|
index('book_duplicate_groups_scan_confidence_idx').on(t.scanId, t.confidence),
|
|
check('book_duplicate_groups_confidence_chk', sql`${t.confidence} between 1 and 4`),
|
|
],
|
|
);
|
|
|
|
/**
|
|
* A pair the user has judged not to be duplicates. Stored per user and outside any scan so the
|
|
* judgement survives a rescan; the scan deletes matching pairs before groups are formed.
|
|
*/
|
|
export const bookDuplicateDismissals = pgTable(
|
|
'book_duplicate_dismissals',
|
|
{
|
|
userId: integer('user_id')
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
bookIdA: integer('book_id_a')
|
|
.notNull()
|
|
.references(() => books.id, { onDelete: 'cascade' }),
|
|
bookIdB: integer('book_id_b')
|
|
.notNull()
|
|
.references(() => books.id, { onDelete: 'cascade' }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(t) => [
|
|
primaryKey({ columns: [t.userId, t.bookIdA, t.bookIdB] }),
|
|
index('book_duplicate_dismissals_user_created_idx').on(t.userId, sql`${t.createdAt} desc`),
|
|
check('book_duplicate_dismissals_order_chk', sql`${t.bookIdA} < ${t.bookIdB}`),
|
|
],
|
|
);
|
|
|
|
export const bookDuplicatePairs = pgTable(
|
|
'book_duplicate_pairs',
|
|
{
|
|
scanId: integer('scan_id')
|
|
.notNull()
|
|
.references(() => bookDuplicateScans.id, { onDelete: 'cascade' }),
|
|
groupId: integer('group_id').references(() => bookDuplicateGroups.id, { onDelete: 'cascade' }),
|
|
bookIdA: integer('book_id_a')
|
|
.notNull()
|
|
.references(() => books.id, { onDelete: 'cascade' }),
|
|
bookIdB: integer('book_id_b')
|
|
.notNull()
|
|
.references(() => books.id, { onDelete: 'cascade' }),
|
|
reasons: text('reasons').array().notNull(),
|
|
titleSimilarity: real('title_similarity'),
|
|
},
|
|
(t) => [
|
|
primaryKey({ columns: [t.scanId, t.bookIdA, t.bookIdB] }),
|
|
index('book_duplicate_pairs_group_idx').on(t.groupId),
|
|
index('book_duplicate_pairs_scan_a_idx').on(t.scanId, t.bookIdA),
|
|
index('book_duplicate_pairs_scan_b_idx').on(t.scanId, t.bookIdB),
|
|
check('book_duplicate_pairs_order_chk', sql`${t.bookIdA} < ${t.bookIdB}`),
|
|
],
|
|
);
|
|
|
|
export const bookDuplicateGroupMembers = pgTable(
|
|
'book_duplicate_group_members',
|
|
{
|
|
groupId: integer('group_id')
|
|
.notNull()
|
|
.references(() => bookDuplicateGroups.id, { onDelete: 'cascade' }),
|
|
scanId: integer('scan_id')
|
|
.notNull()
|
|
.references(() => bookDuplicateScans.id, { onDelete: 'cascade' }),
|
|
bookId: integer('book_id')
|
|
.notNull()
|
|
.references(() => books.id, { onDelete: 'cascade' }),
|
|
},
|
|
(t) => [primaryKey({ columns: [t.scanId, t.bookId] }), index('book_duplicate_group_members_group_idx').on(t.groupId, t.bookId)],
|
|
);
|
|
|
|
export type BookDuplicateScanRow = typeof bookDuplicateScans.$inferSelect;
|
|
export type BookDuplicateDismissalRow = typeof bookDuplicateDismissals.$inferSelect;
|