import { tandoorConfig } from '$lib/server/tandoor-config'; import { z } from 'zod'; /** * Tandoor Recipe Export Format * Based on the Default/JSON-LD Tandoor export format */ export const TandoorRecipeSchema = z.object({ name: z.string(), author: z.string().optional().nullable(), description: z.string().optional().nullable(), servings: z.number().optional().nullable(), servings_text: z.string().optional().nullable(), keywords: z.array(z.string()).optional(), prep_time: z.string().optional(), cook_time: z.string().optional(), waiting_time: z.string().optional(), steps: z.array( z.object({ step: z.number(), instruction: z.string(), ingredients: z.array( z.object({ food: z.object({ id: z.number(), name: z.string() }), unit: z.object({ id: z.number(), name: z.string() }).nullable(), amount: z.number(), note: z.string().optional() }) ).optional() }) ).optional(), ingredients: z.array( z.object({ food: z.object({ name: z.string() }), unit: z.object({ name: z.string() }).nullable(), amount: z.number(), note: z.string().optional() }) ).optional() }); export type TandoorRecipe = z.infer; 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; } /** * DTO for Tandoor Recipe API (POST /api/recipe/) * Matches the Tandoor endpoint schema for recipe creation */ interface TandoorRecipeDTO { name: string; description?: string; keywords: Array<{ name: string; description?: string; }>; steps: Array<{ name?: string; instruction: string; ingredients: Array<{ food: { name: string; }; unit: { name: string; } | null; amount: string; note?: string; }>; order?: number; show_as_header?: boolean; }>; working_time?: number; waiting_time?: number; servings?: number; servings_text?: string; private?: boolean; show_ingredient_overview?: boolean; } /** * Helper function to make authenticated API calls */ async function fetchFromTandoor( url: string, options: Partial = { method: 'GET' }, ): Promise<{ ok: boolean; data?: T; error?: string }> { const headers = new Headers({ 'Content-Type': 'application/json', 'Accept': 'application/json', Authorization: `Token ${tandoorConfig.token}` }); // Merge any additional headers from options if (options.headers) { const optHeaders = new Headers(options.headers); optHeaders.forEach((value, key) => { headers.set(key, value); }); } console.debug(`Fetching from Tandoor: ${url}`, { method: options.method, headers: Object.fromEntries(headers), body: options.body }); try { const response = await fetch(`${tandoorConfig.serverUrl}${url}`, { ...options, headers }); if (!response.ok) { const errorBody = await response.json().catch(() => ({})); console.error(`API Error ${response.status}: ${response.statusText}`, errorBody); return { ok: false, error: `API Error: ${response.statusText} - ${JSON.stringify(errorBody)}` }; } const data = (await response.json()) as T; console.debug(`Tandoor response OK:`, data); return { ok: true, data }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error'; console.error(`Fetch error: ${errorMsg}`); return { ok: false, error: `Fetch error: ${errorMsg}` }; } } /** * Partitions ingredients across steps by distributing them evenly * When step association is unknown, this spreads ingredients proportionally */ function partitionIngredientsAcrossSteps( ingredients: Array<{ item: string; amount: string; unit: string; }>, stepCount: number ): Array> { if (stepCount === 0 || !ingredients || ingredients.length === 0) { return []; } const partitions: Array> = Array.from( { length: stepCount }, () => [] ); // Distribute ingredients round-robin across steps ingredients.forEach((ingredient, index) => { partitions[index % stepCount].push(ingredient); }); console.debug(`Partitioned ${ingredients.length} ingredients across ${stepCount} steps:`, partitions); return partitions; } /** * Parses amount string to a number, handling special cases * Returns null if amount cannot be parsed to a valid number */ function parseAmount(amountStr: string): number | null { if (!amountStr || typeof amountStr !== 'string') { return null; } const trimmed = amountStr.trim().toLowerCase(); // Skip special cases that can't be converted to numbers if (!trimmed || trimmed === 'q.b.' || trimmed === 'qb' || trimmed === 'to taste') { return null; } // Try to extract the first number from the string const numberMatch = trimmed.match(/^[\d.,]+/); if (!numberMatch) { return null; } const numStr = numberMatch[0].replace(',', '.'); const parsed = parseFloat(numStr); // Return null for zero or invalid numbers if (isNaN(parsed) || parsed === 0) { return null; } return parsed; } /** * Builds a complete Tandoor Recipe DTO from extracted recipe data * Includes ingredients partitioned across steps */ function buildTandoorRecipeDTO(recipe: ExtractedRecipe): TandoorRecipeDTO { const stepCount = recipe.steps?.length || 1; const ingredientPartitions = partitionIngredientsAcrossSteps( recipe.ingredients || [], stepCount ); const steps: TandoorRecipeDTO['steps'] = (recipe.steps || []).map((instruction, index) => { // Map ingredients, converting unparseable amounts to 1 q.b. const mappedIngredients = (ingredientPartitions[index] || []).map((ing) => { const amount = parseAmount(ing.amount); if (amount === null) { console.debug(`Converting ingredient with unparseable amount to 1 q.b.: ${ing.item} (${ing.amount})`); return { food: { name: ing.item }, unit: { name: 'q.b.' }, amount: '1', note: '' }; } return { food: { name: ing.item }, unit: ing.unit && ing.unit.trim() ? { name: ing.unit } : null, amount: amount.toString(), note: '' }; }); return { instruction, order: index, ingredients: mappedIngredients }; }); return { name: recipe.name, description: recipe.description || undefined, keywords: [], steps, servings: recipe.servings || undefined, servings_text: recipe.servings ? `${recipe.servings} servings` : undefined, private: false, show_ingredient_overview: true }; } /** * Uploads a recipe to Tandoor server using the DTO-based approach * Creates recipe with ingredients partitioned across steps in a single request */ export async function uploadRecipeWithIngredientsDTO( recipe: ExtractedRecipe ): Promise<{ success: boolean; recipeId?: number; imageUrl?: string; error?: string }> { try { // Validate token const token = tandoorConfig.token; if (!token) { return { success: false, error: 'TANDOOR_TOKEN environment variable not set' }; } // Build the complete DTO const recipeDTO = buildTandoorRecipeDTO(recipe); console.debug('Uploading recipe with ingredients DTO:', recipeDTO); // Call the API with the DTO const recipeResult = await fetchFromTandoor<{ id: number }>( `/api/recipe/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(recipeDTO) } ); if (!recipeResult.ok || !recipeResult.data) { console.error('Recipe creation failed:', recipeResult.error); return { success: false, error: `Failed to create recipe: ${recipeResult.error}` }; } const createdRecipe = recipeResult.data; console.debug('Successfully created recipe with ID:', createdRecipe.id); return { success: true, recipeId: createdRecipe.id, imageUrl: recipe.image || undefined }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error'; console.error(`Error uploading recipe to Tandoor: ${errorMsg}`); return { success: false, error: `Error uploading to Tandoor: ${errorMsg}` }; } } /** * Determine if a string is a direct HTTP(S) URL */ function isDirectUrl(url: string): boolean { return url.startsWith('http://') || url.startsWith('https://'); } /** * Determine if a string is a base64 data URL */ function isDataUrl(url: string): boolean { return url.startsWith('data:'); } /** * Extract MIME type and base64 data from data URL */ function parseDataUrl(dataUrl: string): { mimeType: string; base64Data: string } | null { const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); if (!match) return null; return { mimeType: match[1], base64Data: match[2] }; } /** * Convert MIME type to file extension */ function getExtensionFromMimeType(mimeType: string): string { const mimeToExt: Record = { 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp' }; return mimeToExt[mimeType] || '.jpg'; } /** * Uploads an image to a Tandoor recipe with intelligent format handling * * Supports three upload strategies: * 1. Direct URL pass-through (most efficient) - for meta tags, Instagram URLs * 2. Base64 data URL conversion to file upload - for screenshots * 3. Fallback blob upload - for any other format * * @param recipeId - Tandoor recipe ID * @param imageUrl - Image URL (can be HTTP(S) URL or base64 data URL) * @returns Success status and optional error message */ export async function uploadRecipeImage( recipeId: number, imageUrl: string ): Promise<{ success: boolean; error?: string }> { try { const token = tandoorConfig.token; if (!token) { return { success: false, error: 'TANDOOR_TOKEN not set' }; } console.log(`[Tandoor Upload] Recipe ID: ${recipeId}`); console.log(`[Tandoor Upload] Image type: ${isDirectUrl(imageUrl) ? 'URL' : isDataUrl(imageUrl) ? 'Base64' : 'Unknown'}`); console.log(`[Tandoor Upload] Image source: ${imageUrl.substring(0, 100)}...`); // Strategy 1: Direct URL pass-through (preferred) if (isDirectUrl(imageUrl)) { console.log('[Tandoor Upload] Using URL pass-through strategy'); const formData = new FormData(); formData.append('image_url', imageUrl); const uploadResponse = await fetch( `${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`, { method: 'PUT', headers: { 'Authorization': `Token ${token}` }, body: formData } ); if (uploadResponse.ok) { console.log('[Tandoor Upload] ✓ Success via URL pass-through'); return { success: true }; } // If URL strategy fails, fall through to file upload const errorText = await uploadResponse.text().catch(() => uploadResponse.statusText); console.warn(`[Tandoor Upload] URL pass-through failed (${uploadResponse.status}), trying file upload: ${errorText}`); } // Strategy 2: Base64 data URL to file upload if (isDataUrl(imageUrl)) { console.log('[Tandoor Upload] Using base64 file upload strategy'); const parsed = parseDataUrl(imageUrl); if (!parsed) { return { success: false, error: 'Invalid data URL format' }; } // Convert base64 to buffer const imageBuffer = Buffer.from(parsed.base64Data, 'base64'); const extension = getExtensionFromMimeType(parsed.mimeType); // Create a proper file blob const blob = new Blob([imageBuffer], { type: parsed.mimeType }); const formData = new FormData(); formData.append('image', blob, `recipe-image${extension}`); const uploadResponse = await fetch( `${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`, { method: 'PUT', headers: { 'Authorization': `Token ${token}` }, body: formData } ); if (!uploadResponse.ok) { const errorText = await uploadResponse.text().catch(() => uploadResponse.statusText); console.error(`[Tandoor Upload] Failed: ${uploadResponse.status} ${uploadResponse.statusText}`); console.error(`[Tandoor Upload] Response: ${errorText.substring(0, 200)}`); return { success: false, error: `Upload failed (${uploadResponse.status}): ${errorText.substring(0, 200)}` }; } console.log(`[Tandoor Upload] ✓ Success via base64 file upload (${imageBuffer.length} bytes)`); return { success: true }; } // Strategy 3: Fallback - try to fetch and upload console.log('[Tandoor Upload] Using fallback fetch strategy'); const response = await fetch(imageUrl); const imageBlob = await response.blob(); // Determine file extension from blob type or default to jpg let extension = '.jpg'; if (imageBlob.type) { extension = getExtensionFromMimeType(imageBlob.type); } const formData = new FormData(); formData.append('image', imageBlob, `recipe-image${extension}`); const uploadResponse = await fetch( `${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`, { method: 'PUT', headers: { 'Authorization': `Token ${token}` }, body: formData } ); if (!uploadResponse.ok) { const errorText = await uploadResponse.text().catch(() => uploadResponse.statusText); console.error(`[Tandoor Upload] Failed: ${uploadResponse.status} ${uploadResponse.statusText}`); console.error(`[Tandoor Upload] Response: ${errorText.substring(0, 200)}`); return { success: false, error: `Upload failed (${uploadResponse.status}): ${errorText.substring(0, 200)}` }; } console.log(`[Tandoor Upload] ✓ Success via fallback (${imageBlob.size} bytes)`); return { success: true }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error'; console.error(`[Tandoor Upload] Exception: ${errorMsg}`); // Don't fail recipe creation if image fails return { success: false, error: errorMsg }; } }