pantrybase 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -0
- package/dist/{chunk-QUWSBZ2V.js → chunk-IUQQ3I2E.js} +512 -3
- package/dist/cli.js +1 -1
- package/dist/server.js +190 -30
- package/dist/web/assets/{graph-qSs25SSD.js → graph-70CI6xc-.js} +1 -1
- package/dist/web/assets/{index-Dsc3USAJ.css → index--7z6Fb_H.css} +1 -1
- package/dist/web/assets/index-PBzjTL7i.js +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/skills/cook/SKILL.md +9 -1
- package/dist/web/assets/index-lHc1E7AV.js +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantrybase",
|
|
3
3
|
"displayName": "Pantrybase",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"description": "Local-first recipe knowledge base for Claude Code: capture meal-box recipe cards from photos, search by ingredient, tag, allergen or mood, match recipes to your pantry, and synthesize new recipes. SQLite FTS5 + vector search, markdown recipes as the source of truth.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Ryan Dick"
|
package/README.md
CHANGED
|
@@ -79,6 +79,9 @@ steps:
|
|
|
79
79
|
| `pb_search` | Hybrid keyword + semantic search with tag, allergen, time and review filters |
|
|
80
80
|
| `pb_get_recipe` | One recipe, compact or full |
|
|
81
81
|
| `pb_pantry_match` | Recipes ranked by pantry coverage, with what's missing |
|
|
82
|
+
| `pb_plan_suggest` | Meal planning: recipes that share the shopping of a seed recipe or the current plan; `auto: n` fills a plan |
|
|
83
|
+
| `pb_plan` | The saved meal plan (shared with the web UI): get, set, add, remove, servings |
|
|
84
|
+
| `pb_plan_shopping_list` | One grocery list for the plan, by aisle with merged quantities; asks you to confirm the pantry items it relies on |
|
|
82
85
|
| `pb_synthesis_context` | Source recipes and shared patterns for inventing a new recipe |
|
|
83
86
|
| `pb_list_tags` | Tags and facets in use, plus the vocabulary |
|
|
84
87
|
| `pb_pantry_list` / `_add` / `_remove` | Manage the pantry |
|
|
@@ -98,6 +101,7 @@ Skills: `/pantrybase:ingest` (capture cards) and `/pantrybase:cook` (find, match
|
|
|
98
101
|
|
|
99
102
|
- **Recipes** — search with facets; recipe pages with photos, pantry ticks, steps, nutrition, allergens and lineage
|
|
100
103
|
- **Pantry** — your inventory and *what can I cook*
|
|
104
|
+
- **Plan** — pick a recipe, see which others share its shopping (what's already on the list, what's in the pantry, what each adds), auto-fill a week, confirm the pantry items the plan counts on, and get one grocery list by aisle
|
|
101
105
|
- **Graph** — recipes, ingredients and tags as a navigable network
|
|
102
106
|
- **Inbox** — drag-and-drop photo upload and the extraction queue
|
|
103
107
|
- **Tags** — the whole vocabulary at a glance
|
|
@@ -1925,8 +1925,8 @@ var SqliteAdapter = class {
|
|
|
1925
1925
|
const where = recipeIds ? `WHERE ri.recipe_id IN (${placeholders(recipeIds.length)})` : "";
|
|
1926
1926
|
if (recipeIds && recipeIds.length === 0) return [];
|
|
1927
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
|
|
1928
|
+
`SELECT ri.recipe_id, r.slug, r.title, ri.ingredient_id, i.canonical, ri.raw, ri.qty, ri.unit,
|
|
1929
|
+
i.category, (ri.staple OR i.is_staple) AS staple, ri.optional, ri.extra
|
|
1930
1930
|
FROM recipe_ingredients ri
|
|
1931
1931
|
JOIN ingredients i ON i.id = ri.ingredient_id
|
|
1932
1932
|
JOIN recipes r ON r.id = ri.recipe_id
|
|
@@ -3037,6 +3037,469 @@ function matchRecipes(ctx, opts = {}) {
|
|
|
3037
3037
|
return { results, available: ids.size, unknown_ingredients: unknown };
|
|
3038
3038
|
}
|
|
3039
3039
|
|
|
3040
|
+
// src/plan/plan.ts
|
|
3041
|
+
var PLAN_META_KEY = "meal_plan";
|
|
3042
|
+
function getPlan(ctx) {
|
|
3043
|
+
const raw = ctx.store.getMeta(PLAN_META_KEY);
|
|
3044
|
+
if (!raw) return { items: [], confirmed: [], updated_at: "" };
|
|
3045
|
+
try {
|
|
3046
|
+
const p = JSON.parse(raw);
|
|
3047
|
+
return {
|
|
3048
|
+
items: (p.items ?? []).filter((i) => ctx.store.getRecipe(i.slug)),
|
|
3049
|
+
confirmed: p.confirmed ?? [],
|
|
3050
|
+
updated_at: p.updated_at ?? ""
|
|
3051
|
+
};
|
|
3052
|
+
} catch {
|
|
3053
|
+
return { items: [], confirmed: [], updated_at: "" };
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
function savePlan(ctx, plan) {
|
|
3057
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3058
|
+
const items = [];
|
|
3059
|
+
for (const i of plan.items) {
|
|
3060
|
+
if (seen.has(i.slug) || !ctx.store.getRecipe(i.slug)) continue;
|
|
3061
|
+
seen.add(i.slug);
|
|
3062
|
+
items.push(i.servings ? { slug: i.slug, servings: i.servings } : { slug: i.slug });
|
|
3063
|
+
}
|
|
3064
|
+
const stored = {
|
|
3065
|
+
items,
|
|
3066
|
+
confirmed: [...new Set(plan.confirmed)],
|
|
3067
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3068
|
+
};
|
|
3069
|
+
ctx.store.setMeta(PLAN_META_KEY, JSON.stringify(stored));
|
|
3070
|
+
return stored;
|
|
3071
|
+
}
|
|
3072
|
+
var CATEGORY_WEIGHT = {
|
|
3073
|
+
herb: 1.5,
|
|
3074
|
+
protein: 1.3,
|
|
3075
|
+
seafood: 1.3,
|
|
3076
|
+
vegetable: 1.2,
|
|
3077
|
+
fruit: 1.2,
|
|
3078
|
+
dairy: 1.2
|
|
3079
|
+
};
|
|
3080
|
+
var DEFAULT_WEIGHT = 0.7;
|
|
3081
|
+
function wasteWeight(category) {
|
|
3082
|
+
return category && CATEGORY_WEIGHT[category] || DEFAULT_WEIGHT;
|
|
3083
|
+
}
|
|
3084
|
+
var AISLE_ORDER = [
|
|
3085
|
+
"fruit & veg",
|
|
3086
|
+
"meat, fish & eggs",
|
|
3087
|
+
"dairy & chilled",
|
|
3088
|
+
"bakery & grains",
|
|
3089
|
+
"pantry",
|
|
3090
|
+
"spices",
|
|
3091
|
+
"other"
|
|
3092
|
+
];
|
|
3093
|
+
var AISLES = {
|
|
3094
|
+
vegetable: "fruit & veg",
|
|
3095
|
+
fruit: "fruit & veg",
|
|
3096
|
+
herb: "fruit & veg",
|
|
3097
|
+
protein: "meat, fish & eggs",
|
|
3098
|
+
seafood: "meat, fish & eggs",
|
|
3099
|
+
dairy: "dairy & chilled",
|
|
3100
|
+
grain: "bakery & grains",
|
|
3101
|
+
legume: "pantry",
|
|
3102
|
+
baking: "pantry",
|
|
3103
|
+
nut: "pantry",
|
|
3104
|
+
stock: "pantry",
|
|
3105
|
+
oil: "pantry",
|
|
3106
|
+
condiment: "pantry",
|
|
3107
|
+
spice: "spices",
|
|
3108
|
+
seasoning: "spices"
|
|
3109
|
+
};
|
|
3110
|
+
function aisleOf(category) {
|
|
3111
|
+
return category && AISLES[category] || "other";
|
|
3112
|
+
}
|
|
3113
|
+
var UNITS = {
|
|
3114
|
+
g: ["g", 1],
|
|
3115
|
+
gram: ["g", 1],
|
|
3116
|
+
grams: ["g", 1],
|
|
3117
|
+
kg: ["g", 1e3],
|
|
3118
|
+
ml: ["ml", 1],
|
|
3119
|
+
l: ["ml", 1e3],
|
|
3120
|
+
litre: ["ml", 1e3],
|
|
3121
|
+
liter: ["ml", 1e3],
|
|
3122
|
+
tbsp: ["tbsp", 1],
|
|
3123
|
+
tbs: ["tbsp", 1],
|
|
3124
|
+
tablespoon: ["tbsp", 1],
|
|
3125
|
+
tablespoons: ["tbsp", 1],
|
|
3126
|
+
tsp: ["tsp", 1],
|
|
3127
|
+
teaspoon: ["tsp", 1],
|
|
3128
|
+
teaspoons: ["tsp", 1],
|
|
3129
|
+
piece: ["", 1],
|
|
3130
|
+
pieces: ["", 1],
|
|
3131
|
+
pc: ["", 1],
|
|
3132
|
+
pcs: ["", 1],
|
|
3133
|
+
x: ["", 1],
|
|
3134
|
+
whole: ["", 1],
|
|
3135
|
+
tin: ["tin", 1],
|
|
3136
|
+
tins: ["tin", 1],
|
|
3137
|
+
can: ["tin", 1],
|
|
3138
|
+
cans: ["tin", 1],
|
|
3139
|
+
cup: ["cup", 1],
|
|
3140
|
+
cups: ["cup", 1],
|
|
3141
|
+
bunch: ["bunch", 1],
|
|
3142
|
+
bunches: ["bunch", 1],
|
|
3143
|
+
clove: ["clove", 1],
|
|
3144
|
+
cloves: ["clove", 1],
|
|
3145
|
+
sachet: ["sachet", 1],
|
|
3146
|
+
sachets: ["sachet", 1],
|
|
3147
|
+
pack: ["pack", 1],
|
|
3148
|
+
packs: ["pack", 1],
|
|
3149
|
+
packet: ["pack", 1],
|
|
3150
|
+
slice: ["slice", 1],
|
|
3151
|
+
slices: ["slice", 1],
|
|
3152
|
+
sprig: ["sprig", 1],
|
|
3153
|
+
sprigs: ["sprig", 1]
|
|
3154
|
+
};
|
|
3155
|
+
function toAmount(qty, unit, scale) {
|
|
3156
|
+
if (qty === null || qty === void 0 || !Number.isFinite(qty)) return void 0;
|
|
3157
|
+
const key = (unit ?? "").trim().toLowerCase();
|
|
3158
|
+
const [u, mult] = UNITS[key] ?? [key, 1];
|
|
3159
|
+
return { qty: qty * mult * scale, unit: u };
|
|
3160
|
+
}
|
|
3161
|
+
function fmtNumber(n) {
|
|
3162
|
+
const r = Math.round(n * 10) / 10;
|
|
3163
|
+
return Number.isInteger(r) ? String(r) : r.toFixed(1);
|
|
3164
|
+
}
|
|
3165
|
+
function formatAmount(parts, unmeasured = 0) {
|
|
3166
|
+
const sums = /* @__PURE__ */ new Map();
|
|
3167
|
+
for (const p of parts) sums.set(p.unit, (sums.get(p.unit) ?? 0) + p.qty);
|
|
3168
|
+
const out = [];
|
|
3169
|
+
for (const [unit, qty] of sums) {
|
|
3170
|
+
if (unit === "g" && qty >= 1e3) out.push(`${fmtNumber(qty / 1e3)} kg`);
|
|
3171
|
+
else if (unit === "ml" && qty >= 1e3) out.push(`${fmtNumber(qty / 1e3)} l`);
|
|
3172
|
+
else out.push(unit ? `${fmtNumber(qty)} ${unit}` : fmtNumber(qty));
|
|
3173
|
+
}
|
|
3174
|
+
if (unmeasured > 0 && out.length) out.push(`+${unmeasured} unmeasured`);
|
|
3175
|
+
return out.length ? out.join(" + ") : null;
|
|
3176
|
+
}
|
|
3177
|
+
function loadProfiles(ctx, ids) {
|
|
3178
|
+
const map = /* @__PURE__ */ new Map();
|
|
3179
|
+
for (const r of ctx.store.recipeRequirements(ids)) {
|
|
3180
|
+
let p = map.get(r.recipe_id);
|
|
3181
|
+
if (!p) {
|
|
3182
|
+
p = {
|
|
3183
|
+
id: r.recipe_id,
|
|
3184
|
+
slug: r.slug,
|
|
3185
|
+
title: r.title,
|
|
3186
|
+
required: [],
|
|
3187
|
+
optional: [],
|
|
3188
|
+
staples: [],
|
|
3189
|
+
weight: 0
|
|
3190
|
+
};
|
|
3191
|
+
map.set(r.recipe_id, p);
|
|
3192
|
+
}
|
|
3193
|
+
if (r.staple) p.staples.push(r);
|
|
3194
|
+
else if (r.optional) p.optional.push(r);
|
|
3195
|
+
else {
|
|
3196
|
+
p.required.push(r);
|
|
3197
|
+
p.weight += wasteWeight(r.category);
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
return map;
|
|
3201
|
+
}
|
|
3202
|
+
function resolveSlugs(ctx, slugs) {
|
|
3203
|
+
const ids = [];
|
|
3204
|
+
const unknown = [];
|
|
3205
|
+
for (const s of slugs) {
|
|
3206
|
+
const rec = ctx.store.getRecipe(s);
|
|
3207
|
+
if (rec) ids.push(rec.id);
|
|
3208
|
+
else unknown.push(s);
|
|
3209
|
+
}
|
|
3210
|
+
return { ids, unknown };
|
|
3211
|
+
}
|
|
3212
|
+
var VARIETY_PENALTY = 0.15;
|
|
3213
|
+
var BEAM_WIDTH = 4;
|
|
3214
|
+
var BEAM_FANOUT = 12;
|
|
3215
|
+
var Planner = class {
|
|
3216
|
+
constructor(ctx, usePantry) {
|
|
3217
|
+
this.ctx = ctx;
|
|
3218
|
+
this.avail = usePantry ? availableIngredients(ctx, {}).ids : /* @__PURE__ */ new Set();
|
|
3219
|
+
this.profiles = loadProfiles(ctx);
|
|
3220
|
+
for (const p of this.profiles.values()) this.bySlug.set(p.slug, p);
|
|
3221
|
+
for (const s of ctx.store.listRecipes({ limit: 1e4 })) this.summaries.set(s.id, s);
|
|
3222
|
+
}
|
|
3223
|
+
ctx;
|
|
3224
|
+
avail;
|
|
3225
|
+
profiles;
|
|
3226
|
+
bySlug = /* @__PURE__ */ new Map();
|
|
3227
|
+
summaries = /* @__PURE__ */ new Map();
|
|
3228
|
+
cache = /* @__PURE__ */ new Map();
|
|
3229
|
+
dishesOf(id) {
|
|
3230
|
+
return this.summaries.get(id)?.tags.dish ?? [];
|
|
3231
|
+
}
|
|
3232
|
+
state(ids) {
|
|
3233
|
+
const s = {
|
|
3234
|
+
profiles: [],
|
|
3235
|
+
buy: /* @__PURE__ */ new Map(),
|
|
3236
|
+
buyIds: /* @__PURE__ */ new Set(),
|
|
3237
|
+
pantryIds: /* @__PURE__ */ new Set(),
|
|
3238
|
+
demand: 0,
|
|
3239
|
+
dishes: /* @__PURE__ */ new Map(),
|
|
3240
|
+
path: []
|
|
3241
|
+
};
|
|
3242
|
+
for (const id of ids) {
|
|
3243
|
+
const p = this.profiles.get(id);
|
|
3244
|
+
if (p) this.add(s, p);
|
|
3245
|
+
}
|
|
3246
|
+
return s;
|
|
3247
|
+
}
|
|
3248
|
+
add(s, p) {
|
|
3249
|
+
s.profiles.push(p);
|
|
3250
|
+
s.demand += p.weight;
|
|
3251
|
+
for (const l of p.required) {
|
|
3252
|
+
if (lineAvailable(this.ctx, l, this.avail, this.cache)) s.pantryIds.add(l.ingredient_id);
|
|
3253
|
+
else if (!s.buy.has(l.ingredient_id)) {
|
|
3254
|
+
s.buy.set(l.ingredient_id, l);
|
|
3255
|
+
s.buyIds.add(l.ingredient_id);
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
for (const d of this.dishesOf(p.id)) s.dishes.set(d, (s.dishes.get(d) ?? 0) + 1);
|
|
3259
|
+
}
|
|
3260
|
+
extend(s, p, sug) {
|
|
3261
|
+
const next = {
|
|
3262
|
+
profiles: [...s.profiles],
|
|
3263
|
+
buy: new Map(s.buy),
|
|
3264
|
+
buyIds: new Set(s.buyIds),
|
|
3265
|
+
pantryIds: new Set(s.pantryIds),
|
|
3266
|
+
demand: s.demand,
|
|
3267
|
+
dishes: new Map(s.dishes),
|
|
3268
|
+
path: [...s.path, sug]
|
|
3269
|
+
};
|
|
3270
|
+
this.add(next, p);
|
|
3271
|
+
return next;
|
|
3272
|
+
}
|
|
3273
|
+
evaluate(s, p, variety) {
|
|
3274
|
+
const shares = [];
|
|
3275
|
+
const pantry = [];
|
|
3276
|
+
const fresh = [];
|
|
3277
|
+
for (const l of p.required) {
|
|
3278
|
+
if (lineAvailable(this.ctx, l, this.avail, this.cache)) pantry.push(l);
|
|
3279
|
+
else if (lineAvailable(this.ctx, l, s.buyIds, this.cache)) shares.push(l);
|
|
3280
|
+
else fresh.push(l);
|
|
3281
|
+
}
|
|
3282
|
+
const w = (rows) => rows.reduce((n, r) => n + wasteWeight(r.category), 0);
|
|
3283
|
+
const reuse = p.weight === 0 ? 0 : (w(shares) + w(pantry)) / p.weight;
|
|
3284
|
+
const repeated = this.dishesOf(p.id).filter((d) => s.dishes.has(d));
|
|
3285
|
+
const penalty = variety ? repeated.length * VARIETY_PENALTY : 0;
|
|
3286
|
+
const names = (rows) => [...new Set(rows.map((r) => r.canonical))];
|
|
3287
|
+
return {
|
|
3288
|
+
slug: p.slug,
|
|
3289
|
+
title: p.title,
|
|
3290
|
+
total_min: this.summaries.get(p.id)?.total_min ?? null,
|
|
3291
|
+
reuse: Math.round(reuse * 100) / 100,
|
|
3292
|
+
shares: names(shares),
|
|
3293
|
+
from_pantry: names(pantry),
|
|
3294
|
+
new: names(fresh),
|
|
3295
|
+
new_cost: Math.round(w(fresh) * 10) / 10,
|
|
3296
|
+
repeats: repeated.length ? repeated.join(", ") : void 0,
|
|
3297
|
+
score: reuse - penalty
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
rank(s, allowed, variety) {
|
|
3301
|
+
const planned = new Set(s.profiles.map((p) => p.id));
|
|
3302
|
+
const out = [];
|
|
3303
|
+
for (const p of this.profiles.values()) {
|
|
3304
|
+
if (planned.has(p.id) || p.required.length === 0) continue;
|
|
3305
|
+
if (allowed && !allowed.has(p.id)) continue;
|
|
3306
|
+
out.push(this.evaluate(s, p, variety));
|
|
3307
|
+
}
|
|
3308
|
+
return out.sort(
|
|
3309
|
+
(a, b) => b.score - a.score || a.new.length - b.new.length || b.shares.length - a.shares.length || a.title.localeCompare(b.title)
|
|
3310
|
+
);
|
|
3311
|
+
}
|
|
3312
|
+
objective(s, variety) {
|
|
3313
|
+
let repeats = 0;
|
|
3314
|
+
for (const n of s.dishes.values()) if (n > 1) repeats += n - 1;
|
|
3315
|
+
return this.reuse(s) - (variety ? repeats * VARIETY_PENALTY : 0);
|
|
3316
|
+
}
|
|
3317
|
+
reuse(s) {
|
|
3318
|
+
if (s.demand === 0) return 0;
|
|
3319
|
+
let bought = 0;
|
|
3320
|
+
for (const l of s.buy.values()) bought += wasteWeight(l.category);
|
|
3321
|
+
return Math.max(0, 1 - bought / s.demand);
|
|
3322
|
+
}
|
|
3323
|
+
summary(s, servings) {
|
|
3324
|
+
const users = /* @__PURE__ */ new Map();
|
|
3325
|
+
for (const p of s.profiles) {
|
|
3326
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3327
|
+
for (const l of p.required) {
|
|
3328
|
+
if (s.buyIds.has(l.ingredient_id) && !seen.has(l.ingredient_id)) {
|
|
3329
|
+
seen.add(l.ingredient_id);
|
|
3330
|
+
users.set(l.ingredient_id, (users.get(l.ingredient_id) ?? 0) + 1);
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
return {
|
|
3335
|
+
recipes: s.profiles.map((p) => ({
|
|
3336
|
+
slug: p.slug,
|
|
3337
|
+
title: p.title,
|
|
3338
|
+
servings: servings?.get(p.slug) ?? void 0
|
|
3339
|
+
})),
|
|
3340
|
+
to_buy: s.buy.size,
|
|
3341
|
+
from_pantry: s.pantryIds.size,
|
|
3342
|
+
shared: [...users.values()].filter((n) => n >= 2).length,
|
|
3343
|
+
reuse: Math.round(this.reuse(s) * 100) / 100
|
|
3344
|
+
};
|
|
3345
|
+
}
|
|
3346
|
+
};
|
|
3347
|
+
function planContext(ctx, opts) {
|
|
3348
|
+
const slugs = [...opts.plan ?? getPlan(ctx).items.map((i) => i.slug)];
|
|
3349
|
+
if (opts.seed && !slugs.includes(opts.seed)) slugs.push(opts.seed);
|
|
3350
|
+
const { ids, unknown } = resolveSlugs(ctx, slugs);
|
|
3351
|
+
return { ids, slugs: slugs.filter((s) => !unknown.includes(s)), unknown };
|
|
3352
|
+
}
|
|
3353
|
+
function suggestRecipes(ctx, opts = {}) {
|
|
3354
|
+
const planner = new Planner(ctx, opts.use_pantry !== false);
|
|
3355
|
+
const { ids, unknown } = planContext(ctx, opts);
|
|
3356
|
+
const state = planner.state(ids);
|
|
3357
|
+
const allowed = ctx.store.filterRecipeIds(expandFilters(ctx, opts.filters));
|
|
3358
|
+
const limit = Math.min(Math.max(opts.limit ?? 10, 1), 50);
|
|
3359
|
+
return {
|
|
3360
|
+
plan: planner.summary(state),
|
|
3361
|
+
suggestions: planner.rank(state, allowed, opts.variety !== false).slice(0, limit),
|
|
3362
|
+
unknown_slugs: unknown
|
|
3363
|
+
};
|
|
3364
|
+
}
|
|
3365
|
+
function autofillPlan(ctx, opts) {
|
|
3366
|
+
const planner = new Planner(ctx, opts.use_pantry !== false);
|
|
3367
|
+
const { ids, slugs, unknown } = planContext(ctx, opts);
|
|
3368
|
+
const allowed = ctx.store.filterRecipeIds(expandFilters(ctx, opts.filters));
|
|
3369
|
+
const variety = opts.variety !== false;
|
|
3370
|
+
const width = Math.min(Math.max(opts.beam ?? BEAM_WIDTH, 1), 10);
|
|
3371
|
+
const count = Math.min(Math.max(opts.count, 0), 14);
|
|
3372
|
+
let beam = [planner.state(ids)];
|
|
3373
|
+
for (let step = 0; step < count; step++) {
|
|
3374
|
+
const next = /* @__PURE__ */ new Map();
|
|
3375
|
+
for (const s of beam) {
|
|
3376
|
+
for (const sug of planner.rank(s, allowed, variety).slice(0, BEAM_FANOUT)) {
|
|
3377
|
+
const p = planner.bySlug.get(sug.slug);
|
|
3378
|
+
if (!p) continue;
|
|
3379
|
+
const ns = planner.extend(s, p, sug);
|
|
3380
|
+
const key = ns.profiles.map((x) => x.id).sort((a, b) => a - b).join(",");
|
|
3381
|
+
if (!next.has(key)) next.set(key, ns);
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
if (next.size === 0) break;
|
|
3385
|
+
beam = [...next.values()].sort(
|
|
3386
|
+
(a, b) => planner.objective(b, variety) - planner.objective(a, variety) || a.buy.size - b.buy.size
|
|
3387
|
+
).slice(0, width);
|
|
3388
|
+
}
|
|
3389
|
+
const best = beam[0] ?? planner.state(ids);
|
|
3390
|
+
return {
|
|
3391
|
+
plan: planner.summary(best),
|
|
3392
|
+
slugs: [...slugs, ...best.path.map((s) => s.slug)],
|
|
3393
|
+
added: best.path,
|
|
3394
|
+
unknown_slugs: unknown
|
|
3395
|
+
};
|
|
3396
|
+
}
|
|
3397
|
+
function shoppingList(ctx, opts = {}) {
|
|
3398
|
+
const stored = getPlan(ctx);
|
|
3399
|
+
const items = opts.items ?? stored.items;
|
|
3400
|
+
const removed = opts.out_of?.length ? removeFromPantry(ctx, opts.out_of).removed : [];
|
|
3401
|
+
let confirmed = new Set(stored.confirmed);
|
|
3402
|
+
for (const c of opts.confirmed ?? []) confirmed.add(c);
|
|
3403
|
+
for (const r of removed) confirmed.delete(r);
|
|
3404
|
+
if (opts.confirmed?.length || removed.length) {
|
|
3405
|
+
confirmed = new Set(
|
|
3406
|
+
savePlan(ctx, { items: opts.items ? stored.items : items, confirmed: [...confirmed] }).confirmed
|
|
3407
|
+
);
|
|
3408
|
+
}
|
|
3409
|
+
const planner = new Planner(ctx, true);
|
|
3410
|
+
const { ids, unknown } = resolveSlugs(
|
|
3411
|
+
ctx,
|
|
3412
|
+
items.map((i) => i.slug)
|
|
3413
|
+
);
|
|
3414
|
+
const state = planner.state(ids);
|
|
3415
|
+
const wanted = new Map(items.map((i) => [i.slug, i.servings]));
|
|
3416
|
+
const pantryQty = new Map(
|
|
3417
|
+
ctx.store.pantryList().map((p) => [
|
|
3418
|
+
p.ingredient_id,
|
|
3419
|
+
p.qty !== null ? `${fmtNumber(p.qty)}${p.unit ? ` ${p.unit}` : ""}` : null
|
|
3420
|
+
])
|
|
3421
|
+
);
|
|
3422
|
+
const buy = /* @__PURE__ */ new Map();
|
|
3423
|
+
const pantry = /* @__PURE__ */ new Map();
|
|
3424
|
+
const optional = /* @__PURE__ */ new Map();
|
|
3425
|
+
const staples = /* @__PURE__ */ new Set();
|
|
3426
|
+
const recipes = [];
|
|
3427
|
+
const cache = /* @__PURE__ */ new Map();
|
|
3428
|
+
const put = (map, l, scale, title) => {
|
|
3429
|
+
let b = map.get(l.ingredient_id);
|
|
3430
|
+
if (!b) {
|
|
3431
|
+
b = {
|
|
3432
|
+
canonical: l.canonical,
|
|
3433
|
+
category: l.category,
|
|
3434
|
+
amounts: [],
|
|
3435
|
+
unmeasured: 0,
|
|
3436
|
+
for: /* @__PURE__ */ new Set()
|
|
3437
|
+
};
|
|
3438
|
+
map.set(l.ingredient_id, b);
|
|
3439
|
+
}
|
|
3440
|
+
const a = toAmount(l.qty, l.unit, scale);
|
|
3441
|
+
if (a) b.amounts.push(a);
|
|
3442
|
+
else b.unmeasured++;
|
|
3443
|
+
b.for.add(title);
|
|
3444
|
+
};
|
|
3445
|
+
for (const p of state.profiles) {
|
|
3446
|
+
const base = planner.summaries.get(p.id)?.servings ?? null;
|
|
3447
|
+
const want = wanted.get(p.slug) ?? null;
|
|
3448
|
+
const scale = base && want ? want / base : 1;
|
|
3449
|
+
recipes.push({ slug: p.slug, title: p.title, servings: want ?? base, scale });
|
|
3450
|
+
for (const l of p.required) {
|
|
3451
|
+
if (lineAvailable(ctx, l, planner.avail, cache)) put(pantry, l, scale, p.title);
|
|
3452
|
+
else put(buy, l, scale, p.title);
|
|
3453
|
+
}
|
|
3454
|
+
for (const l of p.optional) put(optional, l, scale, p.title);
|
|
3455
|
+
for (const l of p.staples) staples.add(l.canonical);
|
|
3456
|
+
}
|
|
3457
|
+
const line = (b) => ({
|
|
3458
|
+
canonical: b.canonical,
|
|
3459
|
+
amount: formatAmount(b.amounts, b.unmeasured),
|
|
3460
|
+
aisle: aisleOf(b.category),
|
|
3461
|
+
for: [...b.for]
|
|
3462
|
+
});
|
|
3463
|
+
const byAisle = (a, b) => AISLE_ORDER.indexOf(a.aisle) - AISLE_ORDER.indexOf(b.aisle) || a.canonical.localeCompare(b.canonical);
|
|
3464
|
+
const check = [...pantry.entries()].map(([id, b]) => ({
|
|
3465
|
+
canonical: b.canonical,
|
|
3466
|
+
amount: formatAmount(b.amounts, b.unmeasured),
|
|
3467
|
+
for: [...b.for],
|
|
3468
|
+
in_pantry: pantryQty.get(id) ?? null,
|
|
3469
|
+
confirmed: confirmed.has(b.canonical)
|
|
3470
|
+
})).sort((a, b) => a.canonical.localeCompare(b.canonical));
|
|
3471
|
+
return {
|
|
3472
|
+
recipes,
|
|
3473
|
+
buy: [...buy.values()].map(line).sort(byAisle),
|
|
3474
|
+
pantry_check: check,
|
|
3475
|
+
needs_confirmation: check.filter((c) => !c.confirmed).map((c) => c.canonical),
|
|
3476
|
+
optional: [...optional.values()].map(line).sort(byAisle),
|
|
3477
|
+
staples: [...staples].sort(),
|
|
3478
|
+
summary: planner.summary(state, wanted),
|
|
3479
|
+
removed_from_pantry: removed,
|
|
3480
|
+
unknown_slugs: unknown
|
|
3481
|
+
};
|
|
3482
|
+
}
|
|
3483
|
+
function renderShoppingList(list) {
|
|
3484
|
+
const out = [];
|
|
3485
|
+
out.push(`Shopping list for ${list.recipes.map((r) => r.title).join(", ")}`);
|
|
3486
|
+
let aisle;
|
|
3487
|
+
for (const l of list.buy) {
|
|
3488
|
+
if (l.aisle !== aisle) {
|
|
3489
|
+
aisle = l.aisle;
|
|
3490
|
+
out.push("", `${aisle[0]?.toUpperCase()}${aisle.slice(1)}`);
|
|
3491
|
+
}
|
|
3492
|
+
out.push(`- ${l.canonical}${l.amount ? ` \u2014 ${l.amount}` : ""}`);
|
|
3493
|
+
}
|
|
3494
|
+
if (list.pantry_check.length) {
|
|
3495
|
+
out.push("", "From your pantry (check you have enough)");
|
|
3496
|
+
for (const c of list.pantry_check)
|
|
3497
|
+
out.push(`- ${c.canonical}${c.amount ? ` \u2014 ${c.amount}` : ""}${c.confirmed ? "" : " (?)"}`);
|
|
3498
|
+
}
|
|
3499
|
+
if (list.staples.length) out.push("", `Staples used: ${list.staples.join(", ")}`);
|
|
3500
|
+
return out.join("\n");
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3040
3503
|
// src/web/server.ts
|
|
3041
3504
|
var MAX_JSON = 1e6;
|
|
3042
3505
|
var MAX_UPLOAD = 6e7;
|
|
@@ -3125,6 +3588,20 @@ async function thumbnail(abs, width) {
|
|
|
3125
3588
|
thumbCache.set(key, buf);
|
|
3126
3589
|
return buf;
|
|
3127
3590
|
}
|
|
3591
|
+
function planView(ctx) {
|
|
3592
|
+
const plan = getPlan(ctx);
|
|
3593
|
+
const ids = plan.items.map((i) => ctx.store.getRecipe(i.slug)?.id).filter((x) => x !== void 0);
|
|
3594
|
+
const summaries = new Map(ctx.store.summariesByIds(ids).map((s) => [s.slug, s]));
|
|
3595
|
+
const { plan: summary } = suggestRecipes(ctx, {
|
|
3596
|
+
plan: plan.items.map((i) => i.slug),
|
|
3597
|
+
limit: 1
|
|
3598
|
+
});
|
|
3599
|
+
return {
|
|
3600
|
+
items: plan.items.map((i) => ({ ...i, recipe: summaries.get(i.slug) ?? null })),
|
|
3601
|
+
confirmed: plan.confirmed,
|
|
3602
|
+
summary
|
|
3603
|
+
};
|
|
3604
|
+
}
|
|
3128
3605
|
function routes(ctx, version) {
|
|
3129
3606
|
const r = (method, pattern, handler) => ({
|
|
3130
3607
|
method,
|
|
@@ -3136,6 +3613,7 @@ function routes(ctx, version) {
|
|
|
3136
3613
|
...ctx.store.stats(),
|
|
3137
3614
|
inbox: ctx.store.ingestCounts(),
|
|
3138
3615
|
pantry: listPantry(ctx).length,
|
|
3616
|
+
plan: getPlan(ctx).items.length,
|
|
3139
3617
|
providers: ctx.providers.list().map((p) => ({ id: p.id, name: p.name })),
|
|
3140
3618
|
data_dir: ctx.paths.data,
|
|
3141
3619
|
embeddings: ctx.embedder ? ctx.embedder.ready ? "ready" : "loading" : "disabled",
|
|
@@ -3207,6 +3685,31 @@ function routes(ctx, version) {
|
|
|
3207
3685
|
filters: filtersFrom(url)
|
|
3208
3686
|
});
|
|
3209
3687
|
}),
|
|
3688
|
+
// Meal plan: the saved plan plus its summary, with recipe summaries for the UI.
|
|
3689
|
+
r("GET", /^\/api\/plan$/, () => planView(ctx)),
|
|
3690
|
+
r("PUT", /^\/api\/plan$/, async (req) => {
|
|
3691
|
+
const body = await readJson(req);
|
|
3692
|
+
if (!Array.isArray(body.items)) throw new HttpError(400, "items required");
|
|
3693
|
+
savePlan(ctx, { items: body.items, confirmed: getPlan(ctx).confirmed });
|
|
3694
|
+
return planView(ctx);
|
|
3695
|
+
}),
|
|
3696
|
+
r("GET", /^\/api\/plan\/suggest$/, (_req, url) => {
|
|
3697
|
+
const p = url.searchParams;
|
|
3698
|
+
const common = {
|
|
3699
|
+
seed: p.get("seed") ?? void 0,
|
|
3700
|
+
plan: csv(p.get("plan")),
|
|
3701
|
+
variety: p.get("variety") !== "0",
|
|
3702
|
+
use_pantry: p.get("use_pantry") !== "0",
|
|
3703
|
+
filters: filtersFrom(url)
|
|
3704
|
+
};
|
|
3705
|
+
const auto = num2(p.get("auto"));
|
|
3706
|
+
if (auto) return autofillPlan(ctx, { ...common, count: auto });
|
|
3707
|
+
return suggestRecipes(ctx, { ...common, limit: num2(p.get("limit")) ?? 12 });
|
|
3708
|
+
}),
|
|
3709
|
+
r("POST", /^\/api\/plan\/shopping$/, async (req) => {
|
|
3710
|
+
const body = await readJson(req);
|
|
3711
|
+
return shoppingList(ctx, body);
|
|
3712
|
+
}),
|
|
3210
3713
|
r(
|
|
3211
3714
|
"GET",
|
|
3212
3715
|
/^\/api\/ingredients$/,
|
|
@@ -3424,6 +3927,12 @@ export {
|
|
|
3424
3927
|
availableIngredients,
|
|
3425
3928
|
scoreRecipes,
|
|
3426
3929
|
matchRecipes,
|
|
3930
|
+
getPlan,
|
|
3931
|
+
savePlan,
|
|
3932
|
+
suggestRecipes,
|
|
3933
|
+
autofillPlan,
|
|
3934
|
+
shoppingList,
|
|
3935
|
+
renderShoppingList,
|
|
3427
3936
|
startUiServer
|
|
3428
3937
|
};
|
|
3429
|
-
//# sourceMappingURL=chunk-
|
|
3938
|
+
//# sourceMappingURL=chunk-IUQQ3I2E.js.map
|