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
All checks were successful
Deploy production / deploy (push) Successful in 1m11s
This commit is contained in:
28
drizzle/20261025141516_books_archive/migration.sql
Normal file
28
drizzle/20261025141516_books_archive/migration.sql
Normal 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 $$;
|
||||||
26757
drizzle/20261025141516_books_archive/snapshot.json
Normal file
26757
drizzle/20261025141516_books_archive/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
61
scripts/books-quarantine.py
Executable 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()
|
||||||
165
src/lib/books/WorkActions.svelte
Normal file
165
src/lib/books/WorkActions.svelte
Normal 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>
|
||||||
@@ -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 = {
|
||||||
|
|||||||
123
src/lib/server/books/admin.test.ts
Normal file
123
src/lib/server/books/admin.test.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
268
src/lib/server/books/admin.ts
Normal file
268
src/lib/server/books/admin.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 })),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user