full tour
This commit is contained in:
330
src/lib/server/tandoor.ts
Normal file
330
src/lib/server/tandoor.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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; 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
|
||||
};
|
||||
} 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}`
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user