mcp-databnf 1.0.0 → 1.1.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,5 @@
1
1
  // src/version.ts
2
- var PKG_VERSION = "1.0.0";
2
+ var PKG_VERSION = "1.1.0";
3
3
  var REPO_URL = "https://github.com/smeet666/mcp-databnf";
4
4
 
5
5
  // src/config.ts
@@ -387,8 +387,36 @@ function escapeLiteral(value) {
387
387
  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
388
  }
389
389
  var literal = (value) => `"${escapeLiteral(value)}"`;
390
+ var INVISIBLE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/gu;
391
+ function withoutInvisibles(input) {
392
+ return input.replace(INVISIBLE, "");
393
+ }
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
+ }
390
418
  function toSearchWords(input) {
391
- const cleaned = input.normalize("NFC").replace(/[‘’ʼ]/gu, "'").replace(/[^\p{L}\p{N}'-]+/gu, " ");
419
+ const cleaned = withoutInvisibles(input).normalize("NFC").replace(/[‘’ʼ]/gu, "'").replace(/[^\p{L}\p{N}'-]+/gu, " ");
392
420
  const words = [];
393
421
  for (const raw of cleaned.split(" ")) {
394
422
  const word = raw.replace(/^['-]+/u, "").replace(/['-]+$/u, "");
@@ -398,6 +426,15 @@ function toSearchWords(input) {
398
426
  }
399
427
  return words;
400
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);
434
+ }
435
+ }
436
+ return terms;
437
+ }
401
438
  function containsAllWords(words) {
402
439
  if (words.length === 0) {
403
440
  throw invalidInput(
@@ -469,31 +506,47 @@ var TEXT_WINDOW = 400;
469
506
  var prefixed = (body) => `${PREFIX_BLOCK}
470
507
  ${body.trim()}
471
508
  `;
509
+ function windowOccupancy(predicate, words) {
510
+ return `
511
+ {
512
+ SELECT (COUNT(*) AS ?windowRows) WHERE {
513
+ SELECT DISTINCT ?windowEntity ?windowText WHERE {
514
+ ?windowEntity ${predicate} ?windowText .
515
+ ?windowText bif:contains ${containsAllWords(words)} .
516
+ }
517
+ LIMIT ${TEXT_WINDOW}
518
+ }
519
+ }`;
520
+ }
472
521
  function searchAuthorsQuery(words, limit, offset) {
473
522
  const take = boundedInteger(limit, 1, 50, "limit") + 1;
474
523
  const skip = boundedInteger(offset, 0, 5e3, "offset");
475
524
  return prefixed(`
476
- SELECT ?person ?name ?label ?birthYear ?deathYear ?role WHERE {
525
+ SELECT ?person ?name ?label ?birthYear ?deathYear ?role ?windowRows WHERE {
477
526
  {
478
- SELECT DISTINCT ?person WHERE {
479
- {
480
- SELECT DISTINCT ?person ?name WHERE {
481
- ?person foaf:name ?name .
482
- ?name bif:contains ${containsAllWords(words)} .
527
+ {
528
+ SELECT DISTINCT ?person WHERE {
529
+ {
530
+ SELECT DISTINCT ?person ?name WHERE {
531
+ ?person foaf:name ?name .
532
+ ?name bif:contains ${containsAllWords(words)} .
533
+ }
534
+ LIMIT ${TEXT_WINDOW}
483
535
  }
484
- LIMIT ${TEXT_WINDOW}
536
+ ?person a foaf:Person .
485
537
  }
486
- ?person a foaf:Person .
538
+ ORDER BY ?person
539
+ LIMIT ${take} OFFSET ${skip}
487
540
  }
488
- ORDER BY ?person
489
- LIMIT ${take} OFFSET ${skip}
541
+ OPTIONAL { ?person foaf:name ?name }
542
+ OPTIONAL { ?record foaf:focus ?person ; skos:prefLabel ?label }
543
+ OPTIONAL { ?person bnf:firstYear ?birthYear }
544
+ OPTIONAL { ?person bnf:lastYear ?deathYear }
545
+ OPTIONAL { ?person rdag2:biographicalInformation ?role }
490
546
  }
491
- OPTIONAL { ?person foaf:name ?name }
492
- OPTIONAL { ?record foaf:focus ?person ; skos:prefLabel ?label }
493
- OPTIONAL { ?person bnf:firstYear ?birthYear }
494
- OPTIONAL { ?person bnf:lastYear ?deathYear }
495
- OPTIONAL { ?person rdag2:biographicalInformation ?role }
496
- }`);
547
+ UNION${windowOccupancy("foaf:name", words)}
548
+ }
549
+ ORDER BY ?person`);
497
550
  }
498
551
  var MOST_TRIPLES = 2e3;
499
552
  function authorQuery(id) {
@@ -516,29 +569,54 @@ function searchWorksQuery(words, limit, offset) {
516
569
  const take = boundedInteger(limit, 1, 50, "limit") + 1;
517
570
  const skip = boundedInteger(offset, 0, 5e3, "offset");
518
571
  return prefixed(`
519
- SELECT ?work ?title ?date ?status ?creator ?creatorName WHERE {
572
+ SELECT ?work ?title ?date ?status ?creator ?creatorName ?windowRows WHERE {
520
573
  {
521
- SELECT DISTINCT ?work WHERE {
522
- {
523
- SELECT DISTINCT ?work ?title WHERE {
524
- ?work dcterms:title ?title .
525
- ?title bif:contains ${containsAllWords(words)} .
574
+ {
575
+ SELECT DISTINCT ?work WHERE {
576
+ {
577
+ SELECT DISTINCT ?work ?title WHERE {
578
+ ?work dcterms:title ?title .
579
+ ?title bif:contains ${containsAllWords(words)} .
580
+ }
581
+ LIMIT ${TEXT_WINDOW}
526
582
  }
527
- LIMIT ${TEXT_WINDOW}
583
+ ?work a rdafrbr:Work .
528
584
  }
529
- ?work a rdafrbr:Work .
585
+ ORDER BY ?work
586
+ LIMIT ${take} OFFSET ${skip}
587
+ }
588
+ OPTIONAL { ?work dcterms:title ?title }
589
+ OPTIONAL { ?work dcterms:date ?date }
590
+ OPTIONAL { ?work rdae:statusOfIdentification ?status }
591
+ OPTIONAL {
592
+ ?work dcterms:creator ?creator .
593
+ OPTIONAL { ?creator foaf:name ?creatorName }
594
+ }
595
+ }
596
+ UNION${windowOccupancy("dcterms:title", words)}
597
+ }
598
+ ORDER BY ?work`);
599
+ }
600
+ function worksByCreatorQuery(id, limit, offset) {
601
+ const person = iriOf(id);
602
+ const take = boundedInteger(limit, 1, 50, "limit") + 1;
603
+ const skip = boundedInteger(offset, 0, 5e3, "offset");
604
+ return prefixed(`
605
+ SELECT ?work ?title ?date ?year ?status ?form WHERE {
606
+ {
607
+ SELECT DISTINCT ?work WHERE {
608
+ ?work dcterms:creator ${person} .
530
609
  }
531
610
  ORDER BY ?work
532
611
  LIMIT ${take} OFFSET ${skip}
533
612
  }
534
613
  OPTIONAL { ?work dcterms:title ?title }
535
614
  OPTIONAL { ?work dcterms:date ?date }
615
+ OPTIONAL { ?work bnf:firstYear ?year }
536
616
  OPTIONAL { ?work rdae:statusOfIdentification ?status }
537
- OPTIONAL {
538
- ?work dcterms:creator ?creator .
539
- OPTIONAL { ?creator foaf:name ?creatorName }
540
- }
541
- }`);
617
+ OPTIONAL { ?work rdae:formOfWork ?form }
618
+ }
619
+ ORDER BY ?work`);
542
620
  }
543
621
  function workQuery(id) {
544
622
  const work = iriOf(id);
@@ -576,7 +654,8 @@ SELECT ?edition ?title ?date ?year ?publisher ?place ?editionStatement
576
654
  OPTIONAL { ?edition rdfs:seeAlso ?catalogue }
577
655
  OPTIONAL { ?edition rdarel:electronicReproduction ?reproduction }
578
656
  OPTIONAL { ?edition bnf:OCR ?ocr }
579
- }`);
657
+ }
658
+ ORDER BY ?edition`);
580
659
  }
581
660
  function digitisedForWorkQuery(id, limit) {
582
661
  const work = iriOf(id);
@@ -713,12 +792,28 @@ function toDigitisedLink(address, role, fromId, fromTitle) {
713
792
  if (!segment) return null;
714
793
  const ark = segment.split(".")[0] ?? segment;
715
794
  if (ark === "") return null;
716
- return { ark, url: address, role, fromId, fromTitle };
795
+ return { ark, url: address, rendering: renderingOf(address, ark), role, fromId, fromTitle };
796
+ }
797
+ function renderingOf(address, ark) {
798
+ const path = address.split(/[?#]/)[0] ?? address;
799
+ const after = path.slice(path.indexOf(`ark:/12148/${ark}`) + `ark:/12148/${ark}`.length);
800
+ const rendering = after.replace(/^[./]+/, "").replace(/\/+$/, "");
801
+ return rendering === "" ? null : rendering;
717
802
  }
718
803
  function page(rows, limit, skipped) {
719
804
  const more = rows.length > limit;
720
805
  return skipped > 0 ? { rows: rows.slice(0, limit), hasMore: more, skipped } : { rows: rows.slice(0, limit), hasMore: more };
721
806
  }
807
+ function readWindowOccupancy(results) {
808
+ for (const row of results.results.bindings) {
809
+ const raw = text(row.windowRows);
810
+ if (raw === null) continue;
811
+ const count = Number(raw);
812
+ if (Number.isInteger(count)) return count >= TEXT_WINDOW;
813
+ }
814
+ return null;
815
+ }
816
+ var isOccupancyRow = (row, entity) => row.windowRows !== void 0 && row[entity] === void 0;
722
817
  function byPredicate(results) {
723
818
  const index = /* @__PURE__ */ new Map();
724
819
  for (const row of results.results.bindings) {
@@ -773,6 +868,7 @@ function toAuthorSummaries(results, limit) {
773
868
  const seen = /* @__PURE__ */ new Set();
774
869
  let skipped = 0;
775
870
  for (const row of results.results.bindings) {
871
+ if (isOccupancyRow(row, "person")) continue;
776
872
  const iri = text(row.person);
777
873
  const id = iri === null ? null : idFromIri(iri);
778
874
  if (iri === null || id === null) {
@@ -791,7 +887,7 @@ function toAuthorSummaries(results, limit) {
791
887
  sourceUrl: publicPageFor(iri)
792
888
  });
793
889
  }
794
- return page(rows, limit, skipped);
890
+ return { ...page(rows, limit, skipped), indexWindowFull: readWindowOccupancy(results) };
795
891
  }
796
892
  function toAuthorDetail(results, id, pageUrl) {
797
893
  const index = byPredicate(results);
@@ -855,6 +951,7 @@ function toWorkSummaries(results, limit) {
855
951
  const byId = /* @__PURE__ */ new Map();
856
952
  let skipped = 0;
857
953
  for (const row of results.results.bindings) {
954
+ if (isOccupancyRow(row, "work")) continue;
858
955
  const iri = text(row.work);
859
956
  const id = iri === null ? null : idFromIri(iri);
860
957
  if (iri === null || id === null) {
@@ -880,6 +977,46 @@ function toWorkSummaries(results, limit) {
880
977
  work.creators.push({ id: creatorId, name: text(row.creatorName) });
881
978
  }
882
979
  }
980
+ return {
981
+ ...page(
982
+ order.map((id) => byId.get(id)),
983
+ limit,
984
+ skipped
985
+ ),
986
+ indexWindowFull: readWindowOccupancy(results)
987
+ };
988
+ }
989
+ function toAuthoredWorks(results, limit) {
990
+ const order = [];
991
+ const byId = /* @__PURE__ */ new Map();
992
+ let skipped = 0;
993
+ for (const row of results.results.bindings) {
994
+ const iri = text(row.work);
995
+ const id = iri === null ? null : idFromIri(iri);
996
+ if (iri === null || id === null) {
997
+ skipped += 1;
998
+ continue;
999
+ }
1000
+ let work = byId.get(id);
1001
+ if (!work) {
1002
+ work = {
1003
+ id,
1004
+ title: text(row.title),
1005
+ date: text(row.date),
1006
+ year: integer(row.year),
1007
+ forms: [],
1008
+ status: readStatus(text(row.status), iri),
1009
+ sourceUrl: publicPageFor(iri)
1010
+ };
1011
+ byId.set(id, work);
1012
+ order.push(id);
1013
+ }
1014
+ const form = text(row.form);
1015
+ if (form !== null && form.startsWith("http")) {
1016
+ const term = vocabularyTerm(form);
1017
+ if (!work.forms.includes(term)) work.forms.push(term);
1018
+ }
1019
+ }
883
1020
  return page(
884
1021
  order.map((id) => byId.get(id)),
885
1022
  limit,
@@ -1179,6 +1316,25 @@ var BnfClient = class {
1179
1316
  words(input) {
1180
1317
  return toSearchWords(input);
1181
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
+ }
1182
1338
  searchAuthors(name, limit, offset) {
1183
1339
  const words = toSearchWords(name);
1184
1340
  const query = searchAuthorsQuery(words, limit, offset);
@@ -1195,7 +1351,7 @@ var BnfClient = class {
1195
1351
  (results) => toAuthorDetail(results, id.id, id.pageUrl)
1196
1352
  );
1197
1353
  if (!result.data.types.includes(PERSON_CLASS)) {
1198
- await this.refuse(id, result.data.types, "a person", "get_work reads a work.");
1354
+ await this.refuse(id, "a person", "get_work reads a work.");
1199
1355
  }
1200
1356
  return result;
1201
1357
  }
@@ -1206,8 +1362,8 @@ var BnfClient = class {
1206
1362
  * something else are two different answers, and only one of them means the
1207
1363
  * caller should go looking elsewhere.
1208
1364
  */
1209
- async refuse(id, types, wanted, hint) {
1210
- const known = types.length > 0 ? types : (await this.read(`types:${id.id}`, kindQuery(id), toTypes)).data;
1365
+ async refuse(id, wanted, hint) {
1366
+ const known = (await this.read(`types:${id.id}`, kindQuery(id), toTypes)).data;
1211
1367
  if (known.length === 0) {
1212
1368
  throw notFound(`data.bnf.fr describes nothing at "${id.id}".`, {
1213
1369
  hint: "Find the identifier with search_authors or search_works rather than writing one.",
@@ -1237,13 +1393,26 @@ var BnfClient = class {
1237
1393
  if (!result.data.types.some((type) => WORK_CLASSES.has(type))) {
1238
1394
  await this.refuse(
1239
1395
  id,
1240
- result.data.types,
1241
1396
  "a work",
1242
1397
  "get_author reads a person, and list_editions reads the editions of a work."
1243
1398
  );
1244
1399
  }
1245
1400
  return result;
1246
1401
  }
1402
+ /**
1403
+ * The works one person is named the creator of.
1404
+ *
1405
+ * The record is not checked here. A person the catalogue credits with nothing
1406
+ * and an address that names no person both answer with no rows, and telling
1407
+ * them apart costs a second query that only an empty answer needs.
1408
+ */
1409
+ worksByAuthor(id, limit, offset) {
1410
+ return this.read(
1411
+ `author-works:${id.id}:${limit}:${offset}`,
1412
+ worksByCreatorQuery(id, limit, offset),
1413
+ (results) => toAuthoredWorks(results, limit)
1414
+ );
1415
+ }
1247
1416
  listEditions(id, limit, offset) {
1248
1417
  const query = editionsQuery(id, limit, offset);
1249
1418
  return this.read(