pantrybase 0.0.0-stage → 0.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/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +27 -0
- package/LICENSE +21 -0
- package/README.md +153 -2
- package/bin/_resolve.sh +38 -0
- package/bin/pantrybase +20 -0
- package/bin/pb-server +13 -0
- package/dist/chunk-QUWSBZ2V.js +3429 -0
- package/dist/cli.js +441 -0
- package/dist/server.js +915 -0
- package/dist/web/assets/graph-qSs25SSD.js +3225 -0
- package/dist/web/assets/index-Dsc3USAJ.css +1 -0
- package/dist/web/assets/index-lHc1E7AV.js +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +85 -4
- package/providers/generic/guide.md +13 -0
- package/providers/generic/profile.yaml +17 -0
- package/providers/ucook/guide.md +62 -0
- package/providers/ucook/profile.yaml +62 -0
- package/seed/ingredients.yaml +194 -0
- package/seed/vocabulary.yaml +277 -0
- package/skills/cook/SKILL.md +28 -0
- package/skills/ingest/SKILL.md +41 -0
|
@@ -0,0 +1,3429 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
var MODEL_ID = "Xenova/bge-small-en-v1.5";
|
|
7
|
+
var EMBED_DIMS = 384;
|
|
8
|
+
var DEFAULT_UI_PORT = 7433;
|
|
9
|
+
var packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
var cachedVersion;
|
|
11
|
+
function packageVersion() {
|
|
12
|
+
if (!cachedVersion) {
|
|
13
|
+
try {
|
|
14
|
+
cachedVersion = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version;
|
|
15
|
+
} catch {
|
|
16
|
+
cachedVersion = "0.0.0";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return cachedVersion;
|
|
20
|
+
}
|
|
21
|
+
function homeDir() {
|
|
22
|
+
return process.env.PANTRYBASE_HOME ?? path.join(os.homedir(), ".pantrybase");
|
|
23
|
+
}
|
|
24
|
+
function modelsDir() {
|
|
25
|
+
return path.join(homeDir(), "models");
|
|
26
|
+
}
|
|
27
|
+
function resolveProjectRoot(given) {
|
|
28
|
+
const fromEnv = [process.env.PANTRYBASE_PROJECT_ROOT, process.env.CLAUDE_PROJECT_DIR].find(
|
|
29
|
+
(v) => v && !v.includes("${")
|
|
30
|
+
);
|
|
31
|
+
return path.resolve(given ?? fromEnv ?? process.cwd());
|
|
32
|
+
}
|
|
33
|
+
function dataPaths(projectRoot) {
|
|
34
|
+
const data = process.env.PANTRYBASE_DATA ? path.resolve(projectRoot, process.env.PANTRYBASE_DATA) : path.join(projectRoot, "pantrybase");
|
|
35
|
+
return {
|
|
36
|
+
root: projectRoot,
|
|
37
|
+
data,
|
|
38
|
+
recipes: path.join(data, "recipes"),
|
|
39
|
+
inbox: path.join(data, "inbox"),
|
|
40
|
+
images: path.join(data, "images"),
|
|
41
|
+
db: path.join(data, "pantrybase.db"),
|
|
42
|
+
ingredientsYaml: path.join(data, "ingredients.yaml"),
|
|
43
|
+
vocabularyYaml: path.join(data, "vocabulary.yaml"),
|
|
44
|
+
providers: path.join(data, "providers")
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
var DATA_GITIGNORE = `# pantrybase: the database is a rebuildable index of recipes/*.md
|
|
48
|
+
pantrybase.db
|
|
49
|
+
pantrybase.db-*
|
|
50
|
+
# byte-identical copies of photos that were already imported
|
|
51
|
+
inbox/duplicates/
|
|
52
|
+
`;
|
|
53
|
+
function ensureDataDirs(p) {
|
|
54
|
+
for (const dir of [p.data, p.recipes, p.inbox, p.images]) {
|
|
55
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
const ignore = path.join(p.data, ".gitignore");
|
|
58
|
+
if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, DATA_GITIGNORE);
|
|
59
|
+
}
|
|
60
|
+
function log(...args) {
|
|
61
|
+
console.error("[pantrybase]", ...args);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/embeddings/model.ts
|
|
65
|
+
import fs2 from "fs";
|
|
66
|
+
var QUERY_PREFIX = "Represent this sentence for searching relevant passages: ";
|
|
67
|
+
var BATCH = 16;
|
|
68
|
+
var MAX_CHARS = 2e3;
|
|
69
|
+
var Embedder = class {
|
|
70
|
+
constructor(modelId = MODEL_ID) {
|
|
71
|
+
this.modelId = modelId;
|
|
72
|
+
}
|
|
73
|
+
modelId;
|
|
74
|
+
pipe = null;
|
|
75
|
+
loaded = false;
|
|
76
|
+
get ready() {
|
|
77
|
+
return this.loaded;
|
|
78
|
+
}
|
|
79
|
+
load() {
|
|
80
|
+
if (!this.pipe) {
|
|
81
|
+
this.pipe = (async () => {
|
|
82
|
+
const tf = await import("@huggingface/transformers");
|
|
83
|
+
fs2.mkdirSync(modelsDir(), { recursive: true });
|
|
84
|
+
tf.env.cacheDir = modelsDir();
|
|
85
|
+
const started = Date.now();
|
|
86
|
+
const p = await tf.pipeline("feature-extraction", this.modelId, {
|
|
87
|
+
dtype: "q8",
|
|
88
|
+
device: "cpu"
|
|
89
|
+
});
|
|
90
|
+
log(`embedding model ${this.modelId} ready in ${Date.now() - started}ms`);
|
|
91
|
+
this.loaded = true;
|
|
92
|
+
return p;
|
|
93
|
+
})();
|
|
94
|
+
this.pipe.catch((e) => {
|
|
95
|
+
log("embedding model failed to load:", e);
|
|
96
|
+
this.pipe = null;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return this.pipe;
|
|
100
|
+
}
|
|
101
|
+
async embed(texts) {
|
|
102
|
+
const pipe = await this.load();
|
|
103
|
+
const out = [];
|
|
104
|
+
for (let i = 0; i < texts.length; i += BATCH) {
|
|
105
|
+
const batch = texts.slice(i, i + BATCH).map((t) => t.slice(0, MAX_CHARS));
|
|
106
|
+
const tensor = await pipe(batch, { pooling: "cls", normalize: true });
|
|
107
|
+
for (const v of tensor.tolist()) out.push(Float32Array.from(v));
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
async embedQuery(query) {
|
|
112
|
+
const [v] = await this.embed([QUERY_PREFIX + query]);
|
|
113
|
+
if (!v) throw new Error("empty embedding");
|
|
114
|
+
return v;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// src/files/reconcile.ts
|
|
119
|
+
import fs6 from "fs";
|
|
120
|
+
import path3 from "path";
|
|
121
|
+
|
|
122
|
+
// src/ingredients/normalize.ts
|
|
123
|
+
import fs3 from "fs";
|
|
124
|
+
import { parse as parseYaml } from "yaml";
|
|
125
|
+
|
|
126
|
+
// src/recipes/schema.ts
|
|
127
|
+
import { z } from "zod";
|
|
128
|
+
|
|
129
|
+
// src/types.ts
|
|
130
|
+
var TAG_CATEGORIES = [
|
|
131
|
+
"protein",
|
|
132
|
+
"diet",
|
|
133
|
+
"starch",
|
|
134
|
+
"flavor",
|
|
135
|
+
"texture",
|
|
136
|
+
"cuisine",
|
|
137
|
+
"dish",
|
|
138
|
+
"meal",
|
|
139
|
+
"method",
|
|
140
|
+
"time",
|
|
141
|
+
"season",
|
|
142
|
+
"other"
|
|
143
|
+
];
|
|
144
|
+
var FACET_CATEGORIES = [
|
|
145
|
+
"allergen",
|
|
146
|
+
"equipment",
|
|
147
|
+
"technique",
|
|
148
|
+
"chef",
|
|
149
|
+
"provider",
|
|
150
|
+
"plan"
|
|
151
|
+
];
|
|
152
|
+
var ALL_CATEGORIES = [...TAG_CATEGORIES, ...FACET_CATEGORIES];
|
|
153
|
+
var SOURCE_KINDS = ["card", "url", "synthesized", "manual"];
|
|
154
|
+
var DIFFICULTIES = ["easy", "medium", "hard"];
|
|
155
|
+
var INGREDIENT_SOURCES = ["box", "kitchen"];
|
|
156
|
+
var NUTRITION_BASES = ["per_100g", "per_serving"];
|
|
157
|
+
var IMAGE_SIDES = ["front", "back", "other"];
|
|
158
|
+
var INGEST_STATUSES = ["pending", "done", "skipped", "failed"];
|
|
159
|
+
|
|
160
|
+
// src/recipes/schema.ts
|
|
161
|
+
var num = z.number().nullable().optional();
|
|
162
|
+
var str = z.string().nullable().optional();
|
|
163
|
+
var componentSchema = z.object({
|
|
164
|
+
raw: z.string().min(1),
|
|
165
|
+
canonical: z.string().min(1),
|
|
166
|
+
qty: num,
|
|
167
|
+
unit: str
|
|
168
|
+
});
|
|
169
|
+
var ingredientSchema = z.object({
|
|
170
|
+
raw: z.string().min(1).describe('As printed, e.g. "200g Large Pasta Shells"'),
|
|
171
|
+
canonical: z.string().min(1).describe('Generic singular name without brand, e.g. "pasta shells", "cajun rub"'),
|
|
172
|
+
qty: num,
|
|
173
|
+
unit: str,
|
|
174
|
+
prep: str.describe('Prep note, e.g. "rinse & roughly shred"'),
|
|
175
|
+
optional: z.boolean().optional(),
|
|
176
|
+
staple: z.boolean().optional().describe("Pantry staple \u2014 ignored for pantry coverage"),
|
|
177
|
+
source: z.enum(INGREDIENT_SOURCES).optional().describe('"box" = supplied, "kitchen" = from your own kitchen'),
|
|
178
|
+
brand: str,
|
|
179
|
+
group: str.describe('Sub-recipe, e.g. "sauce", "salsa"'),
|
|
180
|
+
alternatives: z.array(z.string()).optional(),
|
|
181
|
+
components: z.array(componentSchema).optional().describe("Parts of a composite line")
|
|
182
|
+
});
|
|
183
|
+
var stepSchema = z.object({
|
|
184
|
+
title: str.describe("Heading as printed"),
|
|
185
|
+
text: z.string().min(1),
|
|
186
|
+
techniques: z.array(z.string()).optional().describe("Inferred, from the method vocabulary"),
|
|
187
|
+
uses: z.array(z.string()).optional().describe("Canonical names of ingredients used"),
|
|
188
|
+
equipment: z.array(z.string()).optional(),
|
|
189
|
+
temp_c: num,
|
|
190
|
+
minutes: z.array(z.number()).max(2).optional().describe("[min] or [min, max]")
|
|
191
|
+
});
|
|
192
|
+
var tagsSchema = z.partialRecord(z.enum(TAG_CATEGORIES), z.array(z.string().min(1)));
|
|
193
|
+
var nutritionSchema = z.object({
|
|
194
|
+
basis: z.enum(NUTRITION_BASES),
|
|
195
|
+
energy_kj: num,
|
|
196
|
+
energy_kcal: num,
|
|
197
|
+
protein_g: num,
|
|
198
|
+
carbs_g: num,
|
|
199
|
+
sugars_g: num,
|
|
200
|
+
fibre_g: num,
|
|
201
|
+
fat_g: num,
|
|
202
|
+
saturated_g: num,
|
|
203
|
+
sodium_mg: num
|
|
204
|
+
});
|
|
205
|
+
var providerSchema = z.object({
|
|
206
|
+
id: z.string().min(1).describe('Provider profile id, e.g. "ucook" (see pb_providers)'),
|
|
207
|
+
name: str,
|
|
208
|
+
plan: str.describe('Plan line as printed, e.g. "Veggie"'),
|
|
209
|
+
chef: str,
|
|
210
|
+
card_code: str,
|
|
211
|
+
eat_within_days: num,
|
|
212
|
+
wine: z.object({ producer: str, name: str, vintage: num }).nullable().optional()
|
|
213
|
+
});
|
|
214
|
+
var extractionSchema = z.object({
|
|
215
|
+
confidence: z.number().min(0).max(1).nullable().optional(),
|
|
216
|
+
uncertain: z.array(z.string()).optional().describe('e.g. ["nutrition.protein_g"]'),
|
|
217
|
+
needs_review: z.boolean().optional(),
|
|
218
|
+
images: z.array(z.object({ file: z.string(), side: z.enum(IMAGE_SIDES) })).optional(),
|
|
219
|
+
notes: str
|
|
220
|
+
});
|
|
221
|
+
var recipeInputSchema = z.object({
|
|
222
|
+
title: z.string().min(1),
|
|
223
|
+
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional().describe("Only to update an existing recipe; omit to create"),
|
|
224
|
+
subtitle: z.string().optional(),
|
|
225
|
+
description: z.string().optional().describe("Card description, transcribed"),
|
|
226
|
+
blurb: z.string().optional().describe("Your own 1\u20132 sentence description for semantic search"),
|
|
227
|
+
servings: z.number().int().positive().nullable().optional(),
|
|
228
|
+
time: z.object({ prep: num, cook: num, active: num, total: num }).optional().describe("Minutes; active = hands-on"),
|
|
229
|
+
difficulty: z.enum(DIFFICULTIES).nullable().optional(),
|
|
230
|
+
source: z.object({
|
|
231
|
+
kind: z.enum(SOURCE_KINDS),
|
|
232
|
+
ref: str,
|
|
233
|
+
ingested: z.string().optional()
|
|
234
|
+
}).optional(),
|
|
235
|
+
provider: providerSchema.optional(),
|
|
236
|
+
derived_from: z.array(z.string()).optional().describe("Slugs this recipe was synthesized from"),
|
|
237
|
+
images: z.array(z.string()).optional().describe("Paths relative to the data dir"),
|
|
238
|
+
tags: tagsSchema.optional(),
|
|
239
|
+
allergens: z.array(z.string()).optional(),
|
|
240
|
+
equipment: z.array(z.string()).optional(),
|
|
241
|
+
nutrition: nutritionSchema.optional(),
|
|
242
|
+
ingredients: z.array(ingredientSchema).min(1),
|
|
243
|
+
steps: z.array(z.union([z.string().min(1), stepSchema])).min(1),
|
|
244
|
+
tips: z.array(z.string()).optional(),
|
|
245
|
+
notes: z.string().optional(),
|
|
246
|
+
extraction: extractionSchema.optional()
|
|
247
|
+
});
|
|
248
|
+
function normalizeName(s) {
|
|
249
|
+
return s.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^\p{L}\p{N}\s'-]/gu, " ").replace(/\s+/g, " ").trim();
|
|
250
|
+
}
|
|
251
|
+
var trimmed = (s) => {
|
|
252
|
+
const t = s?.trim();
|
|
253
|
+
return t ? t : void 0;
|
|
254
|
+
};
|
|
255
|
+
function compact(obj) {
|
|
256
|
+
const out = {};
|
|
257
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
258
|
+
if (v === void 0 || v === null || v === "") continue;
|
|
259
|
+
if (Array.isArray(v) && v.length === 0) continue;
|
|
260
|
+
out[k] = v;
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
function normalizeStep(step) {
|
|
265
|
+
if (typeof step === "string") return { text: step.trim() };
|
|
266
|
+
return compact({
|
|
267
|
+
title: trimmed(step.title),
|
|
268
|
+
text: step.text.trim(),
|
|
269
|
+
techniques: step.techniques?.map((t) => t.trim()).filter(Boolean),
|
|
270
|
+
uses: step.uses?.map(normalizeName).filter(Boolean),
|
|
271
|
+
equipment: step.equipment?.map((e) => e.trim()).filter(Boolean),
|
|
272
|
+
temp_c: step.temp_c ?? void 0,
|
|
273
|
+
minutes: step.minutes?.length ? step.minutes : void 0
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
function normalizeRecipe(input, slug) {
|
|
277
|
+
const time = compact({ ...input.time ?? {} });
|
|
278
|
+
if (time.total == null && (time.prep != null || time.cook != null)) {
|
|
279
|
+
time.total = (time.prep ?? 0) + (time.cook ?? 0);
|
|
280
|
+
}
|
|
281
|
+
const tags = {};
|
|
282
|
+
for (const [cat, names] of Object.entries(input.tags ?? {})) {
|
|
283
|
+
const clean = [...new Set((names ?? []).map((n) => n.trim()).filter(Boolean))];
|
|
284
|
+
if (clean.length) tags[cat] = clean;
|
|
285
|
+
}
|
|
286
|
+
const recipe = {
|
|
287
|
+
slug,
|
|
288
|
+
title: input.title.trim(),
|
|
289
|
+
time,
|
|
290
|
+
source: input.source ?? { kind: "manual" },
|
|
291
|
+
derived_from: input.derived_from ?? [],
|
|
292
|
+
images: input.images ?? [],
|
|
293
|
+
tags,
|
|
294
|
+
allergens: (input.allergens ?? []).map((a) => a.trim()).filter(Boolean),
|
|
295
|
+
equipment: (input.equipment ?? []).map((e) => e.trim()).filter(Boolean),
|
|
296
|
+
ingredients: input.ingredients.map(
|
|
297
|
+
(i) => compact({
|
|
298
|
+
raw: i.raw.trim(),
|
|
299
|
+
canonical: normalizeName(i.canonical),
|
|
300
|
+
qty: i.qty ?? void 0,
|
|
301
|
+
unit: trimmed(i.unit),
|
|
302
|
+
prep: trimmed(i.prep),
|
|
303
|
+
optional: i.optional || void 0,
|
|
304
|
+
staple: i.staple || void 0,
|
|
305
|
+
source: i.source,
|
|
306
|
+
brand: trimmed(i.brand),
|
|
307
|
+
group: trimmed(i.group),
|
|
308
|
+
alternatives: i.alternatives?.map(normalizeName).filter(Boolean),
|
|
309
|
+
components: i.components?.map(
|
|
310
|
+
(c) => compact({
|
|
311
|
+
raw: c.raw.trim(),
|
|
312
|
+
canonical: normalizeName(c.canonical),
|
|
313
|
+
qty: c.qty ?? void 0,
|
|
314
|
+
unit: trimmed(c.unit)
|
|
315
|
+
})
|
|
316
|
+
)
|
|
317
|
+
})
|
|
318
|
+
),
|
|
319
|
+
steps: input.steps.map(normalizeStep).filter((s) => s.text),
|
|
320
|
+
tips: (input.tips ?? []).map((t) => t.trim()).filter(Boolean)
|
|
321
|
+
};
|
|
322
|
+
const opt = {
|
|
323
|
+
subtitle: trimmed(input.subtitle),
|
|
324
|
+
description: trimmed(input.description),
|
|
325
|
+
blurb: trimmed(input.blurb),
|
|
326
|
+
notes: trimmed(input.notes)
|
|
327
|
+
};
|
|
328
|
+
for (const [k, v] of Object.entries(opt))
|
|
329
|
+
if (v) recipe[k] = v;
|
|
330
|
+
if (input.servings != null) recipe.servings = input.servings;
|
|
331
|
+
if (input.difficulty) recipe.difficulty = input.difficulty;
|
|
332
|
+
if (input.provider) {
|
|
333
|
+
const p = input.provider;
|
|
334
|
+
recipe.provider = compact({
|
|
335
|
+
id: p.id.trim().toLowerCase(),
|
|
336
|
+
name: trimmed(p.name),
|
|
337
|
+
plan: trimmed(p.plan),
|
|
338
|
+
chef: trimmed(p.chef),
|
|
339
|
+
card_code: trimmed(p.card_code),
|
|
340
|
+
eat_within_days: p.eat_within_days ?? void 0,
|
|
341
|
+
wine: p.wine ? compact({ ...p.wine }) : void 0
|
|
342
|
+
});
|
|
343
|
+
if (recipe.provider.wine && Object.keys(recipe.provider.wine).length === 0) {
|
|
344
|
+
delete recipe.provider.wine;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (input.nutrition) recipe.nutrition = compact({ ...input.nutrition });
|
|
348
|
+
if (input.extraction) {
|
|
349
|
+
const e = compact({ ...input.extraction });
|
|
350
|
+
if (Object.keys(e).length) recipe.extraction = e;
|
|
351
|
+
}
|
|
352
|
+
return recipe;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// src/ingredients/normalize.ts
|
|
356
|
+
var FUZZY_THRESHOLD = 0.85;
|
|
357
|
+
function dice(a, b) {
|
|
358
|
+
const grams = (s) => {
|
|
359
|
+
const g = /* @__PURE__ */ new Map();
|
|
360
|
+
const t = ` ${s} `;
|
|
361
|
+
for (let i = 0; i < t.length - 1; i++) {
|
|
362
|
+
const k = t.slice(i, i + 2);
|
|
363
|
+
g.set(k, (g.get(k) ?? 0) + 1);
|
|
364
|
+
}
|
|
365
|
+
return g;
|
|
366
|
+
};
|
|
367
|
+
const ga = grams(a);
|
|
368
|
+
const gb = grams(b);
|
|
369
|
+
let overlap = 0;
|
|
370
|
+
for (const [k, n] of ga) overlap += Math.min(n, gb.get(k) ?? 0);
|
|
371
|
+
const total = [...ga.values()].reduce((x, y) => x + y, 0) + [...gb.values()].reduce((x, y) => x + y, 0);
|
|
372
|
+
return total === 0 ? 0 : 2 * overlap / total;
|
|
373
|
+
}
|
|
374
|
+
function singularForms(name) {
|
|
375
|
+
const m = /^(.*?)(\S+)$/.exec(name);
|
|
376
|
+
if (!m) return [];
|
|
377
|
+
const [, head = "", last = ""] = m;
|
|
378
|
+
const out = [];
|
|
379
|
+
if (last.endsWith("ies") && last.length > 4) out.push(`${last.slice(0, -3)}y`);
|
|
380
|
+
if (last.endsWith("oes") || last.endsWith("ches") || last.endsWith("shes"))
|
|
381
|
+
out.push(last.slice(0, -2));
|
|
382
|
+
if (last.endsWith("s") && !last.endsWith("ss") && last.length > 3) out.push(last.slice(0, -1));
|
|
383
|
+
return out.map((w) => `${head}${w}`);
|
|
384
|
+
}
|
|
385
|
+
function lookup(store, name) {
|
|
386
|
+
const exact = store.getIngredient(name);
|
|
387
|
+
if (exact) return exact;
|
|
388
|
+
for (const s of singularForms(name)) {
|
|
389
|
+
const row = store.getIngredient(s);
|
|
390
|
+
if (row) return row;
|
|
391
|
+
}
|
|
392
|
+
return void 0;
|
|
393
|
+
}
|
|
394
|
+
function resolveIngredients(store, ingredients) {
|
|
395
|
+
const res = { ids: /* @__PURE__ */ new Map(), created: [], aliased: [], ingredients: [] };
|
|
396
|
+
for (const ing of ingredients) {
|
|
397
|
+
const name = normalizeName(ing.canonical);
|
|
398
|
+
let row = lookup(store, name);
|
|
399
|
+
if (row) {
|
|
400
|
+
if (row.canonical !== name) res.aliased.push({ from: name, to: row.canonical, via: "alias" });
|
|
401
|
+
} else {
|
|
402
|
+
const best = store.fuzzyIngredients(name, 3)[0];
|
|
403
|
+
if (best && dice(name, best.alias) >= FUZZY_THRESHOLD) {
|
|
404
|
+
row = best.row;
|
|
405
|
+
store.addAlias(row.id, name);
|
|
406
|
+
res.aliased.push({ from: name, to: row.canonical, via: "fuzzy" });
|
|
407
|
+
} else {
|
|
408
|
+
row = store.createIngredient({ canonical: name, staple: ing.staple ?? false });
|
|
409
|
+
res.created.push(name);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
res.ids.set(row.canonical, row.id);
|
|
413
|
+
const out = { ...ing, canonical: row.canonical };
|
|
414
|
+
if (row.is_staple || ing.staple) out.staple = true;
|
|
415
|
+
else delete out.staple;
|
|
416
|
+
res.ingredients.push(out);
|
|
417
|
+
}
|
|
418
|
+
return res;
|
|
419
|
+
}
|
|
420
|
+
function readCatalogYaml(file) {
|
|
421
|
+
if (!fs3.existsSync(file)) return [];
|
|
422
|
+
const data = parseYaml(fs3.readFileSync(file, "utf8"));
|
|
423
|
+
if (!data || typeof data !== "object") return [];
|
|
424
|
+
return Object.entries(data).map(([canonical, v]) => ({
|
|
425
|
+
canonical: normalizeName(canonical),
|
|
426
|
+
aliases: (v?.aliases ?? []).map(normalizeName),
|
|
427
|
+
staple: v?.staple ?? false,
|
|
428
|
+
category: v?.category ?? null
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
431
|
+
function findCanonical(store, name) {
|
|
432
|
+
return lookup(store, normalizeName(name))?.canonical;
|
|
433
|
+
}
|
|
434
|
+
function loadCatalog(store, file) {
|
|
435
|
+
const entries = readCatalogYaml(file);
|
|
436
|
+
store.transaction(() => {
|
|
437
|
+
for (const e of entries) store.upsertCatalogEntry(e);
|
|
438
|
+
});
|
|
439
|
+
return entries.length;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// src/vocabulary/vocabulary.ts
|
|
443
|
+
import fs4 from "fs";
|
|
444
|
+
import { parse as parseYaml2 } from "yaml";
|
|
445
|
+
function normalizeTagValue(s) {
|
|
446
|
+
return s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/['’]/g, "").replace(/&/g, " and ").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
447
|
+
}
|
|
448
|
+
var Vocabulary = class _Vocabulary {
|
|
449
|
+
categories = /* @__PURE__ */ new Map();
|
|
450
|
+
static fromFile(file) {
|
|
451
|
+
const v = new _Vocabulary();
|
|
452
|
+
if (fs4.existsSync(file)) {
|
|
453
|
+
v.load(parseYaml2(fs4.readFileSync(file, "utf8")));
|
|
454
|
+
}
|
|
455
|
+
return v;
|
|
456
|
+
}
|
|
457
|
+
load(data) {
|
|
458
|
+
for (const [name, raw] of Object.entries(data ?? {})) {
|
|
459
|
+
const cat = this.ensure(name, raw?.mode ?? "open");
|
|
460
|
+
if (raw?.mode) cat.mode = raw.mode;
|
|
461
|
+
for (const [value, def] of Object.entries(raw?.values ?? {})) {
|
|
462
|
+
this.addValue(name, value, def?.synonyms ?? [], def?.parent);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
ensure(category, mode) {
|
|
467
|
+
let cat = this.categories.get(category);
|
|
468
|
+
if (!cat) {
|
|
469
|
+
cat = { mode, values: /* @__PURE__ */ new Map(), lookup: /* @__PURE__ */ new Map() };
|
|
470
|
+
this.categories.set(category, cat);
|
|
471
|
+
}
|
|
472
|
+
return cat;
|
|
473
|
+
}
|
|
474
|
+
addValue(category, value, synonyms = [], parent) {
|
|
475
|
+
const cat = this.ensure(category, "open");
|
|
476
|
+
const v = normalizeTagValue(value);
|
|
477
|
+
const existing = cat.values.get(v);
|
|
478
|
+
const entry = existing ?? { synonyms: [] };
|
|
479
|
+
if (parent) entry.parent = normalizeTagValue(parent);
|
|
480
|
+
cat.values.set(v, entry);
|
|
481
|
+
cat.lookup.set(v, v);
|
|
482
|
+
for (const s of synonyms) {
|
|
483
|
+
const n = normalizeTagValue(s);
|
|
484
|
+
if (!n) continue;
|
|
485
|
+
entry.synonyms.push(n);
|
|
486
|
+
if (!cat.values.has(n) || n === v) cat.lookup.set(n, v);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/** Extra synonyms, e.g. from a provider profile. Unknown targets are created. */
|
|
490
|
+
addSynonyms(category, map) {
|
|
491
|
+
for (const [from, to] of Object.entries(map)) this.addValue(category, to, [from]);
|
|
492
|
+
}
|
|
493
|
+
has(category) {
|
|
494
|
+
return this.categories.has(category);
|
|
495
|
+
}
|
|
496
|
+
mode(category) {
|
|
497
|
+
return this.categories.get(category)?.mode ?? "open";
|
|
498
|
+
}
|
|
499
|
+
resolve(category, raw) {
|
|
500
|
+
const n = normalizeTagValue(raw);
|
|
501
|
+
const hit = this.categories.get(category)?.lookup.get(n);
|
|
502
|
+
return hit ? { value: hit, known: true } : { value: n, known: false };
|
|
503
|
+
}
|
|
504
|
+
normalize(category, raws) {
|
|
505
|
+
const out = { values: [], mapped: [], unknown: [] };
|
|
506
|
+
for (const raw of raws) {
|
|
507
|
+
const { value, known } = this.resolve(category, raw);
|
|
508
|
+
if (!value) continue;
|
|
509
|
+
if (known && value !== normalizeTagValue(raw)) out.mapped.push({ from: raw, to: value });
|
|
510
|
+
if (!known) out.unknown.push(value);
|
|
511
|
+
if (!out.values.includes(value)) out.values.push(value);
|
|
512
|
+
}
|
|
513
|
+
return out;
|
|
514
|
+
}
|
|
515
|
+
/** The value plus everything below it in the hierarchy (thai ⊂ southeast-asian ⊂ asian). */
|
|
516
|
+
expand(category, raw) {
|
|
517
|
+
const root = this.resolve(category, raw).value;
|
|
518
|
+
const cat = this.categories.get(category);
|
|
519
|
+
if (!cat) return [root];
|
|
520
|
+
const out = /* @__PURE__ */ new Set([root]);
|
|
521
|
+
let grew = true;
|
|
522
|
+
while (grew) {
|
|
523
|
+
grew = false;
|
|
524
|
+
for (const [value, def] of cat.values) {
|
|
525
|
+
if (def.parent && out.has(def.parent) && !out.has(value)) {
|
|
526
|
+
out.add(value);
|
|
527
|
+
grew = true;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return [...out];
|
|
532
|
+
}
|
|
533
|
+
/** Ancestors of a value, nearest first. */
|
|
534
|
+
ancestors(category, value) {
|
|
535
|
+
const cat = this.categories.get(category);
|
|
536
|
+
const out = [];
|
|
537
|
+
let cur = cat?.values.get(value)?.parent;
|
|
538
|
+
while (cur && !out.includes(cur)) {
|
|
539
|
+
out.push(cur);
|
|
540
|
+
cur = cat?.values.get(cur)?.parent;
|
|
541
|
+
}
|
|
542
|
+
return out;
|
|
543
|
+
}
|
|
544
|
+
/** Compact summary for Claude: closed categories list every value, open ones the top values. */
|
|
545
|
+
summary(maxOpen = 40) {
|
|
546
|
+
const out = {};
|
|
547
|
+
for (const [name, cat] of this.categories) {
|
|
548
|
+
const values = [...cat.values.keys()];
|
|
549
|
+
out[name] = {
|
|
550
|
+
mode: cat.mode,
|
|
551
|
+
values: cat.mode === "closed" ? values : values.slice(0, maxOpen)
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
return out;
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
// src/recipes/enrich.ts
|
|
559
|
+
function addTag(recipe, category, value) {
|
|
560
|
+
const list = recipe.tags[category] ?? [];
|
|
561
|
+
if (!list.includes(value)) list.push(value);
|
|
562
|
+
recipe.tags[category] = list;
|
|
563
|
+
}
|
|
564
|
+
function escapeRe(s) {
|
|
565
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
566
|
+
}
|
|
567
|
+
function applyProvider(recipe, profile, report) {
|
|
568
|
+
if (!recipe.provider) return;
|
|
569
|
+
if (!recipe.provider.name) recipe.provider.name = profile.name;
|
|
570
|
+
if (recipe.servings == null && profile.default_servings != null) {
|
|
571
|
+
recipe.servings = profile.default_servings;
|
|
572
|
+
report.provider.push(`servings defaulted to ${profile.default_servings}`);
|
|
573
|
+
}
|
|
574
|
+
const plan = recipe.provider.plan;
|
|
575
|
+
if (plan) {
|
|
576
|
+
const key = Object.keys(profile.plan_labels).find(
|
|
577
|
+
(k) => normalizeTagValue(k) === normalizeTagValue(plan)
|
|
578
|
+
);
|
|
579
|
+
for (const [cat, values] of Object.entries(key ? profile.plan_labels[key] ?? {} : {})) {
|
|
580
|
+
if (!TAG_CATEGORIES.includes(cat)) continue;
|
|
581
|
+
for (const v of values) {
|
|
582
|
+
if (!recipe.tags[cat]?.includes(v)) {
|
|
583
|
+
addTag(recipe, cat, v);
|
|
584
|
+
report.provider.push(`plan "${plan}" \u2192 ${cat}:${v}`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const equipmentTerms = new Map(
|
|
590
|
+
Object.entries(profile.equipment_terms).map(([k, v]) => [normalizeName(k), v])
|
|
591
|
+
);
|
|
592
|
+
const kept = [];
|
|
593
|
+
for (const ing of recipe.ingredients) {
|
|
594
|
+
const eq = equipmentTerms.get(ing.canonical) ?? equipmentTerms.get(normalizeName(ing.raw));
|
|
595
|
+
if (eq) {
|
|
596
|
+
if (!recipe.equipment.includes(eq)) recipe.equipment.push(eq);
|
|
597
|
+
report.provider.push(`"${ing.raw}" moved to equipment`);
|
|
598
|
+
} else {
|
|
599
|
+
kept.push(ing);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
recipe.ingredients = kept;
|
|
603
|
+
for (const ing of recipe.ingredients) {
|
|
604
|
+
for (const brand of profile.brands) {
|
|
605
|
+
const re = new RegExp(`(^|\\s)${escapeRe(normalizeName(brand))}(\\s|$)`);
|
|
606
|
+
if (re.test(ing.canonical)) {
|
|
607
|
+
const stripped = ing.canonical.replace(re, " ").replace(/\s+/g, " ").trim();
|
|
608
|
+
if (stripped) {
|
|
609
|
+
report.provider.push(`brand "${brand}" stripped from "${ing.canonical}"`);
|
|
610
|
+
ing.canonical = stripped;
|
|
611
|
+
if (!ing.brand) ing.brand = brand;
|
|
612
|
+
}
|
|
613
|
+
} else if (!ing.brand && re.test(normalizeName(ing.raw))) {
|
|
614
|
+
ing.brand = brand;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (recipe.nutrition && !recipe.nutrition.basis) recipe.nutrition.basis = profile.nutrition_basis;
|
|
619
|
+
}
|
|
620
|
+
function vocabList(vocab, category, values, report, closedFallback) {
|
|
621
|
+
const r = vocab.normalize(category, values);
|
|
622
|
+
for (const m of r.mapped) report.mapped.push(`${category}: "${m.from}" \u2192 ${m.to}`);
|
|
623
|
+
const closed = vocab.has(category) && vocab.mode(category) === "closed";
|
|
624
|
+
if (!closed) {
|
|
625
|
+
for (const u of r.unknown) report.new_tags.push(`${category}:${u}`);
|
|
626
|
+
return { kept: r.values, toOther: [] };
|
|
627
|
+
}
|
|
628
|
+
for (const u of r.unknown) {
|
|
629
|
+
report.rejected.push(
|
|
630
|
+
`${category}:${u}${closedFallback === "other" ? " (moved to other)" : " (kept)"}`
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
if (closedFallback === "keep") return { kept: r.values, toOther: [] };
|
|
634
|
+
return {
|
|
635
|
+
kept: r.values.filter((v) => !r.unknown.includes(v)),
|
|
636
|
+
toOther: r.unknown
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
function enrichRecipe(recipe, vocab, profile) {
|
|
640
|
+
const report = { mapped: [], new_tags: [], rejected: [], provider: [] };
|
|
641
|
+
if (profile) applyProvider(recipe, profile, report);
|
|
642
|
+
const tags = {};
|
|
643
|
+
const toOther = [];
|
|
644
|
+
for (const cat of TAG_CATEGORIES) {
|
|
645
|
+
const values = recipe.tags[cat];
|
|
646
|
+
if (!values?.length || cat === "other") continue;
|
|
647
|
+
const r = vocabList(vocab, cat, values, report, "other");
|
|
648
|
+
if (r.kept.length) tags[cat] = r.kept;
|
|
649
|
+
toOther.push(...r.toOther);
|
|
650
|
+
}
|
|
651
|
+
const other = vocabList(
|
|
652
|
+
vocab,
|
|
653
|
+
"other",
|
|
654
|
+
[...recipe.tags.other ?? [], ...toOther],
|
|
655
|
+
report,
|
|
656
|
+
"keep"
|
|
657
|
+
);
|
|
658
|
+
if (other.kept.length) tags.other = other.kept;
|
|
659
|
+
recipe.tags = tags;
|
|
660
|
+
recipe.allergens = vocabList(vocab, "allergen", recipe.allergens, report, "keep").kept;
|
|
661
|
+
const stepEquipment = recipe.steps.flatMap((s) => s.equipment ?? []);
|
|
662
|
+
recipe.equipment = vocabList(vocab, "equipment", recipe.equipment, report, "keep").kept;
|
|
663
|
+
for (const step of recipe.steps) {
|
|
664
|
+
if (step.equipment?.length) {
|
|
665
|
+
step.equipment = vocab.normalize("equipment", step.equipment).values;
|
|
666
|
+
}
|
|
667
|
+
if (step.techniques?.length) {
|
|
668
|
+
step.techniques = vocabList(vocab, "method", step.techniques, report, "keep").kept;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
for (const e of vocab.normalize("equipment", stepEquipment).values) {
|
|
672
|
+
if (!recipe.equipment.includes(e)) recipe.equipment.push(e);
|
|
673
|
+
}
|
|
674
|
+
for (const ing of recipe.ingredients) {
|
|
675
|
+
if (ing.source === "kitchen") ing.staple = true;
|
|
676
|
+
}
|
|
677
|
+
report.mapped = [...new Set(report.mapped)];
|
|
678
|
+
report.new_tags = [...new Set(report.new_tags)];
|
|
679
|
+
report.rejected = [...new Set(report.rejected)];
|
|
680
|
+
return report;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// src/recipes/pipeline.ts
|
|
684
|
+
function prepareRecipe(ctx, recipe) {
|
|
685
|
+
const profile = recipe.provider ? ctx.providers.resolve(recipe.provider.id) : void 0;
|
|
686
|
+
const originals = new Map(recipe.ingredients.map((i) => [i, i.canonical]));
|
|
687
|
+
const report = enrichRecipe(recipe, ctx.vocab, profile);
|
|
688
|
+
const resolution = resolveIngredients(ctx.store, recipe.ingredients);
|
|
689
|
+
const rename = /* @__PURE__ */ new Map();
|
|
690
|
+
recipe.ingredients.forEach((ing, idx) => {
|
|
691
|
+
const final = resolution.ingredients[idx]?.canonical;
|
|
692
|
+
if (!final) return;
|
|
693
|
+
rename.set(ing.canonical, final);
|
|
694
|
+
const original = originals.get(ing);
|
|
695
|
+
if (original) rename.set(original, final);
|
|
696
|
+
});
|
|
697
|
+
recipe.ingredients = resolution.ingredients;
|
|
698
|
+
const ingredientIds = new Map(resolution.ids);
|
|
699
|
+
const unmatched = [];
|
|
700
|
+
for (const step of recipe.steps) {
|
|
701
|
+
if (!step.uses?.length) continue;
|
|
702
|
+
const uses = [];
|
|
703
|
+
for (const raw of step.uses) {
|
|
704
|
+
const name = normalizeName(raw);
|
|
705
|
+
let final = rename.get(name);
|
|
706
|
+
if (!final) {
|
|
707
|
+
const known = findCanonical(ctx.store, name);
|
|
708
|
+
if (known) {
|
|
709
|
+
final = known;
|
|
710
|
+
const row = ctx.store.getIngredient(known);
|
|
711
|
+
if (row) ingredientIds.set(row.canonical, row.id);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (!final) {
|
|
715
|
+
unmatched.push(name);
|
|
716
|
+
final = name;
|
|
717
|
+
}
|
|
718
|
+
if (!uses.includes(final)) uses.push(final);
|
|
719
|
+
}
|
|
720
|
+
step.uses = uses;
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
recipe,
|
|
724
|
+
ingredientIds,
|
|
725
|
+
resolution,
|
|
726
|
+
report,
|
|
727
|
+
unmatched_uses: [...new Set(unmatched)]
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// src/files/recipe-md.ts
|
|
732
|
+
import { Document, isMap, isSeq, parse as parseYaml3 } from "yaml";
|
|
733
|
+
var FORMAT_VERSION = 2;
|
|
734
|
+
var FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
735
|
+
var KEY_ORDER = [
|
|
736
|
+
"pb",
|
|
737
|
+
"title",
|
|
738
|
+
"slug",
|
|
739
|
+
"subtitle",
|
|
740
|
+
"description",
|
|
741
|
+
"blurb",
|
|
742
|
+
"servings",
|
|
743
|
+
"time",
|
|
744
|
+
"difficulty",
|
|
745
|
+
"source",
|
|
746
|
+
"provider",
|
|
747
|
+
"derived_from",
|
|
748
|
+
"images",
|
|
749
|
+
"tags",
|
|
750
|
+
"allergens",
|
|
751
|
+
"equipment",
|
|
752
|
+
"nutrition",
|
|
753
|
+
"ingredients",
|
|
754
|
+
"steps",
|
|
755
|
+
"tips",
|
|
756
|
+
"extraction"
|
|
757
|
+
];
|
|
758
|
+
function stripEmpty(obj) {
|
|
759
|
+
const out = {};
|
|
760
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
761
|
+
if (v === void 0 || v === null || v === "") continue;
|
|
762
|
+
if (Array.isArray(v) && v.length === 0 && k !== "derived_from" && k !== "images") continue;
|
|
763
|
+
out[k] = v;
|
|
764
|
+
}
|
|
765
|
+
return out;
|
|
766
|
+
}
|
|
767
|
+
function serializeRecipe(recipe) {
|
|
768
|
+
const { notes: _notes, slug, ...rest } = recipe;
|
|
769
|
+
const record = { pb: FORMAT_VERSION, ...rest, slug };
|
|
770
|
+
const fm = {};
|
|
771
|
+
for (const key of KEY_ORDER) {
|
|
772
|
+
const v = record[key];
|
|
773
|
+
if (v === void 0 || v === null || v === "") continue;
|
|
774
|
+
if (Array.isArray(v) && v.length === 0 && key !== "derived_from" && key !== "images") continue;
|
|
775
|
+
if (typeof v === "object" && !Array.isArray(v)) {
|
|
776
|
+
const inner = stripEmpty(v);
|
|
777
|
+
if (Object.keys(inner).length === 0) continue;
|
|
778
|
+
fm[key] = inner;
|
|
779
|
+
} else {
|
|
780
|
+
fm[key] = v;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
const doc = new Document(fm);
|
|
784
|
+
const root = doc.contents;
|
|
785
|
+
if (isMap(root)) {
|
|
786
|
+
for (const key of ["time", "source", "nutrition"]) {
|
|
787
|
+
const n = root.get(key, true);
|
|
788
|
+
if (isMap(n)) n.flow = true;
|
|
789
|
+
}
|
|
790
|
+
for (const key of ["derived_from", "images", "allergens", "equipment"]) {
|
|
791
|
+
const n = root.get(key, true);
|
|
792
|
+
if (isSeq(n)) n.flow = true;
|
|
793
|
+
}
|
|
794
|
+
const provider = root.get("provider", true);
|
|
795
|
+
if (isMap(provider)) {
|
|
796
|
+
const wine = provider.get("wine", true);
|
|
797
|
+
if (isMap(wine)) wine.flow = true;
|
|
798
|
+
}
|
|
799
|
+
const tags = root.get("tags", true);
|
|
800
|
+
if (isMap(tags)) {
|
|
801
|
+
for (const pair of tags.items) if (isSeq(pair.value)) pair.value.flow = true;
|
|
802
|
+
}
|
|
803
|
+
for (const key of ["ingredients", "steps"]) {
|
|
804
|
+
const list = root.get(key, true);
|
|
805
|
+
if (isSeq(list)) {
|
|
806
|
+
for (const item of list.items) if (isMap(item)) item.flow = true;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const extraction = root.get("extraction", true);
|
|
810
|
+
if (isMap(extraction)) {
|
|
811
|
+
for (const pair of extraction.items) if (isSeq(pair.value)) pair.value.flow = true;
|
|
812
|
+
const images = extraction.get("images", true);
|
|
813
|
+
if (isSeq(images)) {
|
|
814
|
+
for (const item of images.items) if (isMap(item)) item.flow = true;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
const yaml = doc.toString({ lineWidth: 0, flowCollectionPadding: false }).trimEnd();
|
|
819
|
+
const lines = ["---", yaml, "---", "", `# ${recipe.title}`, ""];
|
|
820
|
+
if (recipe.subtitle) lines.push(`*${recipe.subtitle}*`, "");
|
|
821
|
+
if (recipe.description) lines.push(recipe.description, "");
|
|
822
|
+
const box = recipe.ingredients.filter((i) => i.source !== "kitchen");
|
|
823
|
+
const kitchen = recipe.ingredients.filter((i) => i.source === "kitchen");
|
|
824
|
+
const line = (i) => `- ${i.raw}${i.prep ? ` \u2014 ${i.prep}` : ""}${i.optional ? " (optional)" : ""}`;
|
|
825
|
+
lines.push("## Ingredients", "");
|
|
826
|
+
for (const i of box) lines.push(line(i));
|
|
827
|
+
if (kitchen.length) {
|
|
828
|
+
lines.push("", "### From your kitchen", "");
|
|
829
|
+
for (const i of kitchen) lines.push(line(i));
|
|
830
|
+
}
|
|
831
|
+
lines.push("", "## Method", "");
|
|
832
|
+
recipe.steps.forEach((s, n) => {
|
|
833
|
+
lines.push(`${n + 1}. ${s.title ? `**${s.title}** ` : ""}${s.text}`);
|
|
834
|
+
});
|
|
835
|
+
if (recipe.tips.length) {
|
|
836
|
+
lines.push("", "## Tips", "");
|
|
837
|
+
for (const t of recipe.tips) lines.push(`- ${t}`);
|
|
838
|
+
}
|
|
839
|
+
if (recipe.notes) lines.push("", "## Notes", "", recipe.notes);
|
|
840
|
+
lines.push("");
|
|
841
|
+
return lines.join("\n");
|
|
842
|
+
}
|
|
843
|
+
function sections(body) {
|
|
844
|
+
const out = /* @__PURE__ */ new Map();
|
|
845
|
+
let current = null;
|
|
846
|
+
let buf = [];
|
|
847
|
+
const flush = () => {
|
|
848
|
+
if (current !== null) out.set(current, buf.join("\n").trim());
|
|
849
|
+
buf = [];
|
|
850
|
+
};
|
|
851
|
+
for (const line of body.split(/\r?\n/)) {
|
|
852
|
+
const m = /^##\s+(.+?)\s*$/.exec(line);
|
|
853
|
+
if (m?.[1]) {
|
|
854
|
+
flush();
|
|
855
|
+
current = m[1].toLowerCase();
|
|
856
|
+
} else {
|
|
857
|
+
buf.push(line);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
flush();
|
|
861
|
+
return out;
|
|
862
|
+
}
|
|
863
|
+
function parseSteps(method) {
|
|
864
|
+
const steps = [];
|
|
865
|
+
for (const raw of method.split(/\r?\n/)) {
|
|
866
|
+
const line = raw.trim();
|
|
867
|
+
if (!line) continue;
|
|
868
|
+
const m = /^(?:\d+[.)]|[-*])\s+(.*)$/.exec(line);
|
|
869
|
+
if (m?.[1]) steps.push(m[1]);
|
|
870
|
+
else if (steps.length) steps[steps.length - 1] = `${steps[steps.length - 1]} ${line}`;
|
|
871
|
+
else steps.push(line);
|
|
872
|
+
}
|
|
873
|
+
return steps;
|
|
874
|
+
}
|
|
875
|
+
function parseRecipe(markdown, slugFromFile) {
|
|
876
|
+
const m = FM_RE.exec(markdown);
|
|
877
|
+
if (!m?.[1]) throw new Error("missing frontmatter");
|
|
878
|
+
const fm = parseYaml3(m[1]);
|
|
879
|
+
if (typeof fm !== "object" || fm === null) throw new Error("frontmatter is not a map");
|
|
880
|
+
const secs = sections(m[2] ?? "");
|
|
881
|
+
const { pb: _pb, summary, ...rest } = fm;
|
|
882
|
+
const fmSteps = Array.isArray(rest.steps) && rest.steps.length > 0 ? rest.steps : void 0;
|
|
883
|
+
const bodySteps = parseSteps(secs.get("method") ?? secs.get("steps") ?? "");
|
|
884
|
+
const notes = secs.get("notes");
|
|
885
|
+
const input = recipeInputSchema.parse({
|
|
886
|
+
...rest,
|
|
887
|
+
// v1 files called the blurb "summary".
|
|
888
|
+
...summary && !rest.blurb ? { blurb: summary } : {},
|
|
889
|
+
slug: slugFromFile,
|
|
890
|
+
steps: fmSteps ?? (bodySteps.length ? bodySteps : ["(no method recorded)"]),
|
|
891
|
+
...notes ? { notes } : {}
|
|
892
|
+
});
|
|
893
|
+
return normalizeRecipe(input, slugFromFile);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// src/files/store.ts
|
|
897
|
+
import { createHash } from "crypto";
|
|
898
|
+
import fs5 from "fs";
|
|
899
|
+
import path2 from "path";
|
|
900
|
+
function recipeFilePath(paths, slug) {
|
|
901
|
+
return path2.join(paths.recipes, `${slug}.md`);
|
|
902
|
+
}
|
|
903
|
+
function relativeToData(paths, abs) {
|
|
904
|
+
return path2.relative(paths.data, abs).split(path2.sep).join("/");
|
|
905
|
+
}
|
|
906
|
+
function hashContent(content) {
|
|
907
|
+
return createHash("sha256").update(content).digest("hex");
|
|
908
|
+
}
|
|
909
|
+
function writeRecipeFile(paths, recipe) {
|
|
910
|
+
const abs = recipeFilePath(paths, recipe.slug);
|
|
911
|
+
const content = serializeRecipe(recipe);
|
|
912
|
+
const tmp = `${abs}.tmp-${process.pid}`;
|
|
913
|
+
fs5.mkdirSync(path2.dirname(abs), { recursive: true });
|
|
914
|
+
fs5.writeFileSync(tmp, content, "utf8");
|
|
915
|
+
fs5.renameSync(tmp, abs);
|
|
916
|
+
const st = fs5.statSync(abs);
|
|
917
|
+
return {
|
|
918
|
+
file_path: relativeToData(paths, abs),
|
|
919
|
+
file_mtime: st.mtimeMs,
|
|
920
|
+
file_size: st.size,
|
|
921
|
+
content_hash: hashContent(content)
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
function recipeFileExists(paths, slug) {
|
|
925
|
+
return fs5.existsSync(recipeFilePath(paths, slug));
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// src/files/reconcile.ts
|
|
929
|
+
function reconcile(ctx, opts = {}) {
|
|
930
|
+
const res = { added: 0, updated: 0, removed: 0, unchanged: 0, errors: [] };
|
|
931
|
+
const index = ctx.store.recipeFileIndex();
|
|
932
|
+
const seen = /* @__PURE__ */ new Set();
|
|
933
|
+
const files = fs6.existsSync(ctx.paths.recipes) ? fs6.readdirSync(ctx.paths.recipes).filter((f) => f.endsWith(".md") && !f.startsWith(".")) : [];
|
|
934
|
+
for (const name of files.sort()) {
|
|
935
|
+
const abs = path3.join(ctx.paths.recipes, name);
|
|
936
|
+
const rel = relativeToData(ctx.paths, abs);
|
|
937
|
+
seen.add(rel);
|
|
938
|
+
const st = fs6.statSync(abs);
|
|
939
|
+
const prev = index.get(rel);
|
|
940
|
+
if (!opts.full && prev && Math.round(prev.file_mtime) === Math.round(st.mtimeMs) && prev.file_size === st.size) {
|
|
941
|
+
res.unchanged++;
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
const content = fs6.readFileSync(abs, "utf8");
|
|
945
|
+
const hash = hashContent(content);
|
|
946
|
+
if (!opts.full && prev && prev.content_hash === hash) {
|
|
947
|
+
ctx.store.touchFile(rel, st.mtimeMs, st.size);
|
|
948
|
+
res.unchanged++;
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
try {
|
|
952
|
+
const slug = name.slice(0, -3);
|
|
953
|
+
const prepared = prepareRecipe(ctx, parseRecipe(content, slug));
|
|
954
|
+
ctx.store.upsertRecipe(
|
|
955
|
+
prepared.recipe,
|
|
956
|
+
{ file_path: rel, file_mtime: st.mtimeMs, file_size: st.size, content_hash: hash },
|
|
957
|
+
prepared.ingredientIds
|
|
958
|
+
);
|
|
959
|
+
if (prev) res.updated++;
|
|
960
|
+
else res.added++;
|
|
961
|
+
} catch (e) {
|
|
962
|
+
res.errors.push({ file: rel, error: e instanceof Error ? e.message : String(e) });
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
for (const [rel, entry] of index) {
|
|
966
|
+
if (!seen.has(rel)) {
|
|
967
|
+
ctx.store.deleteRecipe(entry.slug);
|
|
968
|
+
res.removed++;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
if (opts.reembed) ctx.store.resetVectors();
|
|
972
|
+
ctx.embedQueue.kick();
|
|
973
|
+
return res;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// src/core/context.ts
|
|
977
|
+
import fs8 from "fs";
|
|
978
|
+
import path5 from "path";
|
|
979
|
+
|
|
980
|
+
// src/embeddings/queue.ts
|
|
981
|
+
var PASS = 64;
|
|
982
|
+
var EmbedQueue = class {
|
|
983
|
+
constructor(store, embedder) {
|
|
984
|
+
this.store = store;
|
|
985
|
+
this.embedder = embedder;
|
|
986
|
+
}
|
|
987
|
+
store;
|
|
988
|
+
embedder;
|
|
989
|
+
timer = null;
|
|
990
|
+
running = null;
|
|
991
|
+
stopped = false;
|
|
992
|
+
/** Cancel any scheduled pass; called before the store closes. */
|
|
993
|
+
stop() {
|
|
994
|
+
this.stopped = true;
|
|
995
|
+
if (this.timer) clearTimeout(this.timer);
|
|
996
|
+
this.timer = null;
|
|
997
|
+
}
|
|
998
|
+
kick(delayMs = 300) {
|
|
999
|
+
if (!this.embedder || this.stopped) return;
|
|
1000
|
+
if (this.timer) clearTimeout(this.timer);
|
|
1001
|
+
this.timer = setTimeout(() => {
|
|
1002
|
+
this.timer = null;
|
|
1003
|
+
void this.run();
|
|
1004
|
+
}, delayMs);
|
|
1005
|
+
this.timer.unref();
|
|
1006
|
+
}
|
|
1007
|
+
/** Embed everything pending; resolves when the queue is drained. */
|
|
1008
|
+
run() {
|
|
1009
|
+
if (!this.embedder || this.stopped) return Promise.resolve();
|
|
1010
|
+
if (this.running) return this.running;
|
|
1011
|
+
const embedder = this.embedder;
|
|
1012
|
+
this.running = (async () => {
|
|
1013
|
+
try {
|
|
1014
|
+
while (!this.stopped) {
|
|
1015
|
+
const pending = this.store.pendingChunks(PASS);
|
|
1016
|
+
if (pending.length === 0) break;
|
|
1017
|
+
const vectors = await embedder.embed(pending.map((p) => p.text));
|
|
1018
|
+
if (this.stopped) break;
|
|
1019
|
+
this.store.storeEmbeddings(
|
|
1020
|
+
pending.map((p, i) => {
|
|
1021
|
+
const embedding = vectors[i];
|
|
1022
|
+
if (!embedding) throw new Error("embedding count mismatch");
|
|
1023
|
+
return { id: p.id, embedding };
|
|
1024
|
+
})
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
} catch (e) {
|
|
1028
|
+
log("embedding pass failed:", e);
|
|
1029
|
+
} finally {
|
|
1030
|
+
this.running = null;
|
|
1031
|
+
}
|
|
1032
|
+
})();
|
|
1033
|
+
return this.running;
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
// src/providers/registry.ts
|
|
1038
|
+
import fs7 from "fs";
|
|
1039
|
+
import path4 from "path";
|
|
1040
|
+
import { parse as parseYaml4 } from "yaml";
|
|
1041
|
+
var GENERIC_PROVIDER = "generic";
|
|
1042
|
+
function builtinProvidersDir() {
|
|
1043
|
+
return path4.join(packageRoot, "providers");
|
|
1044
|
+
}
|
|
1045
|
+
function loadProfile(dir) {
|
|
1046
|
+
const file = path4.join(dir, "profile.yaml");
|
|
1047
|
+
if (!fs7.existsSync(file)) return null;
|
|
1048
|
+
const raw = parseYaml4(fs7.readFileSync(file, "utf8")) ?? {};
|
|
1049
|
+
const guideFile = path4.join(dir, "guide.md");
|
|
1050
|
+
const id = (raw.id ?? path4.basename(dir)).toLowerCase();
|
|
1051
|
+
return {
|
|
1052
|
+
id,
|
|
1053
|
+
name: raw.name ?? id,
|
|
1054
|
+
country: raw.country ?? null,
|
|
1055
|
+
website: raw.website,
|
|
1056
|
+
recognise: raw.recognise ?? [],
|
|
1057
|
+
sides: { front: raw.sides?.front ?? [], back: raw.sides?.back ?? [] },
|
|
1058
|
+
pairing: raw.pairing ?? "content",
|
|
1059
|
+
orientation: raw.orientation ?? null,
|
|
1060
|
+
nutrition_basis: raw.nutrition_basis ?? "per_serving",
|
|
1061
|
+
default_servings: raw.default_servings ?? null,
|
|
1062
|
+
kitchen_section: raw.kitchen_section ?? null,
|
|
1063
|
+
plan_labels: raw.plan_labels ?? {},
|
|
1064
|
+
allergen_synonyms: raw.allergen_synonyms ?? {},
|
|
1065
|
+
brands: raw.brands ?? [],
|
|
1066
|
+
equipment_terms: raw.equipment_terms ?? {},
|
|
1067
|
+
ingredient_aliases: raw.ingredient_aliases ?? {},
|
|
1068
|
+
guide: fs7.existsSync(guideFile) ? fs7.readFileSync(guideFile, "utf8") : "",
|
|
1069
|
+
dir
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
var ProviderRegistry = class {
|
|
1073
|
+
profiles = /* @__PURE__ */ new Map();
|
|
1074
|
+
constructor(dirs) {
|
|
1075
|
+
for (const base of dirs) {
|
|
1076
|
+
if (!fs7.existsSync(base)) continue;
|
|
1077
|
+
for (const entry of fs7.readdirSync(base, { withFileTypes: true })) {
|
|
1078
|
+
if (!entry.isDirectory()) continue;
|
|
1079
|
+
const p = loadProfile(path4.join(base, entry.name));
|
|
1080
|
+
if (p) this.profiles.set(p.id, p);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
get(id) {
|
|
1085
|
+
return id ? this.profiles.get(id.toLowerCase()) : void 0;
|
|
1086
|
+
}
|
|
1087
|
+
/** The profile for `id`, or the generic one. */
|
|
1088
|
+
resolve(id) {
|
|
1089
|
+
const p = this.get(id) ?? this.profiles.get(GENERIC_PROVIDER);
|
|
1090
|
+
if (!p) throw new Error("no generic provider profile found");
|
|
1091
|
+
return p;
|
|
1092
|
+
}
|
|
1093
|
+
list() {
|
|
1094
|
+
return [...this.profiles.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
// src/storage/sqlite/adapter.ts
|
|
1099
|
+
import { createHash as createHash2 } from "crypto";
|
|
1100
|
+
import Database from "better-sqlite3";
|
|
1101
|
+
import * as sqliteVec from "sqlite-vec";
|
|
1102
|
+
|
|
1103
|
+
// src/storage/sqlite/schema.ts
|
|
1104
|
+
var SCHEMA_VERSION = 3;
|
|
1105
|
+
var ALL_TABLES = [
|
|
1106
|
+
"chunk_vec",
|
|
1107
|
+
"chunks_fts",
|
|
1108
|
+
"chunks_tri",
|
|
1109
|
+
"chunks",
|
|
1110
|
+
"step_ingredients",
|
|
1111
|
+
"steps",
|
|
1112
|
+
"recipe_tags",
|
|
1113
|
+
"tags",
|
|
1114
|
+
"recipe_ingredients",
|
|
1115
|
+
"pantry_items",
|
|
1116
|
+
"alias_tri",
|
|
1117
|
+
"ingredient_aliases",
|
|
1118
|
+
"ingredients",
|
|
1119
|
+
"ingest_items",
|
|
1120
|
+
"recipes"
|
|
1121
|
+
];
|
|
1122
|
+
var categoryList = ALL_CATEGORIES.map((c) => `'${c}'`).join(",");
|
|
1123
|
+
function schemaSql(dims) {
|
|
1124
|
+
return `
|
|
1125
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
1126
|
+
key TEXT PRIMARY KEY,
|
|
1127
|
+
value TEXT NOT NULL
|
|
1128
|
+
);
|
|
1129
|
+
|
|
1130
|
+
-- One row per recipe file. 'doc' holds the full record (minus ingredients/steps, which have
|
|
1131
|
+
-- their own tables); the other columns exist for filtering and sorting.
|
|
1132
|
+
CREATE TABLE IF NOT EXISTS recipes (
|
|
1133
|
+
id INTEGER PRIMARY KEY,
|
|
1134
|
+
slug TEXT NOT NULL UNIQUE,
|
|
1135
|
+
title TEXT NOT NULL,
|
|
1136
|
+
subtitle TEXT,
|
|
1137
|
+
servings INTEGER,
|
|
1138
|
+
active_min INTEGER,
|
|
1139
|
+
total_min INTEGER,
|
|
1140
|
+
source_kind TEXT NOT NULL CHECK (source_kind IN ('card','url','synthesized','manual')),
|
|
1141
|
+
provider_id TEXT,
|
|
1142
|
+
needs_review INTEGER NOT NULL DEFAULT 0,
|
|
1143
|
+
energy_kcal REAL,
|
|
1144
|
+
nutrition_basis TEXT,
|
|
1145
|
+
doc TEXT NOT NULL,
|
|
1146
|
+
file_path TEXT NOT NULL UNIQUE,
|
|
1147
|
+
file_mtime INTEGER NOT NULL,
|
|
1148
|
+
file_size INTEGER NOT NULL,
|
|
1149
|
+
content_hash TEXT NOT NULL,
|
|
1150
|
+
created_at TEXT NOT NULL,
|
|
1151
|
+
updated_at TEXT NOT NULL
|
|
1152
|
+
);
|
|
1153
|
+
CREATE INDEX IF NOT EXISTS recipes_total_min ON recipes(total_min);
|
|
1154
|
+
CREATE INDEX IF NOT EXISTS recipes_provider ON recipes(provider_id);
|
|
1155
|
+
|
|
1156
|
+
CREATE TABLE IF NOT EXISTS ingredients (
|
|
1157
|
+
id INTEGER PRIMARY KEY,
|
|
1158
|
+
canonical TEXT NOT NULL UNIQUE,
|
|
1159
|
+
is_staple INTEGER NOT NULL DEFAULT 0,
|
|
1160
|
+
category TEXT
|
|
1161
|
+
);
|
|
1162
|
+
|
|
1163
|
+
CREATE TABLE IF NOT EXISTS ingredient_aliases (
|
|
1164
|
+
id INTEGER PRIMARY KEY,
|
|
1165
|
+
alias TEXT NOT NULL UNIQUE,
|
|
1166
|
+
ingredient_id INTEGER NOT NULL REFERENCES ingredients(id) ON DELETE CASCADE
|
|
1167
|
+
);
|
|
1168
|
+
CREATE INDEX IF NOT EXISTS ingredient_aliases_ingredient ON ingredient_aliases(ingredient_id);
|
|
1169
|
+
|
|
1170
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS alias_tri USING fts5(
|
|
1171
|
+
alias, content='ingredient_aliases', content_rowid='id', tokenize='trigram'
|
|
1172
|
+
);
|
|
1173
|
+
CREATE TRIGGER IF NOT EXISTS alias_ai AFTER INSERT ON ingredient_aliases BEGIN
|
|
1174
|
+
INSERT INTO alias_tri(rowid, alias) VALUES (new.id, new.alias);
|
|
1175
|
+
END;
|
|
1176
|
+
CREATE TRIGGER IF NOT EXISTS alias_ad AFTER DELETE ON ingredient_aliases BEGIN
|
|
1177
|
+
INSERT INTO alias_tri(alias_tri, rowid, alias) VALUES ('delete', old.id, old.alias);
|
|
1178
|
+
END;
|
|
1179
|
+
CREATE TRIGGER IF NOT EXISTS alias_au AFTER UPDATE ON ingredient_aliases BEGIN
|
|
1180
|
+
INSERT INTO alias_tri(alias_tri, rowid, alias) VALUES ('delete', old.id, old.alias);
|
|
1181
|
+
INSERT INTO alias_tri(rowid, alias) VALUES (new.id, new.alias);
|
|
1182
|
+
END;
|
|
1183
|
+
|
|
1184
|
+
CREATE TABLE IF NOT EXISTS recipe_ingredients (
|
|
1185
|
+
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
1186
|
+
position INTEGER NOT NULL,
|
|
1187
|
+
ingredient_id INTEGER NOT NULL REFERENCES ingredients(id),
|
|
1188
|
+
raw TEXT NOT NULL,
|
|
1189
|
+
qty REAL,
|
|
1190
|
+
unit TEXT,
|
|
1191
|
+
prep TEXT,
|
|
1192
|
+
optional INTEGER NOT NULL DEFAULT 0,
|
|
1193
|
+
staple INTEGER NOT NULL DEFAULT 0,
|
|
1194
|
+
source TEXT,
|
|
1195
|
+
brand TEXT,
|
|
1196
|
+
grp TEXT,
|
|
1197
|
+
extra TEXT,
|
|
1198
|
+
PRIMARY KEY (recipe_id, position)
|
|
1199
|
+
);
|
|
1200
|
+
CREATE INDEX IF NOT EXISTS recipe_ingredients_ingredient ON recipe_ingredients(ingredient_id);
|
|
1201
|
+
|
|
1202
|
+
CREATE TABLE IF NOT EXISTS steps (
|
|
1203
|
+
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
1204
|
+
position INTEGER NOT NULL,
|
|
1205
|
+
title TEXT,
|
|
1206
|
+
text TEXT NOT NULL,
|
|
1207
|
+
techniques TEXT NOT NULL DEFAULT '[]',
|
|
1208
|
+
equipment TEXT NOT NULL DEFAULT '[]',
|
|
1209
|
+
uses TEXT NOT NULL DEFAULT '[]',
|
|
1210
|
+
temp_c REAL,
|
|
1211
|
+
minutes TEXT,
|
|
1212
|
+
PRIMARY KEY (recipe_id, position)
|
|
1213
|
+
);
|
|
1214
|
+
|
|
1215
|
+
-- Graph edges: which catalog ingredient each step uses.
|
|
1216
|
+
CREATE TABLE IF NOT EXISTS step_ingredients (
|
|
1217
|
+
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
1218
|
+
position INTEGER NOT NULL,
|
|
1219
|
+
ingredient_id INTEGER NOT NULL REFERENCES ingredients(id),
|
|
1220
|
+
PRIMARY KEY (recipe_id, position, ingredient_id)
|
|
1221
|
+
);
|
|
1222
|
+
CREATE INDEX IF NOT EXISTS step_ingredients_ingredient ON step_ingredients(ingredient_id);
|
|
1223
|
+
|
|
1224
|
+
-- Tags Claude assigns and facets derived from structured fields share one table, so every
|
|
1225
|
+
-- category is both a filter and a graph node type.
|
|
1226
|
+
CREATE TABLE IF NOT EXISTS tags (
|
|
1227
|
+
id INTEGER PRIMARY KEY,
|
|
1228
|
+
category TEXT NOT NULL CHECK (category IN (${categoryList})),
|
|
1229
|
+
name TEXT NOT NULL,
|
|
1230
|
+
UNIQUE (category, name)
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
CREATE TABLE IF NOT EXISTS recipe_tags (
|
|
1234
|
+
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
1235
|
+
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
1236
|
+
origin TEXT NOT NULL DEFAULT 'tag' CHECK (origin IN ('tag','derived')),
|
|
1237
|
+
PRIMARY KEY (recipe_id, tag_id)
|
|
1238
|
+
);
|
|
1239
|
+
CREATE INDEX IF NOT EXISTS recipe_tags_tag ON recipe_tags(tag_id);
|
|
1240
|
+
|
|
1241
|
+
CREATE TABLE IF NOT EXISTS pantry_items (
|
|
1242
|
+
ingredient_id INTEGER PRIMARY KEY REFERENCES ingredients(id) ON DELETE CASCADE,
|
|
1243
|
+
qty REAL,
|
|
1244
|
+
unit TEXT,
|
|
1245
|
+
note TEXT,
|
|
1246
|
+
added_at TEXT NOT NULL
|
|
1247
|
+
);
|
|
1248
|
+
|
|
1249
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
1250
|
+
id INTEGER PRIMARY KEY,
|
|
1251
|
+
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
1252
|
+
kind TEXT NOT NULL,
|
|
1253
|
+
text TEXT NOT NULL,
|
|
1254
|
+
hash TEXT NOT NULL,
|
|
1255
|
+
embed_pending INTEGER NOT NULL DEFAULT 1
|
|
1256
|
+
);
|
|
1257
|
+
CREATE INDEX IF NOT EXISTS chunks_recipe ON chunks(recipe_id);
|
|
1258
|
+
CREATE INDEX IF NOT EXISTS chunks_pending ON chunks(embed_pending);
|
|
1259
|
+
|
|
1260
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
|
|
1261
|
+
text, content='chunks', content_rowid='id', tokenize='porter unicode61 remove_diacritics 2'
|
|
1262
|
+
);
|
|
1263
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_tri USING fts5(
|
|
1264
|
+
text, content='chunks', content_rowid='id', tokenize='trigram'
|
|
1265
|
+
);
|
|
1266
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunk_vec USING vec0(
|
|
1267
|
+
chunk_id INTEGER PRIMARY KEY,
|
|
1268
|
+
embedding float[${dims}]
|
|
1269
|
+
);
|
|
1270
|
+
|
|
1271
|
+
CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
|
|
1272
|
+
INSERT INTO chunks_fts(rowid, text) VALUES (new.id, new.text);
|
|
1273
|
+
INSERT INTO chunks_tri(rowid, text) VALUES (new.id, new.text);
|
|
1274
|
+
END;
|
|
1275
|
+
CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
|
|
1276
|
+
INSERT INTO chunks_fts(chunks_fts, rowid, text) VALUES ('delete', old.id, old.text);
|
|
1277
|
+
INSERT INTO chunks_tri(chunks_tri, rowid, text) VALUES ('delete', old.id, old.text);
|
|
1278
|
+
DELETE FROM chunk_vec WHERE chunk_id = old.id;
|
|
1279
|
+
END;
|
|
1280
|
+
|
|
1281
|
+
-- Photo queue. An index over the file system: a file's folder is its status (inbox/ pending,
|
|
1282
|
+
-- inbox/skipped|failed/, images/<slug>/ done), so this table can always be rebuilt by a scan.
|
|
1283
|
+
CREATE TABLE IF NOT EXISTS ingest_items (
|
|
1284
|
+
id INTEGER PRIMARY KEY,
|
|
1285
|
+
sha256 TEXT NOT NULL UNIQUE,
|
|
1286
|
+
phash TEXT NOT NULL,
|
|
1287
|
+
path TEXT NOT NULL UNIQUE,
|
|
1288
|
+
original_name TEXT NOT NULL,
|
|
1289
|
+
mime TEXT NOT NULL,
|
|
1290
|
+
width INTEGER,
|
|
1291
|
+
height INTEGER,
|
|
1292
|
+
bytes INTEGER NOT NULL,
|
|
1293
|
+
mtime INTEGER NOT NULL,
|
|
1294
|
+
captured_at TEXT NOT NULL,
|
|
1295
|
+
status TEXT NOT NULL CHECK (status IN ('pending','done','skipped','failed')),
|
|
1296
|
+
recipe_slug TEXT,
|
|
1297
|
+
side TEXT,
|
|
1298
|
+
reason TEXT,
|
|
1299
|
+
created_at TEXT NOT NULL,
|
|
1300
|
+
updated_at TEXT NOT NULL
|
|
1301
|
+
);
|
|
1302
|
+
CREATE INDEX IF NOT EXISTS ingest_items_status ON ingest_items(status, captured_at);
|
|
1303
|
+
`;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// src/storage/sqlite/adapter.ts
|
|
1307
|
+
var sha = (s) => createHash2("sha256").update(s).digest("hex");
|
|
1308
|
+
var toRow = (r) => ({
|
|
1309
|
+
id: r.id,
|
|
1310
|
+
canonical: r.canonical,
|
|
1311
|
+
is_staple: r.is_staple === 1,
|
|
1312
|
+
category: r.category
|
|
1313
|
+
});
|
|
1314
|
+
function recipeChunks(recipe) {
|
|
1315
|
+
const tagNames = [...new Set(Object.values(recipe.tags).flat())].join(", ");
|
|
1316
|
+
const title = [
|
|
1317
|
+
recipe.title,
|
|
1318
|
+
recipe.subtitle ?? "",
|
|
1319
|
+
tagNames ? `Tags: ${tagNames}` : "",
|
|
1320
|
+
recipe.provider?.plan ? `Plan: ${recipe.provider.plan}` : ""
|
|
1321
|
+
].filter(Boolean).join(". ");
|
|
1322
|
+
const blurb = [recipe.blurb ?? "", recipe.description ?? ""].filter(Boolean).join(" ");
|
|
1323
|
+
const ingredients = `Ingredients: ${recipe.ingredients.map(
|
|
1324
|
+
(i) => i.components?.length ? `${i.raw} (${i.components.map((c) => c.raw).join(", ")})` : i.raw
|
|
1325
|
+
).join("; ")}`;
|
|
1326
|
+
const steps = [
|
|
1327
|
+
...recipe.steps.map((s) => s.title ? `${s.title}: ${s.text}` : s.text),
|
|
1328
|
+
...recipe.tips.map((t) => `Tip: ${t}`)
|
|
1329
|
+
].join(" ");
|
|
1330
|
+
return [
|
|
1331
|
+
{ kind: "title", text: title },
|
|
1332
|
+
{ kind: "blurb", text: blurb },
|
|
1333
|
+
{ kind: "ingredients", text: ingredients },
|
|
1334
|
+
{ kind: "steps", text: steps }
|
|
1335
|
+
].filter((c) => c.text.trim().length > 0);
|
|
1336
|
+
}
|
|
1337
|
+
function recipeFacets(recipe) {
|
|
1338
|
+
const out = [];
|
|
1339
|
+
const add = (cat, v) => {
|
|
1340
|
+
const t = v?.trim();
|
|
1341
|
+
if (t && !out.some(([c, n]) => c === cat && n === t)) out.push([cat, t]);
|
|
1342
|
+
};
|
|
1343
|
+
for (const a of recipe.allergens) add("allergen", a);
|
|
1344
|
+
for (const e of recipe.equipment) add("equipment", e);
|
|
1345
|
+
for (const s of recipe.steps) for (const t of s.techniques ?? []) add("technique", t);
|
|
1346
|
+
add("chef", recipe.provider?.chef);
|
|
1347
|
+
add("provider", recipe.provider?.id);
|
|
1348
|
+
add("plan", recipe.provider?.plan);
|
|
1349
|
+
return out;
|
|
1350
|
+
}
|
|
1351
|
+
var STOPWORDS = new Set(
|
|
1352
|
+
"a an and any are as at be but by can for from have i in into is it me my of on or our recipe recipes some something that the this to use using want what with without you".split(" ")
|
|
1353
|
+
);
|
|
1354
|
+
function queryWords(query) {
|
|
1355
|
+
return (query.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).filter((t) => !STOPWORDS.has(t));
|
|
1356
|
+
}
|
|
1357
|
+
function ftsTerms(query) {
|
|
1358
|
+
return queryWords(query).map((t) => `"${t}"`);
|
|
1359
|
+
}
|
|
1360
|
+
function vecBlob(v) {
|
|
1361
|
+
return Buffer.from(v.buffer, v.byteOffset, v.byteLength);
|
|
1362
|
+
}
|
|
1363
|
+
function dice2(a, b) {
|
|
1364
|
+
const grams = (s) => {
|
|
1365
|
+
const g = /* @__PURE__ */ new Map();
|
|
1366
|
+
const t = ` ${s.toLowerCase()} `;
|
|
1367
|
+
for (let i = 0; i < t.length - 1; i++) {
|
|
1368
|
+
const k = t.slice(i, i + 2);
|
|
1369
|
+
g.set(k, (g.get(k) ?? 0) + 1);
|
|
1370
|
+
}
|
|
1371
|
+
return g;
|
|
1372
|
+
};
|
|
1373
|
+
const ga = grams(a);
|
|
1374
|
+
const gb = grams(b);
|
|
1375
|
+
let overlap = 0;
|
|
1376
|
+
for (const [k, n] of ga) overlap += Math.min(n, gb.get(k) ?? 0);
|
|
1377
|
+
const total = [...ga.values()].reduce((x, y) => x + y, 0) + [...gb.values()].reduce((x, y) => x + y, 0);
|
|
1378
|
+
return total === 0 ? 0 : 2 * overlap / total;
|
|
1379
|
+
}
|
|
1380
|
+
var placeholders = (n) => Array.from({ length: n }, () => "?").join(",");
|
|
1381
|
+
var SqliteAdapter = class {
|
|
1382
|
+
db;
|
|
1383
|
+
cache = /* @__PURE__ */ new Map();
|
|
1384
|
+
dims;
|
|
1385
|
+
/** True when an older schema was found and dropped; the caller should rebuild from files. */
|
|
1386
|
+
wasReset;
|
|
1387
|
+
constructor(file, dims = EMBED_DIMS) {
|
|
1388
|
+
this.dims = dims;
|
|
1389
|
+
this.db = new Database(file);
|
|
1390
|
+
this.db.pragma("journal_mode = WAL");
|
|
1391
|
+
this.db.pragma("foreign_keys = ON");
|
|
1392
|
+
sqliteVec.load(this.db);
|
|
1393
|
+
this.wasReset = this.resetIfStale();
|
|
1394
|
+
this.db.exec(schemaSql(dims));
|
|
1395
|
+
this.setMeta("schema_version", String(SCHEMA_VERSION));
|
|
1396
|
+
this.setMeta("dims", String(dims));
|
|
1397
|
+
}
|
|
1398
|
+
resetIfStale() {
|
|
1399
|
+
const hasMeta = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get();
|
|
1400
|
+
if (!hasMeta) return false;
|
|
1401
|
+
const row = this.db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
1402
|
+
const dims = this.db.prepare("SELECT value FROM meta WHERE key = 'dims'").get();
|
|
1403
|
+
if (row?.value === String(SCHEMA_VERSION) && (!dims || dims.value === String(this.dims))) {
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
this.db.pragma("foreign_keys = OFF");
|
|
1407
|
+
this.db.transaction(() => {
|
|
1408
|
+
for (const t of ALL_TABLES) this.db.exec(`DROP TABLE IF EXISTS ${t}`);
|
|
1409
|
+
this.db.exec("DELETE FROM meta");
|
|
1410
|
+
})();
|
|
1411
|
+
this.db.pragma("foreign_keys = ON");
|
|
1412
|
+
return true;
|
|
1413
|
+
}
|
|
1414
|
+
s(sql) {
|
|
1415
|
+
let st = this.cache.get(sql);
|
|
1416
|
+
if (!st) {
|
|
1417
|
+
st = this.db.prepare(sql);
|
|
1418
|
+
this.cache.set(sql, st);
|
|
1419
|
+
}
|
|
1420
|
+
return st;
|
|
1421
|
+
}
|
|
1422
|
+
close() {
|
|
1423
|
+
this.db.close();
|
|
1424
|
+
}
|
|
1425
|
+
transaction(fn) {
|
|
1426
|
+
return this.db.transaction(fn)();
|
|
1427
|
+
}
|
|
1428
|
+
getMeta(key) {
|
|
1429
|
+
const row = this.s("SELECT value FROM meta WHERE key = ?").get(key);
|
|
1430
|
+
return row?.value;
|
|
1431
|
+
}
|
|
1432
|
+
setMeta(key, value) {
|
|
1433
|
+
this.s(
|
|
1434
|
+
"INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
1435
|
+
).run(key, value);
|
|
1436
|
+
}
|
|
1437
|
+
// ---------------------------------------------------------------- recipes
|
|
1438
|
+
upsertRecipe(recipe, file, ingredientIds) {
|
|
1439
|
+
return this.transaction(() => {
|
|
1440
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1441
|
+
const existing = this.s("SELECT id FROM recipes WHERE slug = ?").get(recipe.slug);
|
|
1442
|
+
const cols = {
|
|
1443
|
+
title: recipe.title,
|
|
1444
|
+
subtitle: recipe.subtitle ?? null,
|
|
1445
|
+
servings: recipe.servings ?? null,
|
|
1446
|
+
active_min: recipe.time.active ?? null,
|
|
1447
|
+
total_min: recipe.time.total ?? null,
|
|
1448
|
+
source_kind: recipe.source.kind,
|
|
1449
|
+
provider_id: recipe.provider?.id ?? null,
|
|
1450
|
+
needs_review: recipe.extraction?.needs_review ? 1 : 0,
|
|
1451
|
+
energy_kcal: recipe.nutrition?.energy_kcal ?? null,
|
|
1452
|
+
nutrition_basis: recipe.nutrition?.basis ?? null,
|
|
1453
|
+
doc: JSON.stringify(recipe),
|
|
1454
|
+
file_path: file.file_path,
|
|
1455
|
+
file_mtime: Math.round(file.file_mtime),
|
|
1456
|
+
file_size: file.file_size,
|
|
1457
|
+
content_hash: file.content_hash,
|
|
1458
|
+
updated_at: now
|
|
1459
|
+
};
|
|
1460
|
+
let id;
|
|
1461
|
+
const keep = /* @__PURE__ */ new Map();
|
|
1462
|
+
if (existing) {
|
|
1463
|
+
id = existing.id;
|
|
1464
|
+
for (const r of this.s(
|
|
1465
|
+
"SELECT c.hash, v.embedding FROM chunks c JOIN chunk_vec v ON v.chunk_id = c.id WHERE c.recipe_id = ?"
|
|
1466
|
+
).all(id)) {
|
|
1467
|
+
keep.set(r.hash, r.embedding);
|
|
1468
|
+
}
|
|
1469
|
+
const sets = Object.keys(cols).map((k) => `${k} = @${k}`).join(", ");
|
|
1470
|
+
this.s(`UPDATE recipes SET ${sets} WHERE id = @id`).run({ ...cols, id });
|
|
1471
|
+
for (const t of [
|
|
1472
|
+
"recipe_ingredients",
|
|
1473
|
+
"step_ingredients",
|
|
1474
|
+
"steps",
|
|
1475
|
+
"recipe_tags",
|
|
1476
|
+
"chunks"
|
|
1477
|
+
]) {
|
|
1478
|
+
this.s(`DELETE FROM ${t} WHERE recipe_id = ?`).run(id);
|
|
1479
|
+
}
|
|
1480
|
+
} else {
|
|
1481
|
+
const keys = Object.keys(cols);
|
|
1482
|
+
const info = this.s(
|
|
1483
|
+
`INSERT INTO recipes(slug, created_at, ${keys.join(", ")}) VALUES (@slug, @created_at, ${keys.map((k) => `@${k}`).join(", ")})`
|
|
1484
|
+
).run({ ...cols, slug: recipe.slug, created_at: now });
|
|
1485
|
+
id = Number(info.lastInsertRowid);
|
|
1486
|
+
}
|
|
1487
|
+
const insIng = this.s(
|
|
1488
|
+
`INSERT INTO recipe_ingredients(recipe_id, position, ingredient_id, raw, qty, unit, prep,
|
|
1489
|
+
optional, staple, source, brand, grp, extra) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1490
|
+
);
|
|
1491
|
+
recipe.ingredients.forEach((ing, i) => {
|
|
1492
|
+
const ingId = ingredientIds.get(ing.canonical);
|
|
1493
|
+
if (ingId === void 0) {
|
|
1494
|
+
throw new Error(`no ingredient id resolved for "${ing.canonical}"`);
|
|
1495
|
+
}
|
|
1496
|
+
const extra = ing.alternatives?.length || ing.components?.length ? JSON.stringify({ alternatives: ing.alternatives, components: ing.components }) : null;
|
|
1497
|
+
insIng.run(
|
|
1498
|
+
id,
|
|
1499
|
+
i,
|
|
1500
|
+
ingId,
|
|
1501
|
+
ing.raw,
|
|
1502
|
+
ing.qty ?? null,
|
|
1503
|
+
ing.unit ?? null,
|
|
1504
|
+
ing.prep ?? null,
|
|
1505
|
+
ing.optional ? 1 : 0,
|
|
1506
|
+
ing.staple ? 1 : 0,
|
|
1507
|
+
ing.source ?? null,
|
|
1508
|
+
ing.brand ?? null,
|
|
1509
|
+
ing.group ?? null,
|
|
1510
|
+
extra
|
|
1511
|
+
);
|
|
1512
|
+
});
|
|
1513
|
+
const insStep = this.s(
|
|
1514
|
+
`INSERT INTO steps(recipe_id, position, title, text, techniques, equipment, uses, temp_c, minutes)
|
|
1515
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1516
|
+
);
|
|
1517
|
+
const insUse = this.s(
|
|
1518
|
+
"INSERT OR IGNORE INTO step_ingredients(recipe_id, position, ingredient_id) VALUES (?, ?, ?)"
|
|
1519
|
+
);
|
|
1520
|
+
recipe.steps.forEach((step, i) => {
|
|
1521
|
+
insStep.run(
|
|
1522
|
+
id,
|
|
1523
|
+
i,
|
|
1524
|
+
step.title ?? null,
|
|
1525
|
+
step.text,
|
|
1526
|
+
JSON.stringify(step.techniques ?? []),
|
|
1527
|
+
JSON.stringify(step.equipment ?? []),
|
|
1528
|
+
JSON.stringify(step.uses ?? []),
|
|
1529
|
+
step.temp_c ?? null,
|
|
1530
|
+
step.minutes ? JSON.stringify(step.minutes) : null
|
|
1531
|
+
);
|
|
1532
|
+
for (const use of step.uses ?? []) {
|
|
1533
|
+
const ingId = ingredientIds.get(use);
|
|
1534
|
+
if (ingId !== void 0) insUse.run(id, i, ingId);
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
const insTag = this.s("INSERT OR IGNORE INTO tags(category, name) VALUES (?, ?)");
|
|
1538
|
+
const getTag = this.s("SELECT id FROM tags WHERE category = ? AND name = ?");
|
|
1539
|
+
const insRT = this.s(
|
|
1540
|
+
"INSERT OR IGNORE INTO recipe_tags(recipe_id, tag_id, origin) VALUES (?, ?, ?)"
|
|
1541
|
+
);
|
|
1542
|
+
const link = (category, name, origin) => {
|
|
1543
|
+
insTag.run(category, name);
|
|
1544
|
+
const t = getTag.get(category, name);
|
|
1545
|
+
insRT.run(id, t.id, origin);
|
|
1546
|
+
};
|
|
1547
|
+
for (const [category, names] of Object.entries(recipe.tags)) {
|
|
1548
|
+
for (const name of names ?? []) link(category, name, "tag");
|
|
1549
|
+
}
|
|
1550
|
+
for (const [category, name] of recipeFacets(recipe)) link(category, name, "derived");
|
|
1551
|
+
const insChunk = this.s(
|
|
1552
|
+
"INSERT INTO chunks(recipe_id, kind, text, hash, embed_pending) VALUES (?, ?, ?, ?, ?)"
|
|
1553
|
+
);
|
|
1554
|
+
const insVec = this.s("INSERT INTO chunk_vec(chunk_id, embedding) VALUES (?, ?)");
|
|
1555
|
+
for (const c of recipeChunks(recipe)) {
|
|
1556
|
+
const h = sha(c.text);
|
|
1557
|
+
const kept = keep.get(h);
|
|
1558
|
+
const info = insChunk.run(id, c.kind, c.text, h, kept ? 0 : 1);
|
|
1559
|
+
if (kept) insVec.run(BigInt(info.lastInsertRowid), kept);
|
|
1560
|
+
}
|
|
1561
|
+
return id;
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
rowToRecord(row) {
|
|
1565
|
+
const doc = JSON.parse(row.doc);
|
|
1566
|
+
return {
|
|
1567
|
+
...doc,
|
|
1568
|
+
id: row.id,
|
|
1569
|
+
file_path: row.file_path,
|
|
1570
|
+
file_mtime: row.file_mtime,
|
|
1571
|
+
file_size: row.file_size,
|
|
1572
|
+
content_hash: row.content_hash,
|
|
1573
|
+
created_at: row.created_at,
|
|
1574
|
+
updated_at: row.updated_at
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
getRecipe(slug) {
|
|
1578
|
+
const row = this.s("SELECT * FROM recipes WHERE slug = ?").get(slug);
|
|
1579
|
+
return row ? this.rowToRecord(row) : void 0;
|
|
1580
|
+
}
|
|
1581
|
+
rowToSummary(row) {
|
|
1582
|
+
const doc = JSON.parse(row.doc);
|
|
1583
|
+
const s = {
|
|
1584
|
+
id: row.id,
|
|
1585
|
+
slug: row.slug,
|
|
1586
|
+
title: row.title,
|
|
1587
|
+
total_min: row.total_min,
|
|
1588
|
+
active_min: row.active_min,
|
|
1589
|
+
servings: row.servings,
|
|
1590
|
+
source_kind: row.source_kind,
|
|
1591
|
+
provider_id: row.provider_id,
|
|
1592
|
+
needs_review: row.needs_review === 1,
|
|
1593
|
+
tags: doc.tags,
|
|
1594
|
+
allergens: doc.allergens ?? [],
|
|
1595
|
+
images: doc.images
|
|
1596
|
+
};
|
|
1597
|
+
if (doc.subtitle) s.subtitle = doc.subtitle;
|
|
1598
|
+
if (doc.blurb) s.blurb = doc.blurb;
|
|
1599
|
+
return s;
|
|
1600
|
+
}
|
|
1601
|
+
filterSql(filters) {
|
|
1602
|
+
const where = [];
|
|
1603
|
+
const params = [];
|
|
1604
|
+
if (!filters) return { where: "", params };
|
|
1605
|
+
if (filters.max_total_min !== void 0) {
|
|
1606
|
+
where.push("total_min IS NOT NULL AND total_min <= ?");
|
|
1607
|
+
params.push(filters.max_total_min);
|
|
1608
|
+
}
|
|
1609
|
+
if (filters.max_active_min !== void 0) {
|
|
1610
|
+
where.push(
|
|
1611
|
+
"COALESCE(active_min, total_min) IS NOT NULL AND COALESCE(active_min, total_min) <= ?"
|
|
1612
|
+
);
|
|
1613
|
+
params.push(filters.max_active_min);
|
|
1614
|
+
}
|
|
1615
|
+
if (filters.source_kind) {
|
|
1616
|
+
where.push("source_kind = ?");
|
|
1617
|
+
params.push(filters.source_kind);
|
|
1618
|
+
}
|
|
1619
|
+
if (filters.provider) {
|
|
1620
|
+
where.push("provider_id = ?");
|
|
1621
|
+
params.push(filters.provider.toLowerCase());
|
|
1622
|
+
}
|
|
1623
|
+
if (filters.needs_review !== void 0) {
|
|
1624
|
+
where.push("needs_review = ?");
|
|
1625
|
+
params.push(filters.needs_review ? 1 : 0);
|
|
1626
|
+
}
|
|
1627
|
+
for (const [category, names] of Object.entries(filters.tags ?? {})) {
|
|
1628
|
+
if (!names || names.length === 0) continue;
|
|
1629
|
+
where.push(
|
|
1630
|
+
`id IN (SELECT rt.recipe_id FROM recipe_tags rt JOIN tags t ON t.id = rt.tag_id
|
|
1631
|
+
WHERE t.category = ? AND lower(t.name) IN (${placeholders(names.length)}))`
|
|
1632
|
+
);
|
|
1633
|
+
params.push(category, ...names.map((n) => n.toLowerCase().trim()));
|
|
1634
|
+
}
|
|
1635
|
+
if (filters.exclude_allergens?.length) {
|
|
1636
|
+
where.push(
|
|
1637
|
+
`id NOT IN (SELECT rt.recipe_id FROM recipe_tags rt JOIN tags t ON t.id = rt.tag_id
|
|
1638
|
+
WHERE t.category = 'allergen' AND t.name IN (${placeholders(filters.exclude_allergens.length)}))`
|
|
1639
|
+
);
|
|
1640
|
+
params.push(...filters.exclude_allergens);
|
|
1641
|
+
}
|
|
1642
|
+
if (filters.exclude_ingredients?.length) {
|
|
1643
|
+
where.push(
|
|
1644
|
+
`id NOT IN (SELECT ri.recipe_id FROM recipe_ingredients ri
|
|
1645
|
+
JOIN ingredient_aliases a ON a.ingredient_id = ri.ingredient_id
|
|
1646
|
+
WHERE a.alias IN (${placeholders(filters.exclude_ingredients.length)}))`
|
|
1647
|
+
);
|
|
1648
|
+
params.push(...filters.exclude_ingredients.map((n) => n.toLowerCase().trim()));
|
|
1649
|
+
}
|
|
1650
|
+
return { where: where.length ? `WHERE ${where.join(" AND ")}` : "", params };
|
|
1651
|
+
}
|
|
1652
|
+
listRecipes(opts) {
|
|
1653
|
+
const { where, params } = this.filterSql(opts.filters);
|
|
1654
|
+
const rows = this.s(
|
|
1655
|
+
`SELECT * FROM recipes ${where} ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?`
|
|
1656
|
+
).all(...params, opts.limit ?? 50, opts.offset ?? 0);
|
|
1657
|
+
return rows.map((r) => this.rowToSummary(r));
|
|
1658
|
+
}
|
|
1659
|
+
summariesByIds(ids) {
|
|
1660
|
+
if (ids.length === 0) return [];
|
|
1661
|
+
const rows = this.s(`SELECT * FROM recipes WHERE id IN (${placeholders(ids.length)})`).all(
|
|
1662
|
+
...ids
|
|
1663
|
+
);
|
|
1664
|
+
const byId = new Map(rows.map((r) => [r.id, this.rowToSummary(r)]));
|
|
1665
|
+
return ids.map((id) => byId.get(id)).filter((x) => x !== void 0);
|
|
1666
|
+
}
|
|
1667
|
+
deleteRecipe(slug) {
|
|
1668
|
+
this.s("DELETE FROM recipes WHERE slug = ?").run(slug);
|
|
1669
|
+
}
|
|
1670
|
+
recipeFileIndex() {
|
|
1671
|
+
const rows = this.s(
|
|
1672
|
+
"SELECT slug, file_path, file_mtime, file_size, content_hash FROM recipes"
|
|
1673
|
+
).all();
|
|
1674
|
+
return new Map(rows.map((r) => [r.file_path, r]));
|
|
1675
|
+
}
|
|
1676
|
+
touchFile(file_path, mtime2, size) {
|
|
1677
|
+
this.s("UPDATE recipes SET file_mtime = ?, file_size = ? WHERE file_path = ?").run(
|
|
1678
|
+
Math.round(mtime2),
|
|
1679
|
+
size,
|
|
1680
|
+
file_path
|
|
1681
|
+
);
|
|
1682
|
+
}
|
|
1683
|
+
filterRecipeIds(filters) {
|
|
1684
|
+
const { where, params } = this.filterSql(filters);
|
|
1685
|
+
if (!where) return null;
|
|
1686
|
+
const rows = this.s(`SELECT id FROM recipes ${where}`).all(...params);
|
|
1687
|
+
return new Set(rows.map((r) => r.id));
|
|
1688
|
+
}
|
|
1689
|
+
findDuplicates(title, canonicals, excludeSlug) {
|
|
1690
|
+
const out = [];
|
|
1691
|
+
const titles = this.s("SELECT id, slug, title FROM recipes").all();
|
|
1692
|
+
for (const t of titles) {
|
|
1693
|
+
if (t.slug === excludeSlug) continue;
|
|
1694
|
+
const score = dice2(title, t.title);
|
|
1695
|
+
if (score >= 0.9) out.push({ slug: t.slug, title: t.title, reason: "title", score });
|
|
1696
|
+
}
|
|
1697
|
+
const names = [...new Set(canonicals.map((c) => c.toLowerCase().trim()))];
|
|
1698
|
+
if (names.length >= 3) {
|
|
1699
|
+
const overlaps = this.s(
|
|
1700
|
+
`SELECT ri.recipe_id, COUNT(DISTINCT ri.ingredient_id) AS overlap,
|
|
1701
|
+
(SELECT COUNT(DISTINCT r2.ingredient_id) FROM recipe_ingredients r2
|
|
1702
|
+
JOIN ingredients i2 ON i2.id = r2.ingredient_id
|
|
1703
|
+
WHERE r2.recipe_id = ri.recipe_id AND i2.is_staple = 0) AS total
|
|
1704
|
+
FROM recipe_ingredients ri JOIN ingredients i ON i.id = ri.ingredient_id
|
|
1705
|
+
WHERE i.is_staple = 0 AND i.canonical IN (${placeholders(names.length)})
|
|
1706
|
+
GROUP BY ri.recipe_id`
|
|
1707
|
+
).all(...names);
|
|
1708
|
+
const staples = new Set(
|
|
1709
|
+
this.s("SELECT canonical FROM ingredients WHERE is_staple = 1").all().map((r) => r.canonical)
|
|
1710
|
+
);
|
|
1711
|
+
const mineNonStaple = names.filter((n) => !staples.has(n)).length;
|
|
1712
|
+
for (const o of overlaps) {
|
|
1713
|
+
const union = mineNonStaple + o.total - o.overlap;
|
|
1714
|
+
const jaccard = union === 0 ? 0 : o.overlap / union;
|
|
1715
|
+
if (jaccard >= 0.8) {
|
|
1716
|
+
const t = titles.find((x) => x.id === o.recipe_id);
|
|
1717
|
+
if (t && t.slug !== excludeSlug && !out.some((d) => d.slug === t.slug)) {
|
|
1718
|
+
out.push({ slug: t.slug, title: t.title, reason: "ingredients", score: jaccard });
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
return out.sort((a, b) => b.score - a.score);
|
|
1724
|
+
}
|
|
1725
|
+
// ------------------------------------------------------------ ingredients
|
|
1726
|
+
getIngredient(nameOrAlias) {
|
|
1727
|
+
const n = nameOrAlias.toLowerCase().trim();
|
|
1728
|
+
const row = this.s(
|
|
1729
|
+
`SELECT i.* FROM ingredients i WHERE i.canonical = ?
|
|
1730
|
+
UNION ALL
|
|
1731
|
+
SELECT i.* FROM ingredient_aliases a JOIN ingredients i ON i.id = a.ingredient_id WHERE a.alias = ?
|
|
1732
|
+
LIMIT 1`
|
|
1733
|
+
).get(n, n);
|
|
1734
|
+
return row ? toRow(row) : void 0;
|
|
1735
|
+
}
|
|
1736
|
+
createIngredient(entry) {
|
|
1737
|
+
return this.transaction(() => {
|
|
1738
|
+
const canonical = entry.canonical.toLowerCase().trim();
|
|
1739
|
+
const info = this.s(
|
|
1740
|
+
"INSERT INTO ingredients(canonical, is_staple, category) VALUES (?, ?, ?)"
|
|
1741
|
+
).run(canonical, entry.staple ? 1 : 0, entry.category ?? null);
|
|
1742
|
+
const id = Number(info.lastInsertRowid);
|
|
1743
|
+
this.addAlias(id, canonical);
|
|
1744
|
+
for (const a of entry.aliases ?? []) this.addAlias(id, a);
|
|
1745
|
+
return { id, canonical, is_staple: !!entry.staple, category: entry.category ?? null };
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
addAlias(ingredientId, alias) {
|
|
1749
|
+
this.s("INSERT OR IGNORE INTO ingredient_aliases(alias, ingredient_id) VALUES (?, ?)").run(
|
|
1750
|
+
alias.toLowerCase().trim(),
|
|
1751
|
+
ingredientId
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
upsertCatalogEntry(entry) {
|
|
1755
|
+
return this.transaction(() => {
|
|
1756
|
+
const existing = this.getIngredient(entry.canonical);
|
|
1757
|
+
if (!existing) return this.createIngredient(entry);
|
|
1758
|
+
if (entry.staple !== void 0 || entry.category !== void 0) {
|
|
1759
|
+
this.s("UPDATE ingredients SET is_staple = ?, category = ? WHERE id = ?").run(
|
|
1760
|
+
entry.staple === void 0 ? existing.is_staple ? 1 : 0 : entry.staple ? 1 : 0,
|
|
1761
|
+
entry.category === void 0 ? existing.category : entry.category,
|
|
1762
|
+
existing.id
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
for (const a of entry.aliases ?? []) this.addAlias(existing.id, a);
|
|
1766
|
+
return this.getIngredient(entry.canonical) ?? existing;
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
fuzzyIngredients(name, limit) {
|
|
1770
|
+
const n = name.toLowerCase().trim();
|
|
1771
|
+
if (n.length < 3) return [];
|
|
1772
|
+
let rows = [];
|
|
1773
|
+
try {
|
|
1774
|
+
rows = this.s(
|
|
1775
|
+
`SELECT i.*, a.alias FROM alias_tri t
|
|
1776
|
+
JOIN ingredient_aliases a ON a.id = t.rowid
|
|
1777
|
+
JOIN ingredients i ON i.id = a.ingredient_id
|
|
1778
|
+
WHERE alias_tri MATCH ? ORDER BY t.rank LIMIT ?`
|
|
1779
|
+
).all(`"${n.replace(/"/g, '""')}"`, limit * 3);
|
|
1780
|
+
} catch {
|
|
1781
|
+
rows = [];
|
|
1782
|
+
}
|
|
1783
|
+
return rows.map((r) => ({ row: toRow(r), alias: r.alias, score: dice2(n, r.alias) })).sort((a, b) => b.score - a.score).slice(0, limit).map(({ row, alias }) => ({ row, alias }));
|
|
1784
|
+
}
|
|
1785
|
+
listIngredients(opts) {
|
|
1786
|
+
const q = opts.query?.toLowerCase().trim();
|
|
1787
|
+
const rows = this.s(
|
|
1788
|
+
`SELECT i.*, (SELECT COUNT(DISTINCT recipe_id) FROM recipe_ingredients ri WHERE ri.ingredient_id = i.id) AS recipe_count
|
|
1789
|
+
FROM ingredients i
|
|
1790
|
+
${q ? "WHERE i.id IN (SELECT ingredient_id FROM ingredient_aliases WHERE alias LIKE ?)" : ""}
|
|
1791
|
+
ORDER BY recipe_count DESC, i.canonical LIMIT ?`
|
|
1792
|
+
).all(...q ? [`%${q}%`] : [], opts.limit ?? 100);
|
|
1793
|
+
return rows.map((r) => ({ ...toRow(r), recipe_count: r.recipe_count }));
|
|
1794
|
+
}
|
|
1795
|
+
// ------------------------------------------------------------------- tags
|
|
1796
|
+
listTags(category) {
|
|
1797
|
+
return this.s(
|
|
1798
|
+
`SELECT t.category, t.name, COUNT(rt.recipe_id) AS count
|
|
1799
|
+
FROM tags t JOIN recipe_tags rt ON rt.tag_id = t.id
|
|
1800
|
+
${category ? "WHERE t.category = ?" : ""}
|
|
1801
|
+
GROUP BY t.id ORDER BY t.category, count DESC, t.name`
|
|
1802
|
+
).all(...category ? [category] : []);
|
|
1803
|
+
}
|
|
1804
|
+
// ----------------------------------------------------------------- search
|
|
1805
|
+
chunkHits(sql, ...params) {
|
|
1806
|
+
const rows = this.s(sql).all(...params);
|
|
1807
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1808
|
+
const out = [];
|
|
1809
|
+
for (const r of rows) {
|
|
1810
|
+
if (seen.has(r.recipe_id)) continue;
|
|
1811
|
+
seen.add(r.recipe_id);
|
|
1812
|
+
out.push({ recipe_id: r.recipe_id, slug: r.slug, chunk_kind: r.kind });
|
|
1813
|
+
}
|
|
1814
|
+
return out;
|
|
1815
|
+
}
|
|
1816
|
+
ftsHits(table, match, limit) {
|
|
1817
|
+
try {
|
|
1818
|
+
return this.chunkHits(
|
|
1819
|
+
`SELECT c.recipe_id, r.slug, c.kind
|
|
1820
|
+
FROM (SELECT rowid, rank FROM ${table} WHERE ${table} MATCH ? ORDER BY rank LIMIT ?) f
|
|
1821
|
+
JOIN chunks c ON c.id = f.rowid JOIN recipes r ON r.id = c.recipe_id
|
|
1822
|
+
ORDER BY f.rank`,
|
|
1823
|
+
match,
|
|
1824
|
+
limit * 3
|
|
1825
|
+
);
|
|
1826
|
+
} catch {
|
|
1827
|
+
return [];
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
searchLexical(query, limit) {
|
|
1831
|
+
const terms = ftsTerms(query);
|
|
1832
|
+
if (terms.length === 0) return [];
|
|
1833
|
+
const strict = this.ftsHits("chunks_fts", terms.join(" "), limit);
|
|
1834
|
+
if (strict.length > 0 || terms.length === 1) return strict;
|
|
1835
|
+
return this.ftsHits("chunks_fts", terms.join(" OR "), limit);
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* Typo-tolerant pass: a quoted term in a trigram index only matches as an exact substring,
|
|
1839
|
+
* so split each term into its trigrams and OR them; bm25 then rewards the most overlap.
|
|
1840
|
+
*/
|
|
1841
|
+
searchTrigram(query, limit) {
|
|
1842
|
+
const grams = /* @__PURE__ */ new Set();
|
|
1843
|
+
for (const term of queryWords(query).filter((t) => t.length >= 4)) {
|
|
1844
|
+
for (let i = 0; i + 3 <= term.length; i++) grams.add(`"${term.slice(i, i + 3)}"`);
|
|
1845
|
+
}
|
|
1846
|
+
if (grams.size === 0) return [];
|
|
1847
|
+
return this.ftsHits("chunks_tri", [...grams].join(" OR "), limit);
|
|
1848
|
+
}
|
|
1849
|
+
searchVector(embedding, limit) {
|
|
1850
|
+
return this.chunkHits(
|
|
1851
|
+
`SELECT c.recipe_id, r.slug, c.kind
|
|
1852
|
+
FROM (SELECT chunk_id, distance FROM chunk_vec WHERE embedding MATCH ? AND k = ? ORDER BY distance) v
|
|
1853
|
+
JOIN chunks c ON c.id = v.chunk_id JOIN recipes r ON r.id = c.recipe_id
|
|
1854
|
+
ORDER BY v.distance`,
|
|
1855
|
+
vecBlob(embedding),
|
|
1856
|
+
limit * 3
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1859
|
+
// ------------------------------------------------------------- embeddings
|
|
1860
|
+
pendingChunks(limit) {
|
|
1861
|
+
return this.s("SELECT id, text FROM chunks WHERE embed_pending = 1 ORDER BY id LIMIT ?").all(
|
|
1862
|
+
limit
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
storeEmbeddings(rows) {
|
|
1866
|
+
this.transaction(() => {
|
|
1867
|
+
const del = this.s("DELETE FROM chunk_vec WHERE chunk_id = ?");
|
|
1868
|
+
const ins = this.s("INSERT INTO chunk_vec(chunk_id, embedding) VALUES (?, ?)");
|
|
1869
|
+
const done = this.s("UPDATE chunks SET embed_pending = 0 WHERE id = ?");
|
|
1870
|
+
for (const r of rows) {
|
|
1871
|
+
del.run(r.id);
|
|
1872
|
+
ins.run(BigInt(r.id), vecBlob(r.embedding));
|
|
1873
|
+
done.run(r.id);
|
|
1874
|
+
}
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
resetVectors() {
|
|
1878
|
+
this.transaction(() => {
|
|
1879
|
+
this.db.exec("DELETE FROM chunk_vec");
|
|
1880
|
+
this.db.exec("UPDATE chunks SET embed_pending = 1");
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
stats() {
|
|
1884
|
+
const one = (sql) => this.s(sql).get().n;
|
|
1885
|
+
return {
|
|
1886
|
+
recipes: one("SELECT COUNT(*) n FROM recipes"),
|
|
1887
|
+
ingredients: one("SELECT COUNT(*) n FROM ingredients"),
|
|
1888
|
+
tags: one("SELECT COUNT(*) n FROM tags"),
|
|
1889
|
+
chunks: one("SELECT COUNT(*) n FROM chunks"),
|
|
1890
|
+
chunks_pending: one("SELECT COUNT(*) n FROM chunks WHERE embed_pending = 1"),
|
|
1891
|
+
vectors: one("SELECT COUNT(*) n FROM chunk_vec"),
|
|
1892
|
+
needs_review: one("SELECT COUNT(*) n FROM recipes WHERE needs_review = 1")
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
// ----------------------------------------------------------------- pantry
|
|
1896
|
+
pantryList() {
|
|
1897
|
+
return this.s(
|
|
1898
|
+
`SELECT p.ingredient_id, i.canonical, p.qty, p.unit, p.note, p.added_at
|
|
1899
|
+
FROM pantry_items p JOIN ingredients i ON i.id = p.ingredient_id ORDER BY i.canonical`
|
|
1900
|
+
).all();
|
|
1901
|
+
}
|
|
1902
|
+
pantryUpsert(ingredientId, fields) {
|
|
1903
|
+
this.s(
|
|
1904
|
+
`INSERT INTO pantry_items(ingredient_id, qty, unit, note, added_at) VALUES (@id, @qty, @unit, @note, @now)
|
|
1905
|
+
ON CONFLICT(ingredient_id) DO UPDATE SET
|
|
1906
|
+
qty = COALESCE(excluded.qty, qty), unit = COALESCE(excluded.unit, unit), note = COALESCE(excluded.note, note)`
|
|
1907
|
+
).run({
|
|
1908
|
+
id: ingredientId,
|
|
1909
|
+
qty: fields.qty ?? null,
|
|
1910
|
+
unit: fields.unit ?? null,
|
|
1911
|
+
note: fields.note ?? null,
|
|
1912
|
+
now: (/* @__PURE__ */ new Date()).toISOString()
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
pantryRemove(ingredientIds) {
|
|
1916
|
+
if (ingredientIds.length === 0) return 0;
|
|
1917
|
+
return this.s(
|
|
1918
|
+
`DELETE FROM pantry_items WHERE ingredient_id IN (${placeholders(ingredientIds.length)})`
|
|
1919
|
+
).run(...ingredientIds).changes;
|
|
1920
|
+
}
|
|
1921
|
+
pantryClear() {
|
|
1922
|
+
return this.s("DELETE FROM pantry_items").run().changes;
|
|
1923
|
+
}
|
|
1924
|
+
recipeRequirements(recipeIds) {
|
|
1925
|
+
const where = recipeIds ? `WHERE ri.recipe_id IN (${placeholders(recipeIds.length)})` : "";
|
|
1926
|
+
if (recipeIds && recipeIds.length === 0) return [];
|
|
1927
|
+
const rows = this.s(
|
|
1928
|
+
`SELECT ri.recipe_id, r.slug, r.title, ri.ingredient_id, i.canonical, ri.raw,
|
|
1929
|
+
(ri.staple OR i.is_staple) AS staple, ri.optional, ri.extra
|
|
1930
|
+
FROM recipe_ingredients ri
|
|
1931
|
+
JOIN ingredients i ON i.id = ri.ingredient_id
|
|
1932
|
+
JOIN recipes r ON r.id = ri.recipe_id
|
|
1933
|
+
${where} ORDER BY ri.recipe_id, ri.position`
|
|
1934
|
+
).all(...recipeIds ?? []);
|
|
1935
|
+
return rows.map((r) => ({ ...r, staple: r.staple === 1, optional: r.optional === 1 }));
|
|
1936
|
+
}
|
|
1937
|
+
recipesUsingIngredient(ingredientId) {
|
|
1938
|
+
return this.s(
|
|
1939
|
+
`SELECT DISTINCT r.slug FROM recipes r WHERE r.id IN (
|
|
1940
|
+
SELECT recipe_id FROM recipe_ingredients WHERE ingredient_id = ?
|
|
1941
|
+
UNION SELECT recipe_id FROM step_ingredients WHERE ingredient_id = ?)
|
|
1942
|
+
ORDER BY r.slug`
|
|
1943
|
+
).all(ingredientId, ingredientId).map((r) => r.slug);
|
|
1944
|
+
}
|
|
1945
|
+
mergeIngredientRows(fromId, intoId) {
|
|
1946
|
+
this.transaction(() => {
|
|
1947
|
+
this.s(
|
|
1948
|
+
"UPDATE OR IGNORE ingredient_aliases SET ingredient_id = ? WHERE ingredient_id = ?"
|
|
1949
|
+
).run(intoId, fromId);
|
|
1950
|
+
const pantry = this.s("SELECT * FROM pantry_items WHERE ingredient_id = ?").get(fromId);
|
|
1951
|
+
if (pantry) {
|
|
1952
|
+
this.s(
|
|
1953
|
+
"INSERT OR IGNORE INTO pantry_items(ingredient_id, qty, unit, note, added_at) SELECT ?, qty, unit, note, added_at FROM pantry_items WHERE ingredient_id = ?"
|
|
1954
|
+
).run(intoId, fromId);
|
|
1955
|
+
}
|
|
1956
|
+
this.s(
|
|
1957
|
+
"UPDATE OR IGNORE recipe_ingredients SET ingredient_id = ? WHERE ingredient_id = ?"
|
|
1958
|
+
).run(intoId, fromId);
|
|
1959
|
+
this.s("UPDATE OR IGNORE step_ingredients SET ingredient_id = ? WHERE ingredient_id = ?").run(
|
|
1960
|
+
intoId,
|
|
1961
|
+
fromId
|
|
1962
|
+
);
|
|
1963
|
+
this.s("DELETE FROM step_ingredients WHERE ingredient_id = ?").run(fromId);
|
|
1964
|
+
this.s("DELETE FROM pantry_items WHERE ingredient_id = ?").run(fromId);
|
|
1965
|
+
this.s("DELETE FROM ingredients WHERE id = ?").run(fromId);
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
// ------------------------------------------------------------------ graph
|
|
1969
|
+
graphRows() {
|
|
1970
|
+
const recipes = this.s("SELECT id, slug, title, doc FROM recipes ORDER BY id").all();
|
|
1971
|
+
const uses = this.s(
|
|
1972
|
+
`SELECT DISTINCT ri.recipe_id, ri.ingredient_id, i.canonical,
|
|
1973
|
+
MAX(ri.staple OR i.is_staple) AS staple, i.category
|
|
1974
|
+
FROM recipe_ingredients ri JOIN ingredients i ON i.id = ri.ingredient_id
|
|
1975
|
+
GROUP BY ri.recipe_id, ri.ingredient_id`
|
|
1976
|
+
).all().map((u) => ({ ...u, staple: u.staple === 1 }));
|
|
1977
|
+
const tags = this.s(
|
|
1978
|
+
`SELECT rt.recipe_id, t.category, t.name FROM recipe_tags rt JOIN tags t ON t.id = rt.tag_id`
|
|
1979
|
+
).all();
|
|
1980
|
+
return { recipes, uses, tags };
|
|
1981
|
+
}
|
|
1982
|
+
similarRecipes(recipeId, k) {
|
|
1983
|
+
const own = this.s(
|
|
1984
|
+
`SELECT v.embedding FROM chunks c JOIN chunk_vec v ON v.chunk_id = c.id
|
|
1985
|
+
WHERE c.recipe_id = ? AND c.kind IN ('blurb', 'title') ORDER BY c.kind = 'blurb' DESC LIMIT 1`
|
|
1986
|
+
).get(recipeId);
|
|
1987
|
+
if (!own) return [];
|
|
1988
|
+
const rows = this.s(
|
|
1989
|
+
`SELECT c.recipe_id, MIN(v.distance) AS distance
|
|
1990
|
+
FROM (SELECT chunk_id, distance FROM chunk_vec WHERE embedding MATCH ? AND k = ? ORDER BY distance) v
|
|
1991
|
+
JOIN chunks c ON c.id = v.chunk_id
|
|
1992
|
+
WHERE c.recipe_id != ? AND c.kind IN ('blurb', 'title')
|
|
1993
|
+
GROUP BY c.recipe_id ORDER BY distance LIMIT ?`
|
|
1994
|
+
).all(own.embedding, k * 6, recipeId, k);
|
|
1995
|
+
return rows;
|
|
1996
|
+
}
|
|
1997
|
+
// ------------------------------------------------------------ photo queue
|
|
1998
|
+
ingestByPath(path9) {
|
|
1999
|
+
return this.s("SELECT * FROM ingest_items WHERE path = ?").get(path9);
|
|
2000
|
+
}
|
|
2001
|
+
ingestBySha(sha256) {
|
|
2002
|
+
return this.s("SELECT * FROM ingest_items WHERE sha256 = ?").get(sha256);
|
|
2003
|
+
}
|
|
2004
|
+
ingestByIds(ids) {
|
|
2005
|
+
if (ids.length === 0) return [];
|
|
2006
|
+
const rows = this.s(`SELECT * FROM ingest_items WHERE id IN (${placeholders(ids.length)})`).all(
|
|
2007
|
+
...ids
|
|
2008
|
+
);
|
|
2009
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
2010
|
+
return ids.map((id) => byId.get(id)).filter((r) => r !== void 0);
|
|
2011
|
+
}
|
|
2012
|
+
insertIngest(item) {
|
|
2013
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2014
|
+
const row = { ...item, created_at: now, updated_at: now };
|
|
2015
|
+
const keys = Object.keys(row);
|
|
2016
|
+
const info = this.s(
|
|
2017
|
+
`INSERT INTO ingest_items(${keys.join(", ")}) VALUES (${keys.map((k) => `@${k}`).join(", ")})`
|
|
2018
|
+
).run(row);
|
|
2019
|
+
return { ...row, id: Number(info.lastInsertRowid) };
|
|
2020
|
+
}
|
|
2021
|
+
updateIngest(id, fields) {
|
|
2022
|
+
const row = { ...fields, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2023
|
+
const sets = Object.keys(row).map((k) => `${k} = @${k}`).join(", ");
|
|
2024
|
+
this.s(`UPDATE ingest_items SET ${sets} WHERE id = @id`).run({ ...row, id });
|
|
2025
|
+
}
|
|
2026
|
+
deleteIngest(id) {
|
|
2027
|
+
this.s("DELETE FROM ingest_items WHERE id = ?").run(id);
|
|
2028
|
+
}
|
|
2029
|
+
listIngest(opts) {
|
|
2030
|
+
return this.s(
|
|
2031
|
+
`SELECT * FROM ingest_items ${opts.status ? "WHERE status = ?" : ""}
|
|
2032
|
+
ORDER BY captured_at, original_name, id LIMIT ? OFFSET ?`
|
|
2033
|
+
).all(
|
|
2034
|
+
...opts.status ? [opts.status] : [],
|
|
2035
|
+
opts.limit ?? 50,
|
|
2036
|
+
opts.offset ?? 0
|
|
2037
|
+
);
|
|
2038
|
+
}
|
|
2039
|
+
allIngest() {
|
|
2040
|
+
return this.s(
|
|
2041
|
+
"SELECT * FROM ingest_items ORDER BY captured_at, original_name, id"
|
|
2042
|
+
).all();
|
|
2043
|
+
}
|
|
2044
|
+
ingestCounts() {
|
|
2045
|
+
const counts = { pending: 0, done: 0, skipped: 0, failed: 0 };
|
|
2046
|
+
for (const r of this.s(
|
|
2047
|
+
"SELECT status, COUNT(*) n FROM ingest_items GROUP BY status"
|
|
2048
|
+
).all()) {
|
|
2049
|
+
counts[r.status] = r.n;
|
|
2050
|
+
}
|
|
2051
|
+
return counts;
|
|
2052
|
+
}
|
|
2053
|
+
};
|
|
2054
|
+
|
|
2055
|
+
// src/core/context.ts
|
|
2056
|
+
var SYNC_INTERVAL_MS = 2e3;
|
|
2057
|
+
var contexts = /* @__PURE__ */ new Map();
|
|
2058
|
+
function seedPath(name) {
|
|
2059
|
+
return path5.join(packageRoot, "seed", name);
|
|
2060
|
+
}
|
|
2061
|
+
function ensureSeeded(target, seed) {
|
|
2062
|
+
if (!fs8.existsSync(target) && fs8.existsSync(seed)) fs8.copyFileSync(seed, target);
|
|
2063
|
+
}
|
|
2064
|
+
function mtime(file) {
|
|
2065
|
+
try {
|
|
2066
|
+
return fs8.statSync(file).mtimeMs;
|
|
2067
|
+
} catch {
|
|
2068
|
+
return 0;
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
function buildVocabulary(paths, providers) {
|
|
2072
|
+
const vocab = Vocabulary.fromFile(paths.vocabularyYaml);
|
|
2073
|
+
for (const p of providers.list()) vocab.addSynonyms("allergen", p.allergen_synonyms);
|
|
2074
|
+
return vocab;
|
|
2075
|
+
}
|
|
2076
|
+
function loadProviderAliases(store, providers) {
|
|
2077
|
+
store.transaction(() => {
|
|
2078
|
+
for (const p of providers.list()) {
|
|
2079
|
+
for (const [canonical, aliases] of Object.entries(p.ingredient_aliases)) {
|
|
2080
|
+
store.upsertCatalogEntry({ canonical, aliases });
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
function openContext(projectRoot, opts = {}) {
|
|
2086
|
+
const root = resolveProjectRoot(projectRoot);
|
|
2087
|
+
const cached = contexts.get(root);
|
|
2088
|
+
if (cached) return cached;
|
|
2089
|
+
const paths = dataPaths(root);
|
|
2090
|
+
ensureDataDirs(paths);
|
|
2091
|
+
const store = new SqliteAdapter(paths.db);
|
|
2092
|
+
if (store.wasReset) log("index schema changed; rebuilding from markdown");
|
|
2093
|
+
ensureSeeded(paths.ingredientsYaml, seedPath("ingredients.yaml"));
|
|
2094
|
+
ensureSeeded(paths.vocabularyYaml, seedPath("vocabulary.yaml"));
|
|
2095
|
+
const useEmbeddings = opts.embeddings ?? process.env.PANTRYBASE_NO_EMBED !== "1";
|
|
2096
|
+
const embedder = useEmbeddings ? new Embedder() : null;
|
|
2097
|
+
const embedQueue = new EmbedQueue(store, embedder);
|
|
2098
|
+
let seen = { catalog: -1, vocabulary: -1 };
|
|
2099
|
+
let lastSync = Date.now();
|
|
2100
|
+
const ctx = {
|
|
2101
|
+
root,
|
|
2102
|
+
paths,
|
|
2103
|
+
store,
|
|
2104
|
+
providers: new ProviderRegistry([builtinProvidersDir(), paths.providers]),
|
|
2105
|
+
vocab: new Vocabulary(),
|
|
2106
|
+
embedder,
|
|
2107
|
+
embedQueue,
|
|
2108
|
+
log,
|
|
2109
|
+
refresh(force = false) {
|
|
2110
|
+
const now = {
|
|
2111
|
+
catalog: mtime(paths.ingredientsYaml),
|
|
2112
|
+
vocabulary: mtime(paths.vocabularyYaml)
|
|
2113
|
+
};
|
|
2114
|
+
const changed = {
|
|
2115
|
+
catalog: force || now.catalog !== seen.catalog,
|
|
2116
|
+
vocabulary: force || now.vocabulary !== seen.vocabulary
|
|
2117
|
+
};
|
|
2118
|
+
if (force) ctx.providers = new ProviderRegistry([builtinProvidersDir(), paths.providers]);
|
|
2119
|
+
if (changed.catalog) {
|
|
2120
|
+
loadCatalog(store, paths.ingredientsYaml);
|
|
2121
|
+
loadProviderAliases(store, ctx.providers);
|
|
2122
|
+
}
|
|
2123
|
+
if (changed.vocabulary || force) ctx.vocab = buildVocabulary(paths, ctx.providers);
|
|
2124
|
+
seen = now;
|
|
2125
|
+
return changed;
|
|
2126
|
+
},
|
|
2127
|
+
sync({ now = false } = {}) {
|
|
2128
|
+
const t = Date.now();
|
|
2129
|
+
if (!now && t - lastSync < SYNC_INTERVAL_MS) return null;
|
|
2130
|
+
lastSync = t;
|
|
2131
|
+
const changed = ctx.refresh();
|
|
2132
|
+
const full = changed.catalog || changed.vocabulary;
|
|
2133
|
+
const r = reconcile(ctx, { full });
|
|
2134
|
+
if (full || r.added || r.updated || r.removed || r.errors.length) {
|
|
2135
|
+
log("sync", { config_changed: full, ...r, unchanged: void 0 });
|
|
2136
|
+
}
|
|
2137
|
+
return r;
|
|
2138
|
+
},
|
|
2139
|
+
close() {
|
|
2140
|
+
embedQueue.stop();
|
|
2141
|
+
store.close();
|
|
2142
|
+
contexts.delete(root);
|
|
2143
|
+
}
|
|
2144
|
+
};
|
|
2145
|
+
ctx.refresh(true);
|
|
2146
|
+
contexts.set(root, ctx);
|
|
2147
|
+
if (opts.reconcile ?? true) {
|
|
2148
|
+
const r = reconcile(ctx, { full: false });
|
|
2149
|
+
if (r.added || r.updated || r.removed || r.errors.length) log("reconcile", r);
|
|
2150
|
+
}
|
|
2151
|
+
embedQueue.kick(1e3);
|
|
2152
|
+
return ctx;
|
|
2153
|
+
}
|
|
2154
|
+
function closeAllContexts() {
|
|
2155
|
+
for (const ctx of [...contexts.values()]) ctx.close();
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// src/search/rrf.ts
|
|
2159
|
+
var K = 60;
|
|
2160
|
+
function fuseRrf(lists) {
|
|
2161
|
+
const acc = /* @__PURE__ */ new Map();
|
|
2162
|
+
for (const list of lists) {
|
|
2163
|
+
list.hits.forEach((hit, rank) => {
|
|
2164
|
+
const cur = acc.get(hit.recipe_id) ?? {
|
|
2165
|
+
recipe_id: hit.recipe_id,
|
|
2166
|
+
slug: hit.slug,
|
|
2167
|
+
score: 0,
|
|
2168
|
+
sources: []
|
|
2169
|
+
};
|
|
2170
|
+
cur.score += list.weight / (K + rank + 1);
|
|
2171
|
+
if (!cur.sources.includes(list.name)) cur.sources.push(list.name);
|
|
2172
|
+
acc.set(hit.recipe_id, cur);
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
return [...acc.values()].sort((a, b) => b.score - a.score);
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// src/search/hybrid.ts
|
|
2179
|
+
function expandFilters(ctx, filters) {
|
|
2180
|
+
if (!filters) return filters;
|
|
2181
|
+
const out = { ...filters };
|
|
2182
|
+
if (filters.tags) {
|
|
2183
|
+
const tags = {};
|
|
2184
|
+
for (const [cat, values] of Object.entries(filters.tags)) {
|
|
2185
|
+
if (!values?.length) continue;
|
|
2186
|
+
const free = cat === "chef" || cat === "plan";
|
|
2187
|
+
tags[cat] = free ? values : [
|
|
2188
|
+
...new Set(
|
|
2189
|
+
values.flatMap((v) => ctx.vocab.expand(cat === "technique" ? "method" : cat, v))
|
|
2190
|
+
)
|
|
2191
|
+
];
|
|
2192
|
+
}
|
|
2193
|
+
out.tags = tags;
|
|
2194
|
+
}
|
|
2195
|
+
if (filters.exclude_allergens?.length) {
|
|
2196
|
+
out.exclude_allergens = [
|
|
2197
|
+
...new Set(filters.exclude_allergens.flatMap((a) => ctx.vocab.expand("allergen", a)))
|
|
2198
|
+
];
|
|
2199
|
+
}
|
|
2200
|
+
return out;
|
|
2201
|
+
}
|
|
2202
|
+
async function searchRecipes(ctx, opts) {
|
|
2203
|
+
const limit = Math.min(Math.max(opts.limit ?? 10, 1), 100);
|
|
2204
|
+
const mode = opts.mode ?? "hybrid";
|
|
2205
|
+
const query = opts.query?.trim() ?? "";
|
|
2206
|
+
opts = { ...opts, filters: expandFilters(ctx, opts.filters) };
|
|
2207
|
+
if (!query) {
|
|
2208
|
+
const rows = ctx.store.listRecipes({ filters: opts.filters, limit });
|
|
2209
|
+
return {
|
|
2210
|
+
results: rows.map((r) => ({ ...r, score: null, matched: ["filter"] })),
|
|
2211
|
+
vector_used: false,
|
|
2212
|
+
mode
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
const allowed = ctx.store.filterRecipeIds(opts.filters);
|
|
2216
|
+
const fetch = limit * 5;
|
|
2217
|
+
const lists = [];
|
|
2218
|
+
let vectorUsed = false;
|
|
2219
|
+
if (mode !== "semantic") {
|
|
2220
|
+
lists.push({ name: "lexical", weight: 1, hits: ctx.store.searchLexical(query, fetch) });
|
|
2221
|
+
lists.push({ name: "trigram", weight: 0.7, hits: ctx.store.searchTrigram(query, fetch) });
|
|
2222
|
+
}
|
|
2223
|
+
if (mode !== "lexical" && ctx.embedder) {
|
|
2224
|
+
if (ctx.embedder.ready) {
|
|
2225
|
+
try {
|
|
2226
|
+
const vec = await ctx.embedder.embedQuery(query);
|
|
2227
|
+
lists.push({ name: "vector", weight: 1, hits: ctx.store.searchVector(vec, fetch) });
|
|
2228
|
+
vectorUsed = true;
|
|
2229
|
+
} catch (e) {
|
|
2230
|
+
ctx.log("vector search failed", e);
|
|
2231
|
+
}
|
|
2232
|
+
} else {
|
|
2233
|
+
void ctx.embedder.load().then(() => ctx.embedQueue.kick(0));
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
const filtered = allowed ? lists.map((l) => ({ ...l, hits: l.hits.filter((h) => allowed.has(h.recipe_id)) })) : lists;
|
|
2237
|
+
const fused = fuseRrf(filtered).slice(0, limit);
|
|
2238
|
+
const summaries = ctx.store.summariesByIds(fused.map((f) => f.recipe_id));
|
|
2239
|
+
const byId = new Map(summaries.map((s) => [s.id, s]));
|
|
2240
|
+
const results = [];
|
|
2241
|
+
for (const f of fused) {
|
|
2242
|
+
const s = byId.get(f.recipe_id);
|
|
2243
|
+
if (s) results.push({ ...s, score: Number(f.score.toFixed(4)), matched: f.sources });
|
|
2244
|
+
}
|
|
2245
|
+
return { results, vector_used: vectorUsed, mode };
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// src/web/server.ts
|
|
2249
|
+
import fs11 from "fs";
|
|
2250
|
+
import http from "http";
|
|
2251
|
+
import path8 from "path";
|
|
2252
|
+
|
|
2253
|
+
// src/graph/build.ts
|
|
2254
|
+
var DEFAULT_CATEGORIES = ["cuisine", "dish", "protein", "diet", "allergen", "technique"];
|
|
2255
|
+
function buildGraph(ctx, opts = {}) {
|
|
2256
|
+
const categories = new Set(opts.categories ?? DEFAULT_CATEGORIES);
|
|
2257
|
+
const minDegree = opts.min_degree ?? 2;
|
|
2258
|
+
const { recipes, uses, tags } = ctx.store.graphRows();
|
|
2259
|
+
let allowed = null;
|
|
2260
|
+
if (opts.cuisine) {
|
|
2261
|
+
const wanted = new Set(ctx.vocab.expand("cuisine", opts.cuisine));
|
|
2262
|
+
allowed = new Set(
|
|
2263
|
+
tags.filter((t) => t.category === "cuisine" && wanted.has(t.name)).map((t) => t.recipe_id)
|
|
2264
|
+
);
|
|
2265
|
+
}
|
|
2266
|
+
const keep = recipes.filter((r) => !allowed || allowed.has(r.id));
|
|
2267
|
+
const keepIds = new Set(keep.map((r) => r.id));
|
|
2268
|
+
const bySlug = new Map(keep.map((r) => [r.slug, r]));
|
|
2269
|
+
const slugById = new Map(keep.map((r) => [r.id, r.slug]));
|
|
2270
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
2271
|
+
const edges = [];
|
|
2272
|
+
const docs = /* @__PURE__ */ new Map();
|
|
2273
|
+
for (const r of keep) {
|
|
2274
|
+
const doc = JSON.parse(r.doc);
|
|
2275
|
+
docs.set(r.id, doc);
|
|
2276
|
+
nodes.set(`r:${r.slug}`, {
|
|
2277
|
+
id: `r:${r.slug}`,
|
|
2278
|
+
kind: "recipe",
|
|
2279
|
+
label: r.title,
|
|
2280
|
+
group: doc.tags.cuisine?.[0] ?? null,
|
|
2281
|
+
degree: 0,
|
|
2282
|
+
slug: r.slug,
|
|
2283
|
+
...doc.images[0] ? { image: doc.images[0] } : {},
|
|
2284
|
+
...doc.extraction?.needs_review ? { needs_review: true } : {}
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
const linkCount = /* @__PURE__ */ new Map();
|
|
2288
|
+
const pending = [];
|
|
2289
|
+
if (opts.ingredients !== false) {
|
|
2290
|
+
for (const u of uses) {
|
|
2291
|
+
if (!keepIds.has(u.recipe_id) || u.staple && !opts.include_staples) continue;
|
|
2292
|
+
const id = `i:${u.canonical}`;
|
|
2293
|
+
if (!nodes.has(id)) {
|
|
2294
|
+
nodes.set(id, { id, kind: "ingredient", label: u.canonical, group: u.category, degree: 0 });
|
|
2295
|
+
}
|
|
2296
|
+
pending.push({ source: `r:${slugById.get(u.recipe_id)}`, target: id, kind: "uses" });
|
|
2297
|
+
linkCount.set(id, (linkCount.get(id) ?? 0) + 1);
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
for (const t of tags) {
|
|
2301
|
+
if (!keepIds.has(t.recipe_id) || !categories.has(t.category)) continue;
|
|
2302
|
+
const id = `t:${t.category}:${t.name}`;
|
|
2303
|
+
if (!nodes.has(id))
|
|
2304
|
+
nodes.set(id, { id, kind: "tag", label: t.name, group: t.category, degree: 0 });
|
|
2305
|
+
pending.push({ source: `r:${slugById.get(t.recipe_id)}`, target: id, kind: "tagged" });
|
|
2306
|
+
linkCount.set(id, (linkCount.get(id) ?? 0) + 1);
|
|
2307
|
+
}
|
|
2308
|
+
for (const [id, n] of nodes) {
|
|
2309
|
+
if (n.kind === "recipe") continue;
|
|
2310
|
+
n.degree = linkCount.get(id) ?? 0;
|
|
2311
|
+
if (n.degree < minDegree) nodes.delete(id);
|
|
2312
|
+
}
|
|
2313
|
+
for (const e of pending) if (nodes.has(e.source) && nodes.has(e.target)) edges.push(e);
|
|
2314
|
+
if (categories.has("cuisine")) {
|
|
2315
|
+
for (const n of [...nodes.values()]) {
|
|
2316
|
+
if (n.kind !== "tag" || n.group !== "cuisine") continue;
|
|
2317
|
+
const parent = ctx.vocab.ancestors("cuisine", n.label)[0];
|
|
2318
|
+
if (!parent) continue;
|
|
2319
|
+
const pid = `t:cuisine:${parent}`;
|
|
2320
|
+
if (!nodes.has(pid))
|
|
2321
|
+
nodes.set(pid, { id: pid, kind: "tag", label: parent, group: "cuisine", degree: 0 });
|
|
2322
|
+
edges.push({ source: n.id, target: pid, kind: "parent" });
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
for (const [id, doc] of docs) {
|
|
2326
|
+
const slug = slugById.get(id);
|
|
2327
|
+
for (const src of doc.derived_from) {
|
|
2328
|
+
if (bySlug.has(src))
|
|
2329
|
+
edges.push({ source: `r:${slug}`, target: `r:${src}`, kind: "derived_from" });
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
if (opts.similar && opts.similar > 0) {
|
|
2333
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2334
|
+
for (const r of keep) {
|
|
2335
|
+
for (const s of ctx.store.similarRecipes(r.id, opts.similar)) {
|
|
2336
|
+
const other = slugById.get(s.recipe_id);
|
|
2337
|
+
if (!other) continue;
|
|
2338
|
+
const key = [r.slug, other].sort().join("|");
|
|
2339
|
+
if (seen.has(key)) continue;
|
|
2340
|
+
seen.add(key);
|
|
2341
|
+
edges.push({
|
|
2342
|
+
source: `r:${r.slug}`,
|
|
2343
|
+
target: `r:${other}`,
|
|
2344
|
+
kind: "similar",
|
|
2345
|
+
weight: Number((1 - s.distance / 2).toFixed(3))
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
for (const e of edges) {
|
|
2351
|
+
const s = nodes.get(e.source);
|
|
2352
|
+
if (s?.kind === "recipe") s.degree++;
|
|
2353
|
+
}
|
|
2354
|
+
const stats = {
|
|
2355
|
+
recipes: 0,
|
|
2356
|
+
ingredients: 0,
|
|
2357
|
+
tags: 0,
|
|
2358
|
+
edges: edges.length
|
|
2359
|
+
};
|
|
2360
|
+
for (const n of nodes.values()) {
|
|
2361
|
+
const key = n.kind === "recipe" ? "recipes" : n.kind === "ingredient" ? "ingredients" : "tags";
|
|
2362
|
+
stats[key] = (stats[key] ?? 0) + 1;
|
|
2363
|
+
}
|
|
2364
|
+
return { nodes: [...nodes.values()], edges, stats };
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
// src/ingest/images.ts
|
|
2368
|
+
import { execFile } from "child_process";
|
|
2369
|
+
import { createHash as createHash3 } from "crypto";
|
|
2370
|
+
import fs9 from "fs";
|
|
2371
|
+
import os2 from "os";
|
|
2372
|
+
import path6 from "path";
|
|
2373
|
+
import { promisify } from "util";
|
|
2374
|
+
import sharp from "sharp";
|
|
2375
|
+
var execFileP = promisify(execFile);
|
|
2376
|
+
var IMAGE_EXTS = /* @__PURE__ */ new Set([
|
|
2377
|
+
".jpg",
|
|
2378
|
+
".jpeg",
|
|
2379
|
+
".png",
|
|
2380
|
+
".webp",
|
|
2381
|
+
".heic",
|
|
2382
|
+
".heif",
|
|
2383
|
+
".tif",
|
|
2384
|
+
".tiff"
|
|
2385
|
+
]);
|
|
2386
|
+
var HEIF_EXTS = /* @__PURE__ */ new Set([".heic", ".heif"]);
|
|
2387
|
+
var MODEL_MAX_PX = 1600;
|
|
2388
|
+
var MODEL_MAX_BYTES = 75e4;
|
|
2389
|
+
var RETAKE_DISTANCE = 6;
|
|
2390
|
+
function isImageFile(name) {
|
|
2391
|
+
return IMAGE_EXTS.has(path6.extname(name).toLowerCase()) && !path6.basename(name).startsWith(".");
|
|
2392
|
+
}
|
|
2393
|
+
function mimeFor(file) {
|
|
2394
|
+
const ext = path6.extname(file).toLowerCase();
|
|
2395
|
+
if (ext === ".png") return "image/png";
|
|
2396
|
+
if (ext === ".webp") return "image/webp";
|
|
2397
|
+
if (HEIF_EXTS.has(ext)) return "image/heic";
|
|
2398
|
+
if (ext === ".tif" || ext === ".tiff") return "image/tiff";
|
|
2399
|
+
return "image/jpeg";
|
|
2400
|
+
}
|
|
2401
|
+
async function heifToJpeg(file) {
|
|
2402
|
+
if (process.platform === "darwin") {
|
|
2403
|
+
const out = path6.join(os2.tmpdir(), `pantrybase-${process.pid}-${Date.now()}.jpg`);
|
|
2404
|
+
try {
|
|
2405
|
+
await execFileP("sips", ["-s", "format", "jpeg", file, "--out", out]);
|
|
2406
|
+
return fs9.readFileSync(out);
|
|
2407
|
+
} finally {
|
|
2408
|
+
fs9.rmSync(out, { force: true });
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
try {
|
|
2412
|
+
const { default: convert } = await import("heic-convert");
|
|
2413
|
+
const jpeg = await convert({ buffer: fs9.readFileSync(file), format: "JPEG", quality: 0.9 });
|
|
2414
|
+
return Buffer.from(jpeg);
|
|
2415
|
+
} catch (e) {
|
|
2416
|
+
throw new Error(
|
|
2417
|
+
`cannot decode HEIC ${path6.basename(file)} (install heic-convert or convert to JPEG): ${e instanceof Error ? e.message : String(e)}`
|
|
2418
|
+
);
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
async function openImage(file) {
|
|
2422
|
+
const input = HEIF_EXTS.has(path6.extname(file).toLowerCase()) ? await heifToJpeg(file) : file;
|
|
2423
|
+
return sharp(input, { failOn: "none" }).rotate();
|
|
2424
|
+
}
|
|
2425
|
+
async function rotateFile(file, degrees) {
|
|
2426
|
+
const ext = path6.extname(file).toLowerCase();
|
|
2427
|
+
const upright = await (await openImage(file)).toBuffer();
|
|
2428
|
+
let pipeline = sharp(upright, { failOn: "none" }).rotate(degrees);
|
|
2429
|
+
let target = file;
|
|
2430
|
+
if (ext === ".png") pipeline = pipeline.png();
|
|
2431
|
+
else if (ext === ".webp") pipeline = pipeline.webp({ quality: 92 });
|
|
2432
|
+
else if (ext === ".tif" || ext === ".tiff") pipeline = pipeline.tiff();
|
|
2433
|
+
else {
|
|
2434
|
+
pipeline = pipeline.jpeg({ quality: 92, mozjpeg: true });
|
|
2435
|
+
if (HEIF_EXTS.has(ext)) target = `${file.slice(0, -ext.length)}.jpg`;
|
|
2436
|
+
}
|
|
2437
|
+
const out = await pipeline.toBuffer();
|
|
2438
|
+
const tmp = `${target}.rotating-${process.pid}`;
|
|
2439
|
+
fs9.writeFileSync(tmp, out);
|
|
2440
|
+
fs9.renameSync(tmp, target);
|
|
2441
|
+
if (target !== file) fs9.rmSync(file, { force: true });
|
|
2442
|
+
return target;
|
|
2443
|
+
}
|
|
2444
|
+
async function imageForModel(file, maxPx = MODEL_MAX_PX) {
|
|
2445
|
+
const base = await (await openImage(file)).resize(maxPx, maxPx, { fit: "inside", withoutEnlargement: true }).toBuffer({ resolveWithObject: true });
|
|
2446
|
+
let data = base.data;
|
|
2447
|
+
for (const quality of [82, 72, 62, 52, 42]) {
|
|
2448
|
+
data = await sharp(base.data).jpeg({ quality, mozjpeg: true }).toBuffer();
|
|
2449
|
+
if (data.length <= MODEL_MAX_BYTES) break;
|
|
2450
|
+
}
|
|
2451
|
+
return { data, mimeType: "image/jpeg", width: base.info.width, height: base.info.height };
|
|
2452
|
+
}
|
|
2453
|
+
async function dhash(img) {
|
|
2454
|
+
const px = await img.clone().greyscale().resize(9, 8, { fit: "fill" }).raw().toBuffer();
|
|
2455
|
+
let bits = 0n;
|
|
2456
|
+
for (let y = 0; y < 8; y++) {
|
|
2457
|
+
for (let x = 0; x < 8; x++) {
|
|
2458
|
+
bits = bits << 1n | ((px[y * 9 + x] ?? 0) > (px[y * 9 + x + 1] ?? 0) ? 1n : 0n);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
return bits.toString(16).padStart(16, "0");
|
|
2462
|
+
}
|
|
2463
|
+
function hamming(a, b) {
|
|
2464
|
+
let x = BigInt(`0x${a}`) ^ BigInt(`0x${b}`);
|
|
2465
|
+
let n = 0;
|
|
2466
|
+
while (x) {
|
|
2467
|
+
n += Number(x & 1n);
|
|
2468
|
+
x >>= 1n;
|
|
2469
|
+
}
|
|
2470
|
+
return n;
|
|
2471
|
+
}
|
|
2472
|
+
function captureTimeFromName(name) {
|
|
2473
|
+
const m = /(20\d{2})(\d{2})(\d{2})[_-](\d{2})(\d{2})(\d{2})(\d{0,3})/.exec(name);
|
|
2474
|
+
if (!m) return null;
|
|
2475
|
+
const [, y, mo, d, h, mi, s, ms] = m;
|
|
2476
|
+
const iso = `${y}-${mo}-${d}T${h}:${mi}:${s}.${(ms ?? "").padEnd(3, "0")}`;
|
|
2477
|
+
return Number.isNaN(Date.parse(iso)) ? null : iso;
|
|
2478
|
+
}
|
|
2479
|
+
function captureTimeFromExif(exif) {
|
|
2480
|
+
if (!exif) return null;
|
|
2481
|
+
const m = /(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(exif.toString("latin1"));
|
|
2482
|
+
if (!m) return null;
|
|
2483
|
+
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.000`;
|
|
2484
|
+
return Number.isNaN(Date.parse(iso)) ? null : iso;
|
|
2485
|
+
}
|
|
2486
|
+
async function inspectImage(file) {
|
|
2487
|
+
const buf = fs9.readFileSync(file);
|
|
2488
|
+
const st = fs9.statSync(file);
|
|
2489
|
+
const img = await openImage(file);
|
|
2490
|
+
const meta = await img.metadata();
|
|
2491
|
+
const rotated = (meta.orientation ?? 1) >= 5;
|
|
2492
|
+
const captured = captureTimeFromName(path6.basename(file)) ?? captureTimeFromExif(meta.exif) ?? new Date(st.mtimeMs).toISOString().slice(0, 23);
|
|
2493
|
+
return {
|
|
2494
|
+
sha256: createHash3("sha256").update(buf).digest("hex"),
|
|
2495
|
+
phash: await dhash(img),
|
|
2496
|
+
width: (rotated ? meta.height : meta.width) ?? 0,
|
|
2497
|
+
height: (rotated ? meta.width : meta.height) ?? 0,
|
|
2498
|
+
mime: mimeFor(file),
|
|
2499
|
+
bytes: st.size,
|
|
2500
|
+
mtime: Math.round(st.mtimeMs),
|
|
2501
|
+
captured_at: captured
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
// src/ingest/inbox.ts
|
|
2506
|
+
import fs10 from "fs";
|
|
2507
|
+
import os3 from "os";
|
|
2508
|
+
import path7 from "path";
|
|
2509
|
+
var SKIPPED = "skipped";
|
|
2510
|
+
var FAILED = "failed";
|
|
2511
|
+
var DUPLICATES = "duplicates";
|
|
2512
|
+
var REASON_SUFFIX = ".reason.txt";
|
|
2513
|
+
function walk(dir, skipDirs = /* @__PURE__ */ new Set()) {
|
|
2514
|
+
if (!fs10.existsSync(dir)) return [];
|
|
2515
|
+
const out = [];
|
|
2516
|
+
for (const entry of fs10.readdirSync(dir, { withFileTypes: true })) {
|
|
2517
|
+
if (entry.name.startsWith(".")) continue;
|
|
2518
|
+
const abs = path7.join(dir, entry.name);
|
|
2519
|
+
if (entry.isDirectory()) {
|
|
2520
|
+
if (!skipDirs.has(abs)) out.push(...walk(abs, skipDirs));
|
|
2521
|
+
} else if (isImageFile(entry.name)) {
|
|
2522
|
+
out.push(abs);
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
return out;
|
|
2526
|
+
}
|
|
2527
|
+
function locate(ctx, abs) {
|
|
2528
|
+
const inImages = path7.relative(ctx.paths.images, abs);
|
|
2529
|
+
if (!inImages.startsWith("..") && !path7.isAbsolute(inImages)) {
|
|
2530
|
+
return { status: "done", slug: inImages.split(path7.sep)[0] ?? null };
|
|
2531
|
+
}
|
|
2532
|
+
const inInbox = path7.relative(ctx.paths.inbox, abs).split(path7.sep);
|
|
2533
|
+
if (inInbox[0] === SKIPPED) return { status: "skipped", slug: null };
|
|
2534
|
+
if (inInbox[0] === FAILED) return { status: "failed", slug: null };
|
|
2535
|
+
return { status: "pending", slug: null };
|
|
2536
|
+
}
|
|
2537
|
+
function readReason(abs) {
|
|
2538
|
+
try {
|
|
2539
|
+
return fs10.readFileSync(abs + REASON_SUFFIX, "utf8").trim() || null;
|
|
2540
|
+
} catch {
|
|
2541
|
+
return null;
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
function freeName(dir, name, taken = /* @__PURE__ */ new Set()) {
|
|
2545
|
+
const ext = path7.extname(name);
|
|
2546
|
+
const stem = path7.basename(name, ext);
|
|
2547
|
+
let candidate = name;
|
|
2548
|
+
for (let n = 2; fs10.existsSync(path7.join(dir, candidate)) || taken.has(path7.join(dir, candidate)); n++) {
|
|
2549
|
+
candidate = `${stem}-${n}${ext}`;
|
|
2550
|
+
}
|
|
2551
|
+
return candidate;
|
|
2552
|
+
}
|
|
2553
|
+
function moveFile(from, to) {
|
|
2554
|
+
fs10.mkdirSync(path7.dirname(to), { recursive: true });
|
|
2555
|
+
try {
|
|
2556
|
+
fs10.renameSync(from, to);
|
|
2557
|
+
} catch (e) {
|
|
2558
|
+
if (e.code !== "EXDEV") throw e;
|
|
2559
|
+
fs10.copyFileSync(from, to);
|
|
2560
|
+
fs10.rmSync(from);
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
var scanning = /* @__PURE__ */ new WeakMap();
|
|
2564
|
+
function scanInbox(ctx) {
|
|
2565
|
+
const running2 = scanning.get(ctx);
|
|
2566
|
+
if (running2) return running2;
|
|
2567
|
+
const job = (async () => {
|
|
2568
|
+
const res = { added: 0, updated: 0, removed: 0, duplicates: [], errors: [] };
|
|
2569
|
+
const dupDir = path7.join(ctx.paths.inbox, DUPLICATES);
|
|
2570
|
+
const files = [...walk(ctx.paths.inbox, /* @__PURE__ */ new Set([dupDir])), ...walk(ctx.paths.images)];
|
|
2571
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2572
|
+
for (const abs of files) {
|
|
2573
|
+
const rel = relativeToData(ctx.paths, abs);
|
|
2574
|
+
const where = locate(ctx, abs);
|
|
2575
|
+
const st = fs10.statSync(abs);
|
|
2576
|
+
const row = ctx.store.ingestByPath(rel);
|
|
2577
|
+
if (row && row.mtime === Math.round(st.mtimeMs) && row.bytes === st.size) {
|
|
2578
|
+
seen.add(rel);
|
|
2579
|
+
const reason = where.status === "skipped" || where.status === "failed" ? readReason(abs) : null;
|
|
2580
|
+
if (row.status !== where.status || row.recipe_slug !== where.slug || row.reason !== reason) {
|
|
2581
|
+
ctx.store.updateIngest(row.id, { status: where.status, recipe_slug: where.slug, reason });
|
|
2582
|
+
res.updated++;
|
|
2583
|
+
}
|
|
2584
|
+
continue;
|
|
2585
|
+
}
|
|
2586
|
+
try {
|
|
2587
|
+
const info = await inspectImage(abs);
|
|
2588
|
+
const same = ctx.store.ingestBySha(info.sha256);
|
|
2589
|
+
if (same && same.path !== rel) {
|
|
2590
|
+
if (fs10.existsSync(path7.join(ctx.paths.data, same.path))) {
|
|
2591
|
+
const target = path7.join(dupDir, freeName(dupDir, path7.basename(abs)));
|
|
2592
|
+
moveFile(abs, target);
|
|
2593
|
+
res.duplicates.push(`${rel} = #${same.id} ${same.path}`);
|
|
2594
|
+
continue;
|
|
2595
|
+
}
|
|
2596
|
+
ctx.store.updateIngest(same.id, {
|
|
2597
|
+
path: rel,
|
|
2598
|
+
status: where.status,
|
|
2599
|
+
recipe_slug: where.slug,
|
|
2600
|
+
mtime: info.mtime,
|
|
2601
|
+
bytes: info.bytes
|
|
2602
|
+
});
|
|
2603
|
+
seen.add(rel);
|
|
2604
|
+
res.updated++;
|
|
2605
|
+
continue;
|
|
2606
|
+
}
|
|
2607
|
+
if (row) ctx.store.deleteIngest(row.id);
|
|
2608
|
+
ctx.store.insertIngest({
|
|
2609
|
+
...info,
|
|
2610
|
+
path: rel,
|
|
2611
|
+
original_name: path7.basename(abs),
|
|
2612
|
+
status: where.status,
|
|
2613
|
+
recipe_slug: where.slug,
|
|
2614
|
+
side: null,
|
|
2615
|
+
reason: where.status === "skipped" || where.status === "failed" ? readReason(abs) : null
|
|
2616
|
+
});
|
|
2617
|
+
seen.add(rel);
|
|
2618
|
+
res.added++;
|
|
2619
|
+
} catch (e) {
|
|
2620
|
+
res.errors.push({ file: rel, error: e instanceof Error ? e.message : String(e) });
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
for (const row of ctx.store.allIngest()) {
|
|
2624
|
+
if (!seen.has(row.path) && !fs10.existsSync(path7.join(ctx.paths.data, row.path))) {
|
|
2625
|
+
ctx.store.deleteIngest(row.id);
|
|
2626
|
+
res.removed++;
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
return res;
|
|
2630
|
+
})().finally(() => {
|
|
2631
|
+
scanning.delete(ctx);
|
|
2632
|
+
});
|
|
2633
|
+
scanning.set(ctx, job);
|
|
2634
|
+
return job;
|
|
2635
|
+
}
|
|
2636
|
+
var lastScan = /* @__PURE__ */ new WeakMap();
|
|
2637
|
+
async function maybeScanInbox(ctx, intervalMs = 3e3) {
|
|
2638
|
+
const t = Date.now();
|
|
2639
|
+
if (t - (lastScan.get(ctx) ?? 0) < intervalMs) return null;
|
|
2640
|
+
lastScan.set(ctx, t);
|
|
2641
|
+
return scanInbox(ctx);
|
|
2642
|
+
}
|
|
2643
|
+
function expandHome(p) {
|
|
2644
|
+
return p === "~" || p.startsWith("~/") ? path7.join(os3.homedir(), p.slice(1)) : p;
|
|
2645
|
+
}
|
|
2646
|
+
async function importPaths(ctx, inputs, opts = {}) {
|
|
2647
|
+
const res = {
|
|
2648
|
+
copied: 0,
|
|
2649
|
+
already_known: 0,
|
|
2650
|
+
unsupported: 0,
|
|
2651
|
+
missing: []
|
|
2652
|
+
};
|
|
2653
|
+
const files = [];
|
|
2654
|
+
for (const input of inputs) {
|
|
2655
|
+
const abs = path7.resolve(ctx.root, expandHome(input));
|
|
2656
|
+
if (!fs10.existsSync(abs)) {
|
|
2657
|
+
res.missing.push(input);
|
|
2658
|
+
continue;
|
|
2659
|
+
}
|
|
2660
|
+
if (fs10.statSync(abs).isDirectory()) {
|
|
2661
|
+
const list = opts.recursive === false ? fs10.readdirSync(abs).map((f) => path7.join(abs, f)).filter((f) => fs10.statSync(f).isFile()) : walkAll(abs);
|
|
2662
|
+
for (const f of list) {
|
|
2663
|
+
if (isImageFile(f)) files.push(f);
|
|
2664
|
+
else res.unsupported++;
|
|
2665
|
+
}
|
|
2666
|
+
} else if (isImageFile(abs)) {
|
|
2667
|
+
files.push(abs);
|
|
2668
|
+
} else {
|
|
2669
|
+
res.unsupported++;
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
const known = new Set(ctx.store.allIngest().map((r) => r.sha256));
|
|
2673
|
+
const { createHash: createHash4 } = await import("crypto");
|
|
2674
|
+
const taken = /* @__PURE__ */ new Set();
|
|
2675
|
+
for (const f of files.sort()) {
|
|
2676
|
+
const sha2 = createHash4("sha256").update(fs10.readFileSync(f)).digest("hex");
|
|
2677
|
+
if (known.has(sha2)) {
|
|
2678
|
+
res.already_known++;
|
|
2679
|
+
continue;
|
|
2680
|
+
}
|
|
2681
|
+
known.add(sha2);
|
|
2682
|
+
const target = path7.join(ctx.paths.inbox, freeName(ctx.paths.inbox, path7.basename(f), taken));
|
|
2683
|
+
taken.add(target);
|
|
2684
|
+
fs10.copyFileSync(f, target);
|
|
2685
|
+
const st = fs10.statSync(f);
|
|
2686
|
+
fs10.utimesSync(target, st.atime, st.mtime);
|
|
2687
|
+
res.copied++;
|
|
2688
|
+
}
|
|
2689
|
+
return { ...res, scan: await scanInbox(ctx) };
|
|
2690
|
+
}
|
|
2691
|
+
function walkAll(dir) {
|
|
2692
|
+
const out = [];
|
|
2693
|
+
for (const entry of fs10.readdirSync(dir, { withFileTypes: true })) {
|
|
2694
|
+
if (entry.name.startsWith(".")) continue;
|
|
2695
|
+
const abs = path7.join(dir, entry.name);
|
|
2696
|
+
if (entry.isDirectory()) out.push(...walkAll(abs));
|
|
2697
|
+
else out.push(abs);
|
|
2698
|
+
}
|
|
2699
|
+
return out;
|
|
2700
|
+
}
|
|
2701
|
+
async function addUpload(ctx, name, data) {
|
|
2702
|
+
const safe = path7.basename(name).replace(/[^\w.\- ]+/g, "_") || "upload.jpg";
|
|
2703
|
+
if (!isImageFile(safe)) throw new Error(`unsupported image type: ${safe}`);
|
|
2704
|
+
const target = path7.join(ctx.paths.inbox, freeName(ctx.paths.inbox, safe));
|
|
2705
|
+
fs10.writeFileSync(target, data);
|
|
2706
|
+
await scanInbox(ctx);
|
|
2707
|
+
return ctx.store.ingestByPath(relativeToData(ctx.paths, target));
|
|
2708
|
+
}
|
|
2709
|
+
function shapeOf(item) {
|
|
2710
|
+
const w = item.width ?? 0;
|
|
2711
|
+
const h = item.height ?? 0;
|
|
2712
|
+
if (w > h * 1.1) return "landscape";
|
|
2713
|
+
if (h > w * 1.1) return "portrait";
|
|
2714
|
+
return "square";
|
|
2715
|
+
}
|
|
2716
|
+
function dominantShape(all) {
|
|
2717
|
+
const counts = { landscape: 0, portrait: 0, square: 0 };
|
|
2718
|
+
for (const i of all) if (i.width && i.height) counts[shapeOf(i)]++;
|
|
2719
|
+
const total = counts.landscape + counts.portrait + counts.square;
|
|
2720
|
+
if (total < 3) return null;
|
|
2721
|
+
const [shape, n] = Object.entries(counts).sort(
|
|
2722
|
+
(a, b) => b[1] - a[1]
|
|
2723
|
+
)[0] ?? ["square", 0];
|
|
2724
|
+
return shape !== "square" && n / total >= 0.7 ? shape : null;
|
|
2725
|
+
}
|
|
2726
|
+
function rotationHint(item, dominant) {
|
|
2727
|
+
if (!dominant) return void 0;
|
|
2728
|
+
const shape = shapeOf(item);
|
|
2729
|
+
if (shape === "square" || shape === dominant) return void 0;
|
|
2730
|
+
return `${shape} while most photos are ${dominant} \u2014 it may be sideways; if the text doesn't read upright, pb_inbox_rotate it`;
|
|
2731
|
+
}
|
|
2732
|
+
function retakeHint(item, all) {
|
|
2733
|
+
let best;
|
|
2734
|
+
for (const other of all) {
|
|
2735
|
+
if (other.id === item.id) continue;
|
|
2736
|
+
const d = hamming(item.phash, other.phash);
|
|
2737
|
+
if (d <= RETAKE_DISTANCE && (!best || d < best.distance)) {
|
|
2738
|
+
best = { id: other.id, status: other.status, distance: d };
|
|
2739
|
+
if (other.recipe_slug) best.recipe_slug = other.recipe_slug;
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
return best;
|
|
2743
|
+
}
|
|
2744
|
+
function viewItem(item, all, dominant = all ? dominantShape(all) : null) {
|
|
2745
|
+
const v = {
|
|
2746
|
+
id: item.id,
|
|
2747
|
+
name: item.original_name,
|
|
2748
|
+
captured_at: item.captured_at,
|
|
2749
|
+
size: `${item.width}x${item.height}`,
|
|
2750
|
+
status: item.status,
|
|
2751
|
+
version: item.mtime
|
|
2752
|
+
};
|
|
2753
|
+
if (item.recipe_slug) v.recipe_slug = item.recipe_slug;
|
|
2754
|
+
if (item.reason) v.reason = item.reason;
|
|
2755
|
+
if (all) {
|
|
2756
|
+
const r = retakeHint(item, all);
|
|
2757
|
+
if (r) v.retake_of = r;
|
|
2758
|
+
const hint = rotationHint(item, dominant);
|
|
2759
|
+
if (hint) v.rotation_hint = hint;
|
|
2760
|
+
}
|
|
2761
|
+
return v;
|
|
2762
|
+
}
|
|
2763
|
+
function viewItems(items, all) {
|
|
2764
|
+
const dominant = dominantShape(all);
|
|
2765
|
+
return items.map((i) => viewItem(i, all, dominant));
|
|
2766
|
+
}
|
|
2767
|
+
function nextBatch(ctx, window = 4) {
|
|
2768
|
+
const all = ctx.store.allIngest();
|
|
2769
|
+
const items = all.filter((i) => i.status === "pending").slice(0, Math.max(1, window));
|
|
2770
|
+
return { progress: ctx.store.ingestCounts(), items: viewItems(items, all) };
|
|
2771
|
+
}
|
|
2772
|
+
async function rotateItem(ctx, id, degrees) {
|
|
2773
|
+
const [item] = ctx.store.ingestByIds([id]);
|
|
2774
|
+
if (!item) throw new Error(`no photo #${id}`);
|
|
2775
|
+
const abs = path7.join(ctx.paths.data, item.path);
|
|
2776
|
+
if (!fs10.existsSync(abs)) throw new Error(`photo #${id} is missing on disk (${item.path})`);
|
|
2777
|
+
const heif = /\.(heic|heif)$/i.test(abs);
|
|
2778
|
+
if (item.status === "done" && heif) {
|
|
2779
|
+
throw new Error(
|
|
2780
|
+
`photo #${id} is an archived HEIC; rotating would rename the file ${item.recipe_slug} uses`
|
|
2781
|
+
);
|
|
2782
|
+
}
|
|
2783
|
+
const target = await rotateFile(abs, degrees);
|
|
2784
|
+
const info = await inspectImage(target);
|
|
2785
|
+
const rel = relativeToData(ctx.paths, target);
|
|
2786
|
+
const clash = ctx.store.ingestBySha(info.sha256);
|
|
2787
|
+
if (clash && clash.id !== item.id)
|
|
2788
|
+
throw new Error(`rotated photo #${id} is identical to #${clash.id}`);
|
|
2789
|
+
ctx.store.updateIngest(item.id, {
|
|
2790
|
+
path: rel,
|
|
2791
|
+
sha256: info.sha256,
|
|
2792
|
+
phash: info.phash,
|
|
2793
|
+
width: info.width,
|
|
2794
|
+
height: info.height,
|
|
2795
|
+
bytes: info.bytes,
|
|
2796
|
+
mtime: info.mtime,
|
|
2797
|
+
mime: info.mime
|
|
2798
|
+
});
|
|
2799
|
+
const updated = ctx.store.ingestByIds([id])[0] ?? item;
|
|
2800
|
+
return viewItem(updated, ctx.store.allIngest());
|
|
2801
|
+
}
|
|
2802
|
+
function resolveItems(ctx, ids, status, reason) {
|
|
2803
|
+
const out = { moved: 0, refused: [] };
|
|
2804
|
+
const items = ctx.store.ingestByIds(ids);
|
|
2805
|
+
for (const id of ids) {
|
|
2806
|
+
if (!items.some((i) => i.id === id)) out.refused.push({ id, why: "unknown id" });
|
|
2807
|
+
}
|
|
2808
|
+
for (const item of items) {
|
|
2809
|
+
if (item.status === "done") {
|
|
2810
|
+
out.refused.push({ id: item.id, why: `already attached to ${item.recipe_slug}` });
|
|
2811
|
+
continue;
|
|
2812
|
+
}
|
|
2813
|
+
const from = path7.join(ctx.paths.data, item.path);
|
|
2814
|
+
const dir = status === "pending" ? ctx.paths.inbox : path7.join(ctx.paths.inbox, status === "skipped" ? SKIPPED : FAILED);
|
|
2815
|
+
const name = path7.dirname(from) === dir ? path7.basename(from) : freeName(dir, path7.basename(from));
|
|
2816
|
+
const to = path7.join(dir, name);
|
|
2817
|
+
fs10.rmSync(from + REASON_SUFFIX, { force: true });
|
|
2818
|
+
if (from !== to) moveFile(from, to);
|
|
2819
|
+
const why = status === "pending" ? null : reason?.trim() || null;
|
|
2820
|
+
if (why) fs10.writeFileSync(to + REASON_SUFFIX, `${why}
|
|
2821
|
+
`);
|
|
2822
|
+
const st = fs10.statSync(to);
|
|
2823
|
+
ctx.store.updateIngest(item.id, {
|
|
2824
|
+
path: relativeToData(ctx.paths, to),
|
|
2825
|
+
status,
|
|
2826
|
+
reason: why,
|
|
2827
|
+
recipe_slug: null,
|
|
2828
|
+
side: null,
|
|
2829
|
+
mtime: Math.round(st.mtimeMs)
|
|
2830
|
+
});
|
|
2831
|
+
out.moved++;
|
|
2832
|
+
}
|
|
2833
|
+
return out;
|
|
2834
|
+
}
|
|
2835
|
+
function planAttach(ctx, slug, ingest) {
|
|
2836
|
+
const errors = [];
|
|
2837
|
+
const items = ctx.store.ingestByIds(ingest.map((i) => i.id));
|
|
2838
|
+
const dir = path7.join(ctx.paths.images, slug);
|
|
2839
|
+
const taken = /* @__PURE__ */ new Set();
|
|
2840
|
+
const targets = [];
|
|
2841
|
+
for (const { id, side } of ingest) {
|
|
2842
|
+
const item = items.find((i) => i.id === id);
|
|
2843
|
+
if (!item) {
|
|
2844
|
+
errors.push(`unknown photo #${id}`);
|
|
2845
|
+
continue;
|
|
2846
|
+
}
|
|
2847
|
+
if (item.status === "done") {
|
|
2848
|
+
if (item.recipe_slug !== slug)
|
|
2849
|
+
errors.push(`photo #${id} is already attached to ${item.recipe_slug}`);
|
|
2850
|
+
else targets.push({ item, side: item.side ?? side, rel: item.path });
|
|
2851
|
+
continue;
|
|
2852
|
+
}
|
|
2853
|
+
if (!fs10.existsSync(path7.join(ctx.paths.data, item.path))) {
|
|
2854
|
+
errors.push(`photo #${id} is missing on disk (${item.path})`);
|
|
2855
|
+
continue;
|
|
2856
|
+
}
|
|
2857
|
+
const ext = path7.extname(item.path).toLowerCase() || ".jpg";
|
|
2858
|
+
const name = freeName(dir, `${side}${ext}`, taken);
|
|
2859
|
+
taken.add(path7.join(dir, name));
|
|
2860
|
+
targets.push({ item, side, rel: relativeToData(ctx.paths, path7.join(dir, name)) });
|
|
2861
|
+
}
|
|
2862
|
+
return { targets, errors };
|
|
2863
|
+
}
|
|
2864
|
+
function finalizeAttach(ctx, slug, targets) {
|
|
2865
|
+
for (const t of targets) {
|
|
2866
|
+
if (t.item.status === "done" && t.item.path === t.rel) continue;
|
|
2867
|
+
const from = path7.join(ctx.paths.data, t.item.path);
|
|
2868
|
+
const to = path7.join(ctx.paths.data, t.rel);
|
|
2869
|
+
fs10.rmSync(from + REASON_SUFFIX, { force: true });
|
|
2870
|
+
moveFile(from, to);
|
|
2871
|
+
const st = fs10.statSync(to);
|
|
2872
|
+
ctx.store.updateIngest(t.item.id, {
|
|
2873
|
+
path: t.rel,
|
|
2874
|
+
status: "done",
|
|
2875
|
+
recipe_slug: slug,
|
|
2876
|
+
side: t.side,
|
|
2877
|
+
reason: null,
|
|
2878
|
+
mtime: Math.round(st.mtimeMs)
|
|
2879
|
+
});
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
function mergeImages(existing, targets, claimed = []) {
|
|
2883
|
+
const order = { front: 0, back: 1, other: 2 };
|
|
2884
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
2885
|
+
for (const e of existing.extraction?.images ?? []) byFile.set(e.file, e);
|
|
2886
|
+
for (const t of targets) byFile.set(t.rel, { file: t.rel, side: t.side });
|
|
2887
|
+
for (const c of claimed)
|
|
2888
|
+
if (c.file.startsWith("images/") && !byFile.has(c.file)) byFile.set(c.file, c);
|
|
2889
|
+
const extraction = [...byFile.values()].sort(
|
|
2890
|
+
(a, b) => order[a.side] - order[b.side] || a.file.localeCompare(b.file)
|
|
2891
|
+
);
|
|
2892
|
+
const images = [.../* @__PURE__ */ new Set([...extraction.map((e) => e.file), ...existing.images])];
|
|
2893
|
+
return { images, extraction };
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
// src/pantry/pantry.ts
|
|
2897
|
+
var FUZZY_THRESHOLD2 = 0.85;
|
|
2898
|
+
function dice3(a, b) {
|
|
2899
|
+
const grams = (s) => {
|
|
2900
|
+
const g = /* @__PURE__ */ new Map();
|
|
2901
|
+
const t = ` ${s} `;
|
|
2902
|
+
for (let i = 0; i < t.length - 1; i++) {
|
|
2903
|
+
const k = t.slice(i, i + 2);
|
|
2904
|
+
g.set(k, (g.get(k) ?? 0) + 1);
|
|
2905
|
+
}
|
|
2906
|
+
return g;
|
|
2907
|
+
};
|
|
2908
|
+
const ga = grams(a);
|
|
2909
|
+
const gb = grams(b);
|
|
2910
|
+
let overlap = 0;
|
|
2911
|
+
for (const [k, n] of ga) overlap += Math.min(n, gb.get(k) ?? 0);
|
|
2912
|
+
const total = [...ga.values()].reduce((x, y) => x + y, 0) + [...gb.values()].reduce((x, y) => x + y, 0);
|
|
2913
|
+
return total === 0 ? 0 : 2 * overlap / total;
|
|
2914
|
+
}
|
|
2915
|
+
function resolveItem(ctx, raw, create) {
|
|
2916
|
+
const name = normalizeName(raw);
|
|
2917
|
+
if (!name) return void 0;
|
|
2918
|
+
const canonical = findCanonical(ctx.store, name);
|
|
2919
|
+
if (canonical) {
|
|
2920
|
+
const row = ctx.store.getIngredient(canonical);
|
|
2921
|
+
if (row) return { row, how: canonical === name ? "exact" : "alias" };
|
|
2922
|
+
}
|
|
2923
|
+
const best = ctx.store.fuzzyIngredients(name, 3)[0];
|
|
2924
|
+
if (best && dice3(name, best.alias) >= FUZZY_THRESHOLD2) {
|
|
2925
|
+
ctx.store.addAlias(best.row.id, name);
|
|
2926
|
+
return { row: best.row, how: "fuzzy" };
|
|
2927
|
+
}
|
|
2928
|
+
if (!create) return void 0;
|
|
2929
|
+
return { row: ctx.store.createIngredient({ canonical: name }), how: "new" };
|
|
2930
|
+
}
|
|
2931
|
+
function addToPantry(ctx, items) {
|
|
2932
|
+
return ctx.store.transaction(
|
|
2933
|
+
() => items.flatMap((item) => {
|
|
2934
|
+
const hit = resolveItem(ctx, item.name, true);
|
|
2935
|
+
if (!hit) return [];
|
|
2936
|
+
ctx.store.pantryUpsert(hit.row.id, item);
|
|
2937
|
+
return [{ name: item.name, canonical: hit.row.canonical, matched: hit.how }];
|
|
2938
|
+
})
|
|
2939
|
+
);
|
|
2940
|
+
}
|
|
2941
|
+
function removeFromPantry(ctx, names) {
|
|
2942
|
+
const inPantry = new Map(ctx.store.pantryList().map((p) => [p.ingredient_id, p.canonical]));
|
|
2943
|
+
const removed = [];
|
|
2944
|
+
const missing = [];
|
|
2945
|
+
const ids = [];
|
|
2946
|
+
for (const n of names) {
|
|
2947
|
+
const hit = resolveItem(ctx, n, false);
|
|
2948
|
+
if (hit && inPantry.has(hit.row.id)) {
|
|
2949
|
+
ids.push(hit.row.id);
|
|
2950
|
+
removed.push(hit.row.canonical);
|
|
2951
|
+
} else {
|
|
2952
|
+
missing.push(n);
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
ctx.store.pantryRemove(ids);
|
|
2956
|
+
return { removed, not_in_pantry: missing };
|
|
2957
|
+
}
|
|
2958
|
+
function listPantry(ctx) {
|
|
2959
|
+
return ctx.store.pantryList();
|
|
2960
|
+
}
|
|
2961
|
+
function availableIngredients(ctx, opts) {
|
|
2962
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2963
|
+
if (opts.use_pantry !== false) for (const p of ctx.store.pantryList()) ids.add(p.ingredient_id);
|
|
2964
|
+
const unknown = [];
|
|
2965
|
+
for (const raw of opts.ingredients ?? []) {
|
|
2966
|
+
const hit = resolveItem(ctx, raw, false);
|
|
2967
|
+
if (hit) ids.add(hit.row.id);
|
|
2968
|
+
else unknown.push(raw);
|
|
2969
|
+
}
|
|
2970
|
+
return { ids, unknown };
|
|
2971
|
+
}
|
|
2972
|
+
function lineAvailable(ctx, line, avail, cache) {
|
|
2973
|
+
if (avail.has(line.ingredient_id)) return true;
|
|
2974
|
+
if (!line.extra) return false;
|
|
2975
|
+
const extra = JSON.parse(line.extra);
|
|
2976
|
+
const idOf = (name) => {
|
|
2977
|
+
if (!cache.has(name))
|
|
2978
|
+
cache.set(name, ctx.store.getIngredient(findCanonical(ctx.store, name) ?? name)?.id ?? null);
|
|
2979
|
+
return cache.get(name) ?? null;
|
|
2980
|
+
};
|
|
2981
|
+
if (extra.alternatives?.some((a) => {
|
|
2982
|
+
const id = idOf(a);
|
|
2983
|
+
return id !== null && avail.has(id);
|
|
2984
|
+
})) {
|
|
2985
|
+
return true;
|
|
2986
|
+
}
|
|
2987
|
+
const comps = extra.components ?? [];
|
|
2988
|
+
return comps.length > 0 && comps.every((c) => {
|
|
2989
|
+
const id = idOf(c.canonical);
|
|
2990
|
+
return id !== null && avail.has(id);
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
2993
|
+
function scoreRecipes(ctx, avail, recipeIds) {
|
|
2994
|
+
const rows = ctx.store.recipeRequirements(recipeIds);
|
|
2995
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2996
|
+
const byRecipe = /* @__PURE__ */ new Map();
|
|
2997
|
+
for (const r of rows) {
|
|
2998
|
+
const list = byRecipe.get(r.recipe_id) ?? [];
|
|
2999
|
+
list.push(r);
|
|
3000
|
+
byRecipe.set(r.recipe_id, list);
|
|
3001
|
+
}
|
|
3002
|
+
const out = [];
|
|
3003
|
+
for (const lines of byRecipe.values()) {
|
|
3004
|
+
const first = lines[0];
|
|
3005
|
+
if (!first) continue;
|
|
3006
|
+
const required = lines.filter((l) => !l.staple && !l.optional);
|
|
3007
|
+
const missing = [];
|
|
3008
|
+
const fromPantry = [];
|
|
3009
|
+
for (const l of required) {
|
|
3010
|
+
if (lineAvailable(ctx, l, avail, cache)) fromPantry.push(l.canonical);
|
|
3011
|
+
else missing.push({ canonical: l.canonical, raw: l.raw });
|
|
3012
|
+
}
|
|
3013
|
+
const need = required.length;
|
|
3014
|
+
const have = need - missing.length;
|
|
3015
|
+
out.push({
|
|
3016
|
+
slug: first.slug,
|
|
3017
|
+
title: first.title,
|
|
3018
|
+
coverage: need === 0 ? 1 : Math.round(have / need * 100) / 100,
|
|
3019
|
+
have,
|
|
3020
|
+
need,
|
|
3021
|
+
missing,
|
|
3022
|
+
from_pantry: fromPantry,
|
|
3023
|
+
near_miss: missing.length > 0 && missing.length <= 2
|
|
3024
|
+
});
|
|
3025
|
+
}
|
|
3026
|
+
return out.sort(
|
|
3027
|
+
(a, b) => b.coverage - a.coverage || a.missing.length - b.missing.length || a.title.localeCompare(b.title)
|
|
3028
|
+
);
|
|
3029
|
+
}
|
|
3030
|
+
function matchRecipes(ctx, opts = {}) {
|
|
3031
|
+
const { ids, unknown } = availableIngredients(ctx, opts);
|
|
3032
|
+
const allowed = ctx.store.filterRecipeIds(expandFilters(ctx, opts.filters));
|
|
3033
|
+
let scope = opts.recipe_ids;
|
|
3034
|
+
if (allowed) scope = scope ? scope.filter((id) => allowed.has(id)) : [...allowed];
|
|
3035
|
+
const minCoverage = opts.min_coverage ?? 0;
|
|
3036
|
+
const results = scoreRecipes(ctx, ids, scope).filter((m) => m.coverage >= minCoverage).filter((m) => opts.max_missing === void 0 || m.missing.length <= opts.max_missing).slice(0, Math.min(Math.max(opts.limit ?? 10, 1), 100));
|
|
3037
|
+
return { results, available: ids.size, unknown_ingredients: unknown };
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
// src/web/server.ts
|
|
3041
|
+
var MAX_JSON = 1e6;
|
|
3042
|
+
var MAX_UPLOAD = 6e7;
|
|
3043
|
+
var HttpError = class extends Error {
|
|
3044
|
+
constructor(status, message) {
|
|
3045
|
+
super(message);
|
|
3046
|
+
this.status = status;
|
|
3047
|
+
}
|
|
3048
|
+
status;
|
|
3049
|
+
};
|
|
3050
|
+
var MIME = {
|
|
3051
|
+
".html": "text/html; charset=utf-8",
|
|
3052
|
+
".js": "text/javascript; charset=utf-8",
|
|
3053
|
+
".css": "text/css; charset=utf-8",
|
|
3054
|
+
".svg": "image/svg+xml",
|
|
3055
|
+
".json": "application/json",
|
|
3056
|
+
".png": "image/png",
|
|
3057
|
+
".jpg": "image/jpeg",
|
|
3058
|
+
".jpeg": "image/jpeg",
|
|
3059
|
+
".webp": "image/webp",
|
|
3060
|
+
".ico": "image/x-icon",
|
|
3061
|
+
".woff2": "font/woff2"
|
|
3062
|
+
};
|
|
3063
|
+
function readBody(req, limit) {
|
|
3064
|
+
return new Promise((resolve, reject) => {
|
|
3065
|
+
const chunks = [];
|
|
3066
|
+
let size = 0;
|
|
3067
|
+
req.on("data", (c) => {
|
|
3068
|
+
size += c.length;
|
|
3069
|
+
if (size > limit) {
|
|
3070
|
+
reject(new HttpError(413, "request body too large"));
|
|
3071
|
+
req.destroy();
|
|
3072
|
+
} else {
|
|
3073
|
+
chunks.push(c);
|
|
3074
|
+
}
|
|
3075
|
+
});
|
|
3076
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
3077
|
+
req.on("error", reject);
|
|
3078
|
+
});
|
|
3079
|
+
}
|
|
3080
|
+
async function readJson(req) {
|
|
3081
|
+
const body = await readBody(req, MAX_JSON);
|
|
3082
|
+
try {
|
|
3083
|
+
return JSON.parse(body.toString("utf8") || "{}");
|
|
3084
|
+
} catch {
|
|
3085
|
+
throw new HttpError(400, "invalid JSON body");
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
var num2 = (v) => v === null || v === "" ? void 0 : Number(v);
|
|
3089
|
+
var csv = (v) => v ? v.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
|
|
3090
|
+
function filtersFrom(url) {
|
|
3091
|
+
const p = url.searchParams;
|
|
3092
|
+
let tags;
|
|
3093
|
+
if (p.get("tags")) {
|
|
3094
|
+
try {
|
|
3095
|
+
tags = JSON.parse(p.get("tags") ?? "{}");
|
|
3096
|
+
} catch {
|
|
3097
|
+
throw new HttpError(400, 'tags must be JSON, e.g. {"cuisine":["thai"]}');
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
const nr = p.get("needs_review");
|
|
3101
|
+
return {
|
|
3102
|
+
tags,
|
|
3103
|
+
exclude_allergens: csv(p.get("exclude_allergens")),
|
|
3104
|
+
exclude_ingredients: csv(p.get("exclude_ingredients")),
|
|
3105
|
+
max_total_min: num2(p.get("max_total_min")),
|
|
3106
|
+
max_active_min: num2(p.get("max_active_min")),
|
|
3107
|
+
provider: p.get("provider") ?? void 0,
|
|
3108
|
+
needs_review: nr === null ? void 0 : nr === "true" || nr === "1"
|
|
3109
|
+
};
|
|
3110
|
+
}
|
|
3111
|
+
function safeImagePath(ctx, rel) {
|
|
3112
|
+
const abs = path8.resolve(ctx.paths.data, rel);
|
|
3113
|
+
const ok = [ctx.paths.images, ctx.paths.inbox].some((dir) => abs.startsWith(dir + path8.sep));
|
|
3114
|
+
if (!ok || !fs11.existsSync(abs) || !fs11.statSync(abs).isFile())
|
|
3115
|
+
throw new HttpError(404, "not found");
|
|
3116
|
+
return abs;
|
|
3117
|
+
}
|
|
3118
|
+
var thumbCache = /* @__PURE__ */ new Map();
|
|
3119
|
+
async function thumbnail(abs, width) {
|
|
3120
|
+
const key = `${abs}|${fs11.statSync(abs).mtimeMs}|${width}`;
|
|
3121
|
+
const hit = thumbCache.get(key);
|
|
3122
|
+
if (hit) return hit;
|
|
3123
|
+
const buf = await (await openImage(abs)).resize(width, width, { fit: "inside", withoutEnlargement: true }).jpeg({ quality: 78, mozjpeg: true }).toBuffer();
|
|
3124
|
+
if (thumbCache.size > 400) thumbCache.delete(thumbCache.keys().next().value);
|
|
3125
|
+
thumbCache.set(key, buf);
|
|
3126
|
+
return buf;
|
|
3127
|
+
}
|
|
3128
|
+
function routes(ctx, version) {
|
|
3129
|
+
const r = (method, pattern, handler) => ({
|
|
3130
|
+
method,
|
|
3131
|
+
pattern,
|
|
3132
|
+
handler
|
|
3133
|
+
});
|
|
3134
|
+
return [
|
|
3135
|
+
r("GET", /^\/api\/stats$/, () => ({
|
|
3136
|
+
...ctx.store.stats(),
|
|
3137
|
+
inbox: ctx.store.ingestCounts(),
|
|
3138
|
+
pantry: listPantry(ctx).length,
|
|
3139
|
+
providers: ctx.providers.list().map((p) => ({ id: p.id, name: p.name })),
|
|
3140
|
+
data_dir: ctx.paths.data,
|
|
3141
|
+
embeddings: ctx.embedder ? ctx.embedder.ready ? "ready" : "loading" : "disabled",
|
|
3142
|
+
version
|
|
3143
|
+
})),
|
|
3144
|
+
r("GET", /^\/api\/facets$/, () => {
|
|
3145
|
+
const used = {};
|
|
3146
|
+
for (const t of ctx.store.listTags()) {
|
|
3147
|
+
const list = used[t.category] ?? [];
|
|
3148
|
+
list.push({ name: t.name, count: t.count });
|
|
3149
|
+
used[t.category] = list;
|
|
3150
|
+
}
|
|
3151
|
+
return { used, vocabulary: ctx.vocab.summary(200) };
|
|
3152
|
+
}),
|
|
3153
|
+
r("GET", /^\/api\/search$/, async (_req, url) => {
|
|
3154
|
+
const p = url.searchParams;
|
|
3155
|
+
return searchRecipes(ctx, {
|
|
3156
|
+
query: p.get("q") ?? void 0,
|
|
3157
|
+
limit: num2(p.get("limit")) ?? 48,
|
|
3158
|
+
mode: p.get("mode") ?? void 0,
|
|
3159
|
+
filters: filtersFrom(url)
|
|
3160
|
+
});
|
|
3161
|
+
}),
|
|
3162
|
+
r("GET", /^\/api\/recipes\/([a-z0-9-]+)$/, (_req, _url, [slug]) => {
|
|
3163
|
+
const rec = ctx.store.getRecipe(slug ?? "");
|
|
3164
|
+
if (!rec) throw new HttpError(404, `no recipe "${slug}"`);
|
|
3165
|
+
const { ids } = availableIngredients(ctx, {});
|
|
3166
|
+
const have = new Set(
|
|
3167
|
+
rec.ingredients.filter((i) => {
|
|
3168
|
+
const row = ctx.store.getIngredient(i.canonical);
|
|
3169
|
+
return row ? ids.has(row.id) : false;
|
|
3170
|
+
}).map((i) => i.canonical)
|
|
3171
|
+
);
|
|
3172
|
+
const similar = ctx.store.summariesByIds(
|
|
3173
|
+
ctx.store.similarRecipes(rec.id, 6).map((s) => s.recipe_id)
|
|
3174
|
+
);
|
|
3175
|
+
const all = ctx.store.listRecipes({ limit: 1e4 });
|
|
3176
|
+
const derived = all.filter((s) => {
|
|
3177
|
+
const d = ctx.store.getRecipe(s.slug)?.derived_from ?? [];
|
|
3178
|
+
return d.includes(rec.slug);
|
|
3179
|
+
});
|
|
3180
|
+
const sources = ctx.store.summariesByIds(
|
|
3181
|
+
rec.derived_from.map((d) => ctx.store.getRecipe(d)?.id).filter((x) => x !== void 0)
|
|
3182
|
+
);
|
|
3183
|
+
return { recipe: rec, pantry_have: [...have], similar, derived, sources };
|
|
3184
|
+
}),
|
|
3185
|
+
r("GET", /^\/api\/files\/(.+)$/, () => {
|
|
3186
|
+
throw new HttpError(500, "handled by the file route");
|
|
3187
|
+
}),
|
|
3188
|
+
r("GET", /^\/api\/pantry$/, () => listPantry(ctx)),
|
|
3189
|
+
r("POST", /^\/api\/pantry$/, async (req) => {
|
|
3190
|
+
const body = await readJson(req);
|
|
3191
|
+
if (!body.items?.length) throw new HttpError(400, "items required");
|
|
3192
|
+
return { added: addToPantry(ctx, body.items), pantry: listPantry(ctx) };
|
|
3193
|
+
}),
|
|
3194
|
+
r("DELETE", /^\/api\/pantry$/, async (req) => {
|
|
3195
|
+
const body = await readJson(req);
|
|
3196
|
+
if (body.all) ctx.store.pantryClear();
|
|
3197
|
+
else removeFromPantry(ctx, body.names ?? []);
|
|
3198
|
+
return { pantry: listPantry(ctx) };
|
|
3199
|
+
}),
|
|
3200
|
+
r("GET", /^\/api\/pantry\/match$/, (_req, url) => {
|
|
3201
|
+
const p = url.searchParams;
|
|
3202
|
+
return matchRecipes(ctx, {
|
|
3203
|
+
ingredients: csv(p.get("ingredients")),
|
|
3204
|
+
min_coverage: num2(p.get("min_coverage")) ?? 0.3,
|
|
3205
|
+
max_missing: num2(p.get("max_missing")),
|
|
3206
|
+
limit: num2(p.get("limit")) ?? 30,
|
|
3207
|
+
filters: filtersFrom(url)
|
|
3208
|
+
});
|
|
3209
|
+
}),
|
|
3210
|
+
r(
|
|
3211
|
+
"GET",
|
|
3212
|
+
/^\/api\/ingredients$/,
|
|
3213
|
+
(_req, url) => ctx.store.listIngredients({
|
|
3214
|
+
query: url.searchParams.get("q") ?? void 0,
|
|
3215
|
+
limit: num2(url.searchParams.get("limit")) ?? 20
|
|
3216
|
+
})
|
|
3217
|
+
),
|
|
3218
|
+
r("GET", /^\/api\/graph$/, (_req, url) => {
|
|
3219
|
+
const p = url.searchParams;
|
|
3220
|
+
return buildGraph(ctx, {
|
|
3221
|
+
categories: csv(p.get("categories")),
|
|
3222
|
+
ingredients: p.get("ingredients") !== "0",
|
|
3223
|
+
min_degree: num2(p.get("min_degree")),
|
|
3224
|
+
cuisine: p.get("cuisine") ?? void 0,
|
|
3225
|
+
similar: num2(p.get("similar")),
|
|
3226
|
+
include_staples: p.get("staples") === "1"
|
|
3227
|
+
});
|
|
3228
|
+
}),
|
|
3229
|
+
r("GET", /^\/api\/inbox$/, (_req, url) => {
|
|
3230
|
+
const p = url.searchParams;
|
|
3231
|
+
const status = p.get("status");
|
|
3232
|
+
if (status && !INGEST_STATUSES.includes(status))
|
|
3233
|
+
throw new HttpError(400, "bad status");
|
|
3234
|
+
const all = ctx.store.allIngest();
|
|
3235
|
+
const items = ctx.store.listIngest({
|
|
3236
|
+
status: status ?? void 0,
|
|
3237
|
+
limit: num2(p.get("limit")) ?? 200,
|
|
3238
|
+
offset: num2(p.get("offset"))
|
|
3239
|
+
});
|
|
3240
|
+
return {
|
|
3241
|
+
counts: ctx.store.ingestCounts(),
|
|
3242
|
+
items: viewItems(items, all).map((v, n) => ({ ...v, path: items[n]?.path ?? "" }))
|
|
3243
|
+
};
|
|
3244
|
+
}),
|
|
3245
|
+
r("POST", /^\/api\/inbox\/upload$/, async (req) => {
|
|
3246
|
+
const name = decodeURIComponent(String(req.headers["x-filename"] ?? ""));
|
|
3247
|
+
if (!name) throw new HttpError(400, "X-Filename header required");
|
|
3248
|
+
const data = await readBody(req, MAX_UPLOAD);
|
|
3249
|
+
if (data.length === 0) throw new HttpError(400, "empty upload");
|
|
3250
|
+
const item = await addUpload(ctx, name, data);
|
|
3251
|
+
return { item: item ? viewItem(item) : null, counts: ctx.store.ingestCounts() };
|
|
3252
|
+
}),
|
|
3253
|
+
r("POST", /^\/api\/inbox\/rotate$/, async (req) => {
|
|
3254
|
+
const body = await readJson(req);
|
|
3255
|
+
if (typeof body.id !== "number" || ![90, 180, 270].includes(body.degrees ?? 0)) {
|
|
3256
|
+
throw new HttpError(400, "id and degrees (90|180|270, clockwise) required");
|
|
3257
|
+
}
|
|
3258
|
+
return { item: await rotateItem(ctx, body.id, body.degrees) };
|
|
3259
|
+
}),
|
|
3260
|
+
r("POST", /^\/api\/inbox\/resolve$/, async (req) => {
|
|
3261
|
+
const body = await readJson(req);
|
|
3262
|
+
if (!body.ids?.length || !["pending", "skipped", "failed"].includes(body.status ?? "")) {
|
|
3263
|
+
throw new HttpError(400, "ids and status (pending|skipped|failed) required");
|
|
3264
|
+
}
|
|
3265
|
+
return resolveItems(
|
|
3266
|
+
ctx,
|
|
3267
|
+
body.ids,
|
|
3268
|
+
body.status,
|
|
3269
|
+
body.reason
|
|
3270
|
+
);
|
|
3271
|
+
})
|
|
3272
|
+
];
|
|
3273
|
+
}
|
|
3274
|
+
var running = /* @__PURE__ */ new WeakMap();
|
|
3275
|
+
async function startUiServer(ctx, opts = {}) {
|
|
3276
|
+
const existing = running.get(ctx);
|
|
3277
|
+
if (existing) return existing;
|
|
3278
|
+
ctx.embedder?.load().then(
|
|
3279
|
+
() => ctx.embedQueue.kick(0),
|
|
3280
|
+
() => {
|
|
3281
|
+
}
|
|
3282
|
+
);
|
|
3283
|
+
const webDir = path8.join(packageRoot, "dist", "web");
|
|
3284
|
+
const table = routes(ctx, opts.version ?? "dev");
|
|
3285
|
+
const dev = opts.dev ?? process.env.PB_DEV === "1";
|
|
3286
|
+
let port = opts.port ?? Number(process.env.PB_PORT ?? DEFAULT_UI_PORT);
|
|
3287
|
+
const server = http.createServer(async (req, res) => {
|
|
3288
|
+
const send = (status, body, type = "application/json") => {
|
|
3289
|
+
const payload = type === "application/json" ? JSON.stringify(body) : body;
|
|
3290
|
+
res.writeHead(status, {
|
|
3291
|
+
"Content-Type": type,
|
|
3292
|
+
"Cache-Control": "no-store",
|
|
3293
|
+
"X-Content-Type-Options": "nosniff"
|
|
3294
|
+
});
|
|
3295
|
+
res.end(payload);
|
|
3296
|
+
};
|
|
3297
|
+
try {
|
|
3298
|
+
const host = String(req.headers.host ?? "");
|
|
3299
|
+
const allowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
|
3300
|
+
if (!allowedHosts.includes(host) && !(dev && /^(localhost|127\.0\.0\.1):\d+$/.test(host))) {
|
|
3301
|
+
throw new HttpError(403, "forbidden host");
|
|
3302
|
+
}
|
|
3303
|
+
const origin = req.headers.origin;
|
|
3304
|
+
if (origin && !allowedHosts.some((h) => origin === `http://${h}`) && !(dev && /^http:\/\/(localhost|127\.0\.0\.1):\d+$/.test(origin))) {
|
|
3305
|
+
throw new HttpError(403, "forbidden origin");
|
|
3306
|
+
}
|
|
3307
|
+
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
3308
|
+
const method = req.method ?? "GET";
|
|
3309
|
+
if (url.pathname.startsWith("/api/")) {
|
|
3310
|
+
if (method !== "GET" && req.headers["x-pantrybase"] !== "1")
|
|
3311
|
+
throw new HttpError(403, "missing X-Pantrybase header");
|
|
3312
|
+
ctx.sync();
|
|
3313
|
+
await maybeScanInbox(ctx);
|
|
3314
|
+
const file2 = /^\/api\/files\/(.+)$/.exec(url.pathname);
|
|
3315
|
+
if (file2 && method === "GET") {
|
|
3316
|
+
const abs = safeImagePath(ctx, decodeURIComponent(file2[1] ?? ""));
|
|
3317
|
+
const w = num2(url.searchParams.get("w"));
|
|
3318
|
+
if (w)
|
|
3319
|
+
return send(200, await thumbnail(abs, Math.min(Math.max(w, 64), 2400)), "image/jpeg");
|
|
3320
|
+
const ext = path8.extname(abs).toLowerCase();
|
|
3321
|
+
if (ext === ".heic" || ext === ".heif")
|
|
3322
|
+
return send(200, await thumbnail(abs, 2400), "image/jpeg");
|
|
3323
|
+
return send(200, fs11.readFileSync(abs), MIME[ext] ?? "application/octet-stream");
|
|
3324
|
+
}
|
|
3325
|
+
for (const route of table) {
|
|
3326
|
+
if (route.method !== method) continue;
|
|
3327
|
+
const m = route.pattern.exec(url.pathname);
|
|
3328
|
+
if (m) return send(200, await route.handler(req, url, m.slice(1)));
|
|
3329
|
+
}
|
|
3330
|
+
throw new HttpError(404, `no route ${method} ${url.pathname}`);
|
|
3331
|
+
}
|
|
3332
|
+
if (method !== "GET") throw new HttpError(405, "method not allowed");
|
|
3333
|
+
if (!fs11.existsSync(webDir)) {
|
|
3334
|
+
return send(
|
|
3335
|
+
503,
|
|
3336
|
+
"The web UI is not built. Run `npm run build:web` in the pantrybase repo.",
|
|
3337
|
+
"text/plain; charset=utf-8"
|
|
3338
|
+
);
|
|
3339
|
+
}
|
|
3340
|
+
const rel = path8.normalize(decodeURIComponent(url.pathname)).replace(/^([/\\])+/, "");
|
|
3341
|
+
let file = path8.join(webDir, rel);
|
|
3342
|
+
if (!file.startsWith(webDir) || !fs11.existsSync(file) || fs11.statSync(file).isDirectory()) {
|
|
3343
|
+
file = path8.join(webDir, "index.html");
|
|
3344
|
+
}
|
|
3345
|
+
const type = MIME[path8.extname(file)] ?? "application/octet-stream";
|
|
3346
|
+
res.writeHead(200, {
|
|
3347
|
+
"Content-Type": type,
|
|
3348
|
+
"Cache-Control": file.includes(`${path8.sep}assets${path8.sep}`) ? "public, max-age=31536000, immutable" : "no-cache",
|
|
3349
|
+
"X-Content-Type-Options": "nosniff"
|
|
3350
|
+
});
|
|
3351
|
+
res.end(fs11.readFileSync(file));
|
|
3352
|
+
} catch (e) {
|
|
3353
|
+
const status = e instanceof HttpError ? e.status : 500;
|
|
3354
|
+
if (status === 500) log("ui error:", e);
|
|
3355
|
+
send(status, { error: e instanceof Error ? e.message : String(e) });
|
|
3356
|
+
}
|
|
3357
|
+
});
|
|
3358
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
3359
|
+
try {
|
|
3360
|
+
await new Promise((resolve, reject) => {
|
|
3361
|
+
server.once("error", reject);
|
|
3362
|
+
server.listen(port, "127.0.0.1", () => {
|
|
3363
|
+
server.off("error", reject);
|
|
3364
|
+
resolve();
|
|
3365
|
+
});
|
|
3366
|
+
});
|
|
3367
|
+
break;
|
|
3368
|
+
} catch (e) {
|
|
3369
|
+
if (e.code !== "EADDRINUSE" || attempt === 9) throw e;
|
|
3370
|
+
port++;
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
const ui = {
|
|
3374
|
+
url: `http://127.0.0.1:${port}`,
|
|
3375
|
+
port,
|
|
3376
|
+
close: () => new Promise((resolve) => {
|
|
3377
|
+
running.delete(ctx);
|
|
3378
|
+
server.close(() => resolve());
|
|
3379
|
+
})
|
|
3380
|
+
};
|
|
3381
|
+
running.set(ctx, ui);
|
|
3382
|
+
return ui;
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
export {
|
|
3386
|
+
MODEL_ID,
|
|
3387
|
+
packageRoot,
|
|
3388
|
+
packageVersion,
|
|
3389
|
+
homeDir,
|
|
3390
|
+
modelsDir,
|
|
3391
|
+
resolveProjectRoot,
|
|
3392
|
+
dataPaths,
|
|
3393
|
+
log,
|
|
3394
|
+
Embedder,
|
|
3395
|
+
ALL_CATEGORIES,
|
|
3396
|
+
SOURCE_KINDS,
|
|
3397
|
+
IMAGE_SIDES,
|
|
3398
|
+
INGEST_STATUSES,
|
|
3399
|
+
recipeInputSchema,
|
|
3400
|
+
normalizeName,
|
|
3401
|
+
normalizeRecipe,
|
|
3402
|
+
prepareRecipe,
|
|
3403
|
+
writeRecipeFile,
|
|
3404
|
+
recipeFileExists,
|
|
3405
|
+
reconcile,
|
|
3406
|
+
openContext,
|
|
3407
|
+
closeAllContexts,
|
|
3408
|
+
searchRecipes,
|
|
3409
|
+
MODEL_MAX_PX,
|
|
3410
|
+
imageForModel,
|
|
3411
|
+
maybeScanInbox,
|
|
3412
|
+
importPaths,
|
|
3413
|
+
viewItems,
|
|
3414
|
+
nextBatch,
|
|
3415
|
+
rotateItem,
|
|
3416
|
+
resolveItems,
|
|
3417
|
+
planAttach,
|
|
3418
|
+
finalizeAttach,
|
|
3419
|
+
mergeImages,
|
|
3420
|
+
resolveItem,
|
|
3421
|
+
addToPantry,
|
|
3422
|
+
removeFromPantry,
|
|
3423
|
+
listPantry,
|
|
3424
|
+
availableIngredients,
|
|
3425
|
+
scoreRecipes,
|
|
3426
|
+
matchRecipes,
|
|
3427
|
+
startUiServer
|
|
3428
|
+
};
|
|
3429
|
+
//# sourceMappingURL=chunk-QUWSBZ2V.js.map
|