mcp-recipes 1.2.0 → 1.3.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.2.0";
2
+ var PKG_VERSION = "1.3.0";
3
3
  var REPO_URL = "https://github.com/smeet666/mcp-recipes";
4
4
  var ISSUES_URL = "https://github.com/smeet666/mcp-recipes/issues";
5
5
 
@@ -94,11 +94,17 @@ var RecipesError = class extends Error {
94
94
  this.details = details;
95
95
  }
96
96
  };
97
+ var notFound = (message, details) => new RecipesError("not_found", message, details ?? {});
97
98
  var invalidInput = (message, hint) => new RecipesError("invalid_input", message, hint ? { hint } : {});
99
+ var rateLimited = (message, details) => new RecipesError("rate_limited", message, {
100
+ hint: "Wait a moment and ask again. This says nothing about whether the recipe exists.",
101
+ ...details
102
+ });
98
103
  var parseFailure = (message, details) => new RecipesError("parse_failure", message, {
99
104
  hint: `A site may have changed how it answers. Please report this at ${ISSUES_URL} with the arguments you used.`,
100
105
  ...details
101
106
  });
107
+ var networkError = (message, details) => new RecipesError("network_error", message, details ?? {});
102
108
  var timeout = (message, details) => new RecipesError("timeout", message, details ?? {});
103
109
  function toRecipesError(error) {
104
110
  if (error instanceof RecipesError) return error;
@@ -109,6 +115,30 @@ function toRecipesError(error) {
109
115
  const details = raw && typeof raw.details === "object" && raw.details !== null ? raw.details : {};
110
116
  return new RecipesError(known ?? "network_error", message, details);
111
117
  }
118
+ function fromSource(error, sourceName) {
119
+ if (error instanceof RecipesError) return error;
120
+ const known = toRecipesError(error);
121
+ const reported = known.message.replace(/\s+/g, " ").trim();
122
+ const opening = known.code === "not_found" ? `${sourceName} holds nothing under this name.` : `${sourceName} could not be read.`;
123
+ const message = reported === "" ? opening : `${opening} It reports: "${reported}"`;
124
+ const details = {};
125
+ if (known.details.url !== void 0) details.url = known.details.url;
126
+ if (known.details.status !== void 0) details.status = known.details.status;
127
+ switch (known.code) {
128
+ case "not_found":
129
+ return notFound(message, details);
130
+ case "invalid_input":
131
+ return invalidInput(message);
132
+ case "rate_limited":
133
+ return rateLimited(message, details);
134
+ case "parse_failure":
135
+ return parseFailure(message, details);
136
+ case "timeout":
137
+ return timeout(message, details);
138
+ default:
139
+ return networkError(message, details);
140
+ }
141
+ }
112
142
 
113
143
  // src/sources/adapter.ts
114
144
  function text(value) {
@@ -341,6 +371,9 @@ function cookbookDetail(payload) {
341
371
  yieldUnit: text(recipe.yieldUnit) ?? span.unit,
342
372
  ingredients: textList(recipe.ingredients),
343
373
  steps: textList(recipe.steps),
374
+ // The page's own headings, which the reader reports. An answer holding no
375
+ // ingredient line uses them to say whether the page announced a list.
376
+ publishedSections: Array.isArray(recipe.sectionTitles) ? textList(recipe.sectionTitles) : null,
344
377
  // The Cookbook publishes one time for a recipe rather than splitting it.
345
378
  prepMinutes: null,
346
379
  cookMinutes: null,
@@ -458,6 +491,10 @@ function marmitonDetail(payload) {
458
491
  yieldUnit: span.unit ?? text(yields.unit),
459
492
  ingredients: textList(recipe.ingredients),
460
493
  steps: textList(recipe.steps),
494
+ // The reader hands over the parts of a page it recognised and not the
495
+ // headings the page carries, so there is nothing here to say how it is
496
+ // laid out.
497
+ publishedSections: null,
461
498
  prepMinutes: count(recipe.prepMinutes),
462
499
  cookMinutes: count(recipe.cookMinutes),
463
500
  totalMinutes: count(recipe.totalMinutes),
@@ -510,6 +547,473 @@ function selectSources(sources, wanted) {
510
547
  return chosen;
511
548
  }
512
549
 
550
+ // src/sources/wordings.ts
551
+ var MAX_WORDINGS_PER_SOURCE = 3;
552
+ var FRENCH_FRAME = /* @__PURE__ */ new Set([
553
+ "a",
554
+ "ai",
555
+ "aimerais",
556
+ "au",
557
+ "aurais",
558
+ "auriez",
559
+ "aux",
560
+ "avec",
561
+ "avons",
562
+ "bcp",
563
+ "beaucoup",
564
+ "bien",
565
+ "bonjour",
566
+ "bonne",
567
+ "bonnes",
568
+ "cherche",
569
+ "chercher",
570
+ "cherchez",
571
+ "connais",
572
+ "connaissez",
573
+ "coucou",
574
+ "ce",
575
+ "ces",
576
+ "cette",
577
+ "comment",
578
+ "d",
579
+ "dans",
580
+ "de",
581
+ "des",
582
+ "du",
583
+ "en",
584
+ "est",
585
+ "et",
586
+ "facile",
587
+ "faire",
588
+ "fais",
589
+ "fait",
590
+ "idee",
591
+ "idees",
592
+ "id\xE9e",
593
+ "id\xE9es",
594
+ "j",
595
+ "je",
596
+ "l",
597
+ "la",
598
+ "le",
599
+ "les",
600
+ "ma",
601
+ "meilleur",
602
+ "meilleure",
603
+ "merci",
604
+ "mes",
605
+ "mon",
606
+ "ou",
607
+ "par",
608
+ "peux",
609
+ "plait",
610
+ "pla\xEEt",
611
+ "plus",
612
+ "pour",
613
+ "qu",
614
+ "que",
615
+ "quel",
616
+ "quelle",
617
+ "quelles",
618
+ "quels",
619
+ "qui",
620
+ "rapide",
621
+ "recette",
622
+ "recettes",
623
+ "recherche",
624
+ "s",
625
+ "salut",
626
+ "se",
627
+ "si",
628
+ "simple",
629
+ "sommes",
630
+ "souhaite",
631
+ "stp",
632
+ "suis",
633
+ "sur",
634
+ "svp",
635
+ "tres",
636
+ "tr\xE8s",
637
+ "un",
638
+ "une",
639
+ "veux",
640
+ "voudrais",
641
+ "vous",
642
+ "\xE0"
643
+ ]);
644
+ var ENGLISH_FRAME = /* @__PURE__ */ new Set([
645
+ "a",
646
+ "about",
647
+ "am",
648
+ "an",
649
+ "and",
650
+ "any",
651
+ "anything",
652
+ "are",
653
+ "at",
654
+ "be",
655
+ "best",
656
+ "by",
657
+ "can",
658
+ "could",
659
+ "do",
660
+ "does",
661
+ "easy",
662
+ "find",
663
+ "for",
664
+ "from",
665
+ "get",
666
+ "good",
667
+ "hello",
668
+ "hi",
669
+ "how",
670
+ "i",
671
+ "in",
672
+ "into",
673
+ "is",
674
+ "it",
675
+ "like",
676
+ "looking",
677
+ "make",
678
+ "making",
679
+ "me",
680
+ "my",
681
+ "need",
682
+ "of",
683
+ "on",
684
+ "or",
685
+ "please",
686
+ "quick",
687
+ "recipe",
688
+ "recipes",
689
+ "should",
690
+ "simple",
691
+ "some",
692
+ "something",
693
+ "suggest",
694
+ "thank",
695
+ "thanks",
696
+ "that",
697
+ "the",
698
+ "less",
699
+ "over",
700
+ "than",
701
+ "to",
702
+ "under",
703
+ "want",
704
+ "what",
705
+ "which",
706
+ "will",
707
+ "with",
708
+ "would",
709
+ "you",
710
+ "your"
711
+ ]);
712
+ var CONDITION_UNITS = /* @__PURE__ */ new Set([
713
+ "calorie",
714
+ "calories",
715
+ "couvert",
716
+ "couverts",
717
+ "heure",
718
+ "heures",
719
+ "kcal",
720
+ "min",
721
+ "mins",
722
+ "minute",
723
+ "minutes",
724
+ "part",
725
+ "parts",
726
+ "people",
727
+ "person",
728
+ "persons",
729
+ "personne",
730
+ "personnes",
731
+ "portion",
732
+ "portions",
733
+ "serving",
734
+ "servings"
735
+ ]);
736
+ var NEGATORS = /* @__PURE__ */ new Set([
737
+ "aucun",
738
+ "aucune",
739
+ "avoid",
740
+ "avoiding",
741
+ "eviter",
742
+ "evite",
743
+ "evitez",
744
+ "exclude",
745
+ "excluding",
746
+ "exclure",
747
+ "except",
748
+ "ni",
749
+ "no",
750
+ "non",
751
+ "not",
752
+ "pas",
753
+ "sans",
754
+ "sauf",
755
+ "without",
756
+ "zero"
757
+ ]);
758
+ var HEALTH_MARKERS = /* @__PURE__ */ new Set([
759
+ "allergene",
760
+ "allergenes",
761
+ "allergen",
762
+ "allergens",
763
+ "allergic",
764
+ "allergie",
765
+ "allergies",
766
+ "allergique",
767
+ "allergiques",
768
+ "allergy",
769
+ "celiac",
770
+ "coeliaque",
771
+ "intolerance",
772
+ "intolerances",
773
+ "intolerant",
774
+ "intolerante",
775
+ "intolerantes",
776
+ "intolerants"
777
+ ]);
778
+ var DIETS = /* @__PURE__ */ new Set([
779
+ "casher",
780
+ "cetogene",
781
+ "halal",
782
+ "kascher",
783
+ "keto",
784
+ "ketogenic",
785
+ "kosher",
786
+ "paleo",
787
+ "pescatarian",
788
+ "pescetarien",
789
+ "pescetarienne",
790
+ "vegan",
791
+ "vegane",
792
+ "veganes",
793
+ "vegans",
794
+ "vegetalien",
795
+ "vegetalienne",
796
+ "vegetaliennes",
797
+ "vegetaliens",
798
+ "vegetarian",
799
+ "vegetarians",
800
+ "vegetarien",
801
+ "vegetarienne",
802
+ "vegetariennes",
803
+ "vegetariens",
804
+ "veggie"
805
+ ]);
806
+ var CONDITION_ENDS = /* @__PURE__ */ new Set([
807
+ "et",
808
+ "and",
809
+ "ou",
810
+ "or",
811
+ "pour",
812
+ "for",
813
+ "avec",
814
+ "with",
815
+ "mais",
816
+ "but",
817
+ "dans",
818
+ "sur"
819
+ ]);
820
+ var CONDITION_JOINERS = /* @__PURE__ */ new Set([
821
+ "a",
822
+ "au",
823
+ "aux",
824
+ "d",
825
+ "de",
826
+ "des",
827
+ "du",
828
+ "from",
829
+ "l",
830
+ "la",
831
+ "le",
832
+ "les",
833
+ "my",
834
+ "of",
835
+ "the",
836
+ "to"
837
+ ]);
838
+ var FOOD_LINKERS = /* @__PURE__ */ new Set(["d", "de", "des", "du", "of"]);
839
+ var CONDITION_WORDS = 3;
840
+ var SERVING_PHRASES = [
841
+ /\bpour\s+(\d{1,3})\s*(?:personnes?|parts?|couverts?)\b/iu,
842
+ /\bfor\s+(\d{1,3})\s*(?:people|persons?|servings?|portions?)\b/iu,
843
+ /\bserves?\s+(\d{1,3})\b/iu,
844
+ /\b(\d{1,3})\s*(?:personnes?|people|servings?)\b/iu
845
+ ];
846
+ var FREE_OF = /\b([\p{L}]{3,})[\s-]?free\b/iu;
847
+ function foldWord(word) {
848
+ return word.toLowerCase().normalize("NFD").replace(new RegExp("\\p{M}+", "gu"), "").replace(/œ/gu, "oe");
849
+ }
850
+ function readQuestion(question) {
851
+ const words = tokenise(question);
852
+ const conditions = [];
853
+ const dish = [];
854
+ const state = (named, kind) => {
855
+ const trimmed = named === null ? null : named.trim();
856
+ if (trimmed === "") return;
857
+ if (conditions.some((held) => held.named === trimmed && held.kind === kind)) return;
858
+ conditions.push({ named: trimmed, kind });
859
+ };
860
+ for (let index = 0; index < words.length; index += 1) {
861
+ const word = words[index];
862
+ const key = foldWord(word);
863
+ const hyphen = word.indexOf("-");
864
+ if (hyphen > 0 && NEGATORS.has(foldWord(word.slice(0, hyphen)))) {
865
+ state(word.slice(hyphen + 1), "excluded");
866
+ continue;
867
+ }
868
+ const free = FREE_OF.exec(word);
869
+ if (free) {
870
+ state(free[1], "excluded");
871
+ continue;
872
+ }
873
+ if (key === "free") {
874
+ const scope = scopeOf(words, index, dish);
875
+ if (scope.named !== null) {
876
+ state(scope.named, "excluded");
877
+ index = scope.next - 1;
878
+ }
879
+ continue;
880
+ }
881
+ if (NEGATORS.has(key)) {
882
+ const forward = takeFood(words, index + 1);
883
+ state(forward.named === "" ? null : forward.named, "excluded");
884
+ index = forward.next - 1;
885
+ continue;
886
+ }
887
+ if (HEALTH_MARKERS.has(key)) {
888
+ const scope = scopeOf(words, index, dish);
889
+ state(scope.named, "allergy");
890
+ index = scope.next - 1;
891
+ continue;
892
+ }
893
+ if (DIETS.has(key)) {
894
+ state(word, "diet");
895
+ continue;
896
+ }
897
+ if (FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key)) continue;
898
+ if (FRENCH_FRAME.has(word) || ENGLISH_FRAME.has(word)) continue;
899
+ if (/^\d+$/.test(word)) continue;
900
+ if (word.length < 2) continue;
901
+ if (CONDITION_UNITS.has(word)) continue;
902
+ if (!dish.includes(word)) dish.push(word);
903
+ }
904
+ return { dish, conditions };
905
+ }
906
+ function scopeOf(words, marker, dish) {
907
+ if (CONDITION_JOINERS.has(foldWord(words[marker + 1] ?? ""))) {
908
+ const forward = takeFood(words, marker + 1);
909
+ return { named: forward.named === "" ? null : forward.named, next: forward.next };
910
+ }
911
+ const before = words[marker - 1];
912
+ if (before !== void 0) {
913
+ const held = dish.lastIndexOf(before);
914
+ if (held !== -1) {
915
+ dish.splice(held, 1);
916
+ return { named: before, next: marker + 1 };
917
+ }
918
+ }
919
+ return { named: null, next: marker + 1 };
920
+ }
921
+ function takeFood(words, from) {
922
+ let index = from;
923
+ while (index < words.length && isFiller(words[index])) index += 1;
924
+ const named = [];
925
+ while (index < words.length && named.length < CONDITION_WORDS) {
926
+ if (!namesFood(words[index])) break;
927
+ named.push(words[index]);
928
+ index += 1;
929
+ const beyond = words[index + 1];
930
+ if (named.length + 2 <= CONDITION_WORDS && FOOD_LINKERS.has(foldWord(words[index] ?? "")) && beyond !== void 0 && namesFood(beyond)) {
931
+ named.push(words[index]);
932
+ index += 1;
933
+ continue;
934
+ }
935
+ break;
936
+ }
937
+ return { named: named.join(" "), next: index };
938
+ }
939
+ function isFiller(word) {
940
+ const key = foldWord(word);
941
+ return CONDITION_JOINERS.has(key) || FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key);
942
+ }
943
+ function namesFood(word) {
944
+ const key = foldWord(word);
945
+ if (key.length < 2) return false;
946
+ if (/^\d+$/.test(key)) return false;
947
+ if (key === "free") return false;
948
+ if (CONDITION_ENDS.has(key)) return false;
949
+ if (NEGATORS.has(key) || HEALTH_MARKERS.has(key)) return false;
950
+ if (CONDITION_UNITS.has(key)) return false;
951
+ return !(FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key));
952
+ }
953
+ function readConditions(question) {
954
+ const { conditions } = readQuestion(question);
955
+ let servings = null;
956
+ for (const phrase of SERVING_PHRASES) {
957
+ const found = phrase.exec(question);
958
+ if (found) {
959
+ const value = Number(found[1]);
960
+ if (Number.isFinite(value) && value > 0) {
961
+ servings = value;
962
+ break;
963
+ }
964
+ }
965
+ }
966
+ return { conditions, servings };
967
+ }
968
+ function deriveWordings(question) {
969
+ const asked = tidy(question);
970
+ const wordings = [];
971
+ const seen = /* @__PURE__ */ new Set();
972
+ const offer = (candidate, derivation) => {
973
+ const clean = tidy(candidate);
974
+ if (clean === "") return;
975
+ const key = clean.toLowerCase();
976
+ if (seen.has(key)) return;
977
+ seen.add(key);
978
+ wordings.push({ query: clean, derivation });
979
+ };
980
+ offer(asked, "the words as asked, condition words included");
981
+ const dish = dishWords(asked);
982
+ offer(
983
+ dish.join(" "),
984
+ "the words naming the dish, with the words framing the question and the conditions set aside, because a page is named for what it holds"
985
+ );
986
+ offer(
987
+ dish[0] ?? "",
988
+ "the leading dish word on its own, because an index that requires every word given returns nothing for a phrase and a great deal for the word naming the dish"
989
+ );
990
+ return wordings.slice(0, MAX_WORDINGS_PER_SOURCE);
991
+ }
992
+ function namesDish(title, question) {
993
+ const words = namingWords(question);
994
+ if (words.length === 0) return true;
995
+ return dishWordsMissing(title, question).length < words.length;
996
+ }
997
+ function dishWordsMissing(title, question) {
998
+ const haystack = ` ${fold(title)} `;
999
+ return namingWords(question).filter((word) => !haystack.includes(` ${fold(word)}`));
1000
+ }
1001
+ function namingWords(question) {
1002
+ return dishWords(question).filter((word) => word.length > 2);
1003
+ }
1004
+ function fold(value) {
1005
+ return value.normalize("NFD").replace(new RegExp("\\p{M}+", "gu"), "").replace(/œ/gu, "oe").toLowerCase().replace(/[^a-z0-9]+/g, " ");
1006
+ }
1007
+ function dishWords(question) {
1008
+ return readQuestion(question).dish;
1009
+ }
1010
+ function tokenise(value) {
1011
+ return value.toLowerCase().replace(/['’]/gu, " ").split(/[^\p{L}\p{N}-]+/u).map((word) => word.replace(/^-+|-+$/g, "")).filter((word) => word !== "");
1012
+ }
1013
+ function tidy(value) {
1014
+ return value.replace(/\s+/g, " ").trim();
1015
+ }
1016
+
513
1017
  // src/sources/client.ts
514
1018
  var MAX_LIMIT_PER_SOURCE = 25;
515
1019
  function withGuarantees(config) {
@@ -542,9 +1046,7 @@ function withDeadline(work, ms, source) {
542
1046
  const alarm = new Promise((_resolve, reject) => {
543
1047
  timer = setTimeout(
544
1048
  () => reject(
545
- timeout(
546
- `${source.name} did not answer within ${ms}ms and was left out of this answer.`
547
- )
1049
+ timeout(`${source.name} did not answer within ${ms}ms and was left out of this answer.`)
548
1050
  ),
549
1051
  ms
550
1052
  );
@@ -563,7 +1065,20 @@ function reportOf(attempt, count2) {
563
1065
  skipped: attempt.skipped,
564
1066
  mixesReferencePages: attempt.source.mixesReferencePages,
565
1067
  cached: attempt.cached,
566
- error: attempt.error
1068
+ error: attempt.error,
1069
+ wordings: attempt.wordings,
1070
+ preferredByName: attempt.preferredByName
1071
+ };
1072
+ }
1073
+ function heldBack(wording, because) {
1074
+ return {
1075
+ query: wording.query,
1076
+ derivation: wording.derivation,
1077
+ ran: false,
1078
+ count: null,
1079
+ added: null,
1080
+ notRunBecause: because,
1081
+ error: null
567
1082
  };
568
1083
  }
569
1084
  function interleave(groups) {
@@ -623,15 +1138,22 @@ var RecipesClient = class {
623
1138
  * source rather than as long as all of them. Whichever of them fail, the rest
624
1139
  * still come back, and the report says which was which.
625
1140
  */
626
- async searchRecipes(query, limitPerSource, wanted) {
1141
+ async searchRecipes(query, limitPerSource, wanted, options = {}) {
627
1142
  const trimmed = query.trim();
628
1143
  if (trimmed === "") {
629
1144
  throw invalidInput("A search needs something to look for.", "Name a dish or an ingredient.");
630
1145
  }
1146
+ if (!/[\p{L}\p{N}]/u.test(trimmed)) {
1147
+ throw invalidInput(
1148
+ `"${trimmed}" carries no letter and no digit, so there is no word to search for.`,
1149
+ "Name a dish or an ingredient."
1150
+ );
1151
+ }
631
1152
  const chosen = selectSources(this.sources, wanted);
632
1153
  const limit = Math.max(1, Math.min(MAX_LIMIT_PER_SOURCE, Math.trunc(limitPerSource)));
1154
+ const fanOut = options.fanOut ?? true;
633
1155
  const attempts = await Promise.all(
634
- chosen.map((source) => this.searchOne(source, trimmed, limit))
1156
+ chosen.map((source) => this.searchOne(source, trimmed, limit, fanOut))
635
1157
  );
636
1158
  const groups = attempts.map((attempt) => attempt.rows.slice(0, limit));
637
1159
  return {
@@ -639,40 +1161,135 @@ var RecipesClient = class {
639
1161
  reports: attempts.map((attempt, index) => reportOf(attempt, groups[index].length))
640
1162
  };
641
1163
  }
642
- async searchOne(source, query, limit) {
643
- try {
644
- const read = await withDeadline(source.search(query, limit), this.deadlineMs, source);
645
- if (read.skipped > 0) {
646
- this.logger.warn(
647
- `${source.name} sent ${read.skipped} row(s) this server could not read; they were left out.`
1164
+ /**
1165
+ * Ask one source the question in several wordings, and union what comes back.
1166
+ *
1167
+ * The wordings go out one after another rather than together, because each
1168
+ * source is paced and a burst would spend the whole ladder's politeness at
1169
+ * once. They stop early: a wording is only sent when the ones before it left
1170
+ * the source short of what was asked for, so the ordinary case where the
1171
+ * question names a dish costs one request.
1172
+ *
1173
+ * Each wording is a different search rather than a further page of the same
1174
+ * one. One of these sites serves a single page of results and disallows
1175
+ * paging past it, so a second page is never asked for, here or anywhere else.
1176
+ */
1177
+ async searchOne(source, question, limit, fanOut) {
1178
+ const derived = deriveWordings(question);
1179
+ const wordings = [];
1180
+ const rows = [];
1181
+ const seen = /* @__PURE__ */ new Set();
1182
+ let cached = false;
1183
+ let skipped = 0;
1184
+ let reportedTotal = null;
1185
+ let reportedTotalMeans = null;
1186
+ let answered = false;
1187
+ let failure = null;
1188
+ let onTopic = 0;
1189
+ for (const [index, wording] of derived.entries()) {
1190
+ if (index > 0 && !fanOut) {
1191
+ wordings.push(
1192
+ heldBack(wording, "fan_out was turned off, so only the words as asked were sent")
648
1193
  );
1194
+ continue;
649
1195
  }
650
- return {
651
- source,
652
- rows: read.rows,
653
- cached: read.cached,
654
- reportedTotal: read.reportedTotal,
655
- reportedTotalMeans: read.reportedTotalMeans,
656
- skipped: read.skipped,
657
- error: null
658
- };
659
- } catch (error) {
660
- const known = toRecipesError(error);
661
- this.logger.warn(`${source.name} did not answer: [${known.code}] ${known.message}`);
662
- return {
663
- source,
664
- rows: [],
665
- cached: false,
666
- reportedTotal: null,
667
- reportedTotalMeans: null,
668
- skipped: 0,
669
- error: {
1196
+ if (index >= MAX_WORDINGS_PER_SOURCE) {
1197
+ wordings.push(
1198
+ heldBack(
1199
+ wording,
1200
+ `the ceiling of ${MAX_WORDINGS_PER_SOURCE} searches of one source was reached`
1201
+ )
1202
+ );
1203
+ continue;
1204
+ }
1205
+ if (failure !== null) {
1206
+ wordings.push(
1207
+ heldBack(wording, `${source.name} did not answer the wording before this one`)
1208
+ );
1209
+ continue;
1210
+ }
1211
+ if (onTopic >= limit) {
1212
+ wordings.push(
1213
+ heldBack(
1214
+ wording,
1215
+ "the wordings already sent returned as many rows naming the dish as were asked for"
1216
+ )
1217
+ );
1218
+ continue;
1219
+ }
1220
+ try {
1221
+ const read = await withDeadline(
1222
+ source.search(wording.query, limit),
1223
+ this.deadlineMs,
1224
+ source
1225
+ );
1226
+ answered = true;
1227
+ cached = cached || read.cached;
1228
+ skipped += read.skipped;
1229
+ if (reportedTotal === null && reportedTotalMeans === null) {
1230
+ reportedTotal = read.reportedTotal;
1231
+ reportedTotalMeans = read.reportedTotalMeans;
1232
+ }
1233
+ if (read.skipped > 0) {
1234
+ this.logger.warn(
1235
+ `${source.name} sent ${read.skipped} row(s) this server could not read; they were left out.`
1236
+ );
1237
+ }
1238
+ let added = 0;
1239
+ for (const row of read.rows) {
1240
+ if (seen.has(row.id)) continue;
1241
+ seen.add(row.id);
1242
+ rows.push(row);
1243
+ added += 1;
1244
+ if (namesDish(row.title, question)) onTopic += 1;
1245
+ }
1246
+ wordings.push({
1247
+ query: wording.query,
1248
+ derivation: wording.derivation,
1249
+ ran: true,
1250
+ count: read.rows.length,
1251
+ added,
1252
+ notRunBecause: null,
1253
+ error: null
1254
+ });
1255
+ } catch (error) {
1256
+ const known = fromSource(error, source.name);
1257
+ this.logger.warn(
1258
+ `${source.name} did not answer "${wording.query}": [${known.code}] ${known.message}`
1259
+ );
1260
+ const reason = {
670
1261
  code: known.code,
671
1262
  message: known.message,
672
1263
  ...known.details.hint ? { hint: known.details.hint } : {}
673
- }
674
- };
1264
+ };
1265
+ wordings.push({
1266
+ query: wording.query,
1267
+ derivation: wording.derivation,
1268
+ ran: true,
1269
+ count: null,
1270
+ added: null,
1271
+ notRunBecause: null,
1272
+ error: reason
1273
+ });
1274
+ failure = reason;
1275
+ }
675
1276
  }
1277
+ const contributing = wordings.filter((attempt) => (attempt.added ?? 0) > 0).length;
1278
+ const ordered = contributing > 1 ? [
1279
+ ...rows.filter((row) => namesDish(row.title, question)),
1280
+ ...rows.filter((row) => !namesDish(row.title, question))
1281
+ ] : rows;
1282
+ return {
1283
+ source,
1284
+ rows: ordered,
1285
+ preferredByName: contributing > 1,
1286
+ cached,
1287
+ reportedTotal,
1288
+ reportedTotalMeans,
1289
+ skipped,
1290
+ error: answered ? null : failure,
1291
+ wordings
1292
+ };
676
1293
  }
677
1294
  /**
678
1295
  * Read one recipe, from whichever source the identifier names.
@@ -691,16 +1308,20 @@ var RecipesClient = class {
691
1308
  );
692
1309
  return { recipe: outcome.recipe, cached: outcome.cached, read };
693
1310
  } catch (error) {
694
- throw toRecipesError(error);
1311
+ throw fromSource(error, read.source.name);
695
1312
  }
696
1313
  }
697
1314
  };
698
1315
  export {
699
1316
  MAX_LIMIT_PER_SOURCE,
1317
+ MAX_WORDINGS_PER_SOURCE,
700
1318
  RecipesClient,
701
1319
  SOURCE_IDS,
1320
+ deriveWordings,
702
1321
  interleave,
1322
+ namesDish,
703
1323
  namespacedId,
1324
+ readConditions,
704
1325
  resolveId
705
1326
  };
706
1327
  //# sourceMappingURL=client.js.map