full tour

This commit is contained in:
Giancarmine Salucci
2025-11-30 09:06:44 +01:00
parent 0477964009
commit 23583f54c6
18 changed files with 1679 additions and 89 deletions

52
src/lib/server/browser.ts Normal file
View File

@@ -0,0 +1,52 @@
import { chromium, type Browser, type BrowserContext } from 'playwright';
import fs from 'fs';
let browser: Browser | null = null;
export async function initializeBrowser(): Promise<Browser> {
if (browser) {
return browser;
}
console.log('Initializing Playwright browser...');
browser = await chromium.launch({
headless: true,
args: ['--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage']
});
console.log('Browser initialized successfully');
return browser;
}
export async function getBrowser(): Promise<Browser> {
if (!browser) {
return initializeBrowser();
}
return browser;
}
export async function createBrowserContext(
authStoragePath?: string
): Promise<BrowserContext> {
const browserInstance = await getBrowser();
// Load auth if available
let context: BrowserContext;
if (authStoragePath && fs.existsSync(authStoragePath)) {
console.log('Loading authentication from:', authStoragePath);
context = await browserInstance.newContext({ storageState: authStoragePath });
} else {
console.warn('No auth storage found. Running as guest.');
context = await browserInstance.newContext();
}
return context;
}
export async function closeBrowser(): Promise<void> {
if (browser) {
console.log('Closing Playwright browser...');
await browser.close();
browser = null;
}
}

View File

@@ -0,0 +1,12 @@
import { env } from '$env/dynamic/private';
/**
* Server-side environment configuration for Tandoor integration
* These variables should be set in your .env file or as environment variables
*/
export const tandoorConfig = {
enabled: env.TANDOOR_ENABLED === 'true',
serverUrl: (env.TANDOOR_SERVER_URL || '').replace(/\/$/, ''),
space: env.TANDOOR_SPACE ? parseInt(env.TANDOOR_SPACE, 10) : 1,
token: env.TANDOOR_TOKEN || null
};

330
src/lib/server/tandoor.ts Normal file
View 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}`
};
}
}