diff --git a/CLAUDE.md b/CLAUDE.md index 08db180..1a9f129 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -357,6 +357,25 @@ future metadata site can attach enrichment without reshaping the schema. progress/bookmarks). `PUT/DELETE /admin/libraries/{id}/folder-override?path=` sets/clears it and rescans; the admin console's per-library **Detection** browser drives it. `GET /fs` annotates each entry's effective `override`. +- **Library export** (`internal/catalog/export.go` + `api/handlers_export.go`): + `GET /admin/libraries/{id}/export` (admin only) downloads a library's book list + 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, 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 + `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 + handler is transport-only and writes it with a `json.Encoder`. The leak guard + asserts on the MARSHALLED key sets as an allowlist, so a field added to + `ExportBook` fails the test even when its name (`format`, `files`) is invisible + to a substring scan. The single `author`/`narrator` string is split into a list only + where it clearly holds several names (`;`, ` & `, ` and `, and a comma **only** + when every part still has two words, so "Alexandre Dumas, pere" stays whole). + Advertised by the additive `export` capability. - **Library admin**: `PATCH /admin/libraries/{id}` edits name/root/default_view and triggers a background rescan; `DELETE /admin/libraries/{id}` removes the library + its index (files on disk untouched). Both are surfaced in the admin console. @@ -434,8 +453,8 @@ future metadata site can attach enrichment without reshaping the schema. See the plan file. `GET /api/v1/server` advertises capability flags (`admin_ui`, `web_player`, -`upload`, `transcode`, `websocket`, `api_keys`, `metadata`); flip them on as -phases land. `transcode` already reflects whether ffmpeg is configured; +`upload`, `transcode`, `websocket`, `api_keys`, `metadata`, `export`); flip them +on as phases land. `transcode` already reflects whether ffmpeg is configured; `api_keys` is true (user-minted personal access tokens are supported); `metadata` reflects whether the Phase 1.5 metadata lookup is live (`metadataOn()`: a valid `metadata.base_url` AND the runtime enabled flag, which @@ -451,6 +470,8 @@ metadata lookup is `GET /libraries/{id}/meta?path=` (authed, scope-checked like the other `?path=` content endpoints; 404 when metadata is disabled), plus `GET /meta/work?id=` (authed, no library scope - global community data; 404 when metadata is disabled or the work id is unknown). +The library export is `GET /admin/libraries/{id}/export` (admin only; returns a +JSON attachment, not the usual envelope - see Library export above). Runtime-toggleable settings are `GET`/`PATCH /admin/settings` (admin only): a feature-keyed envelope (`{"metadata":{"enabled","base_url","available"}}`) whose `PATCH {"metadata":{"enabled":bool}}` flips the metadata lookup and persists it. diff --git a/internal/api/api.go b/internal/api/api.go index 5ebded7..de5da69 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -228,6 +228,7 @@ func (a *API) Handler() http.Handler { mux.Handle("PUT /api/v1/admin/libraries/{id}/folder-override", a.requireAdmin(http.HandlerFunc(a.handleSetFolderOverride))) mux.Handle("DELETE /api/v1/admin/libraries/{id}/folder-override", a.requireAdmin(http.HandlerFunc(a.handleDeleteFolderOverride))) mux.Handle("PUT /api/v1/admin/libraries/{id}/enrichment", a.requireAdmin(http.HandlerFunc(a.handleSetEnrichment))) + mux.Handle("GET /api/v1/admin/libraries/{id}/export", a.requireAdmin(http.HandlerFunc(a.handleExportLibrary))) mux.Handle("POST /api/v1/admin/libraries/{id}/scan", a.requireAdmin(http.HandlerFunc(a.handleScanLibrary))) mux.Handle("GET /api/v1/admin/libraries/{id}/scan", a.requireAdmin(http.HandlerFunc(a.handleScanStatus))) diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 73f13b6..9c6ff1d 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -35,6 +35,7 @@ func (a *API) handleServerInfo(w http.ResponseWriter, r *http.Request) { "upload": false, // Phase B "websocket": false, // Phase C "api_keys": true, // user-minted personal access tokens (POST /auth/tokens) + "export": true, // admin library export (GET /admin/libraries/{id}/export) "metadata": a.metadataOn(), // community metadata lookup (GET /libraries/{id}/meta); runtime-toggleable }, "auth": map[string]any{ diff --git a/internal/api/handlers_export.go b/internal/api/handlers_export.go new file mode 100644 index 0000000..6045674 --- /dev/null +++ b/internal/api/handlers_export.go @@ -0,0 +1,48 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/kodestar/audiosilo-server/internal/catalog" +) + +// handleExportLibrary serves a library's book list as a downloadable JSON file +// that the community metadata site (meta.audiosilo.app) can import, so a user can +// mark which entries of a series they own. +// +// Admin-only: it is a whole-library dump, not a scoped view, so it is not gated +// on share path rules - requireAdmin is the gate. The envelope is composed in +// internal/catalog (export.go); this handler is transport-only and writes it +// straight to the response with an encoder rather than buffering a string of +// its own. +// +// The file carries bibliographic facts only - no paths, sizes, codecs or anything +// else describing the filesystem - because it is meant to leave the server. +func (a *API) handleExportLibrary(w http.ResponseWriter, r *http.Request) { + id, ok := pathInt(r, "id") + if !ok { + writeError(w, http.StatusBadRequest, "invalid library id") + return + } + exp, err := a.cat.ExportLibraryBooks(r.Context(), id, Version) + if errors.Is(err, catalog.ErrNotFound) { + writeError(w, http.StatusNotFound, "library not found") + return + } + if err != nil { + a.writeCatalogError(w, err, "export library failed", "could not export library", "library", id) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + // strconv.Quote gives the quoted-string form browsers expect (same convention + // as media.ServeFile's download header). + w.Header().Set("Content-Disposition", "attachment; filename="+strconv.Quote(exp.Filename())) + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(exp); err != nil { + // The status and headers are already out; all that is left is a log line. + a.log.Warn("export library: write failed", "err", err, "library", id) + } +} diff --git a/internal/api/handlers_export_test.go b/internal/api/handlers_export_test.go new file mode 100644 index 0000000..3d6eb0d --- /dev/null +++ b/internal/api/handlers_export_test.go @@ -0,0 +1,155 @@ +package api + +import ( + "context" + "encoding/json" + "mime" + "net/http" + "strconv" + "strings" + "testing" + + "github.com/kodestar/audiosilo-server/internal/auth" + "github.com/kodestar/audiosilo-server/internal/catalog" +) + +// TestExportLibrary covers the allowed+denied pair for +// GET /admin/libraries/{id}/export: an admin gets the importable envelope with +// download headers; a non-admin and an anonymous caller are refused; an unknown +// library 404s with the usual {error} envelope. +func TestExportLibrary(t *testing.T) { + e := newTestEnv(t) + ctx := context.Background() + lib, _ := e.cat.CreateLibrary(ctx, catalog.Library{Name: "My Books", Root: t.TempDir()}) + if _, err := e.cat.UpsertBook(ctx, &catalog.Book{ + LibraryID: lib.ID, RelPath: "Lee Child/Die Trying", IsFolder: true, + Title: "Die Trying", Author: "Lee Child", Narrator: "Dick Hill", + Series: "Jack Reacher", SeriesIndex: 2, Duration: 36720, ASIN: "B0TESTASIN", + Format: "mp3", Size: 4242, + }); err != nil { + t.Fatal(err) + } + adminTok, _ := e.auth.IssueToken(ctx, e.adminID, auth.KindSession, "t", 0) + path := "/api/v1/admin/libraries/" + strconv.FormatInt(lib.ID, 10) + "/export" + + // Allowed: the admin downloads the file. + resp, body := e.do(t, "GET", path, adminTok, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("export = %d %s, want 200", resp.StatusCode, body) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Errorf("Content-Type = %q, want application/json", ct) + } + disp, params, err := mime.ParseMediaType(resp.Header.Get("Content-Disposition")) + if err != nil { + t.Fatalf("Content-Disposition %q: %v", resp.Header.Get("Content-Disposition"), err) + } + if disp != "attachment" { + t.Errorf("disposition = %q, want attachment", disp) + } + if !strings.HasPrefix(params["filename"], "audiosilo-my-books-") || + !strings.HasSuffix(params["filename"], ".json") { + t.Errorf("filename = %q, want audiosilo-my-books-.json", params["filename"]) + } + + // Round-trip the envelope: exactly the shape the metadata site imports. + var out struct { + Format string `json:"format"` + Version int `json:"version"` + Source string `json:"source"` + ServerVersion string `json:"server_version"` + Library struct { + ID int64 `json:"id"` + Name string `json:"name"` + } `json:"library"` + ExportedAt string `json:"exported_at"` + Books []struct { + Title string `json:"title"` + Authors []string `json:"authors"` + Narrators []string `json:"narrators"` + Series string `json:"series"` + SeriesPosition string `json:"series_position"` + ASIN string `json:"asin"` + RuntimeMin int `json:"runtime_min"` + } `json:"books"` + } + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("decode: %v (%s)", err, body) + } + if out.Format != "audiosilo-books" || out.Version != 1 || out.Source != "audiosilo-server" { + t.Fatalf("envelope = %+v", out) + } + if out.ServerVersion != Version { + t.Errorf("server_version = %q, want %q", out.ServerVersion, Version) + } + if out.Library.ID != lib.ID || out.Library.Name != "My Books" || out.ExportedAt == "" { + t.Errorf("library/exported_at = %+v %q", out.Library, out.ExportedAt) + } + if len(out.Books) != 1 { + t.Fatalf("books = %d, want 1: %s", len(out.Books), body) + } + b := out.Books[0] + if b.Title != "Die Trying" || b.Series != "Jack Reacher" || b.SeriesPosition != "2" || + b.ASIN != "B0TESTASIN" || b.RuntimeMin != 612 { + t.Errorf("book = %+v", b) + } + if len(b.Authors) != 1 || b.Authors[0] != "Lee Child" || + len(b.Narrators) != 1 || b.Narrators[0] != "Dick Hill" { + t.Errorf("contributors = %v / %v", b.Authors, b.Narrators) + } + // Nothing about the filesystem leaves the server. + for _, leak := range []string{"Lee Child/Die Trying", "rel_path", "4242", `"size"`} { + if strings.Contains(body, leak) { + t.Errorf("export leaks %q: %s", leak, body) + } + } + + // Denied: a non-admin session is refused by requireAdmin. + member, _ := e.auth.CreateUser(ctx, "member", "member-password", auth.RoleUser) + memberTok, _ := e.auth.IssueToken(ctx, member.ID, auth.KindSession, "t", 0) + if resp, body := e.do(t, "GET", path, memberTok, ""); resp.StatusCode != http.StatusForbidden { + t.Fatalf("non-admin export = %d %s, want 403", resp.StatusCode, body) + } + // Denied: no credential at all. + if resp, body := e.do(t, "GET", path, "", ""); resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("anonymous export = %d %s, want 401", resp.StatusCode, body) + } + // Denied: a token in the query string is not accepted (export is not a media + // route, so the ?token= fallback must not apply). + if resp, _ := e.do(t, "GET", path+"?token="+adminTok, "", ""); resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("query-token export = %d, want 401", resp.StatusCode) + } + + // Unknown library: the usual {error} envelope. + resp, body = e.do(t, "GET", "/api/v1/admin/libraries/9999/export", adminTok, "") + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("unknown library = %d %s, want 404", resp.StatusCode, body) + } + var errEnv struct { + Error string `json:"error"` + } + if err := json.Unmarshal([]byte(body), &errEnv); err != nil || errEnv.Error == "" { + t.Errorf("404 body = %s, want an {error} envelope", body) + } + + // A non-numeric id is a 400, not a 404. + if resp, _ := e.do(t, "GET", "/api/v1/admin/libraries/abc/export", adminTok, ""); resp.StatusCode != http.StatusBadRequest { + t.Fatalf("bad id = %d, want 400", resp.StatusCode) + } +} + +// TestServerInfoExportCapability checks the additive capability flag clients gate +// the Export affordance on. +func TestServerInfoExportCapability(t *testing.T) { + e := newTestEnv(t) + _, body := e.do(t, "GET", "/api/v1/server", "", "") + var out struct { + Capabilities map[string]bool `json:"capabilities"` + } + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("decode: %v (%s)", err, body) + } + if !out.Capabilities["export"] { + t.Fatalf("capabilities.export = false, want true: %s", body) + } +} diff --git a/internal/catalog/export.go b/internal/catalog/export.go new file mode 100644 index 0000000..df43df7 --- /dev/null +++ b/internal/catalog/export.go @@ -0,0 +1,303 @@ +package catalog + +import ( + "context" + "math" + "regexp" + "strconv" + "strings" + "time" + "unicode" +) + +// Library export: a portable, filesystem-free list of the books a library holds. +// +// The file is meant to LEAVE the server - a user downloads it and uploads it to +// the community metadata site (meta.audiosilo.app) to mark which entries of a +// series they own. So it carries bibliographic facts only: titles, contributors, +// series position, identifiers, runtime. Never a path, size, codec, format or +// anything else that describes the filesystem or the install. +// +// The envelope is the shape the metadata site's importer already parses; treat +// it as a wire contract (additive changes only, `omitempty` everywhere). + +const ( + // exportFormat/exportVersion identify the envelope to the importer. + exportFormat = "audiosilo-books" + exportVersion = 1 + // exportSource names the producer, so the importer can tell a server export + // from one written by another tool. + exportSource = "audiosilo-server" + + // exportPageSize is how many books are pulled per keyset page while composing + // the export (ListBooks caps a page at 200). + exportPageSize = 200 +) + +// LibraryExport is the downloadable envelope: a library's book list, ready to be +// imported elsewhere. +type LibraryExport struct { + Format string `json:"format"` + Version int `json:"version"` + Source string `json:"source"` + ServerVersion string `json:"server_version,omitempty"` + Library ExportLibrary `json:"library"` + ExportedAt string `json:"exported_at"` + Books []ExportBook `json:"books"` +} + +// ExportLibrary identifies the exported library. Deliberately name + id only - +// the library's root path never leaves the server. +type ExportLibrary struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// ExportBook is one book as the importer sees it: bibliographic facts only. +// Every field but the title is optional and omitted when unknown. (The importer +// also accepts a `subtitle`, which the index has no column for; the envelope is +// additive, so that field appears here the day the index grows one.) +type ExportBook struct { + Title string `json:"title"` + Authors []string `json:"authors,omitempty"` + Narrators []string `json:"narrators,omitempty"` + Series string `json:"series,omitempty"` + SeriesPosition string `json:"series_position,omitempty"` + ASIN string `json:"asin,omitempty"` + ISBN string `json:"isbn,omitempty"` + RuntimeMin int `json:"runtime_min,omitempty"` + Chapters int `json:"chapters,omitempty"` +} + +// Filename is the file name to offer the download as: +// audiosilo--.json. +func (e *LibraryExport) Filename() string { + date, _, _ := strings.Cut(e.ExportedAt, "T") // RFC3339: the date is what precedes the T + slug := slugify(e.Library.Name) + if slug == "" { + slug = "library" + } + return "audiosilo-" + slug + "-" + date + ".json" +} + +// ExportLibraryBooks composes a library's export envelope. It pages over the +// index with the normal keyset cursor (never OFFSET, which degrades on large +// libraries) and returns ErrNotFound when the library does not exist. +// +// serverVersion is passed in rather than read from the api package: catalog must +// not depend on the transport layer. +func (c *Catalog) ExportLibraryBooks(ctx context.Context, libraryID int64, serverVersion string) (*LibraryExport, error) { + lib, err := c.GetLibrary(ctx, libraryID) + if err != nil { + return nil, err + } + out := &LibraryExport{ + Format: exportFormat, + Version: exportVersion, + Source: exportSource, + ServerVersion: serverVersion, + Library: ExportLibrary{ID: lib.ID, Name: lib.Name}, + ExportedAt: c.now().UTC().Format(time.RFC3339), + Books: []ExportBook{}, + } + + counts, err := c.chapterCounts(ctx, libraryID) + if err != nil { + return nil, err + } + + // seen collapses copies of the same book within the library (the same grouping + // key search/"recently added" de-duplicate on), mapping the key to the entry + // already emitted for it. A blank key means the metadata is too weak to merge + // on, so those books are always kept. + seen := map[string]int{} + cursor := "" + for { + page, err := c.ListBooks(ctx, ListOptions{ + LibraryID: libraryID, + Sort: "title", + Limit: exportPageSize, + Cursor: cursor, + }) + if err != nil { + return nil, err + } + for i := range page.Books { + b := &page.Books[i] + eb := exportBook(b, counts[b.ID]) + if key := exposedDedupKey(*b); key != "" { + if at, ok := seen[key]; ok { + mergeExportBook(&out.Books[at], eb) + continue + } + seen[key] = len(out.Books) + } + out.Books = append(out.Books, eb) + } + if page.NextCursor == "" || page.NextCursor == cursor { + // An unchanged cursor would loop forever; stop rather than spin. + break + } + cursor = page.NextCursor + } + return out, nil +} + +// exportBook projects an indexed book onto the export shape. A multi-file book is +// one book here, exactly as it is one row in the index. +func exportBook(b *Book, chapters int) ExportBook { + return ExportBook{ + Title: strings.TrimSpace(b.Title), + Authors: splitNames(b.Author), + Narrators: splitNames(b.Narrator), + Series: strings.TrimSpace(b.Series), + SeriesPosition: formatSeriesPosition(b.SeriesIndex), + ASIN: strings.TrimSpace(b.ASIN), + ISBN: strings.TrimSpace(b.ISBN), + RuntimeMin: runtimeMinutes(b.Duration), + Chapters: chapters, + } +} + +// mergeExportBook folds another copy of an already-exported book into the entry +// kept for it, filling only the facts that entry lacks. Copies are indexed +// independently, so the one that happens to sort first can be the sparsely-tagged +// rip (same asin, but no narrator/series tags) while the fully-tagged copy sorts +// later; taking each fact from whichever copy has it keeps the export as complete +// as the library is, without ever overwriting a fact already recorded. +func mergeExportBook(dst *ExportBook, src ExportBook) { + if dst.Title == "" { + dst.Title = src.Title + } + if len(dst.Authors) == 0 { + dst.Authors = src.Authors + } + if len(dst.Narrators) == 0 { + dst.Narrators = src.Narrators + } + if dst.Series == "" { + dst.Series = src.Series + } + if dst.SeriesPosition == "" { + dst.SeriesPosition = src.SeriesPosition + } + if dst.ASIN == "" { + dst.ASIN = src.ASIN + } + if dst.ISBN == "" { + dst.ISBN = src.ISBN + } + if dst.RuntimeMin == 0 { + dst.RuntimeMin = src.RuntimeMin + } + if dst.Chapters == 0 { + dst.Chapters = src.Chapters + } +} + +// chapterCounts returns the number of indexed chapters per book id for a whole +// library - one grouped query instead of loading every book's chapter rows (and +// instead of one query per export page; the export already holds every book of +// the library in memory, so a count per book id is strictly cheaper than that). +func (c *Catalog) chapterCounts(ctx context.Context, libraryID int64) (map[int64]int, error) { + rows, err := c.db.QueryContext(ctx, + `SELECT c.book_id, COUNT(*) FROM chapters c + JOIN books b ON b.id = c.book_id + WHERE b.library_id = ? GROUP BY c.book_id`, libraryID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]int{} + for rows.Next() { + var id int64 + var n int + if err := rows.Scan(&id, &n); err != nil { + return nil, err + } + out[id] = n + } + return out, rows.Err() +} + +// runtimeMinutes converts an indexed duration in seconds to whole minutes, +// rounded. Zero (unknown duration) stays zero so the field is omitted. +func runtimeMinutes(seconds float64) int { + if seconds <= 0 || math.IsNaN(seconds) || math.IsInf(seconds, 0) { + return 0 + } + return int(math.Round(seconds / 60)) +} + +// formatSeriesPosition renders the float series index the way a reader writes it: +// "2" for 2.0, "2.5" for a novella between books. Zero means "no position" and +// renders as "" so the field is omitted. +func formatSeriesPosition(idx float64) string { + if idx == 0 || math.IsNaN(idx) || math.IsInf(idx, 0) { + return "" + } + return strconv.FormatFloat(idx, 'f', -1, 64) +} + +// nameSeparatorRE matches the joiners that unambiguously separate two +// contributors in the single Author/Narrator string the index stores. A comma is +// NOT here: it is ambiguous ("Alexandre Dumas, pere") and is handled separately +// by splitOnCommas. +var nameSeparatorRE = regexp.MustCompile(`;|\s+&\s+|\s+and\s+`) + +// splitNames turns the catalogue's single Author (or Narrator) string into a list +// of names, splitting ONLY where the string clearly holds several. Tag data is +// messy and a wrong split invents a person, so the rule is deliberately shy: +// unambiguous joiners always split; a comma splits only when every resulting part +// still looks like a full name (at least two words), which keeps suffixed names +// such as "Alexandre Dumas, pere" whole. Returns nil for a blank string. +func splitNames(s string) []string { + var out []string + for _, chunk := range cleanNameParts(nameSeparatorRE.Split(s, -1)) { + out = append(out, splitOnCommas(chunk)...) + } + return out // nil when the string named nobody +} + +// splitOnCommas splits one already-cleaned chunk on commas, but only when every +// piece has at least two words - otherwise the comma is part of a single name +// ("Dumas, pere"; "Doe, John") and the chunk is returned whole. +func splitOnCommas(p string) []string { + pieces := cleanNameParts(strings.Split(p, ",")) + if len(pieces) < 2 { + return []string{p} + } + for _, piece := range pieces { + if len(strings.Fields(piece)) < 2 { + return []string{p} + } + } + return pieces +} + +// cleanNameParts trims whitespace and dangling separator punctuation from BOTH +// ends of each part and drops the empties. Trailing: an "A, B, and C" split +// leaves a trailing comma. Leading: a half-empty "Last, First" tag arrives as +// ", Jane Doe", and splitOnCommas keeps such a chunk whole, so an untrimmed +// leading comma would otherwise be exported as part of the name. +func cleanNameParts(parts []string) []string { + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimFunc(p, func(r rune) bool { + return unicode.IsSpace(r) || r == ';' || r == '&' || r == ',' + }) + if p != "" { + out = append(out, p) + } + } + return out +} + +// slugify reduces a library name to a filename-safe slug: lowercase ASCII words +// joined by hyphens, with everything else dropped. +func slugify(s string) string { + words := strings.FieldsFunc(s, func(r rune) bool { + return r >= unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) + }) + return strings.ToLower(strings.Join(words, "-")) +} diff --git a/internal/catalog/export_test.go b/internal/catalog/export_test.go new file mode 100644 index 0000000..812ad7a --- /dev/null +++ b/internal/catalog/export_test.go @@ -0,0 +1,362 @@ +package catalog + +import ( + "encoding/json" + "errors" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/kodestar/audiosilo-server/internal/metadata" +) + +// TestSplitNames pins the contributor-splitting rule: unambiguous joiners always +// split, a comma splits only when every part still looks like a full name, and a +// single name is never broken up. +func TestSplitNames(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"", nil}, + {" ", nil}, + {"Brandon Sanderson", []string{"Brandon Sanderson"}}, + {" Brandon Sanderson ", []string{"Brandon Sanderson"}}, + // The comma here is part of ONE name: "pere" is a single word. + {"Alexandre Dumas, pere", []string{"Alexandre Dumas, pere"}}, + {"Dumas, Alexandre", []string{"Dumas, Alexandre"}}, + {"Martin Luther King, Jr.", []string{"Martin Luther King, Jr."}}, + // Unambiguous joiners. + {"Terry Pratchett & Neil Gaiman", []string{"Terry Pratchett", "Neil Gaiman"}}, + {"Terry Pratchett and Neil Gaiman", []string{"Terry Pratchett", "Neil Gaiman"}}, + {"A; B", []string{"A", "B"}}, + {"A;B;C", []string{"A", "B", "C"}}, + // Every comma part has two words, so the comma splits. + {"Terry Pratchett, Neil Gaiman", []string{"Terry Pratchett", "Neil Gaiman"}}, + {"Terry Pratchett, Neil Gaiman, and Rob Wilkins", + []string{"Terry Pratchett", "Neil Gaiman", "Rob Wilkins"}}, + // Mixed joiners, and the comma rule applied per chunk. + {"Jane Doe; John Roe, Ann Poe", []string{"Jane Doe", "John Roe", "Ann Poe"}}, + {"Jane Doe; Alexandre Dumas, pere", []string{"Jane Doe", "Alexandre Dumas, pere"}}, + // Trailing/duplicated/stacked separators collapse rather than yielding + // blanks or a name beginning with a stranded joiner. + {"A & B;", []string{"A", "B"}}, + {"Jane Doe; and John Roe", []string{"Jane Doe", "John Roe"}}, + // A half-empty "Last, First" tag: the dangling comma is trimmed off both + // ends rather than exported as part of the name. + {", Jane Doe", []string{"Jane Doe"}}, + {"Jane Doe,", []string{"Jane Doe"}}, + } + for _, tc := range cases { + if got := splitNames(tc.in); !reflect.DeepEqual(got, tc.want) { + t.Errorf("splitNames(%q) = %#v, want %#v", tc.in, got, tc.want) + } + } +} + +func TestFormatSeriesPosition(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + {0, ""}, + {2, "2"}, + {2.0, "2"}, + {2.5, "2.5"}, + {10, "10"}, + {1.25, "1.25"}, + } + for _, tc := range cases { + if got := formatSeriesPosition(tc.in); got != tc.want { + t.Errorf("formatSeriesPosition(%v) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestRuntimeMinutes(t *testing.T) { + cases := []struct { + in float64 + want int + }{ + {0, 0}, + {-5, 0}, + {29, 0}, // rounds down to 0 -> omitted + {31, 1}, // rounds up + {36720, 612}, // 10h12m + } + for _, tc := range cases { + if got := runtimeMinutes(tc.in); got != tc.want { + t.Errorf("runtimeMinutes(%v) = %d, want %d", tc.in, got, tc.want) + } + } +} + +func TestSlugifyAndFilename(t *testing.T) { + cases := []struct{ in, want string }{ + {"Main", "main"}, + {"My Audiobooks", "my-audiobooks"}, + {"Sci-Fi / Fantasy", "sci-fi-fantasy"}, + {" ", ""}, + } + for _, tc := range cases { + if got := slugify(tc.in); got != tc.want { + t.Errorf("slugify(%q) = %q, want %q", tc.in, got, tc.want) + } + } + exp := &LibraryExport{ExportedAt: "2026-09-21T10:00:00Z"} + exp.Library.Name = "My Audiobooks" + if got := exp.Filename(); got != "audiosilo-my-audiobooks-2026-09-21.json" { + t.Errorf("Filename() = %q", got) + } + // An unnameable library still yields a usable filename. + exp.Library.Name = "???" + if got := exp.Filename(); got != "audiosilo-library-2026-09-21.json" { + t.Errorf("Filename() fallback = %q", got) + } +} + +// TestExportLibraryBooks covers the composed envelope end to end: the header +// fields, the per-book projection, chapter counts, multi-file books counting +// once, and de-duplication of a second copy of the same book. +func TestExportLibraryBooks(t *testing.T) { + c, ctx := newTestCatalog(t) + lib, _ := c.CreateLibrary(ctx, Library{Name: "Main", Root: "/tmp/main"}) + + // A folder book with several files and embedded chapters. + folder := &Book{ + LibraryID: lib.ID, RelPath: "Lee Child/Die Trying", IsFolder: true, + Title: "Die Trying", Author: "Lee Child", Narrator: "Dick Hill", + Series: "Jack Reacher", SeriesIndex: 2, Duration: 36720, + ASIN: "B0TESTASIN", Format: "mp3", Size: 100, + Files: []BookFile{ + {RelPath: "Lee Child/Die Trying/01.mp3", Seq: 0, Duration: 18360, Format: "mp3", Size: 50}, + {RelPath: "Lee Child/Die Trying/02.mp3", Seq: 1, Duration: 18360, Format: "mp3", Size: 50}, + }, + Chapters: []metadata.Chapter{ + {Index: 0, Title: "One", FilePath: "Lee Child/Die Trying/01.mp3", End: 18360}, + {Index: 1, Title: "Two", FilePath: "Lee Child/Die Trying/02.mp3", End: 18360, BookOffset: 18360}, + }, + } + if _, err := c.UpsertBook(ctx, folder); err != nil { + t.Fatal(err) + } + // A single-file book with two authors and no series position. + if _, err := c.UpsertBook(ctx, &Book{ + LibraryID: lib.ID, RelPath: "Good Omens.m4b", + Title: "Good Omens", Author: "Terry Pratchett & Neil Gaiman", + Narrator: "Martin Jarvis", ISBN: "9780060853983", Duration: 90, + Format: "m4b", Size: 10, + }); err != nil { + t.Fatal(err) + } + // A second copy of Die Trying in the same library: same ASIN, so it collapses. + if _, err := c.UpsertBook(ctx, &Book{ + LibraryID: lib.ID, RelPath: "Duplicates/Die Trying.m4b", + Title: "Die Trying", Author: "Lee Child", ASIN: "B0TESTASIN", + Duration: 36720, Format: "m4b", Size: 200, + }); err != nil { + t.Fatal(err) + } + + exp, err := c.ExportLibraryBooks(ctx, lib.ID, "1.2.3") + if err != nil { + t.Fatalf("ExportLibraryBooks: %v", err) + } + if exp.Format != "audiosilo-books" || exp.Version != 1 || exp.Source != "audiosilo-server" { + t.Fatalf("envelope header = %+v", exp) + } + if exp.ServerVersion != "1.2.3" { + t.Errorf("server_version = %q, want 1.2.3", exp.ServerVersion) + } + if exp.Library.ID != lib.ID || exp.Library.Name != "Main" { + t.Errorf("library = %+v", exp.Library) + } + if exp.ExportedAt == "" || strings.Contains(exp.ExportedAt, ".") { + t.Errorf("exported_at = %q, want whole-second RFC3339", exp.ExportedAt) + } + // Three rows, two distinct books: the duplicate copy collapsed. + if len(exp.Books) != 2 { + t.Fatalf("books = %d, want 2: %+v", len(exp.Books), exp.Books) + } + + byTitle := map[string]ExportBook{} + for _, b := range exp.Books { + byTitle[b.Title] = b + } + die, ok := byTitle["Die Trying"] + if !ok { + t.Fatalf("Die Trying missing: %+v", exp.Books) + } + want := ExportBook{ + Title: "Die Trying", Authors: []string{"Lee Child"}, Narrators: []string{"Dick Hill"}, + Series: "Jack Reacher", SeriesPosition: "2", ASIN: "B0TESTASIN", + RuntimeMin: 612, Chapters: 2, + } + if !reflect.DeepEqual(die, want) { + t.Errorf("Die Trying = %#v, want %#v", die, want) + } + omens := byTitle["Good Omens"] + if !reflect.DeepEqual(omens.Authors, []string{"Terry Pratchett", "Neil Gaiman"}) { + t.Errorf("Good Omens authors = %#v", omens.Authors) + } + if omens.SeriesPosition != "" || omens.Series != "" || omens.Chapters != 0 { + t.Errorf("Good Omens should omit series/chapters: %#v", omens) + } + if omens.ISBN != "9780060853983" || omens.RuntimeMin != 2 { + t.Errorf("Good Omens isbn/runtime = %q/%d", omens.ISBN, omens.RuntimeMin) + } +} + +// TestExportLibraryBooksMergesDuplicateCopies pins what the collapsed entry +// carries. Copies are indexed independently, so the copy that sorts first can be +// the sparsely-tagged rip; the kept entry must still carry every fact the library +// holds for that book, not just the ones the first-sorting row happened to have. +func TestExportLibraryBooksMergesDuplicateCopies(t *testing.T) { + c, ctx := newTestCatalog(t) + lib, _ := c.CreateLibrary(ctx, Library{Name: "Main", Root: "/tmp/main"}) + + // Indexed first (so it sorts first on the (title, id) keyset): an old rip with + // nothing but the title, author and asin. + if _, err := c.UpsertBook(ctx, &Book{ + LibraryID: lib.ID, RelPath: "Rips/Die Trying.mp3", + Title: "Die Trying", Author: "Lee Child", ASIN: "B0TESTASIN", + Format: "mp3", Size: 10, + }); err != nil { + t.Fatal(err) + } + // Indexed second: the fully-tagged copy of the same book (same asin). + if _, err := c.UpsertBook(ctx, &Book{ + LibraryID: lib.ID, RelPath: "Lee Child/Die Trying", IsFolder: true, + Title: "Die Trying", Author: "Lee Child", Narrator: "Dick Hill", + Series: "Jack Reacher", SeriesIndex: 2, Duration: 36720, + ASIN: "B0TESTASIN", ISBN: "9780515123333", Format: "m4b", Size: 100, + Chapters: []metadata.Chapter{ + {Index: 0, Title: "One", End: 18360}, + {Index: 1, Title: "Two", End: 36720, BookOffset: 18360}, + }, + }); err != nil { + t.Fatal(err) + } + + exp, err := c.ExportLibraryBooks(ctx, lib.ID, "dev") + if err != nil { + t.Fatal(err) + } + if len(exp.Books) != 1 { + t.Fatalf("books = %d, want 1 (the copies collapse): %+v", len(exp.Books), exp.Books) + } + want := ExportBook{ + Title: "Die Trying", Authors: []string{"Lee Child"}, Narrators: []string{"Dick Hill"}, + Series: "Jack Reacher", SeriesPosition: "2", ASIN: "B0TESTASIN", + ISBN: "9780515123333", RuntimeMin: 612, Chapters: 2, + } + if !reflect.DeepEqual(exp.Books[0], want) { + t.Errorf("collapsed entry = %#v, want %#v", exp.Books[0], want) + } +} + +// TestExportLibraryBooksLeaksNoFilesystem is the guard that matters most: the +// file leaves the server, so no path, size, codec or format may appear in it. +func TestExportLibraryBooksLeaksNoFilesystem(t *testing.T) { + c, ctx := newTestCatalog(t) + lib, _ := c.CreateLibrary(ctx, Library{Name: "Main", Root: "/srv/secret-root"}) + if _, err := c.UpsertBook(ctx, &Book{ + LibraryID: lib.ID, RelPath: "Author/Secret Folder/book.m4b", Title: "Book", + Author: "Someone", Format: "m4b", Codec: "aac", Size: 12345, + CoverPath: "/srv/secret-root/cover.jpg", ContentHash: "deadbeef", + }); err != nil { + t.Fatal(err) + } + exp, err := c.ExportLibraryBooks(ctx, lib.ID, "dev") + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(exp) + if err != nil { + t.Fatal(err) + } + // Values that exist on the indexed row but must never reach the file. + for _, leak := range []string{"secret-root", "Secret Folder", "book.m4b", "rel_path", + "aac", "12345", "deadbeef", "cover", "content_hash"} { + if strings.Contains(string(raw), leak) { + t.Errorf("export leaks %q: %s", leak, raw) + } + } + + // The teeth: assert on the MARSHALLED key sets, not on the Go structs, and as + // an allowlist rather than a denylist - so a field added to ExportBook (or to + // the envelope) fails here even when it has a name the substring scan above + // cannot see, such as "format" (the envelope's own marker) or "files". + var doc map[string]json.RawMessage + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + assertKeys := func(what string, obj map[string]json.RawMessage, allowed ...string) { + t.Helper() + ok := map[string]bool{} + for _, k := range allowed { + ok[k] = true + } + for k := range obj { + if !ok[k] { + t.Errorf("export %s carries unexpected field %q: %s", what, k, raw) + } + } + } + assertKeys("envelope", doc, + "format", "version", "source", "server_version", "library", "exported_at", "books") + + var libObj map[string]json.RawMessage + if err := json.Unmarshal(doc["library"], &libObj); err != nil { + t.Fatal(err) + } + assertKeys("library", libObj, "id", "name") // never "root" + + var books []map[string]json.RawMessage + if err := json.Unmarshal(doc["books"], &books); err != nil { + t.Fatal(err) + } + if len(books) != 1 { + t.Fatalf("books = %d, want 1: %s", len(books), raw) + } + assertKeys("book", books[0], "title", "subtitle", "authors", "narrators", + "series", "series_position", "asin", "isbn", "runtime_min", "chapters") +} + +// TestExportLibraryBooksPagesEveryBook checks the keyset paging loop drains a +// library larger than one page (exportPageSize) without repeating or dropping. +func TestExportLibraryBooksPagesEveryBook(t *testing.T) { + c, ctx := newTestCatalog(t) + lib, _ := c.CreateLibrary(ctx, Library{Name: "Big", Root: "/tmp/big"}) + const n = exportPageSize + 25 + for i := range n { + title := "Book " + string(rune('A'+i%26)) + "-" + strconv.Itoa(i) + if _, err := c.UpsertBook(ctx, &Book{ + LibraryID: lib.ID, RelPath: title + ".m4b", Title: title, Author: "Author " + strconv.Itoa(i), + }); err != nil { + t.Fatal(err) + } + } + exp, err := c.ExportLibraryBooks(ctx, lib.ID, "dev") + if err != nil { + t.Fatal(err) + } + if len(exp.Books) != n { + t.Fatalf("books = %d, want %d", len(exp.Books), n) + } + seen := map[string]bool{} + for _, b := range exp.Books { + if seen[b.Title] { + t.Fatalf("duplicate book in export: %q", b.Title) + } + seen[b.Title] = true + } +} + +func TestExportLibraryBooksUnknownLibrary(t *testing.T) { + c, ctx := newTestCatalog(t) + if _, err := c.ExportLibraryBooks(ctx, 999, "dev"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} diff --git a/internal/web/assets/admin.js b/internal/web/assets/admin.js index 120d943..1e23bfc 100644 --- a/internal/web/assets/admin.js +++ b/internal/web/assets/admin.js @@ -14,8 +14,12 @@ const el = (id) => document.getElementById(id); const loginView = el("login-view"); const app = el("app"); -// api wraps fetch with auth + JSON handling. On 401 it forces re-login. -async function api(method, path, body) { +// apiFetch wraps fetch with the API prefix and auth, and owns what a failed +// admin call means: on 401 it forces re-login, and any other error status is +// raised as the server's {error} envelope (falling back to the status text). +// It hands back the raw Response, so a caller that wants the bytes rather than +// the JSON - the library export download - shares that one contract. +async function apiFetch(method, path, body) { const opts = { method, headers: {} }; if (token) opts.headers["Authorization"] = "Bearer " + token; if (body !== undefined) { @@ -27,10 +31,21 @@ async function api(method, path, body) { logout(); throw new Error(asI18n.t("admin.toast.sessionExpired")); } - const text = await resp.text(); - const data = text ? JSON.parse(text) : {}; - if (!resp.ok) throw new Error(data.error || resp.statusText); - return data; + if (!resp.ok) { + let msg = resp.statusText; + try { + const data = await resp.json(); + if (data && data.error) msg = data.error; + } catch { /* a non-JSON error body: keep the status text */ } + throw new Error(msg); + } + return resp; +} + +// api is apiFetch plus JSON decoding - what almost every call wants. +async function api(method, path, body) { + const text = await (await apiFetch(method, path, body)).text(); + return text ? JSON.parse(text) : {}; } // ---- Toast ---- @@ -215,7 +230,7 @@ async function loadLibraries() { tr.append( td(l.name), td(l.root, "code"), - actionTd(...reorderBtns(i, librariesCache.length), detectBtn(l), scanBtn(l.id), deleteLibBtn(l)), + actionTd(...reorderBtns(i, librariesCache.length), detectBtn(l), exportBtn(l), scanBtn(l.id), deleteLibBtn(l)), ); rows.appendChild(tr); }); @@ -268,6 +283,52 @@ function deleteLibBtn(l) { }); } +// Download the library's book list as a JSON file the community metadata site +// (meta.audiosilo.app) can import. The console authenticates with a bearer token +// held in localStorage, so this cannot be a plain download - the request +// has to carry the Authorization header. We therefore fetch it and hand the body +// to the browser as a blob object URL. This stays within the strict CSP: an +// object URL the page itself creates is not a fetched resource, and a download +// triggered by a.download is not a resource load either. +function exportBtn(l) { + const b = button(asI18n.t("admin.libraries.export"), "secondary small", async () => { + b.disabled = true; + b.textContent = asI18n.t("admin.libraries.exporting"); + try { + await downloadLibraryExport(l.id); + toast(asI18n.t("admin.toast.libraryExported")); + } catch (err) { + toast(err.message || asI18n.t("admin.toast.libraryExportFailed"), "error"); + } finally { + b.disabled = false; + b.textContent = asI18n.t("admin.libraries.export"); + } + }); + return b; +} + +async function downloadLibraryExport(id) { + const resp = await apiFetch("GET", `/admin/libraries/${id}/export`); + const name = filenameFromDisposition(resp.headers.get("Content-Disposition")) || `audiosilo-library-${id}.json`; + const url = URL.createObjectURL(await resp.blob()); + const a = document.createElement("a"); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + // Revoking synchronously can cancel the save in some browsers; do it after. + setTimeout(() => URL.revokeObjectURL(url), 10000); +} + +// filenameFromDisposition reads the server's quoted attachment filename so the +// saved file keeps the name (and date) the server chose. The header is always +// quoted (handlers_export.go uses strconv.Quote), so one shape is enough. +function filenameFromDisposition(header) { + const m = header && /filename="([^"]*)"/i.exec(header); + return m ? m[1].trim() : ""; +} + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function scanBtn(id) { diff --git a/internal/web/assets/i18n-dict.js b/internal/web/assets/i18n-dict.js index ef5e1ed..2a86341 100644 --- a/internal/web/assets/i18n-dict.js +++ b/internal/web/assets/i18n-dict.js @@ -82,6 +82,8 @@ window.asI18nDict = { "admin.libraries.moveUp": "Move up (wins duplicates)", "admin.libraries.moveDown": "Move down", "admin.libraries.detection": "Detection", + "admin.libraries.export": "Export", + "admin.libraries.exporting": "Exporting…", "admin.scan.rescan": "Rescan", "admin.scan.scanning": "Scanning…", @@ -225,6 +227,8 @@ window.asI18nDict = { "admin.toast.sessionExpired": "Session expired. Please sign in again.", "admin.toast.libraryAdded": "Library added - scanning in the background.", "admin.toast.libraryDeleted": "Library deleted.", + "admin.toast.libraryExported": "Library exported.", + "admin.toast.libraryExportFailed": "Export failed.", "admin.toast.rescanStarted": "Rescan started.", "admin.toast.rescanComplete": "Rescan complete.", "admin.toast.userCreated": "User created.", @@ -342,6 +346,8 @@ window.asI18nDict = { "admin.libraries.moveUp": "Subir (gana en duplicados)", "admin.libraries.moveDown": "Bajar", "admin.libraries.detection": "Detección", + "admin.libraries.export": "Exportar", + "admin.libraries.exporting": "Exportando…", "admin.scan.rescan": "Reescanear", "admin.scan.scanning": "Escaneando…", @@ -485,6 +491,8 @@ window.asI18nDict = { "admin.toast.sessionExpired": "La sesión ha caducado. Vuelve a iniciar sesión.", "admin.toast.libraryAdded": "Biblioteca añadida - escaneando en segundo plano.", "admin.toast.libraryDeleted": "Biblioteca eliminada.", + "admin.toast.libraryExported": "Biblioteca exportada.", + "admin.toast.libraryExportFailed": "Error al exportar.", "admin.toast.rescanStarted": "Reescaneo iniciado.", "admin.toast.rescanComplete": "Reescaneo completado.", "admin.toast.userCreated": "Usuario creado.", @@ -602,6 +610,8 @@ window.asI18nDict = { "admin.libraries.moveUp": "Monter (l'emporte sur les doublons)", "admin.libraries.moveDown": "Descendre", "admin.libraries.detection": "Détection", + "admin.libraries.export": "Exporter", + "admin.libraries.exporting": "Exportation…", "admin.scan.rescan": "Réanalyser", "admin.scan.scanning": "Analyse en cours…", @@ -745,6 +755,8 @@ window.asI18nDict = { "admin.toast.sessionExpired": "Session expirée. Reconnecte-toi.", "admin.toast.libraryAdded": "Bibliothèque ajoutée - analyse en arrière-plan.", "admin.toast.libraryDeleted": "Bibliothèque supprimée.", + "admin.toast.libraryExported": "Bibliothèque exportée.", + "admin.toast.libraryExportFailed": "Échec de l'exportation.", "admin.toast.rescanStarted": "Réanalyse lancée.", "admin.toast.rescanComplete": "Réanalyse terminée.", "admin.toast.userCreated": "Utilisateur créé.", @@ -862,6 +874,8 @@ window.asI18nDict = { "admin.libraries.moveUp": "Nach oben (gewinnt bei Duplikaten)", "admin.libraries.moveDown": "Nach unten", "admin.libraries.detection": "Erkennung", + "admin.libraries.export": "Exportieren", + "admin.libraries.exporting": "Exportieren…", "admin.scan.rescan": "Neu scannen", "admin.scan.scanning": "Scannen…", @@ -1005,6 +1019,8 @@ window.asI18nDict = { "admin.toast.sessionExpired": "Sitzung abgelaufen. Bitte melde dich erneut an.", "admin.toast.libraryAdded": "Bibliothek hinzugefügt - wird im Hintergrund gescannt.", "admin.toast.libraryDeleted": "Bibliothek gelöscht.", + "admin.toast.libraryExported": "Bibliothek exportiert.", + "admin.toast.libraryExportFailed": "Export fehlgeschlagen.", "admin.toast.rescanStarted": "Neuer Scan gestartet.", "admin.toast.rescanComplete": "Neuer Scan abgeschlossen.", "admin.toast.userCreated": "Benutzer erstellt.", @@ -1122,6 +1138,8 @@ window.asI18nDict = { "admin.libraries.moveUp": "Subir (ganha nos duplicados)", "admin.libraries.moveDown": "Descer", "admin.libraries.detection": "Deteção", + "admin.libraries.export": "Exportar", + "admin.libraries.exporting": "A exportar…", "admin.scan.rescan": "Reanalisar", "admin.scan.scanning": "A analisar…", @@ -1265,6 +1283,8 @@ window.asI18nDict = { "admin.toast.sessionExpired": "Sessão expirada. Inicia sessão novamente.", "admin.toast.libraryAdded": "Biblioteca adicionada - a analisar em segundo plano.", "admin.toast.libraryDeleted": "Biblioteca eliminada.", + "admin.toast.libraryExported": "Biblioteca exportada.", + "admin.toast.libraryExportFailed": "Falha na exportação.", "admin.toast.rescanStarted": "Reanálise iniciada.", "admin.toast.rescanComplete": "Reanálise concluída.", "admin.toast.userCreated": "Utilizador criado.", @@ -1382,6 +1402,8 @@ window.asI18nDict = { "admin.libraries.moveUp": "Sposta su (vince sui duplicati)", "admin.libraries.moveDown": "Sposta giù", "admin.libraries.detection": "Rilevamento", + "admin.libraries.export": "Esporta", + "admin.libraries.exporting": "Esportazione…", "admin.scan.rescan": "Riscansiona", "admin.scan.scanning": "Scansione…", @@ -1525,6 +1547,8 @@ window.asI18nDict = { "admin.toast.sessionExpired": "Sessione scaduta. Accedi di nuovo.", "admin.toast.libraryAdded": "Libreria aggiunta - scansione in corso in background.", "admin.toast.libraryDeleted": "Libreria eliminata.", + "admin.toast.libraryExported": "Libreria esportata.", + "admin.toast.libraryExportFailed": "Esportazione non riuscita.", "admin.toast.rescanStarted": "Riscansione avviata.", "admin.toast.rescanComplete": "Riscansione completata.", "admin.toast.userCreated": "Utente creato.", diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 1886bb7..e25230e 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -8,10 +8,18 @@ import ( "net/http/httptest" "os" "path/filepath" + "regexp" "strings" "testing" ) +// langBlockRE matches the opening of one language block in i18n-dict.js +// (` en: {`); dictKeyRE matches a translation key inside a block. +var ( + langBlockRE = regexp.MustCompile(`(?m)^ ([a-z]{2}): \{$`) + dictKeyRE = regexp.MustCompile(`"([^"]+)":`) +) + // writeFile is a tiny helper that creates name (with parents) under dir. func writeFile(t *testing.T, dir, name, body string) { t.Helper() @@ -138,6 +146,40 @@ func TestI18nAssets(t *testing.T) { } } + // Every language block must define the SAME keys: a string added to `en` only + // silently falls back to the key name in the other five locales. + // Split yields the text before the first block plus one piece per block, so + // blocks[i+1] is the body of names[i]. + blocks := langBlockRE.Split(string(dict), -1) + names := langBlockRE.FindAllStringSubmatch(string(dict), -1) + if len(names) < 2 { + t.Fatalf("could not split i18n-dict.js into language blocks (found %d)", len(names)) + } + keysFor := func(block string) map[string]bool { + out := map[string]bool{} + for _, m := range dictKeyRE.FindAllStringSubmatch(block, -1) { + out[m[1]] = true + } + return out + } + ref, base := names[0][1], keysFor(blocks[1]) // the first block is the reference (en) + if len(base) == 0 { + t.Fatalf("the %q i18n language block defines no keys", ref) + } + for i := 1; i < len(names); i++ { + lang, got := names[i][1], keysFor(blocks[i+1]) + for k := range base { + if !got[k] { + t.Errorf("i18n-dict.js: %q is missing key %q", lang, k) + } + } + for k := range got { + if !base[k] { + t.Errorf("i18n-dict.js: %q has key %q that %q lacks", lang, k, ref) + } + } + } + // The admin + connect pages keep the strict CSP, load the i18n scripts, and // carry no inline