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

@@ -4,7 +4,7 @@ import { blogArticles, bookWorks } from '#lib/server/db/schema/index.js';
import { SITE_ORIGIN } from '#lib/seo.js';
import { races } from '#lib/derby/races.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';
const staticPaths = [
@@ -64,7 +64,7 @@ export const GET: RequestHandler = async () => {
.select({ id: bookWorks.id, updatedAt: bookWorks.updatedAt })
.from(bookWorks)
// 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 }[] = [
...staticPaths.map((path) => ({ path })),

View File

@@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit';
import { isOwner, requireOwner } from '#lib/server/owner.js';
import { libraryCards, libraryStats } from '#lib/server/books/library.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 { isReadingStatus } from '#lib/books/shared.js';
import type { Actions, PageServerLoad } from './$types';
@@ -15,7 +16,34 @@ export const load: PageServerLoad = async (event) => {
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 = {
// 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.
addLink: async (event) => {
requireOwner(event);

View File

@@ -5,6 +5,7 @@
import { resolve } from '$app/paths';
import { page } from '$app/state';
import BookCover from '#lib/books/BookCover.svelte';
import WorkActions from '#lib/books/WorkActions.svelte';
import Stars from '#lib/books/Stars.svelte';
import {
authorLine,
@@ -32,6 +33,7 @@
import ArrowUpRight from 'lucide-svelte/icons/arrow-up-right';
import NotebookPen from 'lucide-svelte/icons/notebook-pen';
import LoaderCircle from 'lucide-svelte/icons/loader-circle';
import Archive from 'lucide-svelte/icons/archive';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
@@ -43,10 +45,13 @@
const kindIcons = { book: BookOpen, paper: FlaskConical, article: Newspaper };
const kindCounts = $derived.by(() => {
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;
});
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) {
if (next === kind) return;
kind = next;
@@ -86,7 +91,7 @@
};
let query = $state('');
let status = $state<ReadingStatus | 'all' | 'none'>('all');
let status = $state<ReadingStatus | 'all' | 'none' | 'archived'>('all');
let subject = $state('');
let format = $state('');
let sort = $state<Sort>(initialKind === 'article' ? 'added' : 'recent');
@@ -135,9 +140,10 @@
const filtered = $derived.by(() => {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
return pool
return (status === 'archived' ? archivedPool : pool)
.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 (format === 'placeholder' ? b.hasFile : format && !b.formats.includes(format))
return false;
@@ -402,6 +408,17 @@
class={[status === 'none' && 'active']}>unsorted <span>{counts.none}</span></button
>
{/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>
{#if filtering}
@@ -412,7 +429,7 @@
</p>
{/if}
{#if !pool.length}
{#if !pool.length && status !== 'archived'}
<div class="empty">
{#if kind === 'article'}
<Newspaper size={28} aria-hidden="true" />
@@ -431,7 +448,7 @@
<ul class="articles">
{#each filtered as b (b.id)}
<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)}
>
<div class="thumb" aria-hidden="true">
@@ -474,6 +491,14 @@
<a href={bookHref(b)} class="details" title="Status and notes"
><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>
</li>
@@ -482,7 +507,7 @@
{:else if view === 'grid'}
<ul class="grid">
{#each filtered as b (b.id)}
<li>
<li class={['has-actions', b.archived && 'is-archived']}>
<a
href={bookHref(b)}
class="card"
@@ -512,13 +537,16 @@
>{/if}
</span>
</a>
{#if data.owner}<span class="card-actions"
><WorkActions id={b.id} title={b.title} archived={b.archived} label={b.kind} /></span
>{/if}
</li>
{/each}
</ul>
{:else}
<ul class="list">
{#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">
<span class="w-10 shrink-0"
><BookCover
@@ -550,6 +578,9 @@
<span class="badge static" title="No file yet"><FileQuestion size={12} /></span>
{/if}
</a>
{#if data.owner}<span class="row-actions"
><WorkActions id={b.id} title={b.title} archived={b.archived} label={b.kind} /></span
>{/if}
</li>
{/each}
</ul>
@@ -557,6 +588,60 @@
</div>
<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 {
display: inline-flex;
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 { db } from '#lib/server/db/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 { isBookKind, isReadingStatus } from '#lib/books/shared.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';
export const load: PageServerLoad = async (event) => {
const id = Number(event.params.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 { work, progress } = found;
if (work.archivedAt && !owner) error(404, 'Not Found');
const book = {
id: work.id,
kind: work.kind,
@@ -63,16 +77,19 @@ export const load: PageServerLoad = async (event) => {
if (!owner) return { book, reading, owner: false as const, seo };
event.setHeaders({ 'cache-control': 'private, no-store' });
const [files, notes, sessions] = await Promise.all([
const [files, notes, sessions, candidates] = await Promise.all([
workFiles(id),
workNotes(id),
workSessions(id)
workSessions(id),
mergeCandidates(work)
]);
return {
book,
reading,
owner: true as const,
seo,
archived: work.archivedAt?.toISOString() ?? null,
candidates,
files: files.map(({ id, format, sizeBytes, readable, path }) => ({
id,
format,
@@ -97,6 +114,35 @@ const date = (value: FormDataEntryValue | null) => {
};
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) => {
requireOwner(event);
const kind = (await event.request.formData()).get('kind');

View File

@@ -24,6 +24,8 @@
import Pencil from 'lucide-svelte/icons/pencil';
import FileQuestion from 'lucide-svelte/icons/file-question';
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 type { SubmitFunction } from '@sveltejs/kit';
import type { PageProps } from './$types';
@@ -116,11 +118,28 @@
</script>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<a
href={backHref}
class="mb-8 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
>
<div class="mb-8 flex items-center justify-between gap-4">
<a
href={backHref}
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">
<aside class="side">
@@ -593,6 +612,17 @@
);
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 {
position: absolute;
inset: 0;

View File

@@ -18,6 +18,20 @@ vi.mock('#lib/server/books/library.js', () => ({
libraryCards: 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) => ({
...(await original<typeof import('#lib/server/books/notes.js')>()),
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 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 createNote } from './[id=integer]/notes/+server';
import {
@@ -104,9 +120,36 @@ describe.each([
expect(
await status(() => action(event(user, { method: 'POST', body: new FormData() })))
).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(notes.createNote).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 () => {
const data = (await detailLoad(event(user))) as Record<string, unknown>;
@@ -151,6 +194,56 @@ describe('owner', () => {
expect(data).toHaveProperty('notes');
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 () => {
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).
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 { bookWorks } from '#lib/server/db/schema/index.js';
import { safeResolve, streamFile } from '#lib/server/books/files.js';
@@ -10,7 +11,12 @@ export const GET: RequestHandler = async (event) => {
const [work] = await db
.select({ coverPath: bookWorks.coverPath })
.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);
const resolved = work?.coverPath ? await safeResolve('covers', work.coverPath) : null;
if (!resolved) error(404, 'Not Found');