diff --git a/CLAUDE.md b/CLAUDE.md index 065a144f..f33acd42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -249,7 +249,14 @@ metadata.json below). `0047` adds `bridge_jobs` (the Bridge's jobs: path-keyed, open states) and the one-row `bridge_status` (the pairing code's hash and expiry, who paired the Bridge and when, its last heartbeat); see The Bridge below. `0048` adds `books.attachments_sig` / `attachments_indexed_at` and `book_attachments` (book_id -> books CASCADE: the Bridge's checked `.audiosilo/` characters, recaps and -provenance; derived from the files like `abs_file`, so rebuildable and never carried by a move). +provenance; derived from the files like `abs_file`, so rebuildable and never carried by a move). `0049` +adds `books.subtitle` / `publisher` / `language` / `abridged` (NULL unknown) / `genres` / `tags` (effective +values; the lists canonical JSON), `books.genres_checked` (the genre tag's one-time backfill, as 0037's +`released_checked`), `book_labels` (book_id -> books CASCADE, `library_id`, kind = the field, +`genres`|`tags`, `label_key`, the book's spelling: derived, rebuilt by `refreshEffective`), recreates +`books_fts` with `genres, tags` columns (rows copied) and sets `abs_indexed_at` NULL on every book with +a metadata.json (`abs_sig <> ''`), so the next scan reads each again for the new fields; see Labels +below. Routes `/me/queue`, `/me/collections/**`, `/me/share-targets` (`handlers_queue.go`, `handlers_collections.go`; a stranger's collection id is 404, a viewer's write 403 @@ -848,7 +855,9 @@ future metadata site can attach enrichment without reshaping the schema. The export read the row, so they see edits with no join. The player's book JSON carries `published` on every book and `description` only on `GET /libraries/{id}/item` (api's `itemBook` adds it; `catalog.Book` keeps it `json:"-"` so list, search and recent pages - stay small); `has_cover` and per-file codec are admin-only (`json:"-"`). Validation: `normalizeOverride`; sources: a scanned value is `path` when + stay small); `has_cover` and per-file codec are admin-only (`json:"-"`). Every player book (list, + search and item alike, all `omitempty`) also carries `subtitle`, `publisher`, `language`, + `abridged` (a bool, absent while unknown) and `genres` / `tags` (lists). Validation: `normalizeOverride`; sources: a scanned value is `path` when it equals what `DeriveFromPath` (or, path-first, `FromPathLayout`) yields, else `tag`; one an ABS file gives is `abs`; an override is `edited` or `community`; an enrichment-attached ASIN/ISBN reads as `community`. Revert = delete the @@ -861,12 +870,13 @@ future metadata site can attach enrichment without reshaping the schema. The off for a library; never for a file book, nor a loose file at the root) is a layer OVER the scanned values, tags and path alike, path-first libraries included (ABS's own default ranks it above the tags): `bookLayers.overABS`, in `scannedFields` before `titlePosition`. `metadata.ParseABSFile` is - lenient (a wrongly typed value costs that value; <= 4 MiB): title (no subtitle), authors and + lenient (a wrongly typed value costs that value; <= 4 MiB): title, subtitle, authors and narrators joined with ", " (strings or `{name}`), series `"Name #seq"` (the first is the main series with its position, the rest `more_series`; a series without a position keeps the tag's only while it is the same series, by `seriesKey`), `publishedDate` else `publishedYear` as `published`, the - HTML description as plain text, asin, isbn; genres, tags, publisher, language and chapters are not - read. An empty value says nothing (never clears a tag's). `absFields` stores each as an edit of its + HTML description as plain text, publisher, language, `abridged` (only `true` says anything: ABS + writes false for every book nobody marked), genres and tags (lists of strings, a non-string item + dropped; `CleanLabels`), asin, isbn; chapters are not read. An empty value says nothing (never clears a tag's). `absFields` stores each as an edit of its field would (`CleanOverride`; a refused value left out), so an ABS ASIN makes a book matched (Health, `/meta`, the community chapter check, a refresh run). `books.abs_file` holds those fields (JSON, `''` for none), `abs_sig` the file's signature (`metadata.ABSSignature`: size + mtime ns, `''` for @@ -969,6 +979,37 @@ future metadata site can attach enrichment without reshaping the schema. The chapter titles) and re-resolves them in batches of 250, flushed every 500 during the scan and on a Stop; no re-index. Until then `bookLayers.pathBaseline` resolves a snapshot against the baseline it was read with, so a path-first library still drops the folder as a series. + **Labels** (`catalog/labels.go`, migration `0049`): `genres` and `tags` are override fields whose + value is a canonical JSON list (`""` for none; `normalizeLabels`: trimmed, spacing collapsed, each + label once by `labelKey` (lowercased, spacing aside) keeping the first spelling, <= 64 characters, + <= 50 labels; an edit over a bound is a 400, a file's list is cleaned leniently instead, + `CleanLabels`, which every file source goes through). The value is `encodeCanonical`'s JSON (no + HTML escaping), as `more_series`' is. Genres come from the genre tag (`metadata.SplitGenres`: split + on `;` and NUL, never `,` (a store category holds one), blanks and placeholders such as "Audiobook", "Speech", "Spoken Word" or + "Other" dropped, any case; the rest left to `CleanLabels`; dhowden's then ffprobe's, ffprobe's when + it gives any, in the `scanned` snapshot) and the ABS file; tags only from the ABS file and edits. + `subtitle`, `publisher`, `language` (<= 64 characters) and `abridged` (`"true"`/`"false"`/`""`, + stored 1/0/NULL) are plain override fields from the ABS file, a match (`matchrun.Propose`: the work's + subtitle and language, the recording's publisher; community genres are not offered, a match + candidate carries none) or an edit. A source's abridged false says nothing (`AbridgedClaim`: ABS and + the community write false for every book nobody marked), so only an edit says unabridged. + `refreshEffective` writes the lists to `books.genres`/`tags`, to `book_labels` by key + (`writeLabels`, beside `keyOtherSeries`) and to the FTS `genres`/`tags` columns (a label a line), so + a search finds a book by a label. `labelFilters` (in the library, `book_labels.library_id`; any + library for an admin list across them and for the library facet) is the player's `GET + /libraries/{id}/books?genre=&tag=` (one value each, any case; both narrow) and the admin + `BookFilter.Genre`/`Tag` (`?genre=`/`?tag=`); `BookFacets` counts `genres`/`tags` (the 100 most + common by key, then the one the list is filtered by when it is rarer, each named by the spelling + most of its books use, `seriesName`; a semi-join over `book_labels`). A bulk edit (and the book PATCH) takes `add` / `remove` (`{"tags": ["Owned"]}`: + field -> labels) besides `set` / `revert`: `labelSets` merges them into each book's own EFFECTIVE + list and writes the result as an override of the field only where it changes the list; a book the + edit changes nothing on (adding a tag it has) is left as it is, its row not even rebuilt (`editTx`). + A field can't be set or reverted and added to or removed from at once, nor one label added and + removed (`normalizeLabelEdits`). + Books indexed before `0049` get their genres from the next scan (`genres_checked`, the primary + file read once beside 0037's date read, `Catalog.SetGenres` writing the snapshot and re-resolving, + flushed with the path readings) and the ABS fields from its re-read (`abs_indexed_at` NULL), no + re-index. Path-keyed like every override, so moves and joins carry them unchanged. - **Series spellings (`catalog/moreseries.go`)**: a series is every name with one `seriesKey` (`match.SeriesKey`, the rails' key: case, accents, punctuation and spacing aside; a name with no letter or number is its own key), so "He Who Fights With Monsters", @@ -1808,10 +1849,17 @@ future metadata site can attach enrichment without reshaping the schema. The as `audiosilo--.json` - the `{"format":"audiosilo-books", "version":1,…}` envelope the community metadata site imports on its Watching page so a user can mark which entries of a series they own. **The file leaves the - server**, so it carries bibliographic facts only (title, authors, narrators, - series + position (`series_position` "1-3" for a range: the importer's grammar takes one), asin/isbn, runtime_min, chapters) and **never** a path, size, - codec, format or anything else about the filesystem - a regression test asserts - that. `catalog.ExportLibraryBooks` composes the envelope (keyset paging over + server**, so by default it carries bibliographic facts only (title, subtitle, authors, narrators, + series + position (`series_position` "1-3" for a range: the importer's grammar takes one), + publisher, language, abridged, genres, asin/isbn, runtime_min, chapters; `version` stays 1, + the meta site's readers ignore keys they don't know) and **never by default** a path, size, + codec, format or anything else about the filesystem, nor the book's tags (the owner's own notes, + "Owned", "Read") - a regression test asserts that. `?include=` is a comma list, repeatable + (`catalog.ExportOptions`; any other value is a 400): `paths` adds each row's `paths` (every copy + collapsed into the row, index order; the envelope's `library.id` names the library, one export = + one library) and `tags` its `tags` (every copy's, each once), for the admin's own scripts; such a + file is named `...--private.json`, apart from the one to share; the console's "Export + with file paths and tags..." asks for both and says the file is then not for a public issue. `catalog.ExportLibraryBooks` composes the envelope (keyset paging over `ListBooks`, never OFFSET; copies of the same book within the library collapse on `exposedDedupKey`, the kept entry taking each fact from whichever copy has it - `mergeExportBook`, since the copy that sorts first may be the untagged rip); the @@ -1994,7 +2042,10 @@ The player's browse lists are `GET /libraries/{id}/authors` (`{authors, unknown} granted books; `api/handlers_browse.go`), and `GET /libraries/{id}/books` filters by `series=` (any spelling, see Series spellings), and by `author=` / `narrator=` matching the whole credit or exactly one person it names (`catalog.creditFilter`: an FTS5 phrase narrows the candidates, -the `credit_has` SQL function keeps the exact ones; `/admin/books` filters the same way). +the `credit_has` SQL function keeps the exact ones; `/admin/books` filters the same way), +and by `genre=` / `tag=` (one value each, any case, `catalog.labelFilters`; see Labels; an older +server ignores them, so a player filtering a loaded library by the books' own `genres` / `tags` +needs no capability). Books in several series (capability `series_memberships`): with `memberships=1`, `/books?series=` also matches a book whose `more_series` names it (`catalog.seriesFilter`, as `/admin/books`) and `/series` counts a book in every series it is in (`extra_books`); diff --git a/admin-ui/STYLEGUIDE.md b/admin-ui/STYLEGUIDE.md index 7fb16ac5..82f34656 100644 --- a/admin-ui/STYLEGUIDE.md +++ b/admin-ui/STYLEGUIDE.md @@ -505,7 +505,10 @@ before → Edited). dashed pink outline. Field row = label (130px) · click-to-edit value · marker (+ "Revert" for edited fields, whose tooltip shows the file-tag value). Editing locks the field against rescans; the legend under the Details card explains the five sources (Audiobookshelf: a -`metadata.json` ABS keeps in the book's folder, over the tags). +`metadata.json` ABS keeps in the book's folder, over the tags). A label list (genres, tags) shows as +secondary 24px chips and edits as a chip box (`LabelInput`: Enter or a comma adds, a chip's x removes, +Backspace takes the last back; the bulk edit's add/remove boxes are the same component); abridged is a +three-way select (not known, abridged, unabridged) that drafts on change. ### Chapter ribbon _custom_ diff --git a/admin-ui/src/api/client.ts b/admin-ui/src/api/client.ts index 89ba22a4..6543c251 100644 --- a/admin-ui/src/api/client.ts +++ b/admin-ui/src/api/client.ts @@ -247,6 +247,9 @@ export interface BookFilter { author?: string; series?: string; narrator?: string; + /** A genre or a tag the book has, in any case. */ + genre?: string; + tag?: string; format?: string[]; codec?: string[]; direct_playable?: boolean; @@ -680,10 +683,18 @@ export const api = { /** * Downloads a library's book list (GET /admin/libraries/{id}/export) as the file - * the server names. Returns the file name. + * the server names. With `personal`, each row also carries the paths of the + * copies it stands for and the book's tags (?include=paths,tags): a file for the + * admin's own scripts, not one to share. Returns the file name. */ -export function downloadLibraryExport(id: number): Promise { - return download(`/admin/libraries/${id}/export`, `audiosilo-library-${id}.json`); +export function downloadLibraryExport( + id: number, + opts: { personal?: boolean } = {}, +): Promise { + const query = opts.personal ? '?include=paths,tags' : ''; + // The fallback name keeps the server's "-private" mark (catalog.Filename). + const fallback = `audiosilo-library-${id}${opts.personal ? '-private' : ''}.json`; + return download(`/admin/libraries/${id}/export${query}`, fallback); } /** Downloads a backup (GET /admin/backups/{name}). Returns the file name. */ diff --git a/admin-ui/src/api/types.ts b/admin-ui/src/api/types.ts index f2a94e97..39f77f00 100644 --- a/admin-ui/src/api/types.ts +++ b/admin-ui/src/api/types.ts @@ -1355,6 +1355,7 @@ export interface AdminBook { path: string; is_folder: boolean; title: string; + subtitle: string; author: string; narrator: string; /** @@ -1372,6 +1373,13 @@ export interface AdminBook { /** Every series the book is in, its main one (`series`) first, with its position in each. */ series_list: SeriesRef[]; published: string; + publisher: string; + language: string; + /** null while nothing says (catalog.AdminBook.Abridged). */ + abridged: boolean | null; + /** The book's labels (catalog labels.go), [] for none. */ + genres: string[]; + tags: string[]; /** Seconds. */ duration: number; format: string; @@ -1447,6 +1455,9 @@ export interface BookFacets { has_chapters: BoolFacet; matched: BoolFacet; edited: BoolFacet; + /** The most common labels (at most 100 each), named by the spelling most of their books use. */ + genres: FacetCount[]; + tags: FacetCount[]; } /** @@ -1520,18 +1531,31 @@ export interface SeriesRef { /** The overridable book fields (catalog.OverrideFields), in display order. */ export const OVERRIDE_FIELDS = [ 'title', + 'subtitle', 'author', 'narrator', 'series', 'series_index', 'more_series', 'published', + 'publisher', + 'language', + 'abridged', + 'genres', + 'tags', 'description', 'asin', 'isbn', ] as const; export type OverrideField = (typeof OVERRIDE_FIELDS)[number]; +/** The label fields (catalog labels.go): a list as canonical JSON, "" for none. */ +export const LABEL_FIELDS = ['genres', 'tags'] as const; +export type LabelField = (typeof LABEL_FIELDS)[number]; +/** Whether a field is a label field. */ +export const isLabelField = (f: string): f is LabelField => + (LABEL_FIELDS as readonly string[]).includes(f); + /** Where a field's value came from (catalog.Source*), in the legend's order. */ export const FIELD_SOURCES = ['tag', 'path', 'abs', 'edited', 'community'] as const; /** A field's source; "" = no value. */ @@ -1681,6 +1705,12 @@ export interface CommunityChaptersDetail { export interface BookEditRequest { set?: Partial>; revert?: OverrideField[]; + /** + * Labels to add to, or take out of (any case), each book's own list; never a + * field the request also sets or reverts (catalog.BookEdit.Add). + */ + add?: Partial>; + remove?: Partial>; /** "community" when the values were accepted from a match; default "edited". */ source?: 'edited' | 'community'; chapters?: { set?: Record; revert?: number[] }; diff --git a/admin-ui/src/components/label-input.tsx b/admin-ui/src/components/label-input.tsx new file mode 100644 index 00000000..8b22ba22 --- /dev/null +++ b/admin-ui/src/components/label-input.tsx @@ -0,0 +1,162 @@ +import { useLayoutEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { X } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { fieldClasses } from '@/components/ui/field-classes'; +import { cleanLabels } from '@/lib/labels'; +import { cn } from '@/lib/utils'; + +/** + * One label as a chip (a secondary pill, 24px; STYLEGUIDE.md "Field row"): with + * `onRemove`, an x that takes it out of its list. + */ +export function LabelChip({ + label, + onRemove, + disabled, +}: { + label: string; + onRemove?: () => void; + disabled?: boolean; +}) { + const { t } = useTranslation(); + return ( + + {label} + {onRemove ? ( + + ) : null} + + ); +} + +/** + * A list of labels (genres, tags) edited as chips: type a label and press Enter + * or a comma to add it (a pasted "a, b" adds both), Backspace in the empty box + * takes the last one back, a chip's x removes it. A label already in the list + * (any case) isn't added twice. Controlled: `value` is the list, `onChange` gets + * the next one; leaving the box adds what is typed. With `onDone` it is an + * in-place editor (the Details card): + * focused on open, Enter in the empty box or leaving it commits (whatever is + * typed is added first), Escape cancels (`onDone(undefined, true)`), and + * `keyboard` says whether a key ended it, so focus can go back to the value. + */ +export function LabelInput({ + id, + value, + onChange, + onDone, + placeholder, + disabled, + autoFocus, + className, + ...aria +}: { + id?: string; + value: string[]; + onChange: (next: string[]) => void; + onDone?: (value: string[] | undefined, keyboard: boolean) => void; + placeholder?: string; + disabled?: boolean; + autoFocus?: boolean; + className?: string; +} & React.AriaAttributes) { + const [text, setText] = useState(''); + const input = useRef(null); + const box = useRef(null); + const ended = useRef(false); + + useLayoutEffect(() => { + if (autoFocus) input.current?.focus(); + }, [autoFocus]); + + /** value with the labels typed in `raw` (comma-separated) added. */ + const withTyped = (raw: string): string[] => cleanLabels([...value, ...raw.split(',')]).labels; + + const addTyped = (raw: string) => { + const next = withTyped(raw); + setText(''); + if (next.length !== value.length) onChange(next); + return next; + }; + + const end = (next: string[] | undefined, keyboard: boolean) => { + if (!onDone || ended.current) return; + ended.current = true; + onDone(next, keyboard); + }; + + const remove = (label: string) => { + onChange(value.filter((l) => l !== label)); + input.current?.focus(); + }; + + return ( +
{ + // Leaving the whole box (not moving to one of its chips' buttons) commits; + // without onDone (the bulk edit's boxes) it adds what is typed, so a label + // left unentered isn't dropped by the form's submit. + if (box.current?.contains(e.relatedTarget as Node | null)) return; + if (onDone) end(withTyped(text), false); + else if (text.trim()) addTyped(text); + }} + > + {value.map((label) => ( + remove(label)} /> + ))} + { + const v = e.target.value; + // A comma ends a label (a pasted list adds every label in it). + if (v.includes(',')) addTyped(v); + else setText(v); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (text.trim()) addTyped(text); + else end(value, true); + } else if (e.key === 'Escape' && onDone) { + e.preventDefault(); + e.stopPropagation(); + end(undefined, true); + } else if (e.key === 'Backspace' && !text && value.length) { + e.preventDefault(); + setText(value[value.length - 1]); + onChange(value.slice(0, -1)); + } + }} + /> +
+ ); +} diff --git a/admin-ui/src/features/book/book-model.test.ts b/admin-ui/src/features/book/book-model.test.ts index 4c0d16b8..3cef052a 100644 --- a/admin-ui/src/features/book/book-model.test.ts +++ b/admin-ui/src/features/book/book-model.test.ts @@ -13,10 +13,36 @@ import { playerConnectUrl, ribbonSegments, saveRequest, + shownValue, undoRevertRequest, + displayValue, } from './book-model'; describe('checkField', () => { + it('checks the subtitle, publisher, language and abridged as the server does', () => { + expect(checkField('subtitle', ' A Novel ')).toEqual({ value: 'A Novel' }); + expect(checkField('publisher', 'x'.repeat(501)).error).toBe('book.invalid.tooLong'); + expect(checkField('language', ' en-GB ')).toEqual({ value: 'en-GB' }); + expect(checkField('language', 'x'.repeat(65)).error).toBe('book.invalid.language'); + expect(checkField('abridged', 'TRUE')).toEqual({ value: 'true' }); + expect(checkField('abridged', '')).toEqual({ value: '' }); + expect(checkField('abridged', 'yes').error).toBe('book.invalid.abridged'); + }); + + it('keeps a label list canonical, shown as its labels', () => { + expect(checkField('tags', '["Owned","owned"," To Read "]')).toEqual({ + value: '["Owned","To Read"]', + }); + expect(checkField('genres', '["' + 'x'.repeat(65) + '"]').error).toBe( + 'book.invalid.labelTooLong', + ); + expect(displayValue('genres', '["Fantasy","Epic"]')).toBe('Fantasy, Epic'); + const t = (k: string) => `<${k}>`; + expect(shownValue(t, 'abridged', 'false')).toBe(''); + expect(shownValue(t, 'abridged', '')).toBe(''); + expect(shownValue(t, 'tags', '["Owned"]')).toBe('Owned'); + }); + it('trims, and refuses an empty title', () => { expect(checkField('title', ' Dune ')).toEqual({ value: 'Dune' }); expect(checkField('title', ' ').error).toBe('book.invalid.title'); diff --git a/admin-ui/src/features/book/book-model.ts b/admin-ui/src/features/book/book-model.ts index e5fc68a3..b34349d8 100644 --- a/admin-ui/src/features/book/book-model.ts +++ b/admin-ui/src/features/book/book-model.ts @@ -1,6 +1,7 @@ import { FITTED_STATUSES, OVERRIDE_FIELDS, + isLabelField, type AdminBookDetail, type AdminChapter, type AdminFile, @@ -14,6 +15,7 @@ import { type PairToken, } from '@/api/types'; import { formatSeriesPosition } from '@/lib/format'; +import { checkLabels, labelsText } from '@/lib/labels'; import type { Phrase } from '@/lib/phrase'; import { sameSeries } from '@/lib/series-key'; import { CONTROL, MAX_SERIES_INDEX, MAX_SHORT, NUMBER, parseSeriesRange } from './field-rules'; @@ -40,6 +42,7 @@ export interface FieldCheck { } const MAX_DESCRIPTION = 20000; +const MAX_LANGUAGE = 64; // A description keeps line breaks and tabs. // eslint-disable-next-line no-control-regex const CONTROL_IN_TEXT = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/; @@ -64,13 +67,27 @@ export function checkField(field: OverrideField, raw: string): FieldCheck { let v = raw.trim(); switch (field) { case 'title': + case 'subtitle': case 'author': case 'narrator': case 'series': + case 'publisher': if (runes(v) > MAX_SHORT) return { value: v, error: 'book.invalid.tooLong' }; if (CONTROL.test(v)) return { value: v, error: 'book.invalid.control' }; if (field === 'title' && !v) return { value: v, error: 'book.invalid.title' }; return { value: v }; + case 'language': + if (runes(v) > MAX_LANGUAGE || CONTROL.test(v)) { + return { value: v, error: 'book.invalid.language' }; + } + return { value: v }; + case 'abridged': + v = v.toLowerCase(); + if (v && v !== 'true' && v !== 'false') return { value: v, error: 'book.invalid.abridged' }; + return { value: v }; + case 'genres': + case 'tags': + return checkLabels(v); case 'description': v = v.replaceAll('\r', ''); if (runes(v) > MAX_DESCRIPTION) return { value: v, error: 'book.invalid.tooLong' }; @@ -105,9 +122,25 @@ export function checkField(field: OverrideField, raw: string): FieldCheck { } } -/** A stored value as it is shown and edited: more_series as its line, the rest as they are. */ +/** + * A stored value as it is shown and edited: more_series as its line, a label + * list as its labels apart by commas, the rest as they are (abridged's words: + * shownValue). + */ export function displayValue(field: OverrideField, value: string): string { - return field === 'more_series' ? moreSeriesText(value) : value; + if (field === 'more_series') return moreSeriesText(value); + if (isLabelField(field)) return labelsText(value); + return value; +} + +/** A stored value in words: displayValue, with abridged's "true"/"false" said. */ +export function shownValue( + t: (key: string) => string, + field: OverrideField, + value: string, +): string { + if (field === 'abridged') return value ? t(`book.abridged.${value}`) : ''; + return displayValue(field, value); } /** diff --git a/admin-ui/src/features/book/book.test.tsx b/admin-ui/src/features/book/book.test.tsx index b732a18e..710316cd 100644 --- a/admin-ui/src/features/book/book.test.tsx +++ b/admin-ui/src/features/book/book.test.tsx @@ -267,6 +267,33 @@ describe('book page', () => { expect(screen.getByText('2 unsaved changes')).toBeInTheDocument(); }); + it('edits tags as chips and abridged as a choice, saved as edits', async () => { + const detail = bookDetail(); + detail.fields.tags = { value: '["Owned"]', source: 'abs', scanned: '["Owned"]', locked: false }; + const calls = mockFetch(routes(detail)); + renderApp(URL); + const user = userEvent.setup(); + const tags = await screen.findByRole('group', { name: 'Tags' }); + expect(within(tags).getByText('Owned')).toBeInTheDocument(); + await user.click(within(tags).getByRole('button')); + const box = within(row('Tags')).getByRole('textbox', { name: 'Tags' }); + expect(box).toHaveFocus(); + // A label already there (any case) isn't added twice; a comma ends a label. + await user.type(box, 'owned, Favourite,'); + await user.click(within(row('Tags')).getByRole('button', { name: 'Remove Owned' })); + await user.type(within(row('Tags')).getByRole('textbox'), '{Enter}'); + expect(within(row('Tags')).getByText('Favourite')).toBeInTheDocument(); + expect(within(row('Tags')).queryByText('Owned')).toBeNull(); + + await user.selectOptions(within(row('Abridged')).getByRole('combobox'), 'Unabridged'); + await user.click(screen.getByRole('button', { name: 'Review and save' })); + const save = await screen.findByRole('dialog'); + expect(within(save).getByText('Unabridged')).toBeInTheDocument(); + await user.click(within(save).getByRole('button', { name: 'Save 2 changes' })); + await waitFor(() => expect(patches(calls)).toHaveLength(1)); + expect(patches(calls)[0].body).toEqual({ set: { abridged: 'false', tags: '["Favourite"]' } }); + }); + it('reverts a locked field to the file, with an undo', async () => { const detail = bookDetail(); detail.fields.title = { @@ -511,7 +538,11 @@ describe('match with community metadata', () => { const description = within(compare).getByRole('checkbox', { name: 'Accept Description' }); expect(description).toBeChecked(); expect(within(compare).queryByRole('checkbox', { name: 'Accept Title' })).toBeNull(); + // The recording's publisher is offered too. + const publisher = within(compare).getByRole('checkbox', { name: 'Accept Publisher' }); + expect(publisher).toBeChecked(); await user.click(description); + await user.click(publisher); await user.click(within(compare).getByRole('button', { name: 'Accept 1 field' })); expect(await screen.findByText('Accepted 1 field from the community')).toBeInTheDocument(); diff --git a/admin-ui/src/features/book/details-card.tsx b/admin-ui/src/features/book/details-card.tsx index 38ac37be..ebc8e0bf 100644 --- a/admin-ui/src/features/book/details-card.tsx +++ b/admin-ui/src/features/book/details-card.tsx @@ -7,17 +7,21 @@ import { settleBookEdit } from '@/api/hooks'; import { FIELD_SOURCES, OVERRIDE_FIELDS, + isLabelField, type AdminBookDetail, type FieldValue, type OverrideField, } from '@/api/types'; import { InlineEdit } from '@/components/inline-edit'; +import { LabelChip, LabelInput } from '@/components/label-input'; import { ProvenanceMarker, UnsavedMarker } from '@/components/provenance'; import { Card, CardHeader } from '@/components/ui/card'; +import { NativeSelect } from '@/components/ui/native-select'; import { toastError } from '@/lib/errors'; +import { encodeLabels, parseLabels } from '@/lib/labels'; import { toast } from '@/lib/toast'; import { cn } from '@/lib/utils'; -import { displayValue, undoRevertRequest, type Drafts } from './book-model'; +import { displayValue, shownValue, undoRevertRequest, type Drafts } from './book-model'; /** How much of a value a toast quotes. */ const QUOTE = 60; @@ -68,7 +72,7 @@ export function DetailsCard({ try { const next = await api.editBook(b.library_id, b.path, { revert: [field] }); settleBookEdit(qc, next); - const now = displayValue(field, next.fields[field]?.value ?? ''); + const now = shownValue(t, field, next.fields[field]?.value ?? ''); toast.add({ title: t('book.revert.done', { field: label }), description: now @@ -122,7 +126,13 @@ export function DetailsCard({ ); } -/** One field: label, the click-to-edit value, and where the value came from. */ +/** The values the abridged field takes, unknown first. */ +const ABRIDGED = ['', 'true', 'false'] as const; + +/** + * One field: label, the value (click to edit; a label list as chips, edited as + * chips; abridged a choice of three), and where the value came from. + */ function FieldRow({ field, value, @@ -150,6 +160,9 @@ function FieldRow({ const id = `field-${field}`; const long = field === 'description'; const mono = field === 'asin' || field === 'isbn'; + const labels = isLabelField(field); + // A label list's draft is its JSON (LabelInput's list), shown as chips. + const chips = labels ? parseLabels(draft ?? value.value) : []; // After Enter or Escape, keyboard focus goes back to the value. useEffect(() => { @@ -180,7 +193,33 @@ function FieldRow({ > {label} - {editing ? ( + {field === 'abridged' ? ( + onCommit(e.target.value)} + aria-labelledby={`${id}-label`} + aria-invalid={!!error || undefined} + aria-describedby={error ? `${id}-error` : undefined} + className="max-w-[240px]" + > + {ABRIDGED.map((v) => ( + + ))} + + ) : editing && labels ? ( + + finish(next === undefined ? undefined : encodeLabels(next), keyboard) + } + /> + ) : editing ? ( - - {empty ? t('book.field.empty') : shown} - + {labels && chips.length ? ( + + {chips.map((c) => ( + + ))} + + ) : ( + + {empty ? t('book.field.empty') : shown} + + )} )} @@ -224,7 +271,7 @@ function FieldRow({ onClick={onRevert} title={ value.scanned - ? t('book.revert.tagHint', { value: quote(displayValue(field, value.scanned)) }) + ? t('book.revert.tagHint', { value: quote(shownValue(t, field, value.scanned)) }) : t('book.revert.noTag') } aria-label={t('book.revert.aria', { field: label })} @@ -247,3 +294,37 @@ function FieldRow({ ); } + +/** + * A label list being edited in place: the chips and the box to add to them, + * committed as a whole when the editor ends (LabelInput's onDone). + */ +function LabelsEdit({ + id, + initial, + label, + error, + onDone, +}: { + id: string; + initial: string[]; + label: string; + error: string | undefined; + onDone: (value: string[] | undefined, keyboard: boolean) => void; +}) { + const { t } = useTranslation(); + const [list, setList] = useState(initial); + return ( + + ); +} diff --git a/admin-ui/src/features/book/match-dialog.tsx b/admin-ui/src/features/book/match-dialog.tsx index 50a82a23..d69e7403 100644 --- a/admin-ui/src/features/book/match-dialog.tsx +++ b/admin-ui/src/features/book/match-dialog.tsx @@ -42,7 +42,7 @@ import { formatDuration } from '@/lib/format'; import { toast } from '@/lib/toast'; import { cn } from '@/lib/utils'; import { regionTag } from '@/lib/regions'; -import { displayValue } from './book-model'; +import { shownValue } from './book-model'; import { acceptRequest, candidateAuthors, @@ -710,7 +710,7 @@ function CompareTable({ )} > {r.mine ? ( - clip(displayValue(r.field, r.mine), 120) + clip(shownValue(t, r.field, r.mine), 120) ) : ( {t('book.match.empty')} )} @@ -722,7 +722,7 @@ function CompareTable({ r.same ? ( {t('book.match.same')} ) : r.theirs ? ( - clip(displayValue(r.field, r.theirs), 150) + clip(shownValue(t, r.field, r.theirs), 150) ) : ( {t('book.match.notProvided')} ) diff --git a/admin-ui/src/features/book/match-model.test.ts b/admin-ui/src/features/book/match-model.test.ts index fdbb60e3..bcf91bdd 100644 --- a/admin-ui/src/features/book/match-model.test.ts +++ b/admin-ui/src/features/book/match-model.test.ts @@ -72,18 +72,35 @@ describe('compare', () => { it('maps a candidate onto the fields', () => { expect(communityValues(candidate(), recording())).toEqual({ title: 'The Way of Kings', + subtitle: '', author: 'Brandon Sanderson', narrator: 'Michael Kramer, Kate Reading', series: 'The Stormlight Archive', series_index: '1', more_series: '', published: '2010', + publisher: 'Macmillan Audio', + language: '', + abridged: '', + genres: '', + tags: '', description: 'Roshar is a world of stone and storms.', asin: 'B003P2WO5E', isbn: '', }); }); + it('offers the subtitle, the language and abridged only when the recording says so', () => { + const c = candidate({ subtitle: 'Book One', language: 'en' }); + const values = communityValues(c, recording({ abridged: true })); + expect(values).toMatchObject({ subtitle: 'Book One', language: 'en', abridged: 'true' }); + // The community's false is also "not recorded". + expect(communityValues(c, recording({ abridged: false })).abridged).toBe(''); + // The label fields have no row: the community offers neither. + const rows = compareRows(bookDetail().fields, c, recording()); + expect(rows.some((r) => r.field === 'genres' || r.field === 'tags')).toBe(false); + }); + it('offers a work in several series as one main series and the rest', () => { const watch = 'Discworld: Ankh-Morpork City Watch'; const c = candidate({ @@ -194,13 +211,17 @@ describe('compare', () => { detail.fields.narrator = { ...detail.fields.narrator, source: 'edited', locked: true }; const rows = compareRows(detail.fields, candidate(), recording()); const offered = rows.filter((r) => r.offered).map((r) => r.field); - expect(offered).toEqual(['narrator', 'published', 'description']); + expect(offered).toEqual(['narrator', 'published', 'publisher', 'description']); expect(rows.find((r) => r.field === 'title')?.same).toBe(true); expect(rows.find((r) => r.field === 'isbn')).toMatchObject({ same: false, offered: false }); const ticks = defaultTicks(rows); - expect([...ticks]).toEqual(['published', 'description']); + expect([...ticks]).toEqual(['published', 'publisher', 'description']); expect(acceptRequest(rows, ticks)).toEqual({ - set: { published: '2010', description: 'Roshar is a world of stone and storms.' }, + set: { + published: '2010', + publisher: 'Macmillan Audio', + description: 'Roshar is a world of stone and storms.', + }, source: 'community', }); }); diff --git a/admin-ui/src/features/book/match-model.ts b/admin-ui/src/features/book/match-model.ts index b407c1ac..63fc7341 100644 --- a/admin-ui/src/features/book/match-model.ts +++ b/admin-ui/src/features/book/match-model.ts @@ -1,5 +1,6 @@ import { OVERRIDE_FIELDS, + isLabelField, type BookEditRequest, type FieldSource, type FieldValue, @@ -51,7 +52,9 @@ export function candidateAuthors(c: MatchCandidate): string { } /** - * What the community says for each field, from the work and the chosen recording. + * What the community says for each field, from the work and the chosen recording + * (as matchrun.Propose: the work's subtitle and language, the recording's + * publisher, abridged only when it is marked so). * A work in several series offers one as the main series - the one the book is * filed under (`mainSeries`) when the work is in it, else the recording's first * (its own edition, when the community lists one) - and the rest as the book's other series (as matchrun.planSeries @@ -70,12 +73,20 @@ export function communityValues( const year = (c.first_published || rec?.release_date || '').match(/^\d{4}/)?.[0]; return { title: clean('title', c.title), + subtitle: clean('subtitle', c.subtitle), author: clean('author', candidateAuthors(c)), narrator: clean('narrator', (rec?.narrators ?? []).map((n) => n.name).join(', ')), series: clean('series', series?.name), series_index: clean('series_index', series?.position), more_series: clean('more_series', otherSeries(others)), published: clean('published', year), + publisher: clean('publisher', rec?.publisher), + language: clean('language', c.language), + // The community's false is also "not recorded": only true says anything. + abridged: rec?.abridged ? 'true' : '', + // A match candidate carries no genres, and the community has no tags. + genres: '', + tags: '', description: clean('description', c.description), asin: clean('asin', rec?.asins?.[0]), isbn: clean('isbn', rec?.isbns?.[0]), @@ -102,13 +113,18 @@ export interface CompareRow { offered: boolean; } +/** + * The compare table's rows, in field order. The label fields are left out: the + * community offers neither, so their rows would only ever say so. + */ export function compareRows( fields: Record, c: MatchCandidate, rec: MatchRecording | undefined, ): CompareRow[] { const theirs = communityValues(c, rec, fields.series.value); - return OVERRIDE_FIELDS.map((f) => { + const offerable = OVERRIDE_FIELDS.filter((f) => !isLabelField(f)); + return offerable.map((f) => { const mine = fields[f].value; const same = !!theirs[f] && clean(f, mine) === theirs[f]; return { diff --git a/admin-ui/src/features/book/save-changes.tsx b/admin-ui/src/features/book/save-changes.tsx index 1315029a..d365c8f9 100644 --- a/admin-ui/src/features/book/save-changes.tsx +++ b/admin-ui/src/features/book/save-changes.tsx @@ -18,7 +18,7 @@ import { import { toastError } from '@/lib/errors'; import { toast } from '@/lib/toast'; import { cn } from '@/lib/utils'; -import { checkField, diffRows, displayValue, saveRequest, type Drafts } from './book-model'; +import { checkField, diffRows, saveRequest, shownValue, type Drafts } from './book-model'; /** * The floating save bar (STYLEGUIDE.md "Save bar"): how many edits are waiting, @@ -164,8 +164,8 @@ function DiffBody({ - - + + ))} diff --git a/admin-ui/src/features/libraries/libraries.test.tsx b/admin-ui/src/features/libraries/libraries.test.tsx index 822de8c6..5d324970 100644 --- a/admin-ui/src/features/libraries/libraries.test.tsx +++ b/admin-ui/src/features/libraries/libraries.test.tsx @@ -280,9 +280,50 @@ describe('libraries', () => { expect(await screen.findByText('audiosilo-fiction-2026-10-03.json')).toBeInTheDocument(); const exp = calls.find((c) => c.path === '/admin/libraries/1/export'); expect(exp?.headers.Authorization).toBe('Bearer stored'); + // The plain export names no paths and carries no tags. + expect(exp?.query.get('include')).toBeNull(); expect(createObjectURL).toHaveBeenCalled(); }); + it('exports with file paths and tags only after a warning', async () => { + vi.stubGlobal( + 'URL', + Object.assign(URL, { createObjectURL: vi.fn(() => 'blob:export'), revokeObjectURL: vi.fn() }), + ); + const calls = mockFetch( + routes({ + 'GET /admin/libraries/1/export': { + raw: '{"format":"audiosilo-books"}', + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': + 'attachment; filename="audiosilo-fiction-2026-10-03-private.json"', + }, + }, + }), + ); + renderApp('/library/libraries'); + const user = userEvent.setup(); + const fiction = await screen.findByRole('listitem', { name: 'Fiction' }); + await user.click(within(fiction).getByRole('button', { name: 'Actions for Fiction' })); + await user.click( + await screen.findByRole('menuitem', { name: 'Export with file paths and tags...' }), + ); + const dialog = await screen.findByRole('dialog', { + name: 'Export Fiction with file paths and tags', + }); + expect( + within(dialog).getByText(/Don't attach this file to a public issue/), + ).toBeInTheDocument(); + expect(calls.some((c) => c.path === '/admin/libraries/1/export')).toBe(false); + await user.click(within(dialog).getByRole('button', { name: 'Export with paths and tags' })); + expect( + await screen.findByText('audiosilo-fiction-2026-10-03-private.json'), + ).toBeInTheDocument(); + const exp = calls.find((c) => c.path === '/admin/libraries/1/export'); + expect(exp?.query.get('include')).toBe('paths,tags'); + }); + it('shows the empty state with no libraries', async () => { mockFetch(routes({ 'GET /admin/libraries': { body: { libraries: [] } } })); renderApp('/library/libraries'); diff --git a/admin-ui/src/features/libraries/library-card.tsx b/admin-ui/src/features/libraries/library-card.tsx index a4c1cdc9..41ec94c3 100644 --- a/admin-ui/src/features/libraries/library-card.tsx +++ b/admin-ui/src/features/libraries/library-card.tsx @@ -10,18 +10,22 @@ import { Clock, Download, Ellipsis, + FileWarning, FolderTree, GripVertical, Pencil, RefreshCw, Split, Trash2, + TriangleAlert, Unplug, } from 'lucide-react'; import { downloadLibraryExport } from '@/api/client'; import { useRecentBooks } from '@/api/hooks'; import type { AdminLibrary, ScanProgress } from '@/api/types'; import { BookCover } from '@/components/book-cover'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { Notice } from '@/components/notice'; import { ProgressBar } from '@/components/progress-bar'; import { Badge } from '@/components/ui/badge'; import { Button, buttonVariants } from '@/components/ui/button'; @@ -43,7 +47,7 @@ import { OfflineNotice } from './offline-notice'; import { rescanLibrary } from './rescan'; import { scheduleLabel } from './scan-settings'; -type OpenDialog = 'edit' | 'detect' | 'delete' | null; +type OpenDialog = 'edit' | 'detect' | 'delete' | 'exportPaths' | null; /** One library: covers, name, status, folder, and everything an admin does to it. */ export function LibraryCard({ @@ -66,10 +70,12 @@ export function LibraryCard({ // Waiting in the job queue behind another library's scan (a running one wins). const queued = !running && !!l.scan.queued; - const exportBooks = async () => { + // Personal, each row carries its books' paths and tags: a file for the admin's + // own scripts (the menu warns before it is made), never the default. + const exportBooks = async (personal = false) => { setExporting(true); try { - const file = await downloadLibraryExport(l.id); + const file = await downloadLibraryExport(l.id, { personal }); toast.add({ title: t('libraries.toast.exported', { name: l.name }), description: file, @@ -212,6 +218,10 @@ export function LibraryCard({