This commit is contained in:
Giancarmine Salucci
2025-12-21 02:03:05 +01:00
parent 167cd1f4bb
commit 9357bd483a
36 changed files with 6251 additions and 1547 deletions

View File

@@ -1,380 +1,380 @@
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: `Bearer ${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}`
};
}
}
/**
* Uploads an image to a Tandoor recipe
*/
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('Uploading image for recipe ID:', recipeId, 'URL:', imageUrl.substring(0, 50));
// Convert base64 data URL to Blob for multipart upload
const response = await fetch(imageUrl);
const imageBlob = await response.blob();
// Use image field with multipart form data (Tandoor's binary upload support)
const formData = new FormData();
formData.append('image', imageBlob, 'recipe-image.jpg');
// Upload to Tandoor
const uploadResponse = await fetch(
`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`,
{
method: 'PUT',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
}
);
if (!uploadResponse.ok) {
console.warn(`Image upload returned ${uploadResponse.status}`);
return { success: false, error: `Upload failed: ${uploadResponse.statusText}` };
}
console.log('Image uploaded successfully');
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
console.warn(`Image upload failed: ${errorMsg}`);
// Don't fail recipe creation if image fails
return { success: false, error: errorMsg };
}
}
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: `Bearer ${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}`
};
}
}
/**
* Uploads an image to a Tandoor recipe
*/
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('Uploading image for recipe ID:', recipeId, 'URL:', imageUrl.substring(0, 50));
// Convert base64 data URL to Blob for multipart upload
const response = await fetch(imageUrl);
const imageBlob = await response.blob();
// Use image field with multipart form data (Tandoor's binary upload support)
const formData = new FormData();
formData.append('image', imageBlob, 'recipe-image.jpg');
// Upload to Tandoor
const uploadResponse = await fetch(
`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`,
{
method: 'PUT',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
}
);
if (!uploadResponse.ok) {
console.warn(`Image upload returned ${uploadResponse.status}`);
return { success: false, error: `Upload failed: ${uploadResponse.statusText}` };
}
console.log('Image uploaded successfully');
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
console.warn(`Image upload failed: ${errorMsg}`);
// Don't fail recipe creation if image fails
return { success: false, error: errorMsg };
}
}