mcp-recipes 1.0.0 → 1.2.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.
- package/CHANGELOG.md +87 -8
- package/LICENSE +0 -0
- package/README.md +4 -5
- package/dist/index.d.ts +0 -0
- package/dist/index.js +263 -46
- package/dist/index.js.map +1 -1
- package/dist/language-BgfmjwtF.d.ts +0 -0
- package/dist/recipe/scale.d.ts +0 -0
- package/dist/recipe/scale.js +203 -38
- package/dist/recipe/scale.js.map +1 -1
- package/dist/sources/client.d.ts +0 -0
- package/dist/sources/client.js +11 -2
- package/dist/sources/client.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
7
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
8
|
|
|
9
9
|
// src/version.ts
|
|
10
|
-
var PKG_VERSION = "1.
|
|
10
|
+
var PKG_VERSION = "1.2.0";
|
|
11
11
|
var REPO_URL = "https://github.com/smeet666/mcp-recipes";
|
|
12
12
|
var ISSUES_URL = "https://github.com/smeet666/mcp-recipes/issues";
|
|
13
13
|
|
|
@@ -379,6 +379,15 @@ var MARMITON_PROFILE = {
|
|
|
379
379
|
};
|
|
380
380
|
var SITE_URL2 = /^https?:\/\/(?:www\.)?marmiton\.org\//i;
|
|
381
381
|
var REFERENCE = /^\d+$/;
|
|
382
|
+
async function readSearch(reader, query) {
|
|
383
|
+
try {
|
|
384
|
+
return await reader.search(query);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
const code = error?.code;
|
|
387
|
+
if (code === "not_found") return { data: [], cached: false };
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
382
391
|
function marmitonAdapter(reader) {
|
|
383
392
|
return {
|
|
384
393
|
...MARMITON_PROFILE,
|
|
@@ -396,7 +405,7 @@ function marmitonAdapter(reader) {
|
|
|
396
405
|
return null;
|
|
397
406
|
},
|
|
398
407
|
async search(query) {
|
|
399
|
-
const outcome = await reader
|
|
408
|
+
const outcome = await readSearch(reader, query);
|
|
400
409
|
const list = rowsOf(outcome.data, MARMITON_PROFILE);
|
|
401
410
|
const rows = [];
|
|
402
411
|
let skipped = 0;
|
|
@@ -748,6 +757,12 @@ var FRENCH_UNITS = {
|
|
|
748
757
|
millilitres: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
|
|
749
758
|
centilitre: { canonical: "cl", kind: "measured", system: "metric", symbol: true },
|
|
750
759
|
centilitres: { canonical: "cl", kind: "measured", system: "metric", symbol: true },
|
|
760
|
+
// A page glossing a metric weight names the pound, which French writes as a
|
|
761
|
+
// livre: "450 g (1 livre) de spaghetti".
|
|
762
|
+
livre: { canonical: "livre", kind: "measured", system: "imperial", plural: "livres" },
|
|
763
|
+
livres: { canonical: "livre", kind: "measured", system: "imperial", plural: "livres" },
|
|
764
|
+
lb: { canonical: "lb", kind: "measured", system: "imperial", symbol: true },
|
|
765
|
+
lbs: { canonical: "lb", kind: "measured", system: "imperial", symbol: true },
|
|
751
766
|
// Spoons and cups: real measures, but only in sensible fractions.
|
|
752
767
|
"cuillere a soupe": {
|
|
753
768
|
canonical: "cuill\xE8re \xE0 soupe",
|
|
@@ -959,6 +974,11 @@ var ENGLISH_UNITS = {
|
|
|
959
974
|
leaf: { canonical: "leaf", kind: "portioned", system: "none", plural: "leaves" },
|
|
960
975
|
leaves: { canonical: "leaf", kind: "portioned", system: "none", plural: "leaves" },
|
|
961
976
|
ea: { canonical: "ea", kind: "portioned", system: "none", symbol: true },
|
|
977
|
+
// A dish of the book standing as an ingredient of another: "1 recipe Flaky
|
|
978
|
+
// Pie Crust". The count is what the word measures, so the plural mark belongs
|
|
979
|
+
// on it and not on the name of the dish.
|
|
980
|
+
recipe: { canonical: "recipe", kind: "portioned", system: "none", plural: "recipes" },
|
|
981
|
+
recipes: { canonical: "recipe", kind: "portioned", system: "none", plural: "recipes" },
|
|
962
982
|
// Held to no better than a hand: the count scales, the size of one does not.
|
|
963
983
|
// `readContainerLoad` explains what else lands here.
|
|
964
984
|
capful: { canonical: "capful", kind: "approximate", system: "none", plural: "capfuls" },
|
|
@@ -1025,7 +1045,7 @@ function buildEmbedded(language) {
|
|
|
1025
1045
|
return new RegExp(`\\d[\\d.,/]*\\s*(?:${keys.join("|")})\\b`, "i");
|
|
1026
1046
|
}
|
|
1027
1047
|
function hasEmbeddedMeasure(text2, language) {
|
|
1028
|
-
return EMBEDDED[language].test(text2);
|
|
1048
|
+
return EMBEDDED[language].test(normalizeUnitKey(text2));
|
|
1029
1049
|
}
|
|
1030
1050
|
var NOT_A_MEASURE = /* @__PURE__ */ new Set([
|
|
1031
1051
|
"peu",
|
|
@@ -1148,9 +1168,10 @@ function isSpoonMeasure(unit) {
|
|
|
1148
1168
|
);
|
|
1149
1169
|
}
|
|
1150
1170
|
function unitDivisibility(unit) {
|
|
1171
|
+
if (unit.kind === "approximate") return "whole";
|
|
1151
1172
|
return QUARTERED_MEASURE.test(unit.canonical) ? "quarter" : "half";
|
|
1152
1173
|
}
|
|
1153
|
-
var QUARTERED_MEASURE = /\b(pots?|bouteilles?|bottles?|jars?|blocs?|blocks?|
|
|
1174
|
+
var QUARTERED_MEASURE = /\b(pots?|bouteilles?|bottles?|jars?|blocs?|blocks?|tranches?|slices?)\b/i;
|
|
1154
1175
|
function countsBarePieces(unit) {
|
|
1155
1176
|
return unit.canonical === "ea";
|
|
1156
1177
|
}
|
|
@@ -1202,6 +1223,103 @@ var VULGAR_FRACTIONS = {
|
|
|
1202
1223
|
"\u215E": 0.875
|
|
1203
1224
|
};
|
|
1204
1225
|
var VULGAR_CLASS = Object.keys(VULGAR_FRACTIONS).join("");
|
|
1226
|
+
var NAMED_ENTITIES = {
|
|
1227
|
+
frac12: "\xBD",
|
|
1228
|
+
frac13: "\u2153",
|
|
1229
|
+
frac23: "\u2154",
|
|
1230
|
+
frac14: "\xBC",
|
|
1231
|
+
frac34: "\xBE",
|
|
1232
|
+
frac15: "\u2155",
|
|
1233
|
+
frac18: "\u215B",
|
|
1234
|
+
nbsp: " ",
|
|
1235
|
+
amp: "&",
|
|
1236
|
+
lt: "<",
|
|
1237
|
+
gt: ">",
|
|
1238
|
+
quot: '"',
|
|
1239
|
+
apos: "'",
|
|
1240
|
+
deg: "\xB0",
|
|
1241
|
+
times: "\xD7",
|
|
1242
|
+
minus: "\u2212",
|
|
1243
|
+
ndash: "\u2013",
|
|
1244
|
+
mdash: "\u2014"
|
|
1245
|
+
};
|
|
1246
|
+
function decodeEntities(text2) {
|
|
1247
|
+
return text2.replace(/&#x([0-9a-f]+);/gi, (whole, hex) => codePoint(parseInt(hex, 16), whole)).replace(/&#(\d+);/g, (whole, digits) => codePoint(Number(digits), whole)).replace(
|
|
1248
|
+
/&([a-z][a-z0-9]*);/gi,
|
|
1249
|
+
(whole, name) => NAMED_ENTITIES[name.toLowerCase()] ?? whole
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
function codePoint(value, published) {
|
|
1253
|
+
if (!Number.isInteger(value) || value < 32 || value > 1114111) return published;
|
|
1254
|
+
try {
|
|
1255
|
+
return String.fromCodePoint(value);
|
|
1256
|
+
} catch {
|
|
1257
|
+
return published;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
var EMPTY_BRACKETS = /\(\s*\)/g;
|
|
1261
|
+
function readable(text2) {
|
|
1262
|
+
return decodeEntities(text2).replace(EMPTY_BRACKETS, " ").replace(/\s+/g, " ").trim();
|
|
1263
|
+
}
|
|
1264
|
+
var APPROXIMATION_PREFIX = {
|
|
1265
|
+
en: /^(?:~|\u2248|about|approx\.?|approximately|around|roughly)\s*/i,
|
|
1266
|
+
fr: /^(?:~|\u2248|environ|approximativement|\u00e0 peu pr\u00e8s|a peu pres)\s*/i
|
|
1267
|
+
};
|
|
1268
|
+
var CONTAINER_CONTENTS = /^\s*(?:de\s|d'|du\s|des\s)/i;
|
|
1269
|
+
function statesItemSize(item) {
|
|
1270
|
+
const attached = /\s+de\s+(?=\d)/i.exec(item);
|
|
1271
|
+
if (!attached) return false;
|
|
1272
|
+
const named = item.slice(0, attached.index).trim();
|
|
1273
|
+
if (!named || /\d/.test(named)) return false;
|
|
1274
|
+
return isStatedSize(item.slice(attached.index + attached[0].length));
|
|
1275
|
+
}
|
|
1276
|
+
function isStatedSize(text2) {
|
|
1277
|
+
const size = parseLeadingQuantity(text2, "fr");
|
|
1278
|
+
if (!size) return false;
|
|
1279
|
+
const measure = takeUnit(text2.slice(size.length).trimStart(), "fr");
|
|
1280
|
+
if (!measure.unit || measure.unit.kind !== "measured") return false;
|
|
1281
|
+
return !CONTAINER_CONTENTS.test(measure.rest);
|
|
1282
|
+
}
|
|
1283
|
+
var PER_PERSON = {
|
|
1284
|
+
en: /\bper\s+(?:person|head|serving|guest|diner)\b/i,
|
|
1285
|
+
fr: /\bpar\s+(?:personne|convive|part|t\u00eate)\b/i
|
|
1286
|
+
};
|
|
1287
|
+
var MEASURE_ADJECTIVES = {
|
|
1288
|
+
en: /* @__PURE__ */ new Set([
|
|
1289
|
+
"big",
|
|
1290
|
+
"generous",
|
|
1291
|
+
"good",
|
|
1292
|
+
"heaped",
|
|
1293
|
+
"heaping",
|
|
1294
|
+
"large",
|
|
1295
|
+
"level",
|
|
1296
|
+
"medium",
|
|
1297
|
+
"scant",
|
|
1298
|
+
"small"
|
|
1299
|
+
]),
|
|
1300
|
+
fr: /* @__PURE__ */ new Set([
|
|
1301
|
+
"beau",
|
|
1302
|
+
"belle",
|
|
1303
|
+
"bon",
|
|
1304
|
+
"bonne",
|
|
1305
|
+
"grand",
|
|
1306
|
+
"grande",
|
|
1307
|
+
"gros",
|
|
1308
|
+
"grosse",
|
|
1309
|
+
"petit",
|
|
1310
|
+
"petite"
|
|
1311
|
+
])
|
|
1312
|
+
};
|
|
1313
|
+
function takeMeasureAdjective(text2, language) {
|
|
1314
|
+
const match = new RegExp("^\\s*(\\p{L}+)\\s+", "u").exec(text2);
|
|
1315
|
+
if (!match) return { adjective: null, rest: text2 };
|
|
1316
|
+
const folded = normalizeUnitKey(match[1]);
|
|
1317
|
+
const listed = MEASURE_ADJECTIVES[language].has(folded) || MEASURE_ADJECTIVES[language].has(folded.replace(/s$/, ""));
|
|
1318
|
+
if (!listed) return { adjective: null, rest: text2 };
|
|
1319
|
+
return { adjective: match[1], rest: text2.slice(match[0].length) };
|
|
1320
|
+
}
|
|
1321
|
+
var COMMA_GROUPED = /^\s*\d{1,3}(?:,\d{3})+(?!\d)/;
|
|
1322
|
+
var COMMA_DECIMAL = /^\s*\d+,\d+/;
|
|
1205
1323
|
function parseLeadingQuantity(text2, language) {
|
|
1206
1324
|
const trimmed = text2.trimStart();
|
|
1207
1325
|
const offset = text2.length - trimmed.length;
|
|
@@ -1307,21 +1425,25 @@ function takeUnitEitherLanguage(text2, language) {
|
|
|
1307
1425
|
if (first.unit) return first;
|
|
1308
1426
|
return takeUnit(text2, language === "fr" ? "en" : "fr");
|
|
1309
1427
|
}
|
|
1310
|
-
function
|
|
1428
|
+
function readEvidence(line) {
|
|
1311
1429
|
const text2 = line.trim();
|
|
1312
1430
|
const afterFigures = text2.replace(/^[\s\d.,/½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞\-–—]+/u, "").trimStart();
|
|
1313
1431
|
const afterArticle = afterFigures.replace(/^(?:un|une|quelques|of\s+an?|an?)\s+/iu, "");
|
|
1314
1432
|
const frenchUnit = takeUnit(afterArticle, "fr").unit !== null;
|
|
1315
1433
|
const englishUnit = takeUnit(afterArticle, "en").unit !== null;
|
|
1316
|
-
return readLanguage(text2, { frenchUnit, englishUnit })
|
|
1434
|
+
return readLanguage(text2, { frenchUnit, englishUnit });
|
|
1317
1435
|
}
|
|
1318
1436
|
function parseIngredient(line, choice = "auto") {
|
|
1319
1437
|
const original = line;
|
|
1320
|
-
const text2 = line
|
|
1321
|
-
const
|
|
1322
|
-
const
|
|
1438
|
+
const text2 = readable(line);
|
|
1439
|
+
const evidence = readEvidence(text2);
|
|
1440
|
+
const language = choice === "auto" ? evidence.language : choice;
|
|
1441
|
+
const empty = (heldBack) => ({
|
|
1323
1442
|
original,
|
|
1324
1443
|
language,
|
|
1444
|
+
heldBack,
|
|
1445
|
+
approximation: null,
|
|
1446
|
+
measureAdjective: null,
|
|
1325
1447
|
amount: null,
|
|
1326
1448
|
amountMax: null,
|
|
1327
1449
|
rangeSeparator: null,
|
|
@@ -1331,25 +1453,39 @@ function parseIngredient(line, choice = "auto") {
|
|
|
1331
1453
|
item: text2,
|
|
1332
1454
|
articleWord: null,
|
|
1333
1455
|
countMultiplier: null
|
|
1334
|
-
};
|
|
1335
|
-
|
|
1336
|
-
const
|
|
1337
|
-
const
|
|
1338
|
-
|
|
1339
|
-
|
|
1456
|
+
});
|
|
1457
|
+
if (unreadableComma(text2, language, evidence)) return empty("ambiguousDecimal");
|
|
1458
|
+
const loose = APPROXIMATION_PREFIX[language].exec(text2);
|
|
1459
|
+
const stated = loose ? text2.slice(loose[0].length) : text2;
|
|
1460
|
+
const range = parseLeadingRange(stated, language);
|
|
1461
|
+
const article = range ? null : readArticle(stated, language);
|
|
1462
|
+
const quantity = range ?? parseLeadingQuantity(stated, language) ?? article;
|
|
1463
|
+
if (!quantity) return empty(null);
|
|
1464
|
+
if (new RegExp("^-\\p{L}", "u").test(stated.slice(quantity.length))) return empty("sizeQualifier");
|
|
1465
|
+
let rest = stated.slice(quantity.length).trimStart();
|
|
1340
1466
|
if (language === "en") rest = rest.replace(/^(?:of\s+)?an?\s+/i, "");
|
|
1341
1467
|
const multiplier = readCountMultiplier(rest);
|
|
1342
1468
|
if (multiplier) rest = multiplier.rest;
|
|
1343
1469
|
const times = multiplier?.times ?? 1;
|
|
1344
|
-
const
|
|
1470
|
+
const fromArticle = quantity === article;
|
|
1471
|
+
const direct = takeLeadingUnit(rest, language, fromArticle);
|
|
1472
|
+
const described = direct.unit ? { adjective: null, rest } : takeMeasureAdjective(rest, language);
|
|
1473
|
+
const behind = described.adjective ? takeLeadingUnit(described.rest, language, fromArticle) : null;
|
|
1474
|
+
const leading = direct.unit ? direct : behind?.unit ? behind : direct;
|
|
1345
1475
|
rest = leading.rest;
|
|
1346
1476
|
const bracketed = takeAlternates(rest, language);
|
|
1347
1477
|
rest = bracketed.rest;
|
|
1348
1478
|
const slashed = bracketed.measures.length > 0 ? null : takeSlashAlternates(rest, language);
|
|
1349
1479
|
if (slashed) rest = slashed.rest;
|
|
1480
|
+
const item = stripItemLead(rest, language);
|
|
1481
|
+
if (language === "fr" && !leading.unit && statesItemSize(item)) return empty("itemSize");
|
|
1482
|
+
if (language === "fr" && leading.partitive && isStatedSize(item)) return empty("itemSize");
|
|
1350
1483
|
return {
|
|
1351
1484
|
original,
|
|
1352
1485
|
language,
|
|
1486
|
+
heldBack: PER_PERSON[language].test(text2) ? "perPerson" : null,
|
|
1487
|
+
approximation: loose ? loose[0] : null,
|
|
1488
|
+
measureAdjective: behind?.unit ? described.adjective : null,
|
|
1353
1489
|
amount: quantity.amount * times,
|
|
1354
1490
|
amountMax: range === null ? null : range.max * times,
|
|
1355
1491
|
rangeSeparator: range?.separator ?? null,
|
|
@@ -1357,10 +1493,18 @@ function parseIngredient(line, choice = "auto") {
|
|
|
1357
1493
|
alternates: slashed ? slashed.measures : bracketed.measures,
|
|
1358
1494
|
alternateStyle: slashed ? "slash" : bracketed.measures.length > 0 ? "bracket" : null,
|
|
1359
1495
|
item: stripItemLead(rest, language),
|
|
1360
|
-
articleWord:
|
|
1496
|
+
articleWord: fromArticle ? article?.word ?? null : null,
|
|
1361
1497
|
countMultiplier: multiplier?.times ?? null
|
|
1362
1498
|
};
|
|
1363
1499
|
}
|
|
1500
|
+
function unreadableComma(text2, language, evidence) {
|
|
1501
|
+
if (COMMA_GROUPED.test(text2)) {
|
|
1502
|
+
const settled = evidence.french !== evidence.english;
|
|
1503
|
+
return !settled;
|
|
1504
|
+
}
|
|
1505
|
+
if (language === "en") return COMMA_DECIMAL.test(text2);
|
|
1506
|
+
return /^\s*\d+,\d+,\d/.test(text2);
|
|
1507
|
+
}
|
|
1364
1508
|
function readArticle(text2, language) {
|
|
1365
1509
|
const counts = (rest) => takeLeadingUnit(rest, language, true).unit !== null || readCountMultiplier(rest) !== null;
|
|
1366
1510
|
if (language === "fr") {
|
|
@@ -1390,12 +1534,12 @@ function readCountMultiplier(text2) {
|
|
|
1390
1534
|
}
|
|
1391
1535
|
function takeLeadingUnit(text2, language, fromArticle) {
|
|
1392
1536
|
const taken = takeUnit(text2, language);
|
|
1393
|
-
if (taken.unit) return taken;
|
|
1537
|
+
if (taken.unit) return { ...taken, partitive: false };
|
|
1394
1538
|
if (language === "fr" && fromArticle) {
|
|
1395
1539
|
const measure = readPartitiveMeasure(text2);
|
|
1396
|
-
if (measure) return { unit: measure.unit, rest: measure.rest };
|
|
1540
|
+
if (measure) return { unit: measure.unit, rest: measure.rest, partitive: true };
|
|
1397
1541
|
}
|
|
1398
|
-
return taken;
|
|
1542
|
+
return { ...taken, partitive: false };
|
|
1399
1543
|
}
|
|
1400
1544
|
function stripItemLead(text2, language) {
|
|
1401
1545
|
if (language === "fr") {
|
|
@@ -1556,7 +1700,7 @@ function scaleMeasure(low, high, unit, factor, divisibility) {
|
|
|
1556
1700
|
const inUnit = (target, ratio) => ({
|
|
1557
1701
|
bounds: raws.map((raw, index) => {
|
|
1558
1702
|
const exact = raw * ratio;
|
|
1559
|
-
const rounded = roundMeasured(exact);
|
|
1703
|
+
const rounded = ratio < 1 ? Number((roundMeasured(raw) * ratio).toPrecision(12)) : roundMeasured(exact);
|
|
1560
1704
|
const usable = rounded === 0 && exact > 0 ? Number(exact.toPrecision(2)) : rounded;
|
|
1561
1705
|
const ceiling = factor < 1 ? sources[index] * ratio : Number.POSITIVE_INFINITY;
|
|
1562
1706
|
return {
|
|
@@ -1590,7 +1734,7 @@ function scaleMeasure(low, high, unit, factor, divisibility) {
|
|
|
1590
1734
|
return { bounds, unit };
|
|
1591
1735
|
}
|
|
1592
1736
|
var PORTION_SIZED_ITEM = new RegExp(
|
|
1593
|
-
"\\b(?:shrimps?|prawns?|langoustines?|mussels?|hazelnuts?|peppercorns?|junipers?|grains?|anise|crevettes?|gambas|moules?|noisettes?|
|
|
1737
|
+
"\\b(?:shrimps?|prawns?|langoustines?|mussels?|hazelnuts?|peppercorns?|junipers?|grains?|anise|crevettes?|gambas|moules?|noisettes?|genievres?|genevriers?|badianes?|anis)\\b",
|
|
1594
1738
|
"iu"
|
|
1595
1739
|
);
|
|
1596
1740
|
var QUARTERED_ITEM = new RegExp(
|
|
@@ -1603,7 +1747,7 @@ var HALVED_CUT = /\b(?:breasts?|thighs?|drumsticks?|wings?|cutlets?|cuisses?|ail
|
|
|
1603
1747
|
function cloveDivisibility(unit, item) {
|
|
1604
1748
|
const counted = unit ? unit.canonical === "clove" : /\bcloves?\b/i.test(item);
|
|
1605
1749
|
if (!counted) return null;
|
|
1606
|
-
return /\bgarlic\b/i.test(item) ? "
|
|
1750
|
+
return /\bgarlic\b/i.test(item) ? "half" : "whole";
|
|
1607
1751
|
}
|
|
1608
1752
|
var BLANC_OF = /\bblancs?\s+d(?:e\s|['’])/iu;
|
|
1609
1753
|
var BLANC_OF_EGG = /\bblancs?\s+d(?:e\s|['’])\s*(?:oeufs?|œufs?)\b/iu;
|
|
@@ -1611,18 +1755,22 @@ function blancDivisibility(item) {
|
|
|
1611
1755
|
if (!BLANC_OF.test(item)) return null;
|
|
1612
1756
|
return BLANC_OF_EGG.test(item) ? "whole" : "half";
|
|
1613
1757
|
}
|
|
1758
|
+
function foldItem(item) {
|
|
1759
|
+
return item.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\u0153/g, "oe").replace(/\u0152/g, "OE");
|
|
1760
|
+
}
|
|
1614
1761
|
function divisibilityOf(unit, item) {
|
|
1615
|
-
const
|
|
1762
|
+
const key = foldItem(item);
|
|
1763
|
+
const clove = cloveDivisibility(unit, key);
|
|
1616
1764
|
if (clove) return clove;
|
|
1617
1765
|
if (unit && !countsBarePieces(unit)) return unitDivisibility(unit);
|
|
1618
|
-
const blanc = blancDivisibility(
|
|
1766
|
+
const blanc = blancDivisibility(key);
|
|
1619
1767
|
if (blanc) return blanc;
|
|
1620
|
-
if (WHOLE_ITEM.test(
|
|
1621
|
-
if (PORTION_SIZED_ITEM.test(
|
|
1622
|
-
if (HALVED_ITEM.test(
|
|
1623
|
-
if (HALVED_CUT.test(
|
|
1624
|
-
if (QUARTERED_MEASURE.test(
|
|
1625
|
-
return QUARTERED_ITEM.test(
|
|
1768
|
+
if (WHOLE_ITEM.test(key)) return "whole";
|
|
1769
|
+
if (PORTION_SIZED_ITEM.test(key)) return "whole";
|
|
1770
|
+
if (HALVED_ITEM.test(key)) return "half";
|
|
1771
|
+
if (HALVED_CUT.test(key)) return "half";
|
|
1772
|
+
if (QUARTERED_MEASURE.test(key)) return "quarter";
|
|
1773
|
+
return QUARTERED_ITEM.test(key) ? "quarter" : "half";
|
|
1626
1774
|
}
|
|
1627
1775
|
var INVARIABLE_ITEM = /* @__PURE__ */ new Set([
|
|
1628
1776
|
"anise",
|
|
@@ -1789,6 +1937,8 @@ var AGREEABLE_ADJECTIVES = /* @__PURE__ */ new Set([
|
|
|
1789
1937
|
"petite",
|
|
1790
1938
|
"grand",
|
|
1791
1939
|
"grande",
|
|
1940
|
+
"gros",
|
|
1941
|
+
"grosse",
|
|
1792
1942
|
"mur",
|
|
1793
1943
|
"mure",
|
|
1794
1944
|
"vert",
|
|
@@ -1852,6 +2002,16 @@ function agreeInFrench(item, amount) {
|
|
|
1852
2002
|
}
|
|
1853
2003
|
return words.join(" ");
|
|
1854
2004
|
}
|
|
2005
|
+
function agreeLeadingAdjective(word, amount, language) {
|
|
2006
|
+
if (language === "en") return word;
|
|
2007
|
+
const wantsPlural = amount >= 2;
|
|
2008
|
+
const folded = foldWord(word);
|
|
2009
|
+
const isPlural = folded.endsWith("s") && !AGREEABLE_ADJECTIVES.has(folded);
|
|
2010
|
+
const singular = isPlural ? word.slice(0, -1) : word;
|
|
2011
|
+
if (!AGREEABLE_ADJECTIVES.has(foldWord(singular))) return word;
|
|
2012
|
+
if (wantsPlural === isPlural) return word;
|
|
2013
|
+
return wantsPlural ? `${singular}s` : singular;
|
|
2014
|
+
}
|
|
1855
2015
|
function agreeWithAmount(item, amount, language) {
|
|
1856
2016
|
return language === "fr" ? agreeInFrench(item, amount) : agreeInEnglish(item, amount);
|
|
1857
2017
|
}
|
|
@@ -1910,11 +2070,17 @@ function scaleAlternative(tail, options) {
|
|
|
1910
2070
|
if (largest < 1) return { text: published, rewritten: false };
|
|
1911
2071
|
return { text: scaleIngredient(tail, options).text, rewritten: true };
|
|
1912
2072
|
}
|
|
2073
|
+
var HELD_BACK_NOTE = {
|
|
2074
|
+
sizeQualifier: "The figures here give the size of one item rather than how many, so the line is left as published.",
|
|
2075
|
+
itemSize: "The measure standing behind the item gives the size of one of them rather than how many, so the line is left as published. Serving more people is a matter of taking a bigger one, and serving fewer a smaller one.",
|
|
2076
|
+
perPerson: "This line already states an amount for one person, and the factor is what changes how many people the recipe serves, so the line is left as published.",
|
|
2077
|
+
ambiguousDecimal: "The comma in this number marks thousands in one convention and the decimal point in another, and the line gives no sign which was meant, so it is left as published."
|
|
2078
|
+
};
|
|
1913
2079
|
function scaleSingleLine(line, options) {
|
|
1914
2080
|
const { factor } = options;
|
|
1915
2081
|
const parsed = parseIngredient(line, options.language ?? "auto");
|
|
1916
2082
|
const language = parsed.language;
|
|
1917
|
-
if (parsed.amount === null) {
|
|
2083
|
+
if (parsed.amount === null || parsed.heldBack) {
|
|
1918
2084
|
return {
|
|
1919
2085
|
text: parsed.original,
|
|
1920
2086
|
original: parsed.original,
|
|
@@ -1923,7 +2089,7 @@ function scaleSingleLine(line, options) {
|
|
|
1923
2089
|
amountMax: null,
|
|
1924
2090
|
unit: null,
|
|
1925
2091
|
language,
|
|
1926
|
-
note: "No quantity given; adjust to taste."
|
|
2092
|
+
note: parsed.heldBack ? HELD_BACK_NOTE[parsed.heldBack] : "No quantity given; adjust to taste."
|
|
1927
2093
|
};
|
|
1928
2094
|
}
|
|
1929
2095
|
const divisibility = divisibilityOf(parsed.unit, parsed.item);
|
|
@@ -1947,13 +2113,14 @@ function scaleSingleLine(line, options) {
|
|
|
1947
2113
|
parsed.rangeSeparator
|
|
1948
2114
|
);
|
|
1949
2115
|
const named = unit && !countsBarePieces(unit) ? unit : null;
|
|
1950
|
-
const
|
|
2116
|
+
const adjective = named && parsed.measureAdjective ? ` ${agreeLeadingAdjective(parsed.measureAdjective, shown, language)}` : "";
|
|
2117
|
+
const unitLabel = named ? `${adjective} ${formatUnit(named, shown, language)}` : "";
|
|
1951
2118
|
const alternateTexts = alternates.map((entry) => entry.text);
|
|
1952
2119
|
const altLabel = alternates.length === 0 ? "" : parsed.alternateStyle === "slash" ? ` / ${alternateTexts.join(" / ")}` : ` (${alternateTexts.join(" / ")})`;
|
|
1953
2120
|
const counted = agreeWithAmount(parsed.item, shown, language);
|
|
1954
2121
|
const itemLabel = named ? joinItem(parsed.item, language) : counted ? ` ${counted}` : "";
|
|
1955
2122
|
const result = {
|
|
1956
|
-
text: `${amountText}${unitLabel}${altLabel}${itemLabel}`.trim(),
|
|
2123
|
+
text: `${parsed.approximation ?? ""}${amountText}${unitLabel}${altLabel}${itemLabel}`.trim(),
|
|
1957
2124
|
original: parsed.original,
|
|
1958
2125
|
scaling: movedPrimary || movedAlternate || restated ? "rounded" : "scaled",
|
|
1959
2126
|
amount: low.amount,
|
|
@@ -1996,6 +2163,11 @@ function scaleSingleLine(line, options) {
|
|
|
1996
2163
|
"This is smaller than a kitchen scale resolves. Make a larger batch, or measure it by eye."
|
|
1997
2164
|
);
|
|
1998
2165
|
}
|
|
2166
|
+
if (parsed.approximation) {
|
|
2167
|
+
sentences.push(
|
|
2168
|
+
"The page gave this amount as an approximation, and the scaled figure is no firmer."
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
1999
2171
|
if (sentences.length > 0) result.note = sentences.join(" ");
|
|
2000
2172
|
if (parsed.unit && parsed.unit.kind === "approximate") {
|
|
2001
2173
|
result.note = withApproximateNote(parsed.unit, result.note);
|
|
@@ -2043,16 +2215,18 @@ function scaleIngredients(lines, options) {
|
|
|
2043
2215
|
}
|
|
2044
2216
|
function passthroughIngredient(line, language = "auto") {
|
|
2045
2217
|
const parsed = parseIngredient(line, language);
|
|
2218
|
+
const held = parsed.amount === null || parsed.heldBack !== null;
|
|
2046
2219
|
const result = {
|
|
2047
2220
|
text: parsed.original,
|
|
2048
2221
|
original: parsed.original,
|
|
2049
|
-
scaling:
|
|
2050
|
-
amount: parsed.amount,
|
|
2051
|
-
amountMax: parsed.amountMax,
|
|
2052
|
-
unit: parsed.unit?.canonical ?? null,
|
|
2222
|
+
scaling: held ? "unscaled" : "scaled",
|
|
2223
|
+
amount: held ? null : parsed.amount,
|
|
2224
|
+
amountMax: held ? null : parsed.amountMax,
|
|
2225
|
+
unit: held ? null : parsed.unit?.canonical ?? null,
|
|
2053
2226
|
language: parsed.language
|
|
2054
2227
|
};
|
|
2055
|
-
if (parsed.
|
|
2228
|
+
if (parsed.heldBack) result.note = HELD_BACK_NOTE[parsed.heldBack];
|
|
2229
|
+
else if (parsed.amount === null) result.note = "No quantity given; adjust to taste.";
|
|
2056
2230
|
else if (parsed.unit?.kind === "approximate") {
|
|
2057
2231
|
result.note = withApproximateNote(parsed.unit, void 0);
|
|
2058
2232
|
}
|
|
@@ -2089,7 +2263,13 @@ var reportSchema = z.object({
|
|
|
2089
2263
|
code: z.string(),
|
|
2090
2264
|
message: z.string(),
|
|
2091
2265
|
hint: z.string().optional()
|
|
2092
|
-
}).nullable().describe("Why this source did not answer. Null when it did.")
|
|
2266
|
+
}).nullable().describe("Why this source did not answer. Null when it did."),
|
|
2267
|
+
read: z.object({
|
|
2268
|
+
status: z.enum(["read", "failed"]),
|
|
2269
|
+
error: z.object({ code: z.string(), message: z.string(), hint: z.string().optional() }).nullable()
|
|
2270
|
+
}).nullable().describe(
|
|
2271
|
+
"What became of the row this source offered, once it was opened. Null when no row was opened, which is what a search that did not answer and a search holding nothing both come to. A search and a read are two moments, and this is the second one."
|
|
2272
|
+
)
|
|
2093
2273
|
});
|
|
2094
2274
|
var ingredientSchema = z.object({
|
|
2095
2275
|
text: z.string().describe("The line as it now reads."),
|
|
@@ -2139,9 +2319,15 @@ function toReportPayload(report) {
|
|
|
2139
2319
|
reported_total_means: report.reportedTotalMeans,
|
|
2140
2320
|
skipped: report.skipped,
|
|
2141
2321
|
cached: report.cached,
|
|
2142
|
-
error: report.error ?? null
|
|
2322
|
+
error: report.error ?? null,
|
|
2323
|
+
// A search alone opens nothing, so only a tool that reads a row fills this
|
|
2324
|
+
// in, through `withRead`.
|
|
2325
|
+
read: null
|
|
2143
2326
|
};
|
|
2144
2327
|
}
|
|
2328
|
+
function withRead(payload, read) {
|
|
2329
|
+
return { ...payload, read };
|
|
2330
|
+
}
|
|
2145
2331
|
function reportNotes(reports) {
|
|
2146
2332
|
const notes = [];
|
|
2147
2333
|
const answered = reports.filter((report) => report.status === "answered");
|
|
@@ -2575,14 +2761,19 @@ async function runCompareRecipes(client, args) {
|
|
|
2575
2761
|
const reads = await Promise.allSettled(offered.map(([, id]) => client.getRecipe(id)));
|
|
2576
2762
|
const recipes = [];
|
|
2577
2763
|
const unread = /* @__PURE__ */ new Map();
|
|
2764
|
+
const opened = /* @__PURE__ */ new Set();
|
|
2578
2765
|
reads.forEach((read, index) => {
|
|
2579
2766
|
const source = offered[index][0];
|
|
2767
|
+
opened.add(source);
|
|
2580
2768
|
if (read.status === "fulfilled") {
|
|
2581
2769
|
recipes.push(read.value.recipe);
|
|
2582
2770
|
return;
|
|
2583
2771
|
}
|
|
2584
2772
|
const reason = read.reason;
|
|
2585
|
-
unread.set(source,
|
|
2773
|
+
unread.set(source, {
|
|
2774
|
+
code: reason?.code ?? "network_error",
|
|
2775
|
+
message: reason?.message ?? "unknown"
|
|
2776
|
+
});
|
|
2586
2777
|
});
|
|
2587
2778
|
const views = recipes.map(
|
|
2588
2779
|
(recipe) => buildRecipeView(recipe, {
|
|
@@ -2610,11 +2801,15 @@ async function runCompareRecipes(client, args) {
|
|
|
2610
2801
|
const failedRead = unread.get(report.source);
|
|
2611
2802
|
if (report.status === "failed") {
|
|
2612
2803
|
notes.push(
|
|
2613
|
-
`${report.name} is missing from this comparison because its search did not answer
|
|
2804
|
+
`${report.name} is missing from this comparison because its search did not answer (${quoteForeign(
|
|
2805
|
+
`${report.error?.code ?? "unknown"}: ${report.error?.message ?? "unknown"}`
|
|
2806
|
+
)}). Nothing here is evidence about what it holds.`
|
|
2614
2807
|
);
|
|
2615
2808
|
} else if (failedRead) {
|
|
2616
2809
|
notes.push(
|
|
2617
|
-
`${report.name} offered a
|
|
2810
|
+
`${report.name}'s search answered and offered a version, and that version could not be read (${quoteForeign(
|
|
2811
|
+
`${failedRead.code}: ${failedRead.message}`
|
|
2812
|
+
)}). The failure is in the reading, so nothing here says whether ${report.name} holds this dish.`
|
|
2618
2813
|
);
|
|
2619
2814
|
} else {
|
|
2620
2815
|
notes.push(
|
|
@@ -2671,7 +2866,17 @@ ${shownDifferences.map((line) => `- ${line}`).join("\n")}${moreDifferences > 0 ?
|
|
|
2671
2866
|
dish: args.dish,
|
|
2672
2867
|
versions: payloads,
|
|
2673
2868
|
differences,
|
|
2674
|
-
|
|
2869
|
+
// Two moments per source, told apart: whether the search answered, and
|
|
2870
|
+
// what became of the row it offered.
|
|
2871
|
+
per_source: merged.reports.map((report) => {
|
|
2872
|
+
const payload = toReportPayload(report);
|
|
2873
|
+
if (!opened.has(report.source)) return payload;
|
|
2874
|
+
const failedRead = unread.get(report.source);
|
|
2875
|
+
return withRead(
|
|
2876
|
+
payload,
|
|
2877
|
+
failedRead ? { status: "failed", error: { code: failedRead.code, message: failedRead.message } } : { status: "read", error: null }
|
|
2878
|
+
);
|
|
2879
|
+
}),
|
|
2675
2880
|
notes
|
|
2676
2881
|
},
|
|
2677
2882
|
summary,
|
|
@@ -2883,6 +3088,13 @@ var searchRecipesOutput = z7.object({
|
|
|
2883
3088
|
notes: z7.array(z7.string())
|
|
2884
3089
|
});
|
|
2885
3090
|
var WORDS_BEFORE_SAYING_SO = 4;
|
|
3091
|
+
function titleCarries(title, query) {
|
|
3092
|
+
const fold = (text2) => text2.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/œ/g, "oe").toLowerCase().replace(/[^a-z0-9]+/g, " ");
|
|
3093
|
+
const haystack = ` ${fold(title)} `;
|
|
3094
|
+
const words = fold(query).split(" ").filter((word) => word.length > 2);
|
|
3095
|
+
if (words.length === 0) return true;
|
|
3096
|
+
return words.some((word) => haystack.includes(` ${word}`));
|
|
3097
|
+
}
|
|
2886
3098
|
async function runSearchRecipes(client, args) {
|
|
2887
3099
|
try {
|
|
2888
3100
|
const merged = await client.searchRecipes(
|
|
@@ -2900,6 +3112,11 @@ async function runSearchRecipes(client, args) {
|
|
|
2900
3112
|
"This query went to each source as free text and no filter was applied to it. Words like 'vegan' or 'under 300 calories' help only where a source's own index happens to match them, so read the rows rather than assuming they meet every condition."
|
|
2901
3113
|
);
|
|
2902
3114
|
}
|
|
3115
|
+
if (results.length > 0 && !results.some((row) => titleCarries(row.title, args.query))) {
|
|
3116
|
+
notes.push(
|
|
3117
|
+
`No title here carries a word of "${args.query}". These rows are what the sources ranked for that spelling, so read them as candidates to check rather than as recipes for the dish.`
|
|
3118
|
+
);
|
|
3119
|
+
}
|
|
2903
3120
|
if (results.length === 0 && answered.length === merged.reports.length) {
|
|
2904
3121
|
notes.push(
|
|
2905
3122
|
"Every source answered and none holds anything under this wording. Try the dish's name in another language, or name a main ingredient instead."
|