- Fix authentication header from 'Bearer' to 'Token' (DRF TokenAuth) - Implement three-strategy upload system: 1. URL pass-through for direct URLs (most efficient) 2. Base64 data URL conversion for screenshots 3. Fallback blob upload for any other format - Add comprehensive error handling with response details - Add detailed logging for debugging upload strategies - Document thumbnail formats in extractThumbnailStealth() Fixes #30 - Tandoor image upload 400 Bad Request error Based on Tandoor source code analysis (cookbook/views/api.py): - RecipeImageSerializer accepts 'image_url' field for server-side download - Uses Token authentication, not Bearer - Supports multipart file upload with proper MIME types
509 lines
14 KiB
TypeScript
509 lines
14 KiB
TypeScript
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<typeof TandoorRecipeSchema>;
|
|
|
|
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<T>(
|
|
url: 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: `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<Array<{ item: string; amount: string; unit: string }>> {
|
|
if (stepCount === 0 || !ingredients || ingredients.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const partitions: Array<Array<{ item: string; amount: string; unit: string }>> = 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<string, string> = {
|
|
'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 };
|
|
}
|
|
}
|