mcp-databnf 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,8 @@
1
+ // src/config.ts
2
+ import process from "process";
3
+
1
4
  // src/version.ts
2
- var PKG_VERSION = "1.1.0";
5
+ var PKG_VERSION = "2.0.0";
3
6
  var REPO_URL = "https://github.com/smeet666/mcp-databnf";
4
7
 
5
8
  // src/config.ts
@@ -12,7 +15,9 @@ var DEFAULT_USER_AGENT = `mcp-databnf/${PKG_VERSION} (+${REPO_URL})`;
12
15
  function createLogger(level) {
13
16
  const rank = LOG_LEVELS.indexOf(level);
14
17
  const write = (at, message) => {
15
- if (rank === 0 || LOG_LEVELS.indexOf(at) > rank) return;
18
+ if (rank === 0 || LOG_LEVELS.indexOf(at) > rank) {
19
+ return;
20
+ }
16
21
  process.stderr.write(`[mcp-databnf] ${at}: ${message}
17
22
  `);
18
23
  };
@@ -27,9 +32,11 @@ function createLogger(level) {
27
32
  }
28
33
  function readInteger(env, name, fallback, min, max) {
29
34
  const raw = env[name];
30
- if (raw === void 0 || raw.trim() === "") return fallback;
35
+ if (raw === void 0 || raw.trim() === "") {
36
+ return fallback;
37
+ }
31
38
  const value = Number(raw);
32
- if (!Number.isFinite(value) || !Number.isInteger(value)) {
39
+ if (!(Number.isFinite(value) && Number.isInteger(value))) {
33
40
  process.stderr.write(
34
41
  `[mcp-databnf] error: ${name}="${raw}" is not a whole number; using ${fallback}.
35
42
  `
@@ -80,7 +87,7 @@ var BnfError = class extends Error {
80
87
  code;
81
88
  details;
82
89
  constructor(code, message, details = {}) {
83
- super(message);
90
+ super(message, details.cause === void 0 ? void 0 : { cause: details.cause });
84
91
  this.name = "BnfError";
85
92
  this.code = code;
86
93
  this.details = details;
@@ -110,7 +117,9 @@ var Cache = class {
110
117
  entries = /* @__PURE__ */ new Map();
111
118
  get(key) {
112
119
  const entry = this.entries.get(key);
113
- if (!entry) return void 0;
120
+ if (!entry) {
121
+ return void 0;
122
+ }
114
123
  if (entry.expiresAt <= Date.now()) {
115
124
  this.entries.delete(key);
116
125
  return void 0;
@@ -120,12 +129,16 @@ var Cache = class {
120
129
  return { value: entry.value, retrievedAt: entry.retrievedAt };
121
130
  }
122
131
  set(key, value, retrievedAt) {
123
- if (this.ttlMs <= 0) return;
132
+ if (this.ttlMs <= 0) {
133
+ return;
134
+ }
124
135
  this.entries.delete(key);
125
136
  this.entries.set(key, { value, expiresAt: Date.now() + this.ttlMs, retrievedAt });
126
137
  while (this.entries.size > this.maxEntries) {
127
138
  const oldest = this.entries.keys().next();
128
- if (oldest.done) break;
139
+ if (oldest.done) {
140
+ break;
141
+ }
129
142
  this.entries.delete(oldest.value);
130
143
  }
131
144
  }
@@ -135,6 +148,9 @@ var Cache = class {
135
148
  };
136
149
 
137
150
  // src/bnf/endpoint.ts
151
+ var TRAILING_DOT = /\.$/;
152
+ var FRAGMENT = /#.*$/;
153
+ var HTTP_SCHEME = /^http:/;
138
154
  var SPARQL_ENDPOINT = "https://data.bnf.fr/sparql";
139
155
  var ALLOWED_HOST = "data.bnf.fr";
140
156
  var GALLICA_HOST = "gallica.bnf.fr";
@@ -142,22 +158,23 @@ function assertRequestable(url) {
142
158
  let parsed;
143
159
  try {
144
160
  parsed = new URL(url);
145
- } catch {
146
- throw refusal(`mcp-databnf refuses to request "${url}": it is not an address.`);
161
+ } catch (cause) {
162
+ throw refusal(`mcp-databnf refuses to request "${url}": it is not an address.`, cause);
147
163
  }
148
- const host = parsed.hostname.toLowerCase().replace(/\.$/, "");
164
+ const host = parsed.hostname.toLowerCase().replace(TRAILING_DOT, "");
149
165
  if (host !== ALLOWED_HOST) {
150
166
  throw refusal(
151
167
  `mcp-databnf requests ${ALLOWED_HOST} and nothing else; "${host}" was refused. Gallica in particular is described from the catalogue and never read.`
152
168
  );
153
169
  }
154
170
  }
155
- var refusal = (message) => new BnfError("network_error", message, {
156
- hint: "This server reads the BnF catalogue at data.bnf.fr. It holds no licence to read the digitised contents, so it does not follow an address that leaves that host."
171
+ var refusal = (message, cause) => new BnfError("network_error", message, {
172
+ hint: "This server reads the BnF catalogue at data.bnf.fr. It holds no licence to read the digitised contents, so it does not follow an address that leaves that host.",
173
+ cause
157
174
  });
158
175
  function isGallicaAddress(address) {
159
176
  try {
160
- const host = new URL(address).hostname.toLowerCase().replace(/\.$/, "");
177
+ const host = new URL(address).hostname.toLowerCase().replace(TRAILING_DOT, "");
161
178
  return host === GALLICA_HOST || host.endsWith(`.${GALLICA_HOST}`);
162
179
  } catch {
163
180
  return false;
@@ -184,21 +201,28 @@ var PREFIXES = {
184
201
  rdafrbr: "http://rdvocab.info/uri/schema/FRBRentitiesRDA/"
185
202
  };
186
203
  var PREFIX_BLOCK = Object.entries(PREFIXES).map(([name, iri]) => `PREFIX ${name}: <${iri}>`).join("\n");
187
- var publicPageFor = (entityIri) => entityIri.replace(/#.*$/, "").replace(/^http:/, "https:");
204
+ var publicPageFor = (entityIri) => entityIri.replace(FRAGMENT, "").replace(HTTP_SCHEME, "https:");
188
205
 
189
206
  // src/bnf/http.ts
207
+ var VIRTUOSO_ERROR = /^Virtuoso\s+(\w+)\s+Error\s+([\s\S]*)$/;
190
208
  var RETRYABLE = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
191
209
  var PUSH_BACK = /* @__PURE__ */ new Set([429, 503]);
192
210
  var LONGEST_WAIT_MS = 3e4;
193
211
  var RETRIES_AFTER_SILENCE = 1;
194
212
  var MAX_REDIRECTS = 2;
195
213
  function parseRetryAfter(value, now = Date.now()) {
196
- if (!value) return null;
214
+ if (!value) {
215
+ return null;
216
+ }
197
217
  const trimmed = value.trim();
198
218
  const seconds = Number(trimmed);
199
- if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
219
+ if (Number.isFinite(seconds) && seconds >= 0) {
220
+ return Math.round(seconds * 1e3);
221
+ }
200
222
  const at = Date.parse(trimmed);
201
- if (Number.isNaN(at)) return null;
223
+ if (Number.isNaN(at)) {
224
+ return null;
225
+ }
202
226
  return Math.max(0, at - now);
203
227
  }
204
228
  function backoffMs(attempt) {
@@ -206,8 +230,10 @@ function backoffMs(attempt) {
206
230
  return base + Math.floor(Math.random() * 500);
207
231
  }
208
232
  function readEngineError(body) {
209
- const match = /^Virtuoso\s+(\w+)\s+Error\s+([\s\S]*)$/.exec(body.trim());
210
- if (!match) return null;
233
+ const match = VIRTUOSO_ERROR.exec(body.trim());
234
+ if (!match) {
235
+ return null;
236
+ }
211
237
  const state = match[1] ?? "";
212
238
  const text2 = (match[2] ?? "").split("\n")[0]?.trim() ?? "";
213
239
  return { kind: state === "37000" ? "compile" : "runtime", text: text2 };
@@ -249,6 +275,78 @@ async function postOnce(options, signal) {
249
275
  url: SPARQL_ENDPOINT
250
276
  });
251
277
  }
278
+ function readRefusal(response, attempt, maxRetries) {
279
+ const pushBack = RETRYABLE.has(response.status);
280
+ if (PUSH_BACK.has(response.status)) {
281
+ const asked = parseRetryAfter(response.headers.get("retry-after"));
282
+ if (asked !== null && asked > LONGEST_WAIT_MS) {
283
+ return {
284
+ kind: "refused",
285
+ pushBack,
286
+ error: rateLimited(
287
+ `data.bnf.fr asked this client to wait ${Math.round(asked / 1e3)} seconds (HTTP ${response.status}).`,
288
+ { url: SPARQL_ENDPOINT, status: response.status }
289
+ )
290
+ };
291
+ }
292
+ if (attempt >= maxRetries) {
293
+ return {
294
+ kind: "refused",
295
+ pushBack,
296
+ error: rateLimited(
297
+ `data.bnf.fr asked this client to slow down (HTTP ${response.status}).`,
298
+ {
299
+ url: SPARQL_ENDPOINT,
300
+ status: response.status
301
+ }
302
+ )
303
+ };
304
+ }
305
+ return { kind: "again", pushBack, waitMs: asked ?? backoffMs(attempt) };
306
+ }
307
+ if (RETRYABLE.has(response.status) && attempt < maxRetries) {
308
+ return { kind: "again", pushBack, waitMs: backoffMs(attempt) };
309
+ }
310
+ if (response.status === 400 || response.status === 422) {
311
+ const engine = readEngineError(response.body);
312
+ return {
313
+ kind: "refused",
314
+ pushBack,
315
+ error: invalidInput(
316
+ `data.bnf.fr would not run this query${engine?.text ? `: ${engine.text}` : "."}`,
317
+ "Try fewer or plainer words. If this keeps happening with ordinary input, please report it."
318
+ )
319
+ };
320
+ }
321
+ return {
322
+ kind: "refused",
323
+ pushBack,
324
+ error: networkError(`data.bnf.fr answered HTTP ${response.status}.`, {
325
+ url: SPARQL_ENDPOINT,
326
+ status: response.status
327
+ })
328
+ };
329
+ }
330
+ function readFailure(error, attempts) {
331
+ const { attempt, maxRetries, timeoutMs } = attempts;
332
+ if (error instanceof Error && error.name === "BnfError") {
333
+ throw error;
334
+ }
335
+ if (error instanceof Error && error.name === "AbortError") {
336
+ if (attempt >= Math.min(maxRetries, RETRIES_AFTER_SILENCE)) {
337
+ throw timeout(
338
+ `No answer from data.bnf.fr within ${timeoutMs}ms. A query spanning the whole catalogue can take longer than the service will spend on it.`,
339
+ { url: SPARQL_ENDPOINT }
340
+ );
341
+ }
342
+ return error;
343
+ }
344
+ const failure = error instanceof Error ? error : new Error(String(error));
345
+ if (attempt >= maxRetries) {
346
+ throw networkError(`Could not reach data.bnf.fr: ${failure.message}`, { url: SPARQL_ENDPOINT });
347
+ }
348
+ return failure;
349
+ }
252
350
  async function runQuery(options) {
253
351
  const { timeoutMs, maxRetries, limiter, logger } = options;
254
352
  let lastError = null;
@@ -256,7 +354,8 @@ async function runQuery(options) {
256
354
  for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
257
355
  if (askedWaitMs > 0) {
258
356
  logger.debug(`waiting ${askedWaitMs}ms, as asked`);
259
- await new Promise((resolve) => setTimeout(resolve, askedWaitMs));
357
+ const asked = askedWaitMs;
358
+ await new Promise((resolve) => setTimeout(resolve, asked));
260
359
  askedWaitMs = 0;
261
360
  }
262
361
  await limiter.beforeRequest();
@@ -276,66 +375,19 @@ async function runQuery(options) {
276
375
  limiter.succeeded();
277
376
  return parsed;
278
377
  }
279
- if (RETRYABLE.has(response.status)) limiter.pushBack();
280
- if (PUSH_BACK.has(response.status)) {
281
- const asked = parseRetryAfter(response.headers.get("retry-after"));
282
- if (asked !== null && asked > LONGEST_WAIT_MS) {
283
- throw rateLimited(
284
- `data.bnf.fr asked this client to wait ${Math.round(asked / 1e3)} seconds (HTTP ${response.status}).`,
285
- { url: SPARQL_ENDPOINT, status: response.status }
286
- );
287
- }
288
- if (attempt >= maxRetries) {
289
- throw rateLimited(
290
- `data.bnf.fr asked this client to slow down (HTTP ${response.status}).`,
291
- {
292
- url: SPARQL_ENDPOINT,
293
- status: response.status
294
- }
295
- );
296
- }
297
- askedWaitMs = asked ?? backoffMs(attempt);
298
- lastError = new Error(`HTTP ${response.status}`);
299
- continue;
300
- }
301
- if (RETRYABLE.has(response.status) && attempt < maxRetries) {
302
- lastError = new Error(`HTTP ${response.status}`);
303
- askedWaitMs = backoffMs(attempt);
304
- continue;
378
+ const verdict = readRefusal(response, attempt, maxRetries);
379
+ if (verdict.pushBack) {
380
+ limiter.pushBack();
305
381
  }
306
- if (response.status === 400 || response.status === 422) {
307
- const engine = readEngineError(response.body);
308
- throw invalidInput(
309
- `data.bnf.fr would not run this query${engine?.text ? `: ${engine.text}` : "."}`,
310
- "Try fewer or plainer words. If this keeps happening with ordinary input, please report it."
311
- );
382
+ if (verdict.kind === "refused") {
383
+ throw verdict.error;
312
384
  }
313
- throw networkError(`data.bnf.fr answered HTTP ${response.status}.`, {
314
- url: SPARQL_ENDPOINT,
315
- status: response.status
316
- });
385
+ askedWaitMs = verdict.waitMs;
386
+ lastError = new Error(`HTTP ${response.status}`);
317
387
  } catch (error) {
318
388
  clearTimeout(deadline);
319
- if (error instanceof Error && error.name === "BnfError") throw error;
320
- if (error instanceof Error && error.name === "AbortError") {
321
- lastError = error;
322
- if (attempt >= Math.min(maxRetries, RETRIES_AFTER_SILENCE)) {
323
- throw timeout(
324
- `No answer from data.bnf.fr within ${timeoutMs}ms. A query spanning the whole catalogue can take longer than the service will spend on it.`,
325
- { url: SPARQL_ENDPOINT }
326
- );
327
- }
328
- askedWaitMs = backoffMs(attempt);
329
- continue;
330
- }
331
- lastError = error instanceof Error ? error : new Error(String(error));
332
- if (attempt >= maxRetries) {
333
- throw networkError(`Could not reach data.bnf.fr: ${lastError.message}`, {
334
- url: SPARQL_ENDPOINT
335
- });
336
- }
389
+ lastError = readFailure(error, { attempt, maxRetries, timeoutMs });
337
390
  askedWaitMs = backoffMs(attempt);
338
- continue;
339
391
  } finally {
340
392
  clearTimeout(deadline);
341
393
  }
@@ -369,9 +421,10 @@ function parseResults(body) {
369
421
  let payload;
370
422
  try {
371
423
  payload = JSON.parse(body);
372
- } catch {
424
+ } catch (cause) {
373
425
  throw parseFailure("data.bnf.fr answered with something that is not JSON.", {
374
- url: SPARQL_ENDPOINT
426
+ url: SPARQL_ENDPOINT,
427
+ cause
375
428
  });
376
429
  }
377
430
  if (typeof payload !== "object" || payload === null || !("results" in payload) || typeof payload.results !== "object" || !Array.isArray(payload.results?.bindings)) {
@@ -383,6 +436,8 @@ function parseResults(body) {
383
436
  }
384
437
 
385
438
  // src/bnf/sparql.ts
439
+ var LEADING_JOINERS = /^['-]+/u;
440
+ var TRAILING_JOINERS = /['-]+$/u;
386
441
  function escapeLiteral(value) {
387
442
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "");
388
443
  }
@@ -391,49 +446,20 @@ var INVISIBLE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f\u00ad\u061c\u180e\u200
391
446
  function withoutInvisibles(input) {
392
447
  return input.replace(INVISIBLE, "");
393
448
  }
394
- function invisibleCharacters(input) {
395
- const found = [];
396
- for (const character of input.match(INVISIBLE) ?? []) {
397
- if (!found.includes(character)) found.push(character);
398
- }
399
- return found;
400
- }
401
- function charactersSetAside(input) {
402
- const found = [];
403
- for (const character of withoutInvisibles(input).normalize("NFC")) {
404
- if (/[\p{L}\p{N}'\-\s]/u.test(character) || /[‘’ʼ]/u.test(character)) continue;
405
- if (!found.includes(character)) found.push(character);
406
- }
407
- return found;
408
- }
409
- function readSearchText(input) {
410
- const words = toSearchWords(input);
411
- return {
412
- words,
413
- terms: toIndexTerms(words),
414
- invisible: invisibleCharacters(input),
415
- setAside: charactersSetAside(input)
416
- };
417
- }
418
449
  function toSearchWords(input) {
419
450
  const cleaned = withoutInvisibles(input).normalize("NFC").replace(/[‘’ʼ]/gu, "'").replace(/[^\p{L}\p{N}'-]+/gu, " ");
420
451
  const words = [];
421
452
  for (const raw of cleaned.split(" ")) {
422
- const word = raw.replace(/^['-]+/u, "").replace(/['-]+$/u, "");
423
- if (word === "") continue;
453
+ const word = raw.replace(LEADING_JOINERS, "").replace(TRAILING_JOINERS, "");
454
+ if (word === "") {
455
+ continue;
456
+ }
424
457
  words.push(word.length > 60 ? word.slice(0, 60) : word);
425
- if (words.length >= 12) break;
426
- }
427
- return words;
428
- }
429
- function toIndexTerms(words) {
430
- const terms = [];
431
- for (const word of words) {
432
- for (const piece of word.split(/['-]+/u)) {
433
- if (piece !== "" && !terms.includes(piece)) terms.push(piece);
458
+ if (words.length >= 12) {
459
+ break;
434
460
  }
435
461
  }
436
- return terms;
462
+ return words;
437
463
  }
438
464
  function containsAllWords(words) {
439
465
  if (words.length === 0) {
@@ -445,54 +471,9 @@ function containsAllWords(words) {
445
471
  const clause = words.map((word) => `'${word.replace(/'/g, "''")}'`).join(" AND ");
446
472
  return literal(clause);
447
473
  }
448
- var ARK_ID = /^c[bc][0-9a-z]{6,20}$/;
449
- var TEMP_WORK_ID = /^[0-9a-f]{32}$/;
450
- function parseEntityId(input) {
451
- const trimmed = input.trim();
452
- if (trimmed === "") {
453
- throw invalidInput("An identifier is required.");
454
- }
455
- const arkMatch = /(?:^|\/)ark:\/12148\/(c[bc][0-9a-z]+)/i.exec(trimmed);
456
- const bare = arkMatch ? arkMatch[1] : trimmed.replace(/^#/, "").split(/[#?]/)[0];
457
- const candidate = (bare ?? "").toLowerCase();
458
- if (ARK_ID.test(candidate)) {
459
- return {
460
- kind: "ark",
461
- id: candidate,
462
- iri: `http://data.bnf.fr/ark:/12148/${candidate}#about`,
463
- pageUrl: `https://data.bnf.fr/ark:/12148/${candidate}`
464
- };
465
- }
466
- const tempMatch = /temp-work\/([0-9a-f]{32})/i.exec(trimmed);
467
- const digest = (tempMatch?.[1] ?? candidate).toLowerCase();
468
- if (TEMP_WORK_ID.test(digest)) {
469
- return {
470
- kind: "temp-work",
471
- id: `temp-work/${digest}`,
472
- iri: `http://data.bnf.fr/temp-work/${digest}/#about`,
473
- pageUrl: `https://data.bnf.fr/temp-work/${digest}/`
474
- };
475
- }
476
- throw invalidInput(`"${input}" is not a data.bnf.fr identifier.`, hintFor(trimmed));
477
- }
478
- var GALLICA_DOCUMENT = /^(?:bpt6k|btv1b|bd6t|cb32|btv1b)[0-9a-z]+$/i;
479
- var PAGE_ADDRESS = /data\.bnf\.fr\/(?:[a-z]{2}\/)?(\d{6,12})\/([a-z0-9_]+)/i;
480
- var GENERAL_HINT = "An identifier looks like 'cb119219976' for an established record, or 'temp-work/22d7f68c1a4bdd081ad7ca791fd3b730' for a provisional one. Take it from a search result rather than building one.";
481
- function hintFor(input) {
482
- const page2 = PAGE_ADDRESS.exec(input);
483
- if (page2) {
484
- const name = (page2[2] ?? "").replace(/_/g, " ");
485
- return `That is the readable address of a data.bnf.fr page, and ${page2[1]} in it is the record's FRBNF number rather than the identifier these tools take. Search for "${name}" with search_authors or search_works, and use the identifier the row carries.`;
486
- }
487
- const gallicaArk = /ark:\/12148\/([0-9a-z]+)/i.exec(input)?.[1] ?? input;
488
- if (GALLICA_DOCUMENT.test(gallicaArk)) {
489
- return "That is a Gallica identifier: it names a digitised document rather than a record in the catalogue, and these tools read the catalogue. Search for the work with search_works, then list_editions will give you the Gallica link alongside the edition it belongs to.";
490
- }
491
- return GENERAL_HINT;
492
- }
493
474
  var iriOf = (id) => `<${id.iri}>`;
494
475
  function boundedInteger(value, min, max, name) {
495
- if (!Number.isFinite(value) || !Number.isInteger(value)) {
476
+ if (!(Number.isFinite(value) && Number.isInteger(value))) {
496
477
  throw invalidInput(`'${name}' has to be a whole number.`);
497
478
  }
498
479
  if (value < min || value > max) {
@@ -713,6 +694,14 @@ LIMIT 20`);
713
694
  }
714
695
 
715
696
  // src/bnf/parse.ts
697
+ var ARK_IN_IRI = /ark:\/12148\/(c[bc][0-9a-z]+)/i;
698
+ var TEMP_WORK_IN_IRI = /temp-work\/([0-9a-f]{32})/i;
699
+ var TRAILING_SEPARATOR = /[/#]$/;
700
+ var PATH_SEPARATOR = /[/#]/;
701
+ var ARK_SEGMENT = /ark:\/12148\/([^/?#]+)/;
702
+ var QUERY_START = /[?#]/;
703
+ var LEADING_DOTS = /^[./]+/;
704
+ var TRAILING_SLASHES = /\/+$/;
716
705
  var P = {
717
706
  type: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
718
707
  label: "http://www.w3.org/2000/01/rdf-schema#label",
@@ -768,36 +757,50 @@ var ALIGNMENT_HOSTS = [
768
757
  { host: "catalogue.bnf.fr", name: "catalogue" }
769
758
  ];
770
759
  var text = (term) => {
771
- if (!term) return null;
760
+ if (!term) {
761
+ return null;
762
+ }
772
763
  const value = term.value.trim();
773
764
  return value === "" ? null : value;
774
765
  };
775
766
  var integer = (term) => {
776
767
  const raw = text(term);
777
- if (raw === null) return null;
768
+ if (raw === null) {
769
+ return null;
770
+ }
778
771
  const value = Number(raw);
779
772
  return Number.isInteger(value) ? value : null;
780
773
  };
781
774
  function idFromIri(iri) {
782
- const ark = /ark:\/12148\/(c[bc][0-9a-z]+)/i.exec(iri);
783
- if (ark?.[1]) return ark[1].toLowerCase();
784
- const temp = /temp-work\/([0-9a-f]{32})/i.exec(iri);
785
- if (temp?.[1]) return `temp-work/${temp[1].toLowerCase()}`;
775
+ const ark = ARK_IN_IRI.exec(iri);
776
+ if (ark?.[1]) {
777
+ return ark[1].toLowerCase();
778
+ }
779
+ const temp = TEMP_WORK_IN_IRI.exec(iri);
780
+ if (temp?.[1]) {
781
+ return `temp-work/${temp[1].toLowerCase()}`;
782
+ }
786
783
  return null;
787
784
  }
788
- var vocabularyTerm = (iri) => decodeURIComponent(iri.replace(/[/#]$/, "").split(/[/#]/).pop() ?? iri);
785
+ var vocabularyTerm = (iri) => decodeURIComponent(iri.replace(TRAILING_SEPARATOR, "").split(PATH_SEPARATOR).pop() ?? iri);
789
786
  function toDigitisedLink(address, role, fromId, fromTitle) {
790
- if (!address || !isGallicaAddress(address)) return null;
791
- const segment = /ark:\/12148\/([^/?#]+)/.exec(address)?.[1] ?? null;
792
- if (!segment) return null;
787
+ if (!(address && isGallicaAddress(address))) {
788
+ return null;
789
+ }
790
+ const segment = ARK_SEGMENT.exec(address)?.[1] ?? null;
791
+ if (!segment) {
792
+ return null;
793
+ }
793
794
  const ark = segment.split(".")[0] ?? segment;
794
- if (ark === "") return null;
795
+ if (ark === "") {
796
+ return null;
797
+ }
795
798
  return { ark, url: address, rendering: renderingOf(address, ark), role, fromId, fromTitle };
796
799
  }
797
800
  function renderingOf(address, ark) {
798
- const path = address.split(/[?#]/)[0] ?? address;
801
+ const path = address.split(QUERY_START)[0] ?? address;
799
802
  const after = path.slice(path.indexOf(`ark:/12148/${ark}`) + `ark:/12148/${ark}`.length);
800
- const rendering = after.replace(/^[./]+/, "").replace(/\/+$/, "");
803
+ const rendering = after.replace(LEADING_DOTS, "").replace(TRAILING_SLASHES, "");
801
804
  return rendering === "" ? null : rendering;
802
805
  }
803
806
  function page(rows, limit, skipped) {
@@ -807,9 +810,13 @@ function page(rows, limit, skipped) {
807
810
  function readWindowOccupancy(results) {
808
811
  for (const row of results.results.bindings) {
809
812
  const raw = text(row.windowRows);
810
- if (raw === null) continue;
813
+ if (raw === null) {
814
+ continue;
815
+ }
811
816
  const count = Number(raw);
812
- if (Number.isInteger(count)) return count >= TEXT_WINDOW;
817
+ if (Number.isInteger(count)) {
818
+ return count >= TEXT_WINDOW;
819
+ }
813
820
  }
814
821
  return null;
815
822
  }
@@ -818,10 +825,15 @@ function byPredicate(results) {
818
825
  const index = /* @__PURE__ */ new Map();
819
826
  for (const row of results.results.bindings) {
820
827
  const predicate = text(row.p);
821
- if (predicate === null) continue;
828
+ if (predicate === null) {
829
+ continue;
830
+ }
822
831
  const bucket = index.get(predicate);
823
- if (bucket) bucket.push(row);
824
- else index.set(predicate, [row]);
832
+ if (bucket) {
833
+ bucket.push(row);
834
+ } else {
835
+ index.set(predicate, [row]);
836
+ }
825
837
  }
826
838
  return index;
827
839
  }
@@ -830,7 +842,9 @@ var allValues = (index, predicate) => {
830
842
  const seen = /* @__PURE__ */ new Set();
831
843
  for (const row of index.get(predicate) ?? []) {
832
844
  const value = text(row.o);
833
- if (value !== null) seen.add(value);
845
+ if (value !== null) {
846
+ seen.add(value);
847
+ }
834
848
  }
835
849
  return [...seen];
836
850
  };
@@ -841,8 +855,12 @@ function alignments(index, selfId) {
841
855
  const found = {};
842
856
  for (const predicate of [P.sameAs, P.exactMatch, P.closeMatch, P.seeAlso]) {
843
857
  for (const address of allValues(index, predicate)) {
844
- if (!address.startsWith("http")) continue;
845
- if (selfId && address.includes(selfId)) continue;
858
+ if (!address.startsWith("http")) {
859
+ continue;
860
+ }
861
+ if (selfId && address.includes(selfId)) {
862
+ continue;
863
+ }
846
864
  let host;
847
865
  try {
848
866
  host = new URL(address).hostname.toLowerCase();
@@ -852,8 +870,12 @@ function alignments(index, selfId) {
852
870
  const known = ALIGNMENT_HOSTS.find(
853
871
  (entry) => host === entry.host || host.endsWith(`.${entry.host}`)
854
872
  );
855
- if (!known) continue;
856
- (found[known.name] ??= /* @__PURE__ */ new Set()).add(address);
873
+ if (!known) {
874
+ continue;
875
+ }
876
+ const addresses = found[known.name] ?? /* @__PURE__ */ new Set();
877
+ found[known.name] = addresses;
878
+ addresses.add(address);
857
879
  }
858
880
  }
859
881
  return Object.fromEntries(Object.entries(found).map(([name, set]) => [name, [...set]]));
@@ -868,14 +890,18 @@ function toAuthorSummaries(results, limit) {
868
890
  const seen = /* @__PURE__ */ new Set();
869
891
  let skipped = 0;
870
892
  for (const row of results.results.bindings) {
871
- if (isOccupancyRow(row, "person")) continue;
893
+ if (isOccupancyRow(row, "person")) {
894
+ continue;
895
+ }
872
896
  const iri = text(row.person);
873
897
  const id = iri === null ? null : idFromIri(iri);
874
898
  if (iri === null || id === null) {
875
899
  skipped += 1;
876
900
  continue;
877
901
  }
878
- if (seen.has(id)) continue;
902
+ if (seen.has(id)) {
903
+ continue;
904
+ }
879
905
  seen.add(id);
880
906
  rows.push({
881
907
  id,
@@ -896,18 +922,24 @@ function toAuthorDetail(results, id, pageUrl) {
896
922
  const seenLabels = /* @__PURE__ */ new Set();
897
923
  for (const row of index.get(P.altLabel) ?? []) {
898
924
  const label = text(row.o);
899
- if (label === null || seenLabels.has(label)) continue;
925
+ if (label === null || seenLabels.has(label)) {
926
+ continue;
927
+ }
900
928
  seenLabels.add(label);
901
929
  otherNames.push({ label, language: text(row.lang) });
902
930
  }
903
931
  const sameAs = alignments(index, id);
904
932
  const isni = isniAddresses(index);
905
- if (isni.length > 0) sameAs.isni = [.../* @__PURE__ */ new Set([...sameAs.isni ?? [], ...isni])];
933
+ if (isni.length > 0) {
934
+ sameAs.isni = [.../* @__PURE__ */ new Set([...sameAs.isni ?? [], ...isni])];
935
+ }
906
936
  const catalogueUrl = allValues(index, P.seeAlso).find((address) => address.includes("catalogue.bnf.fr")) ?? null;
907
937
  const depictions = [];
908
938
  for (const address of allValues(index, P.depiction)) {
909
939
  const link = toDigitisedLink(address, "depiction", id, null);
910
- if (link) depictions.push(link);
940
+ if (link) {
941
+ depictions.push(link);
942
+ }
911
943
  }
912
944
  return {
913
945
  id,
@@ -941,8 +973,12 @@ function toAuthorDetail(results, id, pageUrl) {
941
973
  function readStatus(statement, iri) {
942
974
  if (statement !== null) {
943
975
  const said = statement.toLowerCase();
944
- if (said.includes("provisional")) return "provisional";
945
- if (said.includes("established")) return "established";
976
+ if (said.includes("provisional")) {
977
+ return "provisional";
978
+ }
979
+ if (said.includes("established")) {
980
+ return "established";
981
+ }
946
982
  }
947
983
  return iri.includes("/temp-work/") ? "provisional" : "established";
948
984
  }
@@ -951,7 +987,9 @@ function toWorkSummaries(results, limit) {
951
987
  const byId = /* @__PURE__ */ new Map();
952
988
  let skipped = 0;
953
989
  for (const row of results.results.bindings) {
954
- if (isOccupancyRow(row, "work")) continue;
990
+ if (isOccupancyRow(row, "work")) {
991
+ continue;
992
+ }
955
993
  const iri = text(row.work);
956
994
  const id = iri === null ? null : idFromIri(iri);
957
995
  if (iri === null || id === null) {
@@ -979,7 +1017,7 @@ function toWorkSummaries(results, limit) {
979
1017
  }
980
1018
  return {
981
1019
  ...page(
982
- order.map((id) => byId.get(id)),
1020
+ order.map((id) => byId.get(id)).filter((one) => one !== void 0),
983
1021
  limit,
984
1022
  skipped
985
1023
  ),
@@ -1012,13 +1050,15 @@ function toAuthoredWorks(results, limit) {
1012
1050
  order.push(id);
1013
1051
  }
1014
1052
  const form = text(row.form);
1015
- if (form !== null && form.startsWith("http")) {
1053
+ if (form?.startsWith("http")) {
1016
1054
  const term = vocabularyTerm(form);
1017
- if (!work.forms.includes(term)) work.forms.push(term);
1055
+ if (!work.forms.includes(term)) {
1056
+ work.forms.push(term);
1057
+ }
1018
1058
  }
1019
1059
  }
1020
1060
  return page(
1021
- order.map((id) => byId.get(id)),
1061
+ order.map((id) => byId.get(id)).filter((one) => one !== void 0),
1022
1062
  limit,
1023
1063
  skipped
1024
1064
  );
@@ -1031,13 +1071,17 @@ function toWorkDetail(results, id, iri, pageUrl) {
1031
1071
  for (const row of index.get(P.creator) ?? []) {
1032
1072
  const creatorIri = text(row.o);
1033
1073
  const creatorId = creatorIri ? idFromIri(creatorIri) : null;
1034
- if (!creatorId || creators.some((c) => c.id === creatorId)) continue;
1074
+ if (!creatorId || creators.some((c) => c.id === creatorId)) {
1075
+ continue;
1076
+ }
1035
1077
  creators.push({ id: creatorId, name: text(row.name) });
1036
1078
  }
1037
1079
  const depictions = [];
1038
1080
  for (const address of allValues(index, P.depiction)) {
1039
1081
  const link = toDigitisedLink(address, "depiction", id, null);
1040
- if (link) depictions.push(link);
1082
+ if (link) {
1083
+ depictions.push(link);
1084
+ }
1041
1085
  }
1042
1086
  const statusStatement = firstValue(index, P.statusOfIdentification);
1043
1087
  const catalogueUrl = allValues(index, P.seeAlso).find((address) => address.includes("catalogue.bnf.fr")) ?? null;
@@ -1107,7 +1151,7 @@ function toEditions(results, limit) {
1107
1151
  }
1108
1152
  }
1109
1153
  return page(
1110
- order.map((id) => byId.get(id)),
1154
+ order.map((id) => byId.get(id)).filter((one) => one !== void 0),
1111
1155
  limit,
1112
1156
  skipped
1113
1157
  );
@@ -1126,7 +1170,9 @@ function toDigitisedLinks(results, limit) {
1126
1170
  ["depiction", "depiction"]
1127
1171
  ]) {
1128
1172
  const link = toDigitisedLink(text(row[column]), role, fromId, title);
1129
- if (!link || seen.has(link.url)) continue;
1173
+ if (!link || seen.has(link.url)) {
1174
+ continue;
1175
+ }
1130
1176
  seen.add(link.url);
1131
1177
  links.push(link);
1132
1178
  }
@@ -1137,7 +1183,9 @@ function toTypes(results) {
1137
1183
  const types = /* @__PURE__ */ new Set();
1138
1184
  for (const row of results.results.bindings) {
1139
1185
  const iri = text(row.type);
1140
- if (iri !== null) types.add(iri);
1186
+ if (iri !== null) {
1187
+ types.add(iri);
1188
+ }
1141
1189
  }
1142
1190
  return [...types];
1143
1191
  }
@@ -1178,7 +1226,9 @@ var RateLimiter = class {
1178
1226
  async beforeRequest() {
1179
1227
  const elapsed = Date.now() - this.lastStartedAt;
1180
1228
  const wait = Math.min(this.intervalMs, this.intervalMs - elapsed);
1181
- if (wait > 0) await sleep(wait);
1229
+ if (wait > 0) {
1230
+ await sleep(wait);
1231
+ }
1182
1232
  this.lastStartedAt = Date.now();
1183
1233
  }
1184
1234
  /** Called when the service asks for room: double the gap, up to the ceiling. */
@@ -1191,9 +1241,13 @@ var RateLimiter = class {
1191
1241
  * response after a rough patch does not undo the caution that earned it.
1192
1242
  */
1193
1243
  succeeded() {
1194
- if (this.intervalMs === this.baseIntervalMs) return;
1244
+ if (this.intervalMs === this.baseIntervalMs) {
1245
+ return;
1246
+ }
1195
1247
  this.calmStreak += 1;
1196
- if (this.calmStreak < 3) return;
1248
+ if (this.calmStreak < 3) {
1249
+ return;
1250
+ }
1197
1251
  this.calmStreak = 0;
1198
1252
  this.intervalMs = Math.max(this.baseIntervalMs, Math.round(this.intervalMs / 2));
1199
1253
  }
@@ -1212,7 +1266,9 @@ function withGuarantees(config) {
1212
1266
  const defaults = loadConfig({});
1213
1267
  const bounded = (value, fallback, min, max) => {
1214
1268
  const parsed = Number(value);
1215
- if (!Number.isFinite(parsed)) return fallback;
1269
+ if (!Number.isFinite(parsed)) {
1270
+ return fallback;
1271
+ }
1216
1272
  return Math.min(max, Math.max(min, parsed));
1217
1273
  };
1218
1274
  const claimed = typeof config.userAgent === "string" ? config.userAgent.trim() : "";
@@ -1263,9 +1319,6 @@ var BnfClient = class {
1263
1319
  return this.config.timeoutMs;
1264
1320
  }
1265
1321
  /** The one address this client sends anything to. */
1266
- get endpoint() {
1267
- return SPARQL_ENDPOINT;
1268
- }
1269
1322
  /**
1270
1323
  * `cacheKey` is stated rather than taken from the query, because two reads of
1271
1324
  * one query can produce different values: a list cut to five rows is not the
@@ -1309,32 +1362,7 @@ var BnfClient = class {
1309
1362
  return { data, cached: false, retrievedAt: new Date(retrievedAt).toISOString() };
1310
1363
  }
1311
1364
  /** Read an identifier a caller wrote, refusing anything this dataset cannot address. */
1312
- identify(input) {
1313
- return parseEntityId(input);
1314
- }
1315
1365
  /** The words a search will actually look for, which a tool reports back. */
1316
- words(input) {
1317
- return toSearchWords(input);
1318
- }
1319
- /**
1320
- * The terms the index requires, which is what a row's presence rests on.
1321
- *
1322
- * The index splits inside a word at an apostrophe and at a hyphen, so a word
1323
- * a caller wrote as one can reach it as two, each required on its own.
1324
- */
1325
- indexTerms(words) {
1326
- return toIndexTerms(words);
1327
- }
1328
- /**
1329
- * How a piece of caller text became the terms the search was run with.
1330
- *
1331
- * A tool reports the reading back, so the same reading has to be the one the
1332
- * query was built from: the words, the terms the index makes of them, and
1333
- * the characters that reached neither.
1334
- */
1335
- searchReading(input) {
1336
- return readSearchText(input);
1337
- }
1338
1366
  searchAuthors(name, limit, offset) {
1339
1367
  const words = toSearchWords(name);
1340
1368
  const query = searchAuthorsQuery(words, limit, offset);