feat: add Mealie upload target (Tandoor replacement)
Build & Push Docker Image / test-and-build (push) Successful in 1m44s

InstaChef could only upload to Tandoor. Add a Mealie provider and a
RECIPE_TARGET selector so the queue uploads to Mealie (cook.sal.giize.com).

- mealie-config.ts / mealie.ts: two-step create (POST /api/recipes -> slug,
  PATCH /api/recipes/{slug}) + multipart image PUT. Ingredients sent as
  free-text notes (Mealie PATCH rejects structured unit/food without an id).
- queue/config.ts: `target` ('mealie'|'tandoor', defaults to mealie when
  MEALIE_TOKEN set) + mealie config block.
- QueueProcessor.uploadPhase: branch on target; store mealieSlug.
- QueueManager/types: build public recipe URL, add mealieSlug/recipeUrl.
- tests: buildMealiePatch mapping (free-text notes, placeholder step).
- docs/mealie-adapter-scope.md + .env.example MEALIE_* vars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Giancarmine Salucci
2026-07-21 14:48:13 +02:00
co-authored by Claude Opus 4.8
parent b4764ce887
commit ba5e397788
9 changed files with 506 additions and 35 deletions
+16
View File
@@ -51,6 +51,22 @@ TANDOOR_SPACE=1
# Tandoor API token (generate in Tandoor settings)
TANDOOR_TOKEN=tda_f9460962_c8dd_491a_a716_f11b0b3288f0
# ==============================================================================
# Mealie Integration (Tandoor replacement)
# ==============================================================================
# Which cookbook the queue uploads to: 'mealie' or 'tandoor'.
# Defaults to 'mealie' when MEALIE_TOKEN is set, else 'tandoor'.
RECIPE_TARGET=mealie
# Mealie server URL (no trailing slash). Public host, or internal http://mealie:9000
MEALIE_SERVER_URL=https://cook.sal.giize.com
# Mealie API token (Mealie: Settings > API Tokens)
MEALIE_TOKEN=
# Group slug used to build recipe view URLs (self-host default: home)
MEALIE_GROUP_SLUG=home
# ==============================================================================
# Push Notifications (OPTIONAL)
# ==============================================================================
+100
View File
@@ -0,0 +1,100 @@
# InstaChef → Mealie adapter — scope
InstaChef currently uploads extracted recipes to **Tandoor** only. This doc scopes
adding a **Mealie** upload target so InstaChep can post into the new Mealie cookbook
(Tandoor replacement). Based on reading the current code + probing Mealie's live API.
## Current Tandoor integration (what exists)
| Concern | Location |
|---|---|
| Config (env → object) | `src/lib/server/tandoor-config.ts``{enabled, serverUrl, space, token}` from `TANDOOR_*` |
| API client | `src/lib/server/tandoor.ts``uploadRecipeWithIngredientsDTO()` (POST `/api/recipe/`) + `uploadRecipeImage()` (PUT `/api/recipe/{id}/image/`, multipart) |
| Call site | `src/lib/server/queue/QueueProcessor.ts::uploadPhase()` (~L321), gated by `queueConfig.tandoor.enabled` |
| View URL | `src/lib/server/queue/QueueManager.ts` (~L250) builds `${serverUrl}/view/recipe/{id}` |
| HTTP route | `src/routes/api/tandoor/+server.ts` (manual single-recipe upload) |
| Source model | `ExtractedRecipe { name, servings, description, ingredients:[{item,amount,unit}], steps:string[], image }` |
Auth: `Authorization: Bearer <token>` (same header Mealie uses — good).
## Mealie API contract (verified against the running instance)
Auth is **identical in shape**: long-lived bearer token (mint in UI → Settings → API Tokens,
or already minted for InstaChef on this box — see the seed result). No login/space concept.
Recipe create is a **two-step** flow (unlike Tandoor's single POST):
1. `POST /api/recipes` body `{"name": "<name>"}` → returns the **slug** (a JSON string).
2. `PATCH /api/recipes/{slug}` body:
```json
{
"description": "...",
"recipeServings": 4,
"recipeYield": "4 servings",
"orgURL": "<source url>",
"recipeIngredient": [ { "note": "300 g calamari" }, ... ],
"recipeInstructions": [ { "text": "Step text..." }, ... ]
}
```
3. Image: `PUT /api/recipes/{slug}/image` — **multipart/form-data**, fields `image` (file bytes)
+ `extension` ("jpg"). (Alt: `POST /api/recipes/{slug}/image` with `{"url": "..."}` to let
Mealie scrape a remote URL — handy for direct IG image URLs, skips the download.)
### Critical gotcha (cost us during seeding)
Mealie's PATCH validation **rejects structured `unit`/`food` objects unless they carry an `id`**
(`ValueError: Expected 'id' to be provided for unit`). Do **not** send `{food:{name},unit:{name}}`.
Send each ingredient as a **free-text line** in `note` (compose `"<amount> <unit> <item>"`).
This is simpler *and* maps directly from `ExtractedRecipe.ingredients[{item,amount,unit}]`.
(Confirmed: 84/84 recipes imported clean once switched to free-text notes.)
### Mapping: ExtractedRecipe → Mealie
- `name` → `name` (create step)
- `description` → `description`
- `servings` → `recipeServings` (number) and `recipeYield` = `"{n} servings"`
- `steps[]` → `recipeInstructions[{text}]` (keep the existing "placeholder step when empty" behavior from `buildTandoorRecipeDTO`)
- `ingredients[{item,amount,unit}]` → `recipeIngredient[{ note: join(amount, unit, item) }]`
(drop empty amounts like `q.b.` gracefully — just omit the number)
- `image` (http(s) URL or base64 data URL) → multipart upload (reuse the download/base64 logic
already in `uploadRecipeImage`), OR pass the URL to the scrape endpoint when it's a plain URL.
## Changes required (files)
1. **New** `src/lib/server/mealie-config.ts` — `{enabled, serverUrl, token}` from `MEALIE_*`.
2. **New** `src/lib/server/mealie.ts` — `uploadRecipeToMealie(recipe): {success, slug?, imageUrl?, error?}`
and `uploadRecipeImageMealie(slug, imageUrl)`. Reuse the image download/base64/data-url helpers
from `tandoor.ts` (extract them to `src/lib/server/image.ts` to share, or duplicate — small).
3. **Edit** `src/lib/server/queue/config.ts` — add a `mealie` block + a target selector,
e.g. `RECIPE_TARGET = 'tandoor' | 'mealie' | 'both'` (default `tandoor` for back-compat).
4. **Edit** `QueueProcessor.uploadPhase()` — branch on the selected target(s); on `mealie`, call
the new client; store the resulting slug. Keep image-failure-is-non-fatal behavior.
5. **Edit** `QueueManager.ts` (~L250) — build the Mealie view URL. **Confirm the exact path**
against your Mealie version (recent Mealie: `/g/{groupSlug}/r/{slug}`); grab it from the
PATCH/GET response rather than hardcoding if possible.
6. **Edit** `src/routes/api/tandoor/+server.ts` — optionally generalize to `/api/upload` or add a
sibling `/api/mealie/+server.ts` mirroring it.
7. **Tests** — mirror `src/tests/tandoor-api.spec.ts` → `mealie-api.spec.ts` (mapping + free-text
ingredient assertion + the "no structured unit object" guard).
### New env (add to `.env` / `.env.example` / README)
```
MEALIE_ENABLED=true
MEALIE_SERVER_URL=https://mealie.gsalucci.cloud # no trailing slash needed; config strips it
MEALIE_TOKEN=<long-lived bearer token>
RECIPE_TARGET=mealie # or 'both' during migration, or 'tandoor'
```
## Recommended shape
Introduce a tiny `RecipeTarget` interface — `{ uploadRecipe(recipe), uploadImage(ref, img), viewUrl(ref) }`
— with `tandoor` and `mealie` impls, selected by `RECIPE_TARGET`. Lets you run **both** during the
cutover (belt-and-suspenders) then flip to `mealie` only. Low risk: queue/image/notify plumbing is
untouched; this is a new provider mirroring `tandoor.ts` with a *simpler* payload.
## Effort
~1 new client (~120 lines, most logic already exists in `tandoor.ts`) + ~40 lines of wiring +
one test file. Half a day incl. tests. No schema/queue changes.
## Note on the target
Mealie instance is live on this box (internal `proxy` net, container `mealie:9000`, published web
later via Cloudflare tunnel at `mealie.gsalucci.cloud`). The 84 rescued Tandoor recipes + Elena's
user + an InstaChef API token are already seeded — so the moment this adapter lands, InstaChef writes
straight into the populated cookbook.
+14
View File
@@ -0,0 +1,14 @@
import { env } from '$env/dynamic/private';
/**
* Server-side environment configuration for Mealie integration.
* Mirrors tandoor-config.ts. Set these in your .env / stack env:
* MEALIE_SERVER_URL e.g. https://cook.sal.giize.com (or internal http://mealie:9000)
* MEALIE_TOKEN long-lived API token (Mealie: Settings > API Tokens)
* MEALIE_GROUP_SLUG defaults to "home" (used to build recipe view URLs)
*/
export const mealieConfig = {
enabled: env.MEALIE_ENABLED === 'true' || !!env.MEALIE_TOKEN,
serverUrl: (env.MEALIE_SERVER_URL || '').replace(/\/$/, ''),
token: env.MEALIE_TOKEN || null,
groupSlug: env.MEALIE_GROUP_SLUG || 'home'
};
+216
View File
@@ -0,0 +1,216 @@
import { mealieConfig } from '$lib/server/mealie-config';
import { logError } from './utils/logger';
/**
* Mealie upload client — mirrors tandoor.ts but targets the Mealie API.
*
* Mealie recipe creation is a two-step flow:
* 1. POST /api/recipes { name } -> returns the slug (JSON string)
* 2. PATCH /api/recipes/{slug} { full recipe body }
* Then image:
* 3. PUT /api/recipes/{slug}/image (multipart: image + extension)
*
* IMPORTANT: Mealie's PATCH validation rejects structured unit/food objects unless they
* carry an `id` (ValueError: Expected 'id' to be provided for unit). So ingredients are
* sent as free-text lines in `note` — clean for a simple cookbook and a direct map from
* the extracted { item, amount, unit } strings.
*/
interface ExtractedRecipe {
name: string;
servings: number | null;
description: string | null;
ingredients: Array<{ item: string; amount: string; unit: string }> | null;
steps: string[] | null;
image?: string | null;
}
/** Format a raw amount string to a compact number, or '' when absent/unparseable/zero. */
function formatAmount(amountStr: string): string {
if (!amountStr || typeof amountStr !== 'string') return '';
const trimmed = amountStr.trim().toLowerCase();
if (!trimmed || trimmed === 'q.b.' || trimmed === 'qb' || trimmed === 'to taste') return '';
const m = trimmed.match(/^[\d.,]+/);
if (!m) return amountStr.trim();
const n = parseFloat(m[0].replace(',', '.'));
if (isNaN(n) || n === 0) return '';
return Number.isInteger(n) ? String(n) : String(n);
}
/** Build a single free-text ingredient line: "<amount> <unit> <item>". */
function ingredientLine(ing: { item: string; amount: string; unit: string }): string {
const amount = formatAmount(ing.amount);
const unit = (ing.unit || '').trim();
const item = (ing.item || '').trim();
return [amount, unit, item].filter(Boolean).join(' ').trim();
}
/**
* Map an ExtractedRecipe to the Mealie PATCH body.
* Exported for unit testing the mapping (no structured unit/food; free-text notes only).
*/
export function buildMealiePatch(recipe: ExtractedRecipe): {
description: string;
recipeServings: number;
recipeYield: string;
recipeIngredient: Array<{ note: string }>;
recipeInstructions: Array<{ text: string }>;
} {
const steps = recipe.steps?.length
? recipe.steps
: ['Vedi la ricetta completa al link in bio.'];
const recipeInstructions = steps
.map((s) => (s || '').trim())
.filter(Boolean)
.map((text) => ({ text }));
const recipeIngredient = (recipe.ingredients || [])
.map(ingredientLine)
.filter(Boolean)
.map((note) => ({ note }));
return {
description: recipe.description || '',
recipeServings: recipe.servings || 0,
recipeYield: recipe.servings ? `${recipe.servings} servings` : '',
recipeIngredient,
recipeInstructions
};
}
async function mealieFetch<T>(
path: string,
options: Partial<RequestInit> = { method: 'GET' }
): Promise<{ ok: boolean; data?: T; error?: string }> {
const headers = new Headers({
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${mealieConfig.token}`
});
if (options.headers) {
new Headers(options.headers).forEach((v, k) => headers.set(k, v));
}
try {
const response = await fetch(`${mealieConfig.serverUrl}${path}`, { ...options, headers });
if (!response.ok) {
const body = await response.text().catch(() => '');
logError(`[Mealie] API Error ${response.status}: ${response.statusText}`, body);
return { ok: false, error: `API Error ${response.status}: ${body.slice(0, 200)}` };
}
const text = await response.text();
let data: T;
try {
data = JSON.parse(text) as T;
} catch {
data = text as unknown as T; // create endpoint returns a bare slug string
}
return { ok: true, data };
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
logError('[Mealie] Fetch error', error);
return { ok: false, error: `Fetch error: ${msg}` };
}
}
/**
* Create a recipe in Mealie (create shell -> patch body).
* Returns the recipe slug on success.
*/
export async function uploadRecipeToMealie(
recipe: ExtractedRecipe
): Promise<{ success: boolean; slug?: string; imageUrl?: string; error?: string }> {
if (!mealieConfig.token) {
return { success: false, error: 'MEALIE_TOKEN not set' };
}
// 1) create shell -> slug
const created = await mealieFetch<string>('/api/recipes', {
method: 'POST',
body: JSON.stringify({ name: recipe.name })
});
if (!created.ok || !created.data) {
return { success: false, error: `Failed to create recipe: ${created.error}` };
}
const slug = String(created.data).replace(/^"|"$/g, '').trim();
// 2) patch full body
const patched = await mealieFetch(`/api/recipes/${slug}`, {
method: 'PATCH',
body: JSON.stringify(buildMealiePatch(recipe))
});
if (!patched.ok) {
return { success: false, error: `Failed to update recipe: ${patched.error}` };
}
return { success: true, slug, imageUrl: recipe.image || undefined };
}
// --- image helpers (mirrors tandoor.ts) ---
function isDataUrl(url: string): boolean {
return url.startsWith('data:');
}
function isDirectUrl(url: string): boolean {
return url.startsWith('http://') || url.startsWith('https://');
}
function parseDataUrl(dataUrl: string): { mimeType: string; base64Data: string } | null {
const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
return m ? { mimeType: m[1], base64Data: m[2] } : null;
}
function extFromMime(mime: string): string {
const map: Record<string, string> = {
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp'
};
return map[mime] || 'jpg';
}
/**
* Upload an image to a Mealie recipe. Accepts an http(s) URL or a base64 data URL.
* Image failures are non-fatal to the caller (recipe already exists).
*/
export async function uploadMealieImage(
slug: string,
imageUrl: string
): Promise<{ success: boolean; error?: string }> {
if (!mealieConfig.token) return { success: false, error: 'MEALIE_TOKEN not set' };
try {
let buffer: Buffer;
let mimeType: string;
if (isDataUrl(imageUrl)) {
const parsed = parseDataUrl(imageUrl);
if (!parsed) return { success: false, error: 'Invalid data URL' };
buffer = Buffer.from(parsed.base64Data, 'base64');
mimeType = parsed.mimeType;
} else if (isDirectUrl(imageUrl)) {
const resp = await fetch(imageUrl);
if (!resp.ok) return { success: false, error: `Download failed: ${resp.status}` };
mimeType = (resp.headers.get('content-type') || 'image/jpeg').split(';')[0].trim();
buffer = Buffer.from(await resp.arrayBuffer());
} else {
return { success: false, error: 'Unsupported image source' };
}
const ext = extFromMime(mimeType);
const file = new File([new Uint8Array(buffer)], `recipe-image.${ext}`, { type: mimeType });
const form = new FormData();
form.append('image', file);
form.append('extension', ext);
const resp = await fetch(`${mealieConfig.serverUrl}/api/recipes/${slug}/image`, {
method: 'PUT',
headers: { Authorization: `Bearer ${mealieConfig.token}` }, // let fetch set multipart boundary
body: form
});
if (!resp.ok) {
const t = await resp.text().catch(() => resp.statusText);
return { success: false, error: `Image upload failed (${resp.status}): ${t.slice(0, 150)}` };
}
return { success: true };
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
logError('[Mealie] Image upload exception', error);
return { success: false, error: msg };
}
}
/** Build a public recipe URL for the Mealie UI. */
export function mealieRecipeUrl(slug: string): string | null {
if (!mealieConfig.serverUrl) return null;
return `${mealieConfig.serverUrl}/g/${mealieConfig.groupSlug}/r/${slug}`;
}
+10 -1
View File
@@ -11,6 +11,7 @@
import { v4 as uuidv4 } from 'uuid';
import { tandoorConfig } from '$lib/server/tandoor-config';
import { mealieRecipeUrl } from '$lib/server/mealie';
import { logError } from '../utils/logger';
import type { QueueItem, QueueItemStatus, QueueStatusUpdate, QueueUpdateCallback } from './types';
@@ -224,7 +225,8 @@ export class QueueManager {
data?.extractedText ||
data?.thumbnail !== undefined ||
data?.recipe ||
data?.tandoorRecipeId
data?.tandoorRecipeId ||
data?.mealieSlug
) {
if (!item.results) {
item.results = {};
@@ -249,8 +251,15 @@ export class QueueManager {
// Construct Tandoor URL
if (tandoorConfig.serverUrl) {
item.results.tandoorUrl = `${tandoorConfig.serverUrl}/view/recipe/${data.tandoorRecipeId}`;
item.results.recipeUrl = item.results.tandoorUrl;
}
}
if (data.mealieSlug) {
item.results.mealieSlug = data.mealieSlug;
const url = mealieRecipeUrl(data.mealieSlug);
if (url) item.results.recipeUrl = url;
}
}
if (data?.error) {
+79 -34
View File
@@ -16,6 +16,7 @@ import { extractTextAndThumbnail as extractWithPlaywright } from '$lib/server/ex
import { extractTextAndThumbnail as extractWithYtDlp } from '$lib/server/instagram-extractor';
import { extractRecipe } from '$lib/server/parser';
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
import { uploadRecipeToMealie, uploadMealieImage } from '$lib/server/mealie';
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService';
import { queueConfig } from './config';
import { logError } from '../utils/logger';
@@ -319,15 +320,18 @@ export class QueueProcessor {
* @throws Error if Tandoor upload fails
*/
private async uploadPhase(item: QueueItem): Promise<void> {
// Check if Tandoor is enabled
if (!queueConfig.tandoor.enabled) {
// Skip if Tandoor not configured
const target = queueConfig.target;
const targetEnabled =
target === 'mealie' ? queueConfig.mealie.enabled : queueConfig.tandoor.enabled;
// Skip if the selected cookbook is not configured
if (!targetEnabled) {
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Tandoor not configured, skipping upload',
message: `${target} not configured, skipping upload`,
timestamp: new Date().toISOString()
});
console.log(`[QueueProcessor] Tandoor not configured, skipping: ${item.id}`);
console.log(`[QueueProcessor] ${target} not configured, skipping: ${item.id}`);
return;
}
@@ -341,52 +345,93 @@ export class QueueProcessor {
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Uploading recipe to Tandoor...',
message: `Uploading recipe to ${target}...`,
timestamp: new Date().toISOString()
});
console.log(`[QueueProcessor] Uploading to Tandoor: ${item.id}`);
console.log(`[QueueProcessor] Uploading to ${target}: ${item.id}`);
// Upload recipe
const result = await uploadRecipeWithIngredientsDTO(item.recipe);
if (target === 'mealie') {
// --- Mealie ---
const result = await uploadRecipeToMealie(item.recipe);
if (!result.success) {
throw new Error(`Mealie upload failed: ${result.error}`);
}
if (!result.success) {
throw new Error(`Tandoor upload failed: ${result.error}`);
}
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'uploading',
tandoorRecipeId: result.recipeId
});
console.log(`[QueueProcessor] ✓ Recipe uploaded: ${item.id} → Tandoor #${result.recipeId}`);
// Upload image if available
if (result.recipeId && result.imageUrl) {
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Uploading recipe image to Tandoor...',
timestamp: new Date().toISOString()
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'uploading',
mealieSlug: result.slug
});
const imageResult = await uploadRecipeImage(result.recipeId, result.imageUrl);
console.log(`[QueueProcessor] ✓ Recipe uploaded: ${item.id} → Mealie /${result.slug}`);
if (!imageResult.success) {
// Image upload failure is recoverable - log but don't fail
console.warn(`[QueueProcessor] Image upload failed for ${item.id}: ${imageResult.error}`);
if (result.slug && result.imageUrl) {
queueManager.addProgressEvent(item.id, {
type: 'status',
message: `Image upload failed: ${imageResult.error}`,
message: 'Uploading recipe image to Mealie...',
timestamp: new Date().toISOString()
});
} else {
console.log(`[QueueProcessor] ✓ Image uploaded: ${item.id}`);
const imageResult = await uploadMealieImage(result.slug, result.imageUrl);
if (!imageResult.success) {
// Image upload failure is recoverable - log but don't fail
console.warn(
`[QueueProcessor] Image upload failed for ${item.id}: ${imageResult.error}`
);
queueManager.addProgressEvent(item.id, {
type: 'status',
message: `Image upload failed: ${imageResult.error}`,
timestamp: new Date().toISOString()
});
} else {
console.log(`[QueueProcessor] ✓ Image uploaded: ${item.id}`);
}
}
} else {
// --- Tandoor ---
const result = await uploadRecipeWithIngredientsDTO(item.recipe);
if (!result.success) {
throw new Error(`Tandoor upload failed: ${result.error}`);
}
queueManager.updateStatus(item.id, 'in_progress', {
phase: 'uploading',
tandoorRecipeId: result.recipeId
});
console.log(
`[QueueProcessor] ✓ Recipe uploaded: ${item.id} → Tandoor #${result.recipeId}`
);
if (result.recipeId && result.imageUrl) {
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Uploading recipe image to Tandoor...',
timestamp: new Date().toISOString()
});
const imageResult = await uploadRecipeImage(result.recipeId, result.imageUrl);
if (!imageResult.success) {
// Image upload failure is recoverable - log but don't fail
console.warn(
`[QueueProcessor] Image upload failed for ${item.id}: ${imageResult.error}`
);
queueManager.addProgressEvent(item.id, {
type: 'status',
message: `Image upload failed: ${imageResult.error}`,
timestamp: new Date().toISOString()
});
} else {
console.log(`[QueueProcessor] ✓ Image uploaded: ${item.id}`);
}
}
}
queueManager.addProgressEvent(item.id, {
type: 'status',
message: 'Tandoor upload completed',
message: `${target} upload completed`,
timestamp: new Date().toISOString()
});
}
+14
View File
@@ -20,6 +20,12 @@ export const queueConfig = {
/** Maximum retry attempts for failed items (default: 3) */
maxRetries: parseInt(env.QUEUE_MAX_RETRIES || '3', 10),
/**
* Which cookbook the queue uploads to. Defaults to 'mealie' when a Mealie token is
* present, else 'tandoor' (back-compat). Override with RECIPE_TARGET.
*/
target: (env.RECIPE_TARGET || (env.MEALIE_TOKEN ? 'mealie' : 'tandoor')) as 'tandoor' | 'mealie',
/** Tandoor integration settings */
tandoor: {
enabled: !!env.TANDOOR_TOKEN,
@@ -27,6 +33,14 @@ export const queueConfig = {
serverUrl: env.TANDOOR_SERVER_URL || null
},
/** Mealie integration settings */
mealie: {
enabled: env.MEALIE_ENABLED === 'true' || !!env.MEALIE_TOKEN,
token: env.MEALIE_TOKEN || null,
serverUrl: env.MEALIE_SERVER_URL || null,
groupSlug: env.MEALIE_GROUP_SLUG || 'home'
},
/** Web Push notification settings */
push: {
vapidPublicKey:
+4
View File
@@ -57,6 +57,10 @@ export interface ProcessingResults {
tandoorRecipeId?: number;
/** Tandoor recipe URL (constructed from ID) */
tandoorUrl?: string;
/** Mealie recipe slug */
mealieSlug?: string;
/** Recipe URL in the target cookbook (Tandoor or Mealie) */
recipeUrl?: string;
}
/**
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { buildMealiePatch } from '$lib/server/mealie';
describe('buildMealiePatch (Tandoor → Mealie mapping)', () => {
const base = {
name: 'Test Recipe',
servings: 4,
description: 'A test recipe',
ingredients: [
{ item: 'calamari', amount: '300', unit: 'g' },
{ item: 'sale', amount: 'q.b.', unit: '' },
{ item: 'patate', amount: '2', unit: '' }
],
steps: ['Boil water', 'Add potatoes'],
image: null
};
it('maps ingredients to free-text notes (no structured unit/food objects)', () => {
const patch = buildMealiePatch(base);
expect(patch.recipeIngredient).toEqual([
{ note: '300 g calamari' },
{ note: 'sale' }, // unparseable amount + no unit collapses to the item
{ note: '2 patate' }
]);
// Guard: never emit structured unit/food (Mealie PATCH rejects them without an id)
for (const ing of patch.recipeIngredient) {
expect(ing).not.toHaveProperty('unit');
expect(ing).not.toHaveProperty('food');
expect(ing).not.toHaveProperty('quantity');
}
});
it('maps steps to recipeInstructions and servings', () => {
const patch = buildMealiePatch(base);
expect(patch.recipeInstructions).toEqual([{ text: 'Boil water' }, { text: 'Add potatoes' }]);
expect(patch.recipeServings).toBe(4);
expect(patch.recipeYield).toBe('4 servings');
expect(patch.description).toBe('A test recipe');
});
it('inserts a placeholder step when none are provided', () => {
const patch = buildMealiePatch({ ...base, steps: null });
expect(patch.recipeInstructions.length).toBe(1);
expect(patch.recipeInstructions[0].text).toContain('link in bio');
});
it('handles empty ingredients/servings gracefully', () => {
const patch = buildMealiePatch({ ...base, ingredients: null, servings: null });
expect(patch.recipeIngredient).toEqual([]);
expect(patch.recipeServings).toBe(0);
expect(patch.recipeYield).toBe('');
});
});