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

@@ -0,0 +1,165 @@
<script lang="ts">
// Owner-only ⋯ menu for a work: archive / restore, merge into another work (detail page) and
// delete (with a confirm dialog). The server re-checks ownership on every action.
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
import * as AlertDialog from '#lib/components/ui/alert-dialog/index.js';
import * as Dialog from '#lib/components/ui/dialog/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import Ellipsis from 'lucide-svelte/icons/ellipsis';
import Archive from 'lucide-svelte/icons/archive';
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
import Merge from 'lucide-svelte/icons/merge';
import Trash2 from 'lucide-svelte/icons/trash-2';
type Candidate = { id: number; title: string; subtitle: string | null; authors: string[] };
let {
id,
title,
archived,
label = 'book',
candidates,
class: className = ''
}: {
id: number;
title: string;
archived: boolean;
label?: string;
candidates?: Candidate[];
class?: string;
} = $props();
let archiveForm = $state<HTMLFormElement>();
let confirmDelete = $state(false);
let mergeOpen = $state(false);
let busy = $state(false);
let into = $state('');
const submit: SubmitFunction = () => {
busy = true;
return async ({ update }) => {
await update();
busy = false;
confirmDelete = false;
mergeOpen = false;
};
};
</script>
<form
method="POST"
action={archived ? '?/restore' : '?/archive'}
use:enhance={submit}
bind:this={archiveForm}
hidden
>
<input type="hidden" name="id" value={id} />
</form>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class={['work-actions', className]}
aria-label="Actions for {title}"
disabled={busy}
>
<Ellipsis size={16} aria-hidden="true" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="min-w-44">
<DropdownMenu.Item onSelect={() => archiveForm?.requestSubmit()}>
{#if archived}<ArchiveRestore /> restore{:else}<Archive /> archive{/if}
</DropdownMenu.Item>
{#if candidates}
<DropdownMenu.Item onSelect={() => (mergeOpen = true)}
><Merge /> merge into…</DropdownMenu.Item
>
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onSelect={() => (confirmDelete = true)}
><Trash2 /> delete…</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
<AlertDialog.Root bind:open={confirmDelete}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete “{title}”?</AlertDialog.Title>
<AlertDialog.Description>
This removes the {label} with its progress, reading history, notes and highlights for good. Files
stay on disk and can be moved to quarantine. Archive it instead to just hide it.
</AlertDialog.Description>
</AlertDialog.Header>
<form method="POST" action="?/delete" use:enhance={submit}>
<input type="hidden" name="id" value={id} />
<AlertDialog.Footer>
<AlertDialog.Cancel type="button">Keep it</AlertDialog.Cancel>
<Button type="submit" variant="destructive" disabled={busy}>Delete</Button>
</AlertDialog.Footer>
</form>
</AlertDialog.Content>
</AlertDialog.Root>
{#if candidates}
<Dialog.Root bind:open={mergeOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Merge into another {label}</Dialog.Title>
<Dialog.Description>
Files, progress, notes, highlights and reading history move to the {label} you pick; empty details
there are filled in from this one. Then this one is removed and its link redirects.
</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/merge" use:enhance={submit} class="grid gap-3">
{#if candidates.length}
<NativeSelect name="into" bind:value={into} required class="w-full">
<option value="" disabled>Pick a likely duplicate…</option>
{#each candidates as c (c.id)}
<option value={String(c.id)}
>#{c.id} · {c.title}{c.subtitle ? `: ${c.subtitle}` : ''}{c.authors.length
? ` (${c.authors.join(', ')})`
: ''}</option
>
{/each}
</NativeSelect>
{:else}
<input
name="into"
bind:value={into}
required
inputmode="numeric"
placeholder="id or link, e.g. /trackers/books/289"
class="rounded-md border border-[var(--site-border)] bg-transparent px-3 py-2 text-sm"
/>
{/if}
<Dialog.Footer>
<Button type="submit" disabled={busy || !into}>Merge</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
{/if}
<style>
:global(.work-actions) {
display: inline-grid;
place-items: center;
width: 1.9rem;
height: 1.9rem;
border-radius: 999px;
color: var(--site-muted);
border: 1px solid var(--site-border);
background: color-mix(in oklab, var(--site-bg) 80%, transparent);
backdrop-filter: blur(6px);
transition:
color 0.15s,
background 0.15s,
opacity 0.15s;
}
:global(.work-actions:hover),
:global(.work-actions[data-state='open']) {
color: var(--site-text);
background: var(--site-hover);
}
</style>

View File

@@ -59,6 +59,7 @@ export type BookCard = {
// Owner only (empty / false for everyone else):
formats: string[];
hasFile: boolean;
archived: boolean;
};
export type LibraryStats = {

View File

@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('#lib/server/db/index.js', () => ({ db: {} }));
import { mergedFields, mergedProgress, mergeRefs } from './admin';
const base = {
id: 289,
kind: 'book',
title: 'PostgreSQL: Up and Running',
subtitle: '3rd Edition',
authors: ['Regina Obe', 'Leo Hsu'],
isbn10: null,
isbn13: '9781491963401',
publisher: "O'Reilly Media",
year: 2017,
language: 'en',
pageCount: null,
description: null,
tags: ['sql'],
coverUrl: null,
coverPath: 'a.jpg',
openlibraryWorkId: null,
openlibraryEditionId: null,
googleBooksId: null,
annasMd5: [],
sourceRefs: { origins: ['drive'], drive: ['x.epub'] },
metadataStatus: 'manual',
metadataSource: 'oreilly',
metadataFetchedAt: null,
notes: null,
url: null,
siteName: null,
publishedAt: null,
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date()
} as const;
describe('mergedFields', () => {
it('keeps the survivor and fills only its gaps', () => {
const into = { ...base, tags: [...base.tags], authors: [...base.authors], annasMd5: [] };
const from = {
...into,
id: 7,
title: 'PostgreSQL: Up & Running',
subtitle: null,
isbn13: '9780000000000',
pageCount: 312,
tags: ['sql', 'databases'],
annasMd5: ['abc'],
notes: 'old note',
sourceRefs: { origins: ['lists'], lists_books_id: [128] }
};
const set = mergedFields(into as never, from as never);
expect(set.title).toBeUndefined();
expect(set.isbn13).toBeUndefined();
expect(set.subtitle).toBeUndefined();
expect(set.pageCount).toBe(312);
expect(set.tags).toEqual(['sql', 'databases']);
expect(set.annasMd5).toEqual(['abc']);
expect(set.notes).toBe('old note');
expect(set.sourceRefs).toEqual({
origins: ['drive', 'lists'],
drive: ['x.epub'],
lists_books_id: [128],
merged_from: [7]
});
});
});
describe('mergeRefs', () => {
it('unions arrays, copies new keys and keeps conflicting scalars aside', () => {
expect(
mergeRefs({ a: 1, list: [{ x: 1 }] }, { a: 2, list: [{ x: 1 }, { x: 2 }], b: 3 }, 9)
).toEqual({
a: 1,
merged_a: 2,
list: [{ x: 1 }, { x: 2 }],
b: 3,
merged_from: [9]
});
});
});
describe('mergedProgress', () => {
const p = {
id: 1,
workId: 289,
fileId: 157,
locator: 'cfi',
page: null,
percent: 7,
startedAt: new Date('2026-10-24'),
finishedAt: null,
status: 'reading',
rating: null,
lastReadAt: new Date('2026-10-24'),
createdAt: new Date(),
updatedAt: new Date()
} as const;
it('keeps our position and takes the rating, earlier start and a finish', () => {
const set = mergedProgress(
p as never,
{
...p,
id: 2,
workId: 7,
fileId: null,
rating: 4,
startedAt: new Date('2025-01-01'),
status: 'finished',
finishedAt: new Date('2025-02-01')
} as never
);
expect(set).toEqual({
rating: 4,
startedAt: new Date('2025-01-01'),
status: 'finished',
finishedAt: new Date('2025-02-01'),
percent: 100
});
});
});

View File

@@ -0,0 +1,268 @@
// Owner tools for the reading tracker: archive / restore, hard delete and merge duplicates.
// Callers check ownership (requireOwner) before calling any of these.
//
// Deleting and merging leave a tombstone in books.deleted_works: the ingest skips anything it
// matches (lists id, md5, ISBN, file hash), a merged-away id redirects to the survivor, and the
// file paths are kept so scripts/books-quarantine.py can move the files (the app's view of the
// library is a read-only mount, so it never touches files itself).
import { and, eq, ilike, isNull, ne, or, sql } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import {
bookDeletedWorks,
bookFiles,
bookNotes,
bookProgress,
bookSessions,
bookShelfWorks,
bookWorks
} from '#lib/server/db/schema/index.js';
type Work = typeof bookWorks.$inferSelect;
type Progress = typeof bookProgress.$inferSelect;
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
export async function archiveWork(id: number) {
const rows = await db
.update(bookWorks)
.set({ archivedAt: new Date() })
.where(and(eq(bookWorks.id, id), isNull(bookWorks.archivedAt)))
.returning({ id: bookWorks.id });
return rows.length > 0;
}
export async function restoreWork(id: number) {
const rows = await db
.update(bookWorks)
.set({ archivedAt: null })
.where(eq(bookWorks.id, id))
.returning({ id: bookWorks.id });
return rows.length > 0;
}
/** Where a deleted id now lives (set when it was merged), or null. */
export async function mergedInto(id: number): Promise<number | null> {
const [row] = await db
.select({ mergedInto: bookDeletedWorks.mergedInto })
.from(bookDeletedWorks)
.where(eq(bookDeletedWorks.id, id))
.limit(1);
return row?.mergedInto ?? null;
}
async function tombstone(tx: Tx, work: Work, mergedIntoId: number | null) {
const files = await tx
.select({ path: bookFiles.path, sha256: bookFiles.sha256 })
.from(bookFiles)
.where(eq(bookFiles.workId, work.id));
await tx
.insert(bookDeletedWorks)
.values({
id: work.id,
title: work.title,
authors: work.authors,
isbn13: work.isbn13,
annasMd5: work.annasMd5,
fileSha256: files.map((f) => f.sha256),
filePaths: files.map((f) => f.path),
sourceRefs: { ...work.sourceRefs, ...(work.url ? { url: work.url } : {}) },
mergedInto: mergedIntoId
})
.onConflictDoNothing();
// Anything merged into this work earlier now points at the survivor (or nowhere).
await tx
.update(bookDeletedWorks)
.set({ mergedInto: mergedIntoId })
.where(eq(bookDeletedWorks.mergedInto, work.id));
}
/** Hard delete: removes the work with its files rows, progress, sessions, notes and shelves. */
export async function deleteWork(id: number) {
return db.transaction(async (tx) => {
const [work] = await tx.select().from(bookWorks).where(eq(bookWorks.id, id)).limit(1);
if (!work) return false;
await tombstone(tx, work, null);
await tx.delete(bookWorks).where(eq(bookWorks.id, id)); // dependents cascade
return true;
});
}
const fillable = [
'subtitle',
'isbn10',
'isbn13',
'publisher',
'year',
'language',
'pageCount',
'description',
'coverUrl',
'coverPath',
'openlibraryWorkId',
'openlibraryEditionId',
'googleBooksId',
'url',
'siteName',
'publishedAt'
] as const satisfies readonly (keyof Work)[];
const union = <T>(a: T[], b: T[]) => [...new Set([...a, ...b])];
/** Merges `from`'s source_refs into `into`'s: arrays are unioned, missing keys copied. */
export function mergeRefs(
into: Record<string, unknown>,
from: Record<string, unknown>,
fromId: number
) {
const out: Record<string, unknown> = { ...into };
for (const [k, v] of Object.entries(from)) {
const mine = out[k];
if (mine === undefined) out[k] = v;
else if (Array.isArray(mine) && Array.isArray(v))
out[k] = union(
mine.map((x) => JSON.stringify(x)),
v.map((x) => JSON.stringify(x))
).map((x) => JSON.parse(x));
else if (JSON.stringify(mine) !== JSON.stringify(v)) out[`merged_${k}`] = v; // keep the loser's value next to ours
}
out.merged_from = union((into.merged_from as number[] | undefined) ?? [], [fromId]);
return out;
}
/** The update for the surviving work: its own values win, gaps are filled from `from`. Pure. */
export function mergedFields(into: Work, from: Work): Partial<Work> {
const set: Partial<Work> = {};
for (const k of fillable) {
if ((into[k] === null || into[k] === '') && from[k] !== null && from[k] !== '')
(set as Record<string, unknown>)[k] = from[k];
}
if (!into.authors.length && from.authors.length) set.authors = from.authors;
const tags = union(into.tags, from.tags);
if (tags.length !== into.tags.length) set.tags = tags;
const md5 = union(into.annasMd5, from.annasMd5);
if (md5.length !== into.annasMd5.length) set.annasMd5 = md5;
if (from.notes?.trim() && from.notes.trim() !== into.notes?.trim())
set.notes = into.notes?.trim() ? `${into.notes.trim()}\n\n${from.notes.trim()}` : from.notes;
set.sourceRefs = mergeRefs(into.sourceRefs, from.sourceRefs, from.id);
if (into.archivedAt && !from.archivedAt) set.archivedAt = null;
return set;
}
/** The surviving progress row when both works have one: ours, with gaps filled. Pure. */
export function mergedProgress(into: Progress, from: Progress): Partial<Progress> {
const set: Partial<Progress> = {};
if (into.rating === null && from.rating !== null) set.rating = from.rating;
if (from.startedAt && (!into.startedAt || from.startedAt < into.startedAt))
set.startedAt = from.startedAt;
if (into.fileId === null && from.fileId !== null) {
set.fileId = from.fileId;
if (!into.locator) set.locator = from.locator;
}
if (from.lastReadAt && (!into.lastReadAt || from.lastReadAt > into.lastReadAt))
set.lastReadAt = from.lastReadAt;
if (from.status === 'finished' && into.status !== 'finished') {
set.status = 'finished';
set.finishedAt = from.finishedAt;
set.percent = 100;
}
return set;
}
export type MergeResult = {
files: number;
sessions: number;
notes: number;
progress: 'moved' | 'merged' | 'none';
fields: string[];
};
/** Moves everything from `fromId` onto `intoId`, then deletes `fromId` (tombstoned). */
export async function mergeWork(fromId: number, intoId: number): Promise<MergeResult | null> {
if (fromId === intoId) return null;
return db.transaction(async (tx) => {
const works = await tx
.select()
.from(bookWorks)
.where(or(eq(bookWorks.id, fromId), eq(bookWorks.id, intoId)))
.for('update');
const from = works.find((w) => w.id === fromId);
const into = works.find((w) => w.id === intoId);
if (!from || !into) return null;
const fields = mergedFields(into, from);
// Free the unique keys on the loser before giving them to the survivor.
await tx.update(bookWorks).set({ isbn13: null, url: null }).where(eq(bookWorks.id, fromId));
await tx.update(bookWorks).set(fields).where(eq(bookWorks.id, intoId));
const files = await tx
.update(bookFiles)
.set({ workId: intoId })
.where(eq(bookFiles.workId, fromId))
.returning({ id: bookFiles.id });
const sessions = await tx
.update(bookSessions)
.set({ workId: intoId })
.where(eq(bookSessions.workId, fromId))
.returning({ id: bookSessions.id });
const notes = await tx
.update(bookNotes)
.set({ workId: intoId })
.where(eq(bookNotes.workId, fromId))
.returning({ id: bookNotes.id });
const progress = await tx
.select()
.from(bookProgress)
.where(or(eq(bookProgress.workId, fromId), eq(bookProgress.workId, intoId)));
const pFrom = progress.find((p) => p.workId === fromId);
const pInto = progress.find((p) => p.workId === intoId);
let progressResult: MergeResult['progress'] = 'none';
if (pFrom && !pInto) {
await tx.update(bookProgress).set({ workId: intoId }).where(eq(bookProgress.id, pFrom.id));
progressResult = 'moved';
} else if (pFrom && pInto) {
const set = mergedProgress(pInto, pFrom);
if (Object.keys(set).length)
await tx.update(bookProgress).set(set).where(eq(bookProgress.id, pInto.id));
progressResult = 'merged';
}
await tx.execute(sql`
insert into ${bookShelfWorks} (shelf_id, work_id, sort_order, added_at)
select shelf_id, ${intoId}, sort_order, added_at from ${bookShelfWorks} where work_id = ${fromId}
on conflict do nothing`);
await tombstone(tx, { ...from, isbn13: from.isbn13, url: from.url }, intoId);
await tx.delete(bookWorks).where(eq(bookWorks.id, fromId));
return {
files: files.length,
sessions: sessions.length,
notes: notes.length,
progress: progressResult,
fields: Object.keys(fields).filter((k) => k !== 'sourceRefs')
};
});
}
/** Likely duplicates for the "merge into…" picker: same ISBN or a title starting the same way. */
export async function mergeCandidates(work: Pick<Work, 'id' | 'title' | 'isbn13'>) {
const word = work.title
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.find((w) => w.length >= 3);
const conditions = [
...(word ? [ilike(bookWorks.title, `%${word.replace(/[%_\\]/g, '')}%`)] : []),
...(work.isbn13 ? [eq(bookWorks.isbn13, work.isbn13)] : [])
];
if (!conditions.length) return [];
return db
.select({
id: bookWorks.id,
title: bookWorks.title,
subtitle: bookWorks.subtitle,
authors: bookWorks.authors
})
.from(bookWorks)
.where(and(ne(bookWorks.id, work.id), or(...conditions)))
.orderBy(bookWorks.title)
.limit(8);
}

View File

@@ -8,7 +8,7 @@ import {
bookSessions,
bookWorks
} from '#lib/server/db/schema/index.js';
import { and, asc, desc, eq, gte, sql } from 'drizzle-orm';
import { and, asc, desc, eq, gte, isNull, sql } from 'drizzle-orm';
import {
formatPreference,
isReadable,
@@ -73,11 +73,14 @@ export async function libraryCards(owner: boolean): Promise<BookCard[]> {
rating: bookProgress.rating,
lastReadAt: bookProgress.lastReadAt,
finishedAt: bookProgress.finishedAt,
formats: formats.formats
formats: formats.formats,
archivedAt: bookWorks.archivedAt
})
.from(bookWorks)
.leftJoin(bookProgress, eq(bookProgress.workId, bookWorks.id))
.leftJoin(formats, eq(formats.workId, bookWorks.id))
// Archived works are the owner's business only.
.where(owner ? undefined : isNull(bookWorks.archivedAt))
.orderBy(asc(bookWorks.title));
return rows.map((r) => ({
id: r.id,
@@ -100,7 +103,8 @@ export async function libraryCards(owner: boolean): Promise<BookCard[]> {
finishedAt: iso(r.finishedAt),
addedAt: r.createdAt.toISOString(),
formats: owner ? (r.formats ?? []) : [],
hasFile: owner ? (r.formats?.length ?? 0) > 0 : false
hasFile: owner ? (r.formats?.length ?? 0) > 0 : false,
archived: owner && r.archivedAt !== null
}));
}
@@ -110,14 +114,22 @@ export async function libraryStats(): Promise<LibraryStats> {
const [[counts], [pages]] = await Promise.all([
db
.select({
total: sql<number>`(select count(*)::int from ${bookWorks} where ${bookWorks.kind} <> 'article')`,
total: sql<number>`(select count(*)::int from ${bookWorks} where ${bookWorks.kind} <> 'article' and ${bookWorks.archivedAt} is null)`,
finished: sql<number>`count(*) filter (where ${bookProgress.status} = 'finished' and ${bookProgress.finishedAt} >= ${yearStart})::int`,
reading: sql<number>`count(*) filter (where ${bookProgress.status} = 'reading')::int`
})
.from(bookProgress),
.from(bookProgress)
.innerJoin(
bookWorks,
and(eq(bookWorks.id, bookProgress.workId), isNull(bookWorks.archivedAt))
),
db
.select({ pages: sql<number>`coalesce(sum(${bookSessions.pagesRead}), 0)::int` })
.from(bookSessions)
.innerJoin(
bookWorks,
and(eq(bookWorks.id, bookSessions.workId), isNull(bookWorks.archivedAt))
)
.where(gte(bookSessions.startedAt, yearStart))
]);
return {

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)]
);