pantrybase 0.1.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 +8 -2
- package/README.md +4 -0
- package/dist/{chunk-2EMC6TJB.js → chunk-IUQQ3I2E.js} +605 -8
- package/dist/cli.js +1 -1
- package/dist/server.js +239 -61
- package/dist/web/assets/{graph-Mpnwdw0-.js → graph-70CI6xc-.js} +1 -1
- package/dist/web/assets/{index-PLKhwbDz.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/providers/generic/profile.yaml +1 -0
- package/providers/ucook/guide.md +1 -1
- package/providers/ucook/profile.yaml +2 -0
- package/skills/cook/SKILL.md +9 -1
- package/skills/ingest/SKILL.md +6 -5
- package/dist/web/assets/index-BQVf10Ok.js +0 -1
|
@@ -1056,6 +1056,7 @@ function loadProfile(dir) {
|
|
|
1056
1056
|
recognise: raw.recognise ?? [],
|
|
1057
1057
|
sides: { front: raw.sides?.front ?? [], back: raw.sides?.back ?? [] },
|
|
1058
1058
|
pairing: raw.pairing ?? "content",
|
|
1059
|
+
orientation: raw.orientation ?? null,
|
|
1059
1060
|
nutrition_basis: raw.nutrition_basis ?? "per_serving",
|
|
1060
1061
|
default_servings: raw.default_servings ?? null,
|
|
1061
1062
|
kitchen_section: raw.kitchen_section ?? null,
|
|
@@ -1924,8 +1925,8 @@ var SqliteAdapter = class {
|
|
|
1924
1925
|
const where = recipeIds ? `WHERE ri.recipe_id IN (${placeholders(recipeIds.length)})` : "";
|
|
1925
1926
|
if (recipeIds && recipeIds.length === 0) return [];
|
|
1926
1927
|
const rows = this.s(
|
|
1927
|
-
`SELECT ri.recipe_id, r.slug, r.title, ri.ingredient_id, i.canonical, ri.raw,
|
|
1928
|
-
(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
|
|
1929
1930
|
FROM recipe_ingredients ri
|
|
1930
1931
|
JOIN ingredients i ON i.id = ri.ingredient_id
|
|
1931
1932
|
JOIN recipes r ON r.id = ri.recipe_id
|
|
@@ -2421,6 +2422,25 @@ async function openImage(file) {
|
|
|
2421
2422
|
const input = HEIF_EXTS.has(path6.extname(file).toLowerCase()) ? await heifToJpeg(file) : file;
|
|
2422
2423
|
return sharp(input, { failOn: "none" }).rotate();
|
|
2423
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
|
+
}
|
|
2424
2444
|
async function imageForModel(file, maxPx = MODEL_MAX_PX) {
|
|
2425
2445
|
const base = await (await openImage(file)).resize(maxPx, maxPx, { fit: "inside", withoutEnlargement: true }).toBuffer({ resolveWithObject: true });
|
|
2426
2446
|
let data = base.data;
|
|
@@ -2686,6 +2706,29 @@ async function addUpload(ctx, name, data) {
|
|
|
2686
2706
|
await scanInbox(ctx);
|
|
2687
2707
|
return ctx.store.ingestByPath(relativeToData(ctx.paths, target));
|
|
2688
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
|
+
}
|
|
2689
2732
|
function retakeHint(item, all) {
|
|
2690
2733
|
let best;
|
|
2691
2734
|
for (const other of all) {
|
|
@@ -2698,26 +2741,63 @@ function retakeHint(item, all) {
|
|
|
2698
2741
|
}
|
|
2699
2742
|
return best;
|
|
2700
2743
|
}
|
|
2701
|
-
function viewItem(item, all) {
|
|
2744
|
+
function viewItem(item, all, dominant = all ? dominantShape(all) : null) {
|
|
2702
2745
|
const v = {
|
|
2703
2746
|
id: item.id,
|
|
2704
2747
|
name: item.original_name,
|
|
2705
2748
|
captured_at: item.captured_at,
|
|
2706
2749
|
size: `${item.width}x${item.height}`,
|
|
2707
|
-
status: item.status
|
|
2750
|
+
status: item.status,
|
|
2751
|
+
version: item.mtime
|
|
2708
2752
|
};
|
|
2709
2753
|
if (item.recipe_slug) v.recipe_slug = item.recipe_slug;
|
|
2710
2754
|
if (item.reason) v.reason = item.reason;
|
|
2711
2755
|
if (all) {
|
|
2712
2756
|
const r = retakeHint(item, all);
|
|
2713
2757
|
if (r) v.retake_of = r;
|
|
2758
|
+
const hint = rotationHint(item, dominant);
|
|
2759
|
+
if (hint) v.rotation_hint = hint;
|
|
2714
2760
|
}
|
|
2715
2761
|
return v;
|
|
2716
2762
|
}
|
|
2763
|
+
function viewItems(items, all) {
|
|
2764
|
+
const dominant = dominantShape(all);
|
|
2765
|
+
return items.map((i) => viewItem(i, all, dominant));
|
|
2766
|
+
}
|
|
2717
2767
|
function nextBatch(ctx, window = 4) {
|
|
2718
2768
|
const all = ctx.store.allIngest();
|
|
2719
2769
|
const items = all.filter((i) => i.status === "pending").slice(0, Math.max(1, window));
|
|
2720
|
-
return { progress: ctx.store.ingestCounts(), items: items
|
|
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());
|
|
2721
2801
|
}
|
|
2722
2802
|
function resolveItems(ctx, ids, status, reason) {
|
|
2723
2803
|
const out = { moved: 0, refused: [] };
|
|
@@ -2957,6 +3037,469 @@ function matchRecipes(ctx, opts = {}) {
|
|
|
2957
3037
|
return { results, available: ids.size, unknown_ingredients: unknown };
|
|
2958
3038
|
}
|
|
2959
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
|
+
|
|
2960
3503
|
// src/web/server.ts
|
|
2961
3504
|
var MAX_JSON = 1e6;
|
|
2962
3505
|
var MAX_UPLOAD = 6e7;
|
|
@@ -3045,6 +3588,20 @@ async function thumbnail(abs, width) {
|
|
|
3045
3588
|
thumbCache.set(key, buf);
|
|
3046
3589
|
return buf;
|
|
3047
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
|
+
}
|
|
3048
3605
|
function routes(ctx, version) {
|
|
3049
3606
|
const r = (method, pattern, handler) => ({
|
|
3050
3607
|
method,
|
|
@@ -3056,6 +3613,7 @@ function routes(ctx, version) {
|
|
|
3056
3613
|
...ctx.store.stats(),
|
|
3057
3614
|
inbox: ctx.store.ingestCounts(),
|
|
3058
3615
|
pantry: listPantry(ctx).length,
|
|
3616
|
+
plan: getPlan(ctx).items.length,
|
|
3059
3617
|
providers: ctx.providers.list().map((p) => ({ id: p.id, name: p.name })),
|
|
3060
3618
|
data_dir: ctx.paths.data,
|
|
3061
3619
|
embeddings: ctx.embedder ? ctx.embedder.ready ? "ready" : "loading" : "disabled",
|
|
@@ -3127,6 +3685,31 @@ function routes(ctx, version) {
|
|
|
3127
3685
|
filters: filtersFrom(url)
|
|
3128
3686
|
});
|
|
3129
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
|
+
}),
|
|
3130
3713
|
r(
|
|
3131
3714
|
"GET",
|
|
3132
3715
|
/^\/api\/ingredients$/,
|
|
@@ -3159,7 +3742,7 @@ function routes(ctx, version) {
|
|
|
3159
3742
|
});
|
|
3160
3743
|
return {
|
|
3161
3744
|
counts: ctx.store.ingestCounts(),
|
|
3162
|
-
items: items.map((
|
|
3745
|
+
items: viewItems(items, all).map((v, n) => ({ ...v, path: items[n]?.path ?? "" }))
|
|
3163
3746
|
};
|
|
3164
3747
|
}),
|
|
3165
3748
|
r("POST", /^\/api\/inbox\/upload$/, async (req) => {
|
|
@@ -3170,6 +3753,13 @@ function routes(ctx, version) {
|
|
|
3170
3753
|
const item = await addUpload(ctx, name, data);
|
|
3171
3754
|
return { item: item ? viewItem(item) : null, counts: ctx.store.ingestCounts() };
|
|
3172
3755
|
}),
|
|
3756
|
+
r("POST", /^\/api\/inbox\/rotate$/, async (req) => {
|
|
3757
|
+
const body = await readJson(req);
|
|
3758
|
+
if (typeof body.id !== "number" || ![90, 180, 270].includes(body.degrees ?? 0)) {
|
|
3759
|
+
throw new HttpError(400, "id and degrees (90|180|270, clockwise) required");
|
|
3760
|
+
}
|
|
3761
|
+
return { item: await rotateItem(ctx, body.id, body.degrees) };
|
|
3762
|
+
}),
|
|
3173
3763
|
r("POST", /^\/api\/inbox\/resolve$/, async (req) => {
|
|
3174
3764
|
const body = await readJson(req);
|
|
3175
3765
|
if (!body.ids?.length || !["pending", "skipped", "failed"].includes(body.status ?? "")) {
|
|
@@ -3323,8 +3913,9 @@ export {
|
|
|
3323
3913
|
imageForModel,
|
|
3324
3914
|
maybeScanInbox,
|
|
3325
3915
|
importPaths,
|
|
3326
|
-
|
|
3916
|
+
viewItems,
|
|
3327
3917
|
nextBatch,
|
|
3918
|
+
rotateItem,
|
|
3328
3919
|
resolveItems,
|
|
3329
3920
|
planAttach,
|
|
3330
3921
|
finalizeAttach,
|
|
@@ -3336,6 +3927,12 @@ export {
|
|
|
3336
3927
|
availableIngredients,
|
|
3337
3928
|
scoreRecipes,
|
|
3338
3929
|
matchRecipes,
|
|
3930
|
+
getPlan,
|
|
3931
|
+
savePlan,
|
|
3932
|
+
suggestRecipes,
|
|
3933
|
+
autofillPlan,
|
|
3934
|
+
shoppingList,
|
|
3935
|
+
renderShoppingList,
|
|
3339
3936
|
startUiServer
|
|
3340
3937
|
};
|
|
3341
|
-
//# sourceMappingURL=chunk-
|
|
3938
|
+
//# sourceMappingURL=chunk-IUQQ3I2E.js.map
|