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,28 @@
CREATE TABLE "books"."deleted_works" (
"id" integer PRIMARY KEY,
"title" text NOT NULL,
"authors" text[] DEFAULT '{}'::text[] NOT NULL,
"isbn13" text,
"annas_md5" text[] DEFAULT '{}'::text[] NOT NULL,
"file_sha256" text[] DEFAULT '{}'::text[] NOT NULL,
"file_paths" text[] DEFAULT '{}'::text[] NOT NULL,
"source_refs" jsonb DEFAULT '{}' NOT NULL,
"merged_into" integer,
"deleted_at" timestamp with time zone DEFAULT now() NOT NULL,
"quarantined_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "books"."works" ADD COLUMN "archived_at" timestamp with time zone;--> statement-breakpoint
CREATE INDEX "deleted_works_merged_into_idx" ON "books"."deleted_works" ("merged_into");--> statement-breakpoint
CREATE INDEX "works_archived_at_idx" ON "books"."works" ("archived_at");--> statement-breakpoint
ALTER TABLE "books"."deleted_works" ADD CONSTRAINT "deleted_works_merged_into_works_id_fkey" FOREIGN KEY ("merged_into") REFERENCES "books"."works"("id") ON DELETE SET NULL;--> statement-breakpoint
-- Hand-written: explicit grants (default privileges from the books migration cover new tables too).
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON books.deleted_works TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT SELECT ON books.deleted_works TO personal_ro;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -244,6 +244,7 @@ class Work:
shelves: set = field(default_factory=set) shelves: set = field(default_factory=set)
origins: set = field(default_factory=set) origins: set = field(default_factory=set)
db_id: int | None = None db_id: int | None = None
existing_id: int | None = None # rerun: attach new files to this live work
def add_ref(self, k, v): def add_ref(self, k, v):
self.refs.setdefault(k, []) self.refs.setdefault(k, [])
@@ -844,6 +845,54 @@ def main():
used.add(rel) used.add(rel)
fr.dest_rel = rel fr.dest_rel = rel
# ---- 5b. reruns: never recreate what is already there, archived, deleted or merged away.
# Works that match a live row (lists id, md5, ISBN-13 or file hash) only contribute new
# files to it; works that match a tombstone (books.deleted_works) are dropped entirely.
skipped_existing = skipped_deleted = 0
if not args.reset:
live, dead = {}, {}
with conn.cursor() as cur:
cur.execute("""select w.id, w.isbn13, w.annas_md5, coalesce(w.source_refs->'lists_books_id', '[]'::jsonb),
coalesce((select array_agg(f.sha256) from books.files f where f.work_id = w.id), '{}')
from books.works w""")
rows = [(live, r) for r in cur.fetchall()]
cur.execute("select to_regclass('books.deleted_works')")
if cur.fetchone()[0] is not None:
cur.execute("""select id, isbn13, annas_md5, coalesce(source_refs->'lists_books_id', '[]'::jsonb), file_sha256
from books.deleted_works""")
rows += [(dead, r) for r in cur.fetchall()]
for index, (wid, i13, md5s, lists_ids, shas) in rows:
keys = [("isbn", i13)] if i13 else []
keys += [("md5", m) for m in md5s or []] + [("sha", h) for h in shas or []]
keys += [("lists", int(x)) for x in (lists_ids if isinstance(lists_ids, list) else [lists_ids])]
for k in keys:
index.setdefault(k, wid)
def keys_of(w):
ks = [("lists", int(x)) for x in w.refs.get("lists_books_id", [])]
ks += [("md5", m) for m in w.md5s] + [("sha", f.sha256) for f in w.files]
i13 = next((i for i in w.isbns if i.startswith("978") or i.startswith("979")), None)
return ks + ([("isbn", i13)] if i13 else [])
kept = []
for w in works:
ks = keys_of(w)
gone = next((dead[k] for k in ks if k in dead and k not in live), None)
if gone is not None:
log(f"skip (deleted/merged #{gone}): {w.title}" + (f" — {len(w.files)} file(s) left in inbox" if w.files else ""))
skipped_deleted += 1
continue
hit = next((live[k] for k in ks if k in live), None)
if hit is not None:
skipped_existing += 1
if w.files:
w.existing_id = hit
kept.append(w)
continue
kept.append(w)
works = kept
log(f"reruns: {skipped_existing} already in the library, {skipped_deleted} deleted or merged away")
plan = [{"title": w.title, "authors": w.authors, "isbns": w.isbns, "status": w.status, "origins": sorted(w.origins), plan = [{"title": w.title, "authors": w.authors, "isbns": w.isbns, "status": w.status, "origins": sorted(w.origins),
"files": [{"from": f.rel_inbox, "to": f.dest_rel, "sha256": f.sha256} for f in w.files], "refs": w.refs, "files": [{"from": f.rel_inbox, "to": f.dest_rel, "sha256": f.sha256} for f in w.files], "refs": w.refs,
"cover": w.cover_url} for w in works] "cover": w.cover_url} for w in works]
@@ -866,6 +915,12 @@ def main():
cur.execute("select slug, id from books.shelves") cur.execute("select slug, id from books.shelves")
shelves = dict(cur.fetchall()) shelves = dict(cur.fetchall())
for w in works: for w in works:
if w.existing_id:
w.db_id = w.existing_id
for fr in w.files:
cur.execute("insert into books.files (work_id, path, format, size_bytes, sha256, md5, source) values (%s,%s,%s,%s,%s,%s,%s) on conflict do nothing",
(w.db_id, fr.dest_rel, fr.fmt, fr.size, fr.sha256, fr.md5, args.source))
continue
i13 = isbn13(w) i13 = isbn13(w)
if i13 in seen13: if i13 in seen13:
w.refs["isbn13_shared_with_other_work"] = i13 w.refs["isbn13_shared_with_other_work"] = i13
@@ -899,7 +954,7 @@ def main():
got = 0 got = 0
if not args.no_network: if not args.no_network:
for w in works: for w in works:
if not w.cover_url: if not w.cover_url or w.existing_id:
continue continue
dest = covers / (hashlib.sha1(w.cover_url.encode()).hexdigest()[:16] + ".jpg") dest = covers / (hashlib.sha1(w.cover_url.encode()).hexdigest()[:16] + ".jpg")
if dest.exists() or http.download(w.cover_url, dest): if dest.exists() or http.download(w.cover_url, dest):
@@ -942,6 +997,7 @@ def main():
"obsidian_overlapping_lists": obs_overlap, "placeholders": n_placeholders, "obsidian_overlapping_lists": obs_overlap, "placeholders": n_placeholders,
"files_total": len(files), "files_unique": len(uniq), "sha256_duplicates": len(dups), "files_total": len(files), "files_unique": len(uniq), "sha256_duplicates": len(dups),
"placeholders_with_files": matched_existing, "works_total": len(works), "placeholders_with_files": matched_existing, "works_total": len(works),
"skipped_existing": skipped_existing, "skipped_deleted": skipped_deleted,
"works_with_files": sum(1 for w in works if w.files), "metadata": stats, "covers": got, "works_with_files": sum(1 for w in works if w.files), "metadata": stats, "covers": got,
"papers": sum(1 for w in works if "paper" in w.tags), "http_calls": http.calls, "manifest": str(manifest), "papers": sum(1 for w in works if "paper" in w.tags), "http_calls": http.calls, "manifest": str(manifest),
} }

61
scripts/books-quarantine.py Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Move the files of deleted books to quarantine.
Deleting a book on the site removes its rows but leaves the files in place (the app sees the
library through a read-only mount). Each delete leaves a tombstone in books.deleted_works with
the file paths; this moves those files to $BOOKS_ROOT/quarantine/deleted/<work id>/<path> and
stamps quarantined_at. Files that another work still references are left alone.
Usage (dry run by default):
DB_URL=postgresql://... BOOKS_ROOT=~/books python scripts/books-quarantine.py [--apply]
Undo: move the files back from quarantine/deleted/<id>/ into library/.
"""
from __future__ import annotations
import argparse, os, shutil, sys
from pathlib import Path
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="actually move files (default: dry run)")
args = ap.parse_args()
db_url = os.environ.get("DB_URL") or sys.exit("DB_URL required")
root = Path(os.path.expanduser(os.environ.get("BOOKS_ROOT", "/srv/books")))
library, quarantine = root / "library", root / "quarantine" / "deleted"
import psycopg
with psycopg.connect(db_url) as conn, conn.cursor() as cur:
cur.execute("""select d.id, d.title, d.file_paths from books.deleted_works d
where d.quarantined_at is null and cardinality(d.file_paths) > 0 order by d.id""")
rows = cur.fetchall()
moved = kept = missing = 0
for wid, title, paths in rows:
for rel in paths:
src = (library / rel).resolve()
if not str(src).startswith(str(library.resolve()) + os.sep):
print(f"#{wid}: unsafe path, skipped: {rel}")
continue
cur.execute("select work_id from books.files where path = %s", (rel,))
if cur.fetchone():
kept += 1
print(f"#{wid}: still used by another work, kept: {rel}")
continue
if not src.exists():
missing += 1
continue
dest = quarantine / str(wid) / rel
print(f"#{wid} {title}: {rel} -> {dest.relative_to(root)}")
if args.apply:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), dest)
moved += 1
if args.apply:
cur.execute("update books.deleted_works set quarantined_at = now() where id = %s", (wid,))
if args.apply:
conn.commit()
print(f"{'moved' if args.apply else 'would move'} {moved}, kept {kept}, already gone {missing}")
if __name__ == "__main__":
main()

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): // Owner only (empty / false for everyone else):
formats: string[]; formats: string[];
hasFile: boolean; hasFile: boolean;
archived: boolean;
}; };
export type LibraryStats = { 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, bookSessions,
bookWorks bookWorks
} from '#lib/server/db/schema/index.js'; } 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 { import {
formatPreference, formatPreference,
isReadable, isReadable,
@@ -73,11 +73,14 @@ export async function libraryCards(owner: boolean): Promise<BookCard[]> {
rating: bookProgress.rating, rating: bookProgress.rating,
lastReadAt: bookProgress.lastReadAt, lastReadAt: bookProgress.lastReadAt,
finishedAt: bookProgress.finishedAt, finishedAt: bookProgress.finishedAt,
formats: formats.formats formats: formats.formats,
archivedAt: bookWorks.archivedAt
}) })
.from(bookWorks) .from(bookWorks)
.leftJoin(bookProgress, eq(bookProgress.workId, bookWorks.id)) .leftJoin(bookProgress, eq(bookProgress.workId, bookWorks.id))
.leftJoin(formats, eq(formats.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)); .orderBy(asc(bookWorks.title));
return rows.map((r) => ({ return rows.map((r) => ({
id: r.id, id: r.id,
@@ -100,7 +103,8 @@ export async function libraryCards(owner: boolean): Promise<BookCard[]> {
finishedAt: iso(r.finishedAt), finishedAt: iso(r.finishedAt),
addedAt: r.createdAt.toISOString(), addedAt: r.createdAt.toISOString(),
formats: owner ? (r.formats ?? []) : [], 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([ const [[counts], [pages]] = await Promise.all([
db db
.select({ .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`, finished: sql<number>`count(*) filter (where ${bookProgress.status} = 'finished' and ${bookProgress.finishedAt} >= ${yearStart})::int`,
reading: sql<number>`count(*) filter (where ${bookProgress.status} = 'reading')::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 db
.select({ pages: sql<number>`coalesce(sum(${bookSessions.pagesRead}), 0)::int` }) .select({ pages: sql<number>`coalesce(sum(${bookSessions.pagesRead}), 0)::int` })
.from(bookSessions) .from(bookSessions)
.innerJoin(
bookWorks,
and(eq(bookWorks.id, bookSessions.workId), isNull(bookWorks.archivedAt))
)
.where(gte(bookSessions.startedAt, yearStart)) .where(gte(bookSessions.startedAt, yearStart))
]); ]);
return { return {

View File

@@ -71,6 +71,9 @@ export const bookWorks = booksSchema.table(
url: text('url'), url: text('url'),
siteName: text('site_name'), siteName: text('site_name'),
publishedAt: timestamptz('published_at'), 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 ...timestamps
}, },
(t) => [ (t) => [
@@ -78,6 +81,7 @@ export const bookWorks = booksSchema.table(
.on(t.url) .on(t.url)
.where(sql`${t.url} is not null`), .where(sql`${t.url} is not null`),
index('works_kind_idx').on(t.kind), index('works_kind_idx').on(t.kind),
index('works_archived_at_idx').on(t.archivedAt),
uniqueIndex('works_isbn13_key') uniqueIndex('works_isbn13_key')
.on(t.isbn13) .on(t.isbn13)
.where(sql`${t.isbn13} is not null`), .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 BookSession = typeof bookSessions.$inferSelect;
export type BookKind = (typeof bookKind.enumValues)[number]; export type BookKind = (typeof bookKind.enumValues)[number];
export type BookReadingStatus = (typeof bookReadingStatus.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)]
);

View File

@@ -4,7 +4,7 @@ import { blogArticles, bookWorks } from '#lib/server/db/schema/index.js';
import { SITE_ORIGIN } from '#lib/seo.js'; import { SITE_ORIGIN } from '#lib/seo.js';
import { races } from '#lib/derby/races.js'; import { races } from '#lib/derby/races.js';
import { COURSES } from '#lib/language-learner/courses/index.js'; import { COURSES } from '#lib/language-learner/courses/index.js';
import { eq, ne } from 'drizzle-orm'; import { and, eq, isNull, ne } from 'drizzle-orm';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
const staticPaths = [ const staticPaths = [
@@ -64,7 +64,7 @@ export const GET: RequestHandler = async () => {
.select({ id: bookWorks.id, updatedAt: bookWorks.updatedAt }) .select({ id: bookWorks.id, updatedAt: bookWorks.updatedAt })
.from(bookWorks) .from(bookWorks)
// Articles are links to other sites; their pages here are thin. // Articles are links to other sites; their pages here are thin.
.where(ne(bookWorks.kind, 'article')) .where(and(ne(bookWorks.kind, 'article'), isNull(bookWorks.archivedAt)))
]); ]);
const entries: { path: string; lastmod?: string }[] = [ const entries: { path: string; lastmod?: string }[] = [
...staticPaths.map((path) => ({ path })), ...staticPaths.map((path) => ({ path })),

View File

@@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit';
import { isOwner, requireOwner } from '#lib/server/owner.js'; import { isOwner, requireOwner } from '#lib/server/owner.js';
import { libraryCards, libraryStats } from '#lib/server/books/library.js'; import { libraryCards, libraryStats } from '#lib/server/books/library.js';
import { addLink } from '#lib/server/books/links.js'; import { addLink } from '#lib/server/books/links.js';
import { archiveWork, deleteWork, restoreWork } from '#lib/server/books/admin.js';
import { cleanUrl } from '#lib/server/books/unfurl.js'; import { cleanUrl } from '#lib/server/books/unfurl.js';
import { isReadingStatus } from '#lib/books/shared.js'; import { isReadingStatus } from '#lib/books/shared.js';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@@ -15,7 +16,34 @@ export const load: PageServerLoad = async (event) => {
return { books, stats, owner }; return { books, stats, owner };
}; };
const workId = async (request: Request) => {
const id = Number((await request.formData()).get('id'));
return Number.isSafeInteger(id) && id > 0 ? id : null;
};
export const actions: Actions = { export const actions: Actions = {
// Owner row actions (the ⋯ menu on each card).
archive: async (event) => {
requireOwner(event);
const id = await workId(event.request);
if (!id) return fail(400, { actionError: 'No book' });
await archiveWork(id);
return { archived: id };
},
restore: async (event) => {
requireOwner(event);
const id = await workId(event.request);
if (!id) return fail(400, { actionError: 'No book' });
await restoreWork(id);
return { restored: id };
},
delete: async (event) => {
requireOwner(event);
const id = await workId(event.request);
if (!id) return fail(400, { actionError: 'No book' });
await deleteWork(id);
return { deleted: id };
},
// Owner: "+ add link". Fetches the page server-side for its title, description and image. // Owner: "+ add link". Fetches the page server-side for its title, description and image.
addLink: async (event) => { addLink: async (event) => {
requireOwner(event); requireOwner(event);

View File

@@ -5,6 +5,7 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state'; import { page } from '$app/state';
import BookCover from '#lib/books/BookCover.svelte'; import BookCover from '#lib/books/BookCover.svelte';
import WorkActions from '#lib/books/WorkActions.svelte';
import Stars from '#lib/books/Stars.svelte'; import Stars from '#lib/books/Stars.svelte';
import { import {
authorLine, authorLine,
@@ -32,6 +33,7 @@
import ArrowUpRight from 'lucide-svelte/icons/arrow-up-right'; import ArrowUpRight from 'lucide-svelte/icons/arrow-up-right';
import NotebookPen from 'lucide-svelte/icons/notebook-pen'; import NotebookPen from 'lucide-svelte/icons/notebook-pen';
import LoaderCircle from 'lucide-svelte/icons/loader-circle'; import LoaderCircle from 'lucide-svelte/icons/loader-circle';
import Archive from 'lucide-svelte/icons/archive';
import type { SubmitFunction } from '@sveltejs/kit'; import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -43,10 +45,13 @@
const kindIcons = { book: BookOpen, paper: FlaskConical, article: Newspaper }; const kindIcons = { book: BookOpen, paper: FlaskConical, article: Newspaper };
const kindCounts = $derived.by(() => { const kindCounts = $derived.by(() => {
const c: Record<BookKind, number> = { book: 0, paper: 0, article: 0 }; const c: Record<BookKind, number> = { book: 0, paper: 0, article: 0 };
for (const b of data.books) c[b.kind]++; for (const b of data.books) if (!b.archived) c[b.kind]++;
return c; return c;
}); });
const pool = $derived(data.books.filter((b) => b.kind === kind)); // Archived works only reach the page for the owner, and only show under the archived tab.
const kindPool = $derived(data.books.filter((b) => b.kind === kind));
const pool = $derived(kindPool.filter((b) => !b.archived));
const archivedPool = $derived(kindPool.filter((b) => b.archived));
function setKind(next: BookKind) { function setKind(next: BookKind) {
if (next === kind) return; if (next === kind) return;
kind = next; kind = next;
@@ -86,7 +91,7 @@
}; };
let query = $state(''); let query = $state('');
let status = $state<ReadingStatus | 'all' | 'none'>('all'); let status = $state<ReadingStatus | 'all' | 'none' | 'archived'>('all');
let subject = $state(''); let subject = $state('');
let format = $state(''); let format = $state('');
let sort = $state<Sort>(initialKind === 'article' ? 'added' : 'recent'); let sort = $state<Sort>(initialKind === 'article' ? 'added' : 'recent');
@@ -135,9 +140,10 @@
const filtered = $derived.by(() => { const filtered = $derived.by(() => {
const words = query.toLowerCase().split(/\s+/).filter(Boolean); const words = query.toLowerCase().split(/\s+/).filter(Boolean);
return pool return (status === 'archived' ? archivedPool : pool)
.filter((b) => { .filter((b) => {
if (status !== 'all' && (b.status ?? 'none') !== status) return false; if (status !== 'all' && status !== 'archived' && (b.status ?? 'none') !== status)
return false;
if (subject && !b.tags.includes(subject)) return false; if (subject && !b.tags.includes(subject)) return false;
if (format === 'placeholder' ? b.hasFile : format && !b.formats.includes(format)) if (format === 'placeholder' ? b.hasFile : format && !b.formats.includes(format))
return false; return false;
@@ -402,6 +408,17 @@
class={[status === 'none' && 'active']}>unsorted <span>{counts.none}</span></button class={[status === 'none' && 'active']}>unsorted <span>{counts.none}</span></button
> >
{/if} {/if}
{#if data.owner && archivedPool.length}
<button
type="button"
role="tab"
aria-selected={status === 'archived'}
onclick={() => (status = 'archived')}
class={['archived-tab', status === 'archived' && 'active']}
><Archive size={12} aria-hidden="true" /> archived
<span>{archivedPool.length}</span></button
>
{/if}
</div> </div>
{#if filtering} {#if filtering}
@@ -412,7 +429,7 @@
</p> </p>
{/if} {/if}
{#if !pool.length} {#if !pool.length && status !== 'archived'}
<div class="empty"> <div class="empty">
{#if kind === 'article'} {#if kind === 'article'}
<Newspaper size={28} aria-hidden="true" /> <Newspaper size={28} aria-hidden="true" />
@@ -431,7 +448,7 @@
<ul class="articles"> <ul class="articles">
{#each filtered as b (b.id)} {#each filtered as b (b.id)}
<li <li
class={['article', b.status === 'finished' && 'is-read']} class={['article', b.status === 'finished' && 'is-read', b.archived && 'is-archived']}
style:--hue={coverHue(b.site ?? b.title)} style:--hue={coverHue(b.site ?? b.title)}
> >
<div class="thumb" aria-hidden="true"> <div class="thumb" aria-hidden="true">
@@ -474,6 +491,14 @@
<a href={bookHref(b)} class="details" title="Status and notes" <a href={bookHref(b)} class="details" title="Status and notes"
><NotebookPen size={13} aria-hidden="true" /> {data.owner ? 'notes' : 'details'}</a ><NotebookPen size={13} aria-hidden="true" /> {data.owner ? 'notes' : 'details'}</a
> >
{#if data.owner}<span class="article-actions"
><WorkActions
id={b.id}
title={b.title}
archived={b.archived}
label="article"
/></span
>{/if}
</div> </div>
</div> </div>
</li> </li>
@@ -482,7 +507,7 @@
{:else if view === 'grid'} {:else if view === 'grid'}
<ul class="grid"> <ul class="grid">
{#each filtered as b (b.id)} {#each filtered as b (b.id)}
<li> <li class={['has-actions', b.archived && 'is-archived']}>
<a <a
href={bookHref(b)} href={bookHref(b)}
class="card" class="card"
@@ -512,13 +537,16 @@
>{/if} >{/if}
</span> </span>
</a> </a>
{#if data.owner}<span class="card-actions"
><WorkActions id={b.id} title={b.title} archived={b.archived} label={b.kind} /></span
>{/if}
</li> </li>
{/each} {/each}
</ul> </ul>
{:else} {:else}
<ul class="list"> <ul class="list">
{#each filtered as b (b.id)} {#each filtered as b (b.id)}
<li> <li class={['has-actions', data.owner && 'owner-row', b.archived && 'is-archived']}>
<a href={bookHref(b)} class="row"> <a href={bookHref(b)} class="row">
<span class="w-10 shrink-0" <span class="w-10 shrink-0"
><BookCover ><BookCover
@@ -550,6 +578,9 @@
<span class="badge static" title="No file yet"><FileQuestion size={12} /></span> <span class="badge static" title="No file yet"><FileQuestion size={12} /></span>
{/if} {/if}
</a> </a>
{#if data.owner}<span class="row-actions"
><WorkActions id={b.id} title={b.title} archived={b.archived} label={b.kind} /></span
>{/if}
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -557,6 +588,60 @@
</div> </div>
<style> <style>
.has-actions {
position: relative;
}
.card-actions {
position: absolute;
top: 0.4rem;
right: 0.4rem;
z-index: 2;
}
.row-actions {
position: absolute;
top: 50%;
right: 0.5rem;
z-index: 2;
translate: 0 -50%;
}
.owner-row :global(.row) {
padding-right: 3rem;
}
.article-actions {
position: relative;
z-index: 2;
}
@media (hover: hover) {
.card-actions,
.row-actions,
.article-actions {
opacity: 0;
transition: opacity 0.15s;
}
.has-actions:hover .card-actions,
.has-actions:focus-within .card-actions,
.has-actions:hover .row-actions,
.has-actions:focus-within .row-actions,
.article:hover .article-actions,
.article:focus-within .article-actions,
.card-actions:has(:global([data-state='open'])),
.row-actions:has(:global([data-state='open'])),
.article-actions:has(:global([data-state='open'])) {
opacity: 1;
}
}
.is-archived > :global(a),
.is-archived > :global(div),
.article.is-archived .thumb {
opacity: 0.55;
filter: grayscale(0.6);
}
.archived-tab {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-left: auto;
}
.kinds { .kinds {
display: inline-flex; display: inline-flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -1,4 +1,4 @@
import { error, fail } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js'; import { db } from '#lib/server/db/index.js';
import { bookSessions } from '#lib/server/db/schema/index.js'; import { bookSessions } from '#lib/server/db/schema/index.js';
@@ -17,14 +17,28 @@ import {
import { createNote, deleteNote, updateNote } from '#lib/server/books/notes.js'; import { createNote, deleteNote, updateNote } from '#lib/server/books/notes.js';
import { isBookKind, isReadingStatus } from '#lib/books/shared.js'; import { isBookKind, isReadingStatus } from '#lib/books/shared.js';
import { setKind } from '#lib/server/books/links.js'; import { setKind } from '#lib/server/books/links.js';
import {
archiveWork,
deleteWork,
mergeCandidates,
mergedInto,
mergeWork,
restoreWork
} from '#lib/server/books/admin.js';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
const id = Number(event.params.id); const id = Number(event.params.id);
const found = await getWork(id); const found = await getWork(id);
if (!found) error(404, 'Not Found'); if (!found) {
// Merged duplicates keep working links: send them to the work they were merged into.
const target = await mergedInto(id);
if (target) redirect(308, `/trackers/books/${target}${event.url.search}`);
error(404, 'Not Found');
}
const owner = isOwner(event); const owner = isOwner(event);
const { work, progress } = found; const { work, progress } = found;
if (work.archivedAt && !owner) error(404, 'Not Found');
const book = { const book = {
id: work.id, id: work.id,
kind: work.kind, kind: work.kind,
@@ -63,16 +77,19 @@ export const load: PageServerLoad = async (event) => {
if (!owner) return { book, reading, owner: false as const, seo }; if (!owner) return { book, reading, owner: false as const, seo };
event.setHeaders({ 'cache-control': 'private, no-store' }); event.setHeaders({ 'cache-control': 'private, no-store' });
const [files, notes, sessions] = await Promise.all([ const [files, notes, sessions, candidates] = await Promise.all([
workFiles(id), workFiles(id),
workNotes(id), workNotes(id),
workSessions(id) workSessions(id),
mergeCandidates(work)
]); ]);
return { return {
book, book,
reading, reading,
owner: true as const, owner: true as const,
seo, seo,
archived: work.archivedAt?.toISOString() ?? null,
candidates,
files: files.map(({ id, format, sizeBytes, readable, path }) => ({ files: files.map(({ id, format, sizeBytes, readable, path }) => ({
id, id,
format, format,
@@ -97,6 +114,35 @@ const date = (value: FormDataEntryValue | null) => {
}; };
export const actions: Actions = { export const actions: Actions = {
archive: async (event) => {
requireOwner(event);
await archiveWork(Number(event.params.id));
return { archived: true };
},
restore: async (event) => {
requireOwner(event);
await restoreWork(Number(event.params.id));
return { restored: true };
},
delete: async (event) => {
requireOwner(event);
const id = Number(event.params.id);
const found = await getWork(id);
const kind = found?.work.kind;
if (!(await deleteWork(id))) return fail(404, { message: 'Already gone' });
redirect(303, kind && kind !== 'book' ? `/trackers/books?kind=${kind}s` : '/trackers/books');
},
merge: async (event) => {
requireOwner(event);
const from = Number(event.params.id);
const raw = String((await event.request.formData()).get('into') ?? '');
const into = Number(raw.match(/(\d+)\/?$/)?.[1]);
if (!Number.isSafeInteger(into) || into === from)
return fail(400, { message: 'Pick another book to merge into' });
const result = await mergeWork(from, into);
if (!result) return fail(404, { message: 'That book does not exist' });
redirect(303, `/trackers/books/${into}`);
},
kind: async (event) => { kind: async (event) => {
requireOwner(event); requireOwner(event);
const kind = (await event.request.formData()).get('kind'); const kind = (await event.request.formData()).get('kind');

View File

@@ -24,6 +24,8 @@
import Pencil from 'lucide-svelte/icons/pencil'; import Pencil from 'lucide-svelte/icons/pencil';
import FileQuestion from 'lucide-svelte/icons/file-question'; import FileQuestion from 'lucide-svelte/icons/file-question';
import Lock from 'lucide-svelte/icons/lock'; import Lock from 'lucide-svelte/icons/lock';
import Archive from 'lucide-svelte/icons/archive';
import WorkActions from '#lib/books/WorkActions.svelte';
import ArrowUpRight from 'lucide-svelte/icons/arrow-up-right'; import ArrowUpRight from 'lucide-svelte/icons/arrow-up-right';
import type { SubmitFunction } from '@sveltejs/kit'; import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -116,11 +118,28 @@
</script> </script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14"> <div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a <div class="mb-8 flex items-center justify-between gap-4">
href={backHref} <a
class="mb-8 inline-flex items-center gap-1.5 text-sm text-[var(--site-muted)] hover:text-[var(--site-text)]" href={backHref}
><ArrowLeft size={14} /> all {kindTabs[book.kind].label}</a class="inline-flex items-center gap-1.5 text-sm text-[var(--site-muted)] hover:text-[var(--site-text)]"
> ><ArrowLeft size={14} /> all {kindTabs[book.kind].label}</a
>
{#if data.owner}
<WorkActions
id={book.id}
title={book.title}
archived={!!data.archived}
label={book.kind}
candidates={data.candidates}
/>
{/if}
</div>
{#if data.owner && data.archived}
<p class="archived-note" role="status">
<Archive size={14} aria-hidden="true" /> Archived: hidden from the public list and counts. Restore
it from the ⋯ menu.
</p>
{/if}
<div class="layout"> <div class="layout">
<aside class="side"> <aside class="side">
@@ -593,6 +612,17 @@
); );
box-shadow: 0 8px 24px -12px rgb(0 0 0 / 0.5); box-shadow: 0 8px 24px -12px rgb(0 0 0 / 0.5);
} }
.archived-note {
display: flex;
align-items: center;
gap: 0.5rem;
margin: -1rem 0 1.5rem;
padding: 0.6rem 0.85rem;
border: 1px dashed var(--site-border);
border-radius: 0.6rem;
font-size: 0.85rem;
color: var(--site-muted);
}
.article-letter { .article-letter {
position: absolute; position: absolute;
inset: 0; inset: 0;

View File

@@ -18,6 +18,20 @@ vi.mock('#lib/server/books/library.js', () => ({
libraryCards: vi.fn(async () => []), libraryCards: vi.fn(async () => []),
libraryStats: vi.fn(async () => ({})) libraryStats: vi.fn(async () => ({}))
})); }));
vi.mock('#lib/server/books/admin.js', () => ({
archiveWork: vi.fn(async () => true),
restoreWork: vi.fn(async () => true),
deleteWork: vi.fn(async () => true),
mergeWork: vi.fn(async () => ({
files: 1,
sessions: 0,
notes: 0,
progress: 'moved',
fields: []
})),
mergeCandidates: vi.fn(async () => []),
mergedInto: vi.fn(async () => null)
}));
vi.mock('#lib/server/books/notes.js', async (original) => ({ vi.mock('#lib/server/books/notes.js', async (original) => ({
...(await original<typeof import('#lib/server/books/notes.js')>()), ...(await original<typeof import('#lib/server/books/notes.js')>()),
createNote: vi.fn(async () => ({ id: 1 })), createNote: vi.fn(async () => ({ id: 1 })),
@@ -27,6 +41,8 @@ vi.mock('#lib/server/books/notes.js', async (original) => ({
import * as library from '#lib/server/books/library.js'; import * as library from '#lib/server/books/library.js';
import * as notes from '#lib/server/books/notes.js'; import * as notes from '#lib/server/books/notes.js';
import * as admin from '#lib/server/books/admin.js';
import { actions as listActions } from './+page.server';
import { POST as progress } from './[id=integer]/progress/+server'; import { POST as progress } from './[id=integer]/progress/+server';
import { POST as createNote } from './[id=integer]/notes/+server'; import { POST as createNote } from './[id=integer]/notes/+server';
import { import {
@@ -104,9 +120,36 @@ describe.each([
expect( expect(
await status(() => action(event(user, { method: 'POST', body: new FormData() }))) await status(() => action(event(user, { method: 'POST', body: new FormData() })))
).toBe(404); ).toBe(404);
for (const action of Object.values(listActions)) {
const body = new FormData();
body.set('id', '1');
expect(await status(() => action(event(user, { method: 'POST', body })))).toBe(404);
}
expect(library.saveProgress).not.toHaveBeenCalled(); expect(library.saveProgress).not.toHaveBeenCalled();
expect(notes.createNote).not.toHaveBeenCalled(); expect(notes.createNote).not.toHaveBeenCalled();
expect(library.setStatus).not.toHaveBeenCalled(); expect(library.setStatus).not.toHaveBeenCalled();
expect(admin.archiveWork).not.toHaveBeenCalled();
expect(admin.deleteWork).not.toHaveBeenCalled();
expect(admin.mergeWork).not.toHaveBeenCalled();
});
it('gets 404 for an archived book', async () => {
vi.mocked(library.getWork).mockResolvedValueOnce({
work: { ...work, archivedAt: new Date() },
progress: null
} as never);
expect(await status(() => detailLoad(event(user)))).toBe(404);
});
it('is redirected from a merged-away book to the one it was merged into', async () => {
vi.mocked(library.getWork).mockResolvedValueOnce(null);
vi.mocked(admin.mergedInto).mockResolvedValueOnce(289);
try {
await detailLoad(event(user));
expect.unreachable();
} catch (e) {
expect(e).toMatchObject({ status: 308, location: '/trackers/books/289' });
}
vi.mocked(library.getWork).mockResolvedValueOnce(null);
expect(await status(() => detailLoad(event(user)))).toBe(404);
}); });
it('sees the public catalogue page without files, notes or sessions', async () => { it('sees the public catalogue page without files, notes or sessions', async () => {
const data = (await detailLoad(event(user))) as Record<string, unknown>; const data = (await detailLoad(event(user))) as Record<string, unknown>;
@@ -151,6 +194,56 @@ describe('owner', () => {
expect(data).toHaveProperty('notes'); expect(data).toHaveProperty('notes');
expect(data).toHaveProperty('files'); expect(data).toHaveProperty('files');
}); });
it('sees an archived book with a flag, and merge suggestions', async () => {
vi.mocked(library.getWork).mockResolvedValueOnce({
work: { ...work, archivedAt: new Date('2026-10-25T14:00:00Z') },
progress: null
} as never);
const data = (await detailLoad(event(OWNER))) as Record<string, unknown>;
expect(data.archived).toBe('2026-10-25T14:00:00.000Z');
expect(data.candidates).toEqual([]);
});
it('archives, restores and deletes from the list by id', async () => {
const body = () => {
const f = new FormData();
f.set('id', '5');
return f;
};
await listActions.archive(event(OWNER, { method: 'POST', body: body() }));
await listActions.restore(event(OWNER, { method: 'POST', body: body() }));
await listActions.delete(event(OWNER, { method: 'POST', body: body() }));
expect(admin.archiveWork).toHaveBeenCalledWith(5);
expect(admin.restoreWork).toHaveBeenCalledWith(5);
expect(admin.deleteWork).toHaveBeenCalledWith(5);
expect(
await status(() => listActions.delete(event(OWNER, { method: 'POST', body: new FormData() })))
).toBe(200);
expect(admin.deleteWork).toHaveBeenCalledTimes(1);
});
it('merges into a picked work (id or link) and redirects there', async () => {
const body = new FormData();
body.set('into', 'https://stanrunge.dev/trackers/books/289');
try {
await actions.merge(event(OWNER, { method: 'POST', body }));
expect.unreachable();
} catch (e) {
expect(e).toMatchObject({ status: 303, location: '/trackers/books/289' });
}
expect(admin.mergeWork).toHaveBeenCalledWith(1, 289);
const self = new FormData();
self.set('into', '1');
const res = await actions.merge(event(OWNER, { method: 'POST', body: self }));
expect(res).toMatchObject({ status: 400 });
});
it('deletes from the detail page and goes back to the list', async () => {
try {
await actions.delete(event(OWNER, { method: 'POST', body: new FormData() }));
expect.unreachable();
} catch (e) {
expect(e).toMatchObject({ status: 303, location: '/trackers/books' });
}
expect(admin.deleteWork).toHaveBeenCalledWith(1);
});
it('gets 404 from the reader when the book has no readable file', async () => { it('gets 404 from the reader when the book has no readable file', async () => {
expect(await status(() => readerLoad(event(OWNER)))).toBe(404); expect(await status(() => readerLoad(event(OWNER)))).toBe(404);
}); });

View File

@@ -1,6 +1,7 @@
// Public cover images for the catalogue (covers only, never book files). // Public cover images for the catalogue (covers only, never book files).
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { eq } from 'drizzle-orm'; import { and, eq, isNull } from 'drizzle-orm';
import { isOwner } from '#lib/server/owner.js';
import { db } from '#lib/server/db/index.js'; import { db } from '#lib/server/db/index.js';
import { bookWorks } from '#lib/server/db/schema/index.js'; import { bookWorks } from '#lib/server/db/schema/index.js';
import { safeResolve, streamFile } from '#lib/server/books/files.js'; import { safeResolve, streamFile } from '#lib/server/books/files.js';
@@ -10,7 +11,12 @@ export const GET: RequestHandler = async (event) => {
const [work] = await db const [work] = await db
.select({ coverPath: bookWorks.coverPath }) .select({ coverPath: bookWorks.coverPath })
.from(bookWorks) .from(bookWorks)
.where(eq(bookWorks.id, Number(event.params.id))) .where(
and(
eq(bookWorks.id, Number(event.params.id)),
isOwner(event) ? undefined : isNull(bookWorks.archivedAt)
)
)
.limit(1); .limit(1);
const resolved = work?.coverPath ? await safeResolve('covers', work.coverPath) : null; const resolved = work?.coverPath ? await safeResolve('covers', work.coverPath) : null;
if (!resolved) error(404, 'Not Found'); if (!resolved) error(404, 'Not Found');