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

View File

@@ -1,7 +1,20 @@
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
# Install Playwright system dependencies
RUN apk add --no-cache \
chromium \
font-liberation \
liberation-fonts \
noto \
noto-cjk
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
EXPOSE 5173 RUN npm run build
CMD ["npm", "run", "dev", "--", "--host"]
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "-e", "import('./build/index.js')"]

View File

@@ -1,34 +1,10 @@
services: services:
app: app:
build: . build: .
ports:
- "5173:5173"
environment:
- PLAYWRIGHT_WS_ENDPOINT=ws://playwright-service:3000
- OPENAI_BASE_URL=http://ollama:11434/v1
- OPENAI_API_KEY=ollama
- LLM_MODEL=llama3.2
volumes:
- ./src:/app/src
- ./secrets:/app/secrets:ro
depends_on:
- playwright-service
- ollama
playwright-service:
build: ./playwright-service
ipc: host
ports: ["3000:3000"] ports: ["3000:3000"]
environment: environment:
- DISPLAY=:99 - DISPLAY=:99
security_opt: security_opt:
- seccomp=unconfined - seccomp=unconfined
ollama:
image: ollama/ollama:latest
ports: ["11434:11434"]
volumes: volumes:
- ollama_data:/root/.ollama - ./secrets:/app/secrets
volumes:
ollama_data:

34
docker-compose_ori.yml Normal file
View File

@@ -0,0 +1,34 @@
services:
app:
build: .
ports:
- "5173:5173"
environment:
- PLAYWRIGHT_WS_ENDPOINT=ws://playwright-service:3000
- OPENAI_BASE_URL=http://ollama:11434/v1
- OPENAI_API_KEY=ollama
- LLM_MODEL=llama3.2
volumes:
- ./src:/app/src
- ./secrets:/app/secrets:ro
depends_on:
- playwright-service
- ollama
playwright-service:
build: ./playwright-service
ipc: host
ports: ["3000:3000"]
environment:
- DISPLAY=:99
security_opt:
- seccomp=unconfined
ollama:
image: ollama/ollama:latest
ports: ["11434:11434"]
volumes:
- ollama_data:/root/.ollama
volumes:
ollama_data:

902
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,12 +23,13 @@
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@types/node": "^22", "@types/node": "^22",
"@vite-pwa/sveltekit": "^0.3.0",
"@vitest/browser-playwright": "^4.0.10", "@vitest/browser-playwright": "^4.0.10",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.0", "eslint-plugin-svelte": "^3.13.0",
"fast-glob": "^3.3.3",
"globals": "^16.5.0", "globals": "^16.5.0",
"playwright": "^1.56.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.7.1",
@@ -39,11 +40,11 @@
"typescript-eslint": "^8.47.0", "typescript-eslint": "^8.47.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vitest": "^4.0.10", "vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1", "vitest-browser-svelte": "^2.0.1"
"@vite-pwa/sveltekit": "^0.3.0"
}, },
"dependencies": { "dependencies": {
"zod": "^3.23.0", "openai": "^4.20.0",
"openai": "^4.20.0" "playwright": "^1.56.1",
"zod": "^3.23.0"
} }
} }

View File

@@ -1,6 +0,0 @@
FROM mcr.microsoft.com/playwright:v1.49.0-jammy
WORKDIR /app
RUN npm init -y && npm install playwright
COPY server.js .
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -1,10 +0,0 @@
const { chromium } = require('playwright');
(async () => {
const server = await chromium.launchServer({
port: 3000,
headless: true,
args: ['--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage']
});
console.log('Browser Server running on port 3000...');
await new Promise(() => {});
})();

View File

@@ -13,7 +13,7 @@ import path from 'path';
try { try {
await page.waitForSelector('svg[aria-label="Home"]', { timeout: 120000 }); await page.waitForSelector('svg[aria-label="Home"]', { timeout: 120000 });
const secretsDir = path.resolve('secrets'); const secretsDir = path.resolve('../secrets');
if (!fs.existsSync(secretsDir)) fs.mkdirSync(secretsDir); if (!fs.existsSync(secretsDir)) fs.mkdirSync(secretsDir);
await context.storageState({ path: path.join(secretsDir, 'auth.json') }); await context.storageState({ path: path.join(secretsDir, 'auth.json') });

93
secrets/auth.json Normal file
View File

@@ -0,0 +1,93 @@
{
"cookies": [
{
"name": "csrftoken",
"value": "ykHk3KB03XrauXWLC-ptZt",
"domain": ".instagram.com",
"path": "/",
"expires": 1798994745.094861,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "datr",
"value": "IyMraZYVQ9HkYUYX3GxS_YQH",
"domain": ".instagram.com",
"path": "/",
"expires": 1798994725.55098,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "ig_did",
"value": "C837AEE7-0829-4F5E-A1CB-26576A939240",
"domain": ".instagram.com",
"path": "/",
"expires": 1795970744.095018,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "mid",
"value": "aSsjIwALAAFWEdHviQtn-VWvZ8vX",
"domain": ".instagram.com",
"path": "/",
"expires": 1798994725.551027,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "sessionid",
"value": "59661903731%3AXVkiiTq7Bfg03S%3A13%3AAYi2K9DS84etVK7mLwkdOxT_NCNWzuGM7pwyc-S2MQ",
"domain": ".instagram.com",
"path": "/",
"expires": 1795970744.094852,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "ds_user_id",
"value": "59661903731",
"domain": ".instagram.com",
"path": "/",
"expires": 1772210745.094968,
"httpOnly": false,
"secure": true,
"sameSite": "None"
},
{
"name": "rur",
"value": "\"CLN\\05459661903731\\0541795970747:01fe6e28c38cd9db21b75181598de0953055c6279b89492b332d16872ed81561f6513e4c\"",
"domain": ".instagram.com",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://www.instagram.com",
"localStorage": [
{
"name": "Session",
"value": "vdz65y:1764434779842"
},
{
"name": "chatd-deviceid",
"value": "13e8b058-6d14-418e-9b87-ccd98297098c"
},
{
"name": "IGSession",
"value": "nrg2g0:1764436544843"
}
]
}
]
}

27
src/app.server.ts Normal file
View File

@@ -0,0 +1,27 @@
import { initializeBrowser, closeBrowser } from '$lib/server/browser';
// Initialize browser when server starts
export async function init() {
try {
await initializeBrowser();
} catch (error) {
console.error('Failed to initialize browser:', error);
process.exit(1);
}
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully...');
await closeBrowser();
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('SIGINT received, shutting down gracefully...');
await closeBrowser();
process.exit(0);
});
}
// Run initialization immediately
init().catch(console.error);

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}`
};
}
}

View File

@@ -1,74 +1,129 @@
import { json } from '@sveltejs/kit'; import { createBrowserContext } from '$lib/server/browser';
import { createLLM } from '$lib/server/llm'; import { createLLM } from '$lib/server/llm';
import { z } from 'zod'; import { json } from '@sveltejs/kit';
import fs, { writeFileSync } from 'fs';
import { zodResponseFormat } from 'openai/helpers/zod'; import { zodResponseFormat } from 'openai/helpers/zod';
import { chromium } from 'playwright'; import { z } from 'zod';
import fs from 'fs'; import path from 'path';
import { env } from '$env/dynamic/private';
const RecipeSchema = z.object({ const RecipeSchema = z.object({
name: z.string(), name: z.string(),
description: z.string(), servings: z.number().nullable(),
steps: z.array(z.string()), description: z.string().nullable(),
ingredients: z.array(z.object({ ingredients: z.array(z.object({
item: z.string(), item: z.string(),
amount: z.string(), amount: z.string(),
unit: z.string() unit: z.string()
})) })).nullable(),
steps: z.array(z.string()).nullable()
}); });
export async function POST({ request }) { export async function POST({ request }) {
const { url } = await request.json(); const { url } = await request.json();
// 1. Browser Connection // 1. Browser Connection - now managed by SvelteKit
// Fallback to localhost if env var not set (e.g. running outside docker) console.log('Creating browser context for URL:', url);
const wsEndpoint = env.PLAYWRIGHT_WS_ENDPOINT || 'ws://127.0.0.1:3000';
console.log('Connecting to browser at:', wsEndpoint);
const browser = await chromium.connect(wsEndpoint); // Try to find auth storage
const authPathDocker = '/app/secrets/auth.json';
// 2. Load Auth if available const authPathLocal = './secrets/auth.json';
const authPath = '/app/secrets/auth.json'; const authPath = fs.existsSync(authPathDocker) ? authPathDocker :
let context; fs.existsSync(authPathLocal) ? authPathLocal :
// We check absolute path (Docker) or relative (Local) undefined;
if (fs.existsSync(authPath)) {
context = await browser.newContext({ storageState: authPath }); const context = await createBrowserContext(authPath);
} else if (fs.existsSync('./secrets/auth.json')) {
context = await browser.newContext({ storageState: './secrets/auth.json' });
} else {
console.warn('No auth.json found. Running as guest.');
context = await browser.newContext();
}
const page = await context.newPage(); const page = await context.newPage();
let bodyText = ''; let bodyText = '';
try { try {
await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.goto(url, { waitUntil: 'domcontentloaded' });
// Naive scraper attempt // Extract HTML from the page
bodyText = await page.evaluate(() => document.body.innerText); bodyText = (await page.evaluate(() => document.body.innerText)).replace(/^(?:.*\n){6}/, '').split('More posts from')[0].trim();
// Cleaning steps
// 1. Remove @tags and #hashtags
bodyText = bodyText.replace(/@\w+/g, '').replace(/#\w+/g, '');
writeFileSync(path.resolve('debug_page.txt'), bodyText); // Save for debugging, overwriting if exists
} catch (e) { } catch (e) {
console.error('Scraping error:', e); console.error('Scraping error:', e);
return json({ error: 'Failed to scrape URL' }, { status: 500 }); return json({ error: 'Failed to scrape URL' }, { status: 500 });
} finally { } finally {
await page.close(); await page.close();
await context.close(); await context.close();
await browser.close();
} }
// 3. LLM Processing // 2. LLM Processing - Two-step validation
try { try {
const { client, model } = createLLM(); const { client, model } = createLLM();
// STEP 1: Binary recipe detection (yes/no only)
const detectionResponse = await client.chat.completions.create({
model,
messages: [
{ role: "system", content: "You are a recipe detector. Answer with ONLY 'yes' or 'no' - nothing else. A recipe MUST have: (1) name/title, (2) ingredients with quantities, (3) numbered cooking steps. If ANY are missing, answer 'no'." },
{ role: "user", content: `Does this text contain a recipe?\n\n${bodyText}` }
],
max_tokens: 10,
});
const detectionResult = detectionResponse.choices[0].message.content?.toLowerCase() ?? '';
const hasRecipe = detectionResult.includes("yes");
if (!hasRecipe) {
return json({ error: "No recipe found in provided text" }, { status: 400 });
}
// STEP 2: Extract recipe (only proceeds if recipe detected)
const completion = await client.beta.chat.completions.parse({ const completion = await client.beta.chat.completions.parse({
model, model,
messages: [ messages: [
{ role: "system", content: "Extract a recipe structure from this text. If it is not a recipe, return empty arrays." }, { role: "system", content: `You are a RECIPE EXTRACTOR. Extract the recipe from the provided text.
{ role: "user", content: bodyText.substring(0, 8000) } // Limit context window
✅ REQUIREMENTS:
1. Extract the exact recipe name from the text
2. List all ingredients with their quantities and units
3. List all cooking steps in order
4. Translate everything to Italian
5. Convert measurements to SI units (g, mL, °C)
📋 CONVERSION TABLE:
- 1 cup = 240 mL, 1 tbsp = 15 mL, 1 tsp = 5 mL
- 1 oz = 28.35 g, 1 lb = 453.59 g
- 1 stick butter = 113 g
- °F→°C: (°F32)×5/9
🔄 OUTPUT FORMAT:
{
"name": "recipe name in Italian",
"servings": number or null,
"description": "description in Italian or null",
"ingredients": [{"item": "ingredient name", "amount": "quantity", "unit": "SI unit"}],
"steps": ["1. First step", "2. Second step", ...]
}
Extract ONLY what's explicitly in the text. Be accurate and literal.
` },
{ role: "user", content: `Extract the recipe from this text:\n\n${bodyText}` }
], ],
response_format: zodResponseFormat(RecipeSchema, "recipe") response_format: zodResponseFormat(RecipeSchema, "recipe")
}); });
console.log('LLM extraction successful:', completion.choices[0].message);
return json({ recipe: completion.choices[0].message.parsed });
const recipe = completion.choices[0].message.parsed;
if (!recipe || !recipe.name) {
return json({ error: "Failed to extract recipe" }, { status: 400 });
}
// Append original Instagram link to description
if (recipe.description) {
recipe.description += `\n\nLink: ${url}`;
} else {
recipe.description = `Link: ${url}`;
}
return json({ recipe });
} catch (e) { } catch (e) {
console.error('LLM error:', e); console.error('LLM error:', e);
return json({ error: 'Failed to parse recipe' }, { status: 500 }); return json({ error: 'Failed to parse recipe' }, { status: 500 });

View File

@@ -0,0 +1,5 @@
import { json } from '@sveltejs/kit';
import {tandoorConfig} from '$lib/server/tandoor-config';
export async function GET() {
return json({...tandoorConfig, token: ''});
}

View File

@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import { uploadRecipeWithIngredientsDTO } from '$lib/server/tandoor';
export async function POST({ request }) {
const { recipe } = await request.json();
if (!recipe) {
return json({ error: 'No recipe provided' }, { status: 400 });
}
try {
const result = await uploadRecipeWithIngredientsDTO(recipe);
if (!result.success) {
return json({ error: result.error || 'Failed to upload recipe' }, { status: 500 });
}
return json({
success: true,
message: 'Recipe successfully imported to Tandoor',
recipeId: result.recipeId
});
} catch (error) {
console.error('Tandoor upload error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Unknown error occurred'
},
{ status: 500 }
);
}
}

View File

@@ -4,6 +4,9 @@
let status = $state('idle'); let status = $state('idle');
let logs = $state<string[]>([]); let logs = $state<string[]>([]);
let recipe = $state<any>(null); let recipe = $state<any>(null);
let tandoorEnabled = $state(false);
let tandoorImporting = $state(false);
let tandoorError = $state<string | null>(null);
// URL param parsing for Share Target // URL param parsing for Share Target
// Instagram typically shares text that contains the URL, so we might need to parse it out // Instagram typically shares text that contains the URL, so we might need to parse it out
@@ -17,6 +20,22 @@
let targetUrl = $derived(sharedUrl || extractUrl(sharedText)); let targetUrl = $derived(sharedUrl || extractUrl(sharedText));
$effect.pre(() => {
loadTandoorConfig();
});
// Load Tandoor config on mount
async function loadTandoorConfig() {
try {
const res = await fetch('/api/tandoor-config');
const config = await res.json();
tandoorEnabled = config.enabled;
logs = [...logs, `Tandoor integration ${config.enabled ? 'enabled' : 'disabled'}`];
} catch(e) {
logs = [...logs, 'Failed to load Tandoor config'];
}
}
async function process() { async function process() {
if(!targetUrl) return; if(!targetUrl) return;
status = 'extracting'; status = 'extracting';
@@ -33,6 +52,7 @@
if (data.recipe) { if (data.recipe) {
recipe = data.recipe; recipe = data.recipe;
status = 'done'; status = 'done';
logs = [...logs, 'Recipe extraction successful'];
} else { } else {
logs = [...logs, 'Error: ' + JSON.stringify(data)]; logs = [...logs, 'Error: ' + JSON.stringify(data)];
status = 'error'; status = 'error';
@@ -42,6 +62,38 @@
status = 'error'; status = 'error';
} }
} }
async function importToTandoor() {
if (!recipe) return;
tandoorImporting = true;
tandoorError = null;
logs = [...logs, 'Importing recipe to Tandoor...'];
try {
const res = await fetch('/api/tandoor', {
method: 'POST',
body: JSON.stringify({ recipe }),
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
if (data.success) {
logs = [...logs, `✓ Recipe imported successfully (ID: ${data.recipeId})`];
tandoorError = null;
} else {
logs = [...logs, `✗ Import failed: ${data.error}`];
tandoorError = data.error;
}
} catch(e) {
const errorMsg = e instanceof Error ? e.message : 'Unknown error';
logs = [...logs, `✗ Network error: ${errorMsg}`];
tandoorError = errorMsg;
} finally {
tandoorImporting = false;
}
}
</script> </script>
<div class="p-8 max-w-lg mx-auto space-y-4"> <div class="p-8 max-w-lg mx-auto space-y-4">
@@ -68,12 +120,37 @@
<div class="border rounded p-4 bg-green-50 space-y-2"> <div class="border rounded p-4 bg-green-50 space-y-2">
<h2 class="font-bold text-xl">{recipe.name}</h2> <h2 class="font-bold text-xl">{recipe.name}</h2>
<p class="text-sm">{recipe.description}</p> <p class="text-sm">{recipe.description}</p>
<p class="text-muted"><strong>Servings:</strong> {recipe.servings}</p>
<h3 class="font-bold mt-2">Ingredients</h3> <h3 class="font-bold mt-2">Ingredients</h3>
<ul class="list-disc pl-5 text-sm"> <ul class="list-disc pl-5 text-sm">
{#each recipe.ingredients as ing} {#each recipe.ingredients as ing}
<li>{ing.amount} {ing.unit} {ing.item}</li> <li>{ing.amount} {ing.unit} {ing.item}</li>
{/each} {/each}
</ul> </ul>
<h3 class="font-bold mt-2">Steps</h3>
<ol class="list-decimal pl-5 text-sm">
{#each recipe.steps as step}
<li>{step}</li>
{/each}
</ol>
{#if tandoorEnabled}
<div class="mt-4 pt-4 border-t space-y-2">
<h3 class="font-bold">Tandoor Integration</h3>
{#if tandoorError}
<div class="bg-red-100 text-red-800 p-2 rounded text-sm">
Error: {tandoorError}
</div>
{/if}
<button
onclick={importToTandoor}
disabled={tandoorImporting}
class="bg-orange-600 text-white px-4 py-2 rounded shadow hover:bg-orange-700 w-full disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{tandoorImporting ? 'Importing...' : 'Import to Tandoor'}
</button>
</div>
{/if}
</div> </div>
{/if} {/if}

View File

@@ -5,6 +5,11 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit'; import { SvelteKitPWA } from '@vite-pwa/sveltekit';
export default defineConfig({ export default defineConfig({
server: {
watch: {
ignored: ['**/debug_page.txt']
}
},
plugins: [ plugins: [
SvelteKitPWA({ SvelteKitPWA({
srcDir: './src', srcDir: './src',