Repository navigation
feat(metadata): BnF as an opt-in primary provider (#3144) - #3150
Merged
Merged
Conversation
A read-only client for the Bibliothèque nationale de France catalogue général through its public SRU 1.2 service, asking for UNIMARC (unimarcXchange). Dublin Core was the other candidate: it folds the statement of responsibility into the title, gives the role as text in at least three spellings, and carries no authority links, so it cannot tell the author from the translator reliably. - Books are works folded from edition records: editions sharing a title, or a translation's original title (454, a bracketed original, or the uniform title), group together per author. The work is named after its French edition and its ID is the ARK of the earliest entered French record, so it is stable across new printings. - Authors are BnF person authority records (bnf:author:<record number>). Only relator 070, or the 2026 text role "Auteur du texte", is the author; translators, illustrators and adapted-from authors never are. Records BnF has not linked to an authority yet are linked by exact name when one person has that heading. - Only printed text records are read (bib.doctype "a" in the query, and the record's own type checked again), so music and sound recordings never get through. No covers, no Dewey or subject authorities. - An author's catalogue is never reported complete: records created since BnF's June 2026 model change are not linked to their author yet, and are found by a bounded name query. Politeness: one request a second, two in flight, for the whole process, through a pacer in the HTTP transport so retries are paced too. The shared provider loop honours Retry-After and holds the provider on a refusal; a 429 or a local pacing refusal surfaces as ErrRateLimited, a 5xx or network failure as ErrUnavailable. 20 s per request, 4 MiB per body, at most 10 pages for a catalogue and 2 for the name query, 2 name lookups per search. User text is reduced to one quoted CQL term with the special characters removed rather than escaped. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
metadata.primary_provider accepts "bnf". Like Nasjonalbiblioteket it is wired only when chosen, never as an enricher, so an install that did not choose it sends BnF nothing. The "bnf:" prefix is known to the foreign ID classifiers. Before, it fell through to openlibrary: with OpenLibrary primary a bnf: author would have been routed to the OpenLibrary client, and SafeToBind would have taken a BnF match for the primary's after an OpenLibrary failure. Catalogue reconciliation skips the per work editions lookup for bnf works, as for dnb and nb: each one is a full GetBook, at one request a second. Calibre identifiers carry the bare ARK. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
The primary provider select offers BnF. The Licence Ouverte asks for credit to the source and the retrieval date, so the setting's description names it, and choosing BnF shows the credit line and that BnF does not endorse Bindery. BnF book IDs link to their catalogue record by ARK in the Links menu; authors have no link, as their ARK needs a check character Bindery does not store. Only en.json gets the new strings. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
docs/third-party-data.md gains a Bibliothèque nationale de France section: the Licence Ouverte 2.0 and its credit line, where the retrieval date lives, no endorsement, the CGU carve outs (music records, Dewey authorities) and how Bindery keeps clear of them, no covers, summaries not claimed as open data, names as personal data, and the pacing. README, ARCHITECTURE and the User Guide list BnF as an opt-in primary. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…ements (#3144) bib.doctype "a" includes periodicals, and a serial title record carries no 461 link, so a search for "Le Monde diplomatique" read ten serial records as books. Leader position 7 "s" (serial) and "i" (integrating resource) are now rejected; monographs, collections, component parts and the blank level of a 2026 record are kept. Title statements such as "Sylvie and Bruno, by Lewis Carroll" kept their statement of responsibility, and `L. Carrol ["sic"]. Alice au pays des merveilles` became the title "L. Carrol" with the original title "sic". A ", by" or ", par" with no space before the comma is now cut only when what follows names one of the record's people or is a plain cue ("the author of"), so "Ruby, by Moonlight" keeps its comma. A leading name flagged [sic] is dropped when it is a misspelling of one of the record's people, and a bracketed sic is never an original title. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
…ists (#3144) The catalogue walk's name query adopted every record crediting, without an authority link, an author named exactly like the heading. Two people named "Martin, Jean" are two authority records, so the other one's new books joined this author's catalogue. Adoption now makes one authority lookup, only when there is something to adopt, and adopts nothing unless this person is the only one with that heading. Both that check and the search time linking now read one page of 20 and treat a lookup that matched more records than it read as inconclusive: the second namesake could be on the page not read. Before, linking read 5 records and could miss it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
Each test below fails with its guard disabled on a compiling tree: - the shared refusal gate and pacer are compared with the package defaults, so a New() without the gate (two nil gates are equal) fails - the French edition represents a work even when another language's edition was entered earlier and comes first - GetBook adds the work's other editions (workOf) - cqlTerm replaces control characters strings.Fields does not split on (NUL, SOH, DEL, the C1 markers) - the pacer's concurrency slot is held until the response body is closed Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
…on (#3144) The provider switch sat inline in main(), so dropping its "bnf" case left every test green while a BnF setting booted OpenLibrary. It moves to primaryMetadataProvider, unchanged, with a test that each accepted value builds that provider. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
cqlTerm passed decomposed input through, so "Les Misérables" was sent as e plus a combining accent and counted the accent against the 200 rune cap. It is composed first now. golang.org/x/text is already a direct dependency; go.mod and go.sum are unchanged. Checked live with one request each: BnF folds both forms itself (both queries were echoed as "Les Miserables" and matched 638 records), so this changes no results today. It keeps the query in the form BnF's records use and the cap counting letters. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
#3144) A 4 MiB body of nested elements grew encoding/xml's element stack to about 256 MiB before failing. Decoding now goes through a token reader that refuses nesting past 64 levels (a real SRU response nests about eight), and the test measures the allocation. The byte order mark skip is removed: encoding/xml already skips a leading BOM, and the BOM test passes without it. Work grouping keyed titles on CanonicalDedupKey alone, which keeps accents and ligatures, so "Les Misérables" and "les miserables", or "Œuvres" and "Oeuvres", became two works. The key now also goes through textutil.FoldForSearch, the fold the search keys use. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
…#3144) With MinPages or SkipMissingISBN set, author refresh called GetEditions for every new work. For BnF that is a full GetBook: the ARK lookup, up to two name lookups and the work search, at one request a second, so a refresh of Lewis Carroll (about 49 works) cost 100 to 200 requests and around three minutes. BnF works arrive from the author catalogue with every edition attached, the same records the lookup would fetch again, so the prefetch uses them as catalogue reconciliation already does. A work with none attached still goes to the lookup. The filters reach the same verdicts; the test checks both filters and that no lookup is made. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
The Licence Ouverte / Open Licence 2.0 that BnF's records are under asks for the source and the date, and no date was shown anywhere. A BnF book's Metadata source row and a BnF author's header now read "Data from the BnF, Licence Ouverte / Open Licence 2.0, retrieved <date>". The date is the row's own: lastMetadataRefreshAt, else createdAt, both already in the API response; no column or migration. The Settings credit now says where the date is shown. Also covered by tests now: the BnF label in the link metadata dialog, authorProviderKey for bnf: ids, and bnf as a primary the add dialog honours. Each fails with its line removed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
…#3144) - third-party-data.md: the Licence Ouverte condition applies to every reuse, a self-hosted display included, and is met by the credit and the date together; says where each is shown and which stored date it is. Adds BnF's reuse conditions page and the data.bnf.fr licence page to the sources, states that an unsourced 330 summary is kept as a judgment call, and gives the request cost of a search, a book and an author refresh. - Every 856 field is removed from the redistributed fixtures (cover image references and links to other catalogues): nothing reads 856 and Bindery fetches no BnF image. SOURCE.md records the change; the tests pass on the stripped fixtures. - User Guide and the changelog fragment mention the dated credit on book and author pages and the namesake check. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
No conflicts. Build, vet and the metadata, models, calibre and cmd tests and the web typecheck and component tests pass on the merged tree. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
12 of 15 tasks
vavallee
added a commit
that referenced
this pull request
Oct 12, 2026
Provider lists in the README, the roadmap Delivered and Next up sections, the In Library meaning for partly owned dual format books, ratings, the Authors list have / total count and the author page sort and filters, the series field in the book edit table, the availableBookCount and rating fields, the rating sort values and the primary provider values in the API reference, and the one time dedup key recompute on first boot. Documents #3125, #3095, #3088, #3134, #3090, #3127, #3150, #3158, #3116, #2915 and #2994. No new environment variables, dependencies or breaking behaviour shipped, so DEPLOYMENT.md env tables, THIRD_PARTY_LICENSES.md and docs/upgrade-v1.md are unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
vavallee
added a commit
that referenced
this pull request
Oct 12, 2026
Provider lists in the README, the roadmap Delivered and Next up sections, the In Library meaning for partly owned dual format books, ratings, the Authors list have / total count and the author page sort and filters, the series field in the book edit table, the availableBookCount and rating fields, the rating sort values and the primary provider values in the API reference, and the one time dedup key recompute on first boot. Documents #3125, #3095, #3088, #3134, #3090, #3127, #3150, #3158, #3116, #2915 and #2994. No new environment variables, dependencies or breaking behaviour shipped, so DEPLOYMENT.md env tables, THIRD_PARTY_LICENSES.md and docs/upgrade-v1.md are unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the Bibliothèque nationale de France as an opt-in primary metadata provider, modelled on DNB and Nasjonalbiblioteket, so a French library gets French edition titles ("Alice au pays des merveilles") instead of the original English titles OpenLibrary and Hardcover carry. Requested by @mnivoliez.
Closes #3144.
Live behaviour was exercised with about 22 requests to BnF (18 by curl while choosing the schema, then one pass of the finished client: GetAuthor, SearchBooks with its name lookups, GetBookByISBN), plus 2 during the review fixes (NFC against NFD). It has not been tested by a human in the app. The author catalogue walk was checked live query by query (author2bib, the creation date name query), not end to end.
Record schema: UNIMARC, not Dublin Core
From Explain and real searchRetrieve responses in both schemas (title+author,
bib.fuzzyIsbnwith an ISBN-10, ARK lookup, author2bib, authority records):dublincoreunimarcXchange(chosen)Alice au pays des merveilles / Lewis Carroll ; édité par Edmond Bronstein, edition and responsibility mixed in(Auteur du texte),. Auteur du texte,Auteur du texte / Autrice du texteéditeur,Electre), so third party text can be skippedWhat the June 2026 change ("Noemi") looks like today: a record created 2026-06-01 has a mostly blank leader, puts both author and translator in 702 with the role as text (
$c Auteur du texte,$c Traducteur) and no $3 link, and links a French uniform title (500 $3). A query proved such a record is not returned bybib.author2bibfor its author. The parser accepts both shapes, every field is optional (missing means empty, a garbage or truncated body is an error, never a panic), zero records is an empty result, and an SRU diagnostic (BnF sends them with HTTP 200) is an error. If BnF changes the format again, unknown fields are ignored; the risk is fewer fields filled, not a crash.What it does
bib.anywhere all "<term>" and bib.doctype any "a", 30 records, folded into worksaut.accesspoint all "<term>" and aut.type any "pep", kept only when the heading itself matches every word (Lewis Carroll yes, Johnny Carroll via his variant "Johnny Lewis" no)bnf:author:<authority record number>; variants become AlternateNames (filtered by LatinAliasBinds as for NB)bib.author2bibpages (10 x 100 max), plus a name query for records created since 2026-06-01 that BnF has not linked yet; never reported Completebnf:<ARK name>(e.g.bnf:cb48780493c), the earliest entered French edition, stable across new printings; GetBook adds the work's other editions with one more searchbib.fuzzyIsbn(ISBN-10, ISBN-13 and EAN all match, verified live)L'outsiderkept as is in queries and titles;L’outsiderandL'outsidergroup; "Jean d'" + "Ormesson" displays as "Jean d'Ormesson"NormalizeLanguageCodeBook.OriginalTitle, which the search title code already usesLicence requirements
docs/third-party-data.mdsection "Bibliothèque nationale de France", Reviewed 2026-10-11; sources include the catalogue CGU, BnF's reuse conditions page, the data.bnf.fr licence page and the Etalab text. The licence condition is stated as applying to every reuse, a self-hosted display included, met by the credit plus the dateen.jsonmetadataProviderHintandmetadataProviderBnfCredit;MetadataTab.tsxshows the credit when BnF is selected and says where the date is shown; test inMetadataTab.primaryProvider.test.tsxrecords.gorecordARK), linked tocatalogue.bnf.fr/ark:/12148/...from the Links menu (metadataSource.ts). The date is now displayed: a BnF book's Metadata source row and a BnF author's header read "Data from the BnF, Licence Ouverte / Open Licence 2.0, retrieved " (common.bnfSourceCredit), the date beinglastMetadataRefreshAt, elsecreatedAt, both already in the API response; no new column or migration. Tests inBookDetailPage.test.tsx,AuthorDetailPage.test.tsx,metadataSource.test.ts. A book's date is when it was added or rebound: an author refresh stamps the author, not each bookREADME.mdline 158;docs/User-Guide-Wiki.mdlines 787 to 803testdata/SOURCE.mdaut.type any "pep"is ever queried, no 676 or subject field read;bookFilter(cql.go) on every bibliographic search andisBookRecord(records.go) drops sound recordings, music, serials and integrating resources (leader position 7s,i), periodical issues and authority records; testsTestParseRecord_DropsWhatIsNotABook,TestGetAuthorWorksSnapshottestdata/SOURCE.mdrecords it)recordDescription(records.go) keeps only unsourced oréditeursummaries, skips Electre and any other third party; stored as the description only; fixtures have the summary text replaced (testdata/SOURCE.md). Keeping a 330 with no $2 is a judgment call, stated as such in the docsencoding/xmland the standard library only; go.mod, go.sum and package files unchangedcmd/bindery/metadata_providers.goprimaryMetadataProvider, constructed only when primary, no enricher, no startup probe;TestPrimaryMetadataProviderThe fixtures are real BnF responses (retrieved 2026-10-11), which the repo now redistributes, so
internal/metadata/bnf/testdata/SOURCE.mdcarries the credit line and lists the two changes made (summaries replaced, 856 fields removed).Rate limit design
pacer.goline 30TestPacer_SpacesRequestStartspacer.goline 31TestPacer_CapsConcurrentRequestspacer.goreleasingBodyTestPacer_SlotHeldUntilBodyClosedNew()TestNewClientsShareOnePacerAndGateErrRateLimited(as Hardcover's pacer does)pacer.go,classifyTestPacer_RefusesPastTheDeadlineAsRateLimitedproviderhttp.Do: 429, 502, 503, 504 retried 3 times (4 requests), Retry-After honoured up to 30 s, jittered exponential backoff otherwise, the gate holds every BnF request during a refusal (#3100); other 4xx not retriedclient.gogetTestRetryAfterIsHonoured,TestPersistent429IsRateLimited,TestOther4xxIsNotRetriedOrMarkedErrRateLimited; 5xx and network failures:ErrUnavailableclassifyTestServerErrorsAreUnavailable,TestNetworkFailureIsUnavailableclient.goline 85TestHungServerHitsTheTimeoutclient.gomaxResponseBytesTestOversizedBodyIsCutOff<a>allocated about 256 MiB before)client.godecodeXML,depthLimiterTestDeeplyNestedBodyIsRefused(measures allocation)client.goconstantsTestPageCapStopsARunawayCursor,TestLinkUnlinked_BoundedLookups,TestAdoptUnlinked_NamesakeCheck" \ * ? ^ ( ) < > = /and control characters replaced by spaces, capped at 200 runescql.goTestCQLTerm(includesx" or bib.title any "*, NUL, DEL, C1 markers, NFD input)Why remove rather than escape: sent
bib.title all "x\" or bib.title any \"*", BnF echoed the query asbib.title all "x" or bib.title any "*", so its handling of a backslash escape could not be confirmed. Word searches lose nothing when punctuation goes.Why 1 a second: BnF publishes nothing and every install is an independent client. A search is 1 to 3 requests and fits the aggregator's 8 s budget; opening or adding one book is up to 4; an author refresh is up to 15 requests (the author's record, then the authority record, up to 10 catalogue pages, up to 2 pages of unlinked records, 1 namesake check), around 15 s. With MinPages or SkipMissingISBN set, refresh used to call GetEditions per new work, which for BnF is a full GetBook (up to 4 requests each, so 100 to 200 requests and about 3 minutes for Lewis Carroll); BnF works are now judged on the editions the catalogue already returned, with no extra request.
Mutation checks
Each guard disabled on a compiling tree, the named test run:
bnfskip removedbnf:prefix removed from modelsbnfremoved from accepted settingssameName; without the namesake lookup; inconclusive lookup acceptedgate: defaultGateremoved fromNew(); per client pacerworkOf; control character replacement; slot released at headers;case "bnf"Touchpoints compared with the NB PR (#2995)
All of #2995's touchpoints are covered: provider package,
main.go,settings_handler.go,catalogue_reconciliation.go(+ test),models/author.goandbook.go(+ tests incl.book_identity_test.go),MetadataTab.tsx,metadataSource.ts(+ test),AddToLibraryModal.tsx,AuthorMetadataLinkModal.tsx,authorMetadata.ts,en.json, README, ARCHITECTURE, User Guide, third-party-data, changelog fragment. Also touched:aggregator_foreign_id_classifier_test.go(routing and SafeToBind forbnf:),cmd/bindery/metadata_providers_test.go,settings_handler_test.go,internal/calibre/identifiers.go, and a newMetadataTab.primaryProvidertest. Not needed: a series file (no series parsing).docs/API.mdlists no provider values, so it is unchanged.Not in this PR
bnf:cb35611382jper the review) can carry a different ID from the same work in the catalogue walk (bnf:cb37615303n). Not reproduced live here. Author refresh does not duplicate it: the title index (seenTitles) matches it to the existing row. What it does not do: the ID lookup misses, so the work counts as new for the MinPages/SkipMissingISBN filters, and a work that fails one is reported as skipped instead of updated. Matching on edition IDs would fix that for every provider and is left for a follow up.Changes after independent review
Each defect was reproduced by a test that failed before its fix; guards were then disabled on a compiling tree to show the test catches them.
sandirejected; hand built serial (cas) and integrating resource records fail before, pass after["sic"], by/, parcut only when what follows names one of the record's people or is a plain cue ("the author of"); "Ruby, by Moonlight" kept; a leading name flagged [sic] dropped when it misspells one of the record's people; bracketed sic never an original title; 12 cases including the four real titlesadoptUnlinkedhad no namesake checksameNamenow failsdefaultGateanddefaultPacer; removing the gate now failsworkOf, control characters (NUL, SOH, DEL, C1; vertical tab, form feed, NEL and U+2028 are whitespace tostrings.Fieldsso they cannot exercise the replacement and are kept only as a guard),case "bnf"(switch moved to a tested function), pacer slot held until body close, and the three web survivorstextutil.FoldForSearch, dead BOM skip removed (encoding/xml skips it; the BOM test passes without it). Representative ID drift confirmed by reading, reconciled by title, documented aboveChecklist
docs/,README.md, godoc)Test plan
go build ./...,go veton touched packagesgolangci-lint run --concurrency 2on bnf, metadata, models, calibre, api, cmd/bindery: 0 issuesgo test -p 2 ./internal/metadata/... ./internal/models/ ./internal/calibre/ ./cmd/bindery/ ./internal/api/go test -race ./internal/metadata/bnf/(count 3); after review-count=20and-race -count=5cd web && npm run typecheck, eslint on touched files (one existing warning in MetadataTab.tsx line 76, unchanged)npx vitest runMetadataTab, metadataSource, authorMetadata, AddToLibraryModal, AuthorMetadataLinkModal; after review also BookDetailPage, AuthorDetailPage and the settings pages🤖 Generated with Claude Code
https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9