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:
@@ -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 })),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user