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:
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):
|
||||
formats: string[];
|
||||
hasFile: boolean;
|
||||
archived: boolean;
|
||||
};
|
||||
|
||||
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,
|
||||
bookWorks
|
||||
} from '#lib/server/db/schema/index.js';
|
||||
import { and, asc, desc, eq, gte, sql } from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, gte, isNull, sql } from 'drizzle-orm';
|
||||
import {
|
||||
formatPreference,
|
||||
isReadable,
|
||||
@@ -73,11 +73,14 @@ export async function libraryCards(owner: boolean): Promise<BookCard[]> {
|
||||
rating: bookProgress.rating,
|
||||
lastReadAt: bookProgress.lastReadAt,
|
||||
finishedAt: bookProgress.finishedAt,
|
||||
formats: formats.formats
|
||||
formats: formats.formats,
|
||||
archivedAt: bookWorks.archivedAt
|
||||
})
|
||||
.from(bookWorks)
|
||||
.leftJoin(bookProgress, eq(bookProgress.workId, bookWorks.id))
|
||||
.leftJoin(formats, eq(formats.workId, bookWorks.id))
|
||||
// Archived works are the owner's business only.
|
||||
.where(owner ? undefined : isNull(bookWorks.archivedAt))
|
||||
.orderBy(asc(bookWorks.title));
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -100,7 +103,8 @@ export async function libraryCards(owner: boolean): Promise<BookCard[]> {
|
||||
finishedAt: iso(r.finishedAt),
|
||||
addedAt: r.createdAt.toISOString(),
|
||||
formats: owner ? (r.formats ?? []) : [],
|
||||
hasFile: owner ? (r.formats?.length ?? 0) > 0 : false
|
||||
hasFile: owner ? (r.formats?.length ?? 0) > 0 : false,
|
||||
archived: owner && r.archivedAt !== null
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -110,14 +114,22 @@ export async function libraryStats(): Promise<LibraryStats> {
|
||||
const [[counts], [pages]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
total: sql<number>`(select count(*)::int from ${bookWorks} where ${bookWorks.kind} <> 'article')`,
|
||||
total: sql<number>`(select count(*)::int from ${bookWorks} where ${bookWorks.kind} <> 'article' and ${bookWorks.archivedAt} is null)`,
|
||||
finished: sql<number>`count(*) filter (where ${bookProgress.status} = 'finished' and ${bookProgress.finishedAt} >= ${yearStart})::int`,
|
||||
reading: sql<number>`count(*) filter (where ${bookProgress.status} = 'reading')::int`
|
||||
})
|
||||
.from(bookProgress),
|
||||
.from(bookProgress)
|
||||
.innerJoin(
|
||||
bookWorks,
|
||||
and(eq(bookWorks.id, bookProgress.workId), isNull(bookWorks.archivedAt))
|
||||
),
|
||||
db
|
||||
.select({ pages: sql<number>`coalesce(sum(${bookSessions.pagesRead}), 0)::int` })
|
||||
.from(bookSessions)
|
||||
.innerJoin(
|
||||
bookWorks,
|
||||
and(eq(bookWorks.id, bookSessions.workId), isNull(bookWorks.archivedAt))
|
||||
)
|
||||
.where(gte(bookSessions.startedAt, yearStart))
|
||||
]);
|
||||
return {
|
||||
|
||||
@@ -71,6 +71,9 @@ export const bookWorks = booksSchema.table(
|
||||
url: text('url'),
|
||||
siteName: text('site_name'),
|
||||
publishedAt: timestamptz('published_at'),
|
||||
// Archived works stay in the database (and keep their files, notes and progress) but are
|
||||
// hidden from everyone except the owner, who can restore them.
|
||||
archivedAt: timestamptz('archived_at'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
@@ -78,6 +81,7 @@ export const bookWorks = booksSchema.table(
|
||||
.on(t.url)
|
||||
.where(sql`${t.url} is not null`),
|
||||
index('works_kind_idx').on(t.kind),
|
||||
index('works_archived_at_idx').on(t.archivedAt),
|
||||
uniqueIndex('works_isbn13_key')
|
||||
.on(t.isbn13)
|
||||
.where(sql`${t.isbn13} is not null`),
|
||||
@@ -222,3 +226,24 @@ export type BookNote = typeof bookNotes.$inferSelect;
|
||||
export type BookSession = typeof bookSessions.$inferSelect;
|
||||
export type BookKind = (typeof bookKind.enumValues)[number];
|
||||
export type BookReadingStatus = (typeof bookReadingStatus.enumValues)[number];
|
||||
|
||||
// Tombstones for hard-deleted (or merged-away) works. Keeps what the ingest matches on so it
|
||||
// never recreates them, the file paths left on disk (for books-quarantine.py), and, for a
|
||||
// merge, the surviving work so the old URL can redirect there. `id` is the old works.id.
|
||||
export const bookDeletedWorks = booksSchema.table(
|
||||
'deleted_works',
|
||||
{
|
||||
id: integer('id').primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
authors: text('authors').array().notNull().default([]),
|
||||
isbn13: text('isbn13'),
|
||||
annasMd5: text('annas_md5').array().notNull().default([]),
|
||||
fileSha256: text('file_sha256').array().notNull().default([]),
|
||||
filePaths: text('file_paths').array().notNull().default([]),
|
||||
sourceRefs: jsonb('source_refs').$type<Record<string, unknown>>().notNull().default({}),
|
||||
mergedInto: integer('merged_into').references(() => bookWorks.id, { onDelete: 'set null' }),
|
||||
deletedAt: timestamptz('deleted_at').notNull().defaultNow(),
|
||||
quarantinedAt: timestamptz('quarantined_at') // files moved to $BOOKS_ROOT/quarantine/deleted
|
||||
},
|
||||
(t) => [index('deleted_works_merged_into_idx').on(t.mergedInto)]
|
||||
);
|
||||
|
||||
@@ -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