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.
@@ -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.map((i) => viewItem(i, all)) };
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((i) => ({ ...viewItem(i, all), path: i.path }))
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
- viewItem,
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-2EMC6TJB.js.map
3938
+ //# sourceMappingURL=chunk-IUQQ3I2E.js.map