diff --git a/backend/src/quick-import/parsers/generic.parser.ts b/backend/src/quick-import/parsers/generic.parser.ts index d8227fc5..2515d0e0 100644 --- a/backend/src/quick-import/parsers/generic.parser.ts +++ b/backend/src/quick-import/parsers/generic.parser.ts @@ -77,7 +77,41 @@ export class GenericRecipeParser extends RecipeParser { private extractOgImage(html: string): string | undefined { const match = html.match(/]+property="og:image"[^>]+content="([^"]+)"/i) || html.match(/]+content="([^"]+)"[^>]+property="og:image"/i); - return match ? match[1].trim() : undefined; + return match ? this.decodeHtmlEntities(match[1].trim()) : undefined; + } + + private decodeHtmlEntities(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>'); + } + + private extractImageValue(image: any): string | undefined { + if (!image) return undefined; + if (typeof image === 'string') return image; + + if (Array.isArray(image)) { + for (const item of image) { + const extracted = this.extractImageValue(item); + if (extracted) return extracted; + } + return undefined; + } + + if (typeof image === 'object') { + return ( + image.url || + image['@id'] || + image.contentUrl || + image.thumbnailUrl || + this.extractImageValue(image.image) + ); + } + + return undefined; } private extractFromJsonLd(recipe: any, ogImage?: string): ParsedRecipe { @@ -86,14 +120,9 @@ export class GenericRecipeParser extends RecipeParser { // Extrahera bildurl från JSON-LD let imageUrl: string | undefined = ogImage; - if (recipe.image) { - if (typeof recipe.image === 'string') { - imageUrl = recipe.image; - } else if (Array.isArray(recipe.image) && recipe.image.length > 0) { - imageUrl = typeof recipe.image[0] === 'string' ? recipe.image[0] : recipe.image[0]?.url; - } else if (recipe.image?.url) { - imageUrl = recipe.image.url; - } + const extractedImage = this.extractImageValue(recipe.image); + if (extractedImage) { + imageUrl = this.decodeHtmlEntities(extractedImage); } const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = []; diff --git a/backend/src/quick-import/parsers/ica.parser.ts b/backend/src/quick-import/parsers/ica.parser.ts index 90d871f8..b6ae86eb 100644 --- a/backend/src/quick-import/parsers/ica.parser.ts +++ b/backend/src/quick-import/parsers/ica.parser.ts @@ -76,7 +76,41 @@ export class IcaRecipeParser extends RecipeParser { private extractOgImage(html: string): string | undefined { const match = html.match(/]+property="og:image"[^>]+content="([^"]+)"/i) || html.match(/]+content="([^"]+)"[^>]+property="og:image"/i); - return match ? match[1].trim() : undefined; + return match ? this.decodeHtmlEntities(match[1].trim()) : undefined; + } + + private decodeHtmlEntities(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>'); + } + + private extractImageValue(image: any): string | undefined { + if (!image) return undefined; + if (typeof image === 'string') return image; + + if (Array.isArray(image)) { + for (const item of image) { + const extracted = this.extractImageValue(item); + if (extracted) return extracted; + } + return undefined; + } + + if (typeof image === 'object') { + return ( + image.url || + image['@id'] || + image.contentUrl || + image.thumbnailUrl || + this.extractImageValue(image.image) + ); + } + + return undefined; } private extractFromJsonLd(recipe: any, ogImage?: string): ParsedRecipe { @@ -88,14 +122,9 @@ export class IcaRecipeParser extends RecipeParser { // Extrahera bildurl från JSON-LD (kan vara sträng eller array) let imageUrl: string | undefined = ogImage; - if (recipe.image) { - if (typeof recipe.image === 'string') { - imageUrl = recipe.image; - } else if (Array.isArray(recipe.image) && recipe.image.length > 0) { - imageUrl = typeof recipe.image[0] === 'string' ? recipe.image[0] : recipe.image[0]?.url; - } else if (recipe.image?.url) { - imageUrl = recipe.image.url; - } + const extractedImage = this.extractImageValue(recipe.image); + if (extractedImage) { + imageUrl = this.decodeHtmlEntities(extractedImage); } // Extrahera ingredienser diff --git a/backend/src/quick-import/quick-import.service.ts b/backend/src/quick-import/quick-import.service.ts index bc7a07b4..db240e17 100644 --- a/backend/src/quick-import/quick-import.service.ts +++ b/backend/src/quick-import/quick-import.service.ts @@ -320,7 +320,9 @@ export class QuickImportService { let imageUrl: string | undefined; let imageWarning: string | undefined; if (recipe.imageUrl) { + this.logger.log(`Bildkandidat från parser: ${recipe.imageUrl}`); const normalizedImageUrl = this.normalizeImageUrl(recipe.imageUrl, url); + this.logger.log(`Normaliserad bild-URL: ${normalizedImageUrl ?? 'null'}`); if (!normalizedImageUrl) { imageWarning = 'Receptbild kunde inte tolkas till en giltig URL.'; this.logger.warn( @@ -331,7 +333,9 @@ export class QuickImportService { imageUrl = await downloadAndOptimizeImage(normalizedImageUrl, IMAGE_DEST_DIR); this.logger.log(`Bild optimerad och sparad: ${imageUrl}`); } catch (imgErr) { - imageWarning = 'Receptbild kunde inte laddas ner.'; + // Fallback: behåll extern URL så klienten ändå kan visa bild. + imageUrl = normalizedImageUrl; + imageWarning = 'Receptbild kunde inte laddas ner lokalt; extern URL används.'; this.logger.warn( `Kunde inte ladda ner bild: ${imgErr} (källa: ${normalizedImageUrl})`, ); diff --git a/backend/src/recipes/recipes.service.ts b/backend/src/recipes/recipes.service.ts index 653e4690..ca2c67cf 100644 --- a/backend/src/recipes/recipes.service.ts +++ b/backend/src/recipes/recipes.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; @@ -27,6 +27,8 @@ interface ParsedRecipe { @Injectable() export class RecipesService { + private readonly logger = new Logger(RecipesService.name); + constructor(private readonly prisma: PrismaService) {} async getInventoryPreview(id: number) { @@ -301,17 +303,25 @@ export class RecipesService { } async create(createRecipeDto: CreateRecipeDto, userId: number) { + this.logger.log( + `[create] Incoming imageUrl from client: ${createRecipeDto.imageUrl ?? 'null'}`, + ); + // Om imageUrl är en extern URL — ladda ner och optimera let imageUrl: string | null = createRecipeDto.imageUrl || null; if (imageUrl && imageUrl.startsWith('http')) { + const externalImageUrl = imageUrl; try { imageUrl = await downloadAndOptimizeImage(imageUrl, IMAGE_DEST_DIR); } catch (err) { console.warn('[RecipesService] Kunde inte ladda ner receptbild:', err); - imageUrl = null; + // Behåll extern URL som fallback så bild fortfarande visas. + imageUrl = externalImageUrl; } } + this.logger.log(`[create] Final imageUrl persisted to DB: ${imageUrl ?? 'null'}`); + const recipe = await this.prisma.recipe.create({ data: { name: createRecipeDto.name, diff --git a/frontend/app/api/quick-import-proxy/route.ts b/frontend/app/api/quick-import-proxy/route.ts index 6b4f8aa5..26ab6720 100644 --- a/frontend/app/api/quick-import-proxy/route.ts +++ b/frontend/app/api/quick-import-proxy/route.ts @@ -19,6 +19,22 @@ export const POST = withAuth(async (request, session) => { }); const text = await response.text(); + try { + const parsed = JSON.parse(text); + // eslint-disable-next-line no-console + console.log('[QuickImportProxy] backend response', { + status: response.status, + hasMarkdown: Boolean(parsed?.markdown), + imageUrl: parsed?.imageUrl ?? null, + imageWarning: parsed?.imageWarning ?? null, + }); + } catch { + // eslint-disable-next-line no-console + console.log('[QuickImportProxy] backend non-json response', { + status: response.status, + contentType: response.headers.get('content-type'), + }); + } return new NextResponse(text, { status: response.status, headers: { 'Content-Type': response.headers.get('content-type') ?? 'application/json' }, diff --git a/frontend/app/import/ImportTabsClient.tsx b/frontend/app/import/ImportTabsClient.tsx index fa44767d..8bce3d86 100644 --- a/frontend/app/import/ImportTabsClient.tsx +++ b/frontend/app/import/ImportTabsClient.tsx @@ -83,12 +83,23 @@ function ReceptImport() { const res = await fetch('/api/quick-import-proxy', { method: 'POST', body: formData }); if (!res.ok) throw new Error(await parseErrorResponse(res)); const data = await res.json(); + // eslint-disable-next-line no-console + console.log('[ImportTabsClient:file] quick-import response', { + imageUrl: data.imageUrl ?? null, + imageWarning: data.imageWarning ?? null, + markdownLength: (data.markdown ?? '').length, + }); sessionStorage.setItem('prefilled_markdown', data.markdown ?? ''); if (data.imageUrl) { sessionStorage.setItem('prefilled_image_url', data.imageUrl); } else { sessionStorage.removeItem('prefilled_image_url'); } + // eslint-disable-next-line no-console + console.log('[ImportTabsClient:file] sessionStorage snapshot', { + prefilled_markdown: sessionStorage.getItem('prefilled_markdown')?.length ?? 0, + prefilled_image_url: sessionStorage.getItem('prefilled_image_url'), + }); router.push('/recipes/write'); } catch (err) { setError(err instanceof Error ? err.message : 'Importen misslyckades.'); @@ -110,12 +121,23 @@ function ReceptImport() { }); if (!res.ok) throw new Error(await parseErrorResponse(res)); const data = await res.json(); + // eslint-disable-next-line no-console + console.log('[ImportTabsClient:url] quick-import response', { + imageUrl: data.imageUrl ?? null, + imageWarning: data.imageWarning ?? null, + markdownLength: (data.markdown ?? '').length, + }); sessionStorage.setItem('prefilled_markdown', data.markdown ?? ''); if (data.imageUrl) { sessionStorage.setItem('prefilled_image_url', data.imageUrl); } else { sessionStorage.removeItem('prefilled_image_url'); } + // eslint-disable-next-line no-console + console.log('[ImportTabsClient:url] sessionStorage snapshot', { + prefilled_markdown: sessionStorage.getItem('prefilled_markdown')?.length ?? 0, + prefilled_image_url: sessionStorage.getItem('prefilled_image_url'), + }); router.push('/recipes/write'); } catch (err) { setError(err instanceof Error ? err.message : 'Importen misslyckades.'); diff --git a/frontend/app/recipes/import/ImportFilePage.tsx b/frontend/app/recipes/import/ImportFilePage.tsx index 8c806fdd..0d5a79b9 100644 --- a/frontend/app/recipes/import/ImportFilePage.tsx +++ b/frontend/app/recipes/import/ImportFilePage.tsx @@ -39,12 +39,23 @@ export default function ImportFilePage() { } const data = await res.json(); + // eslint-disable-next-line no-console + console.log('[ImportFilePage:file] quick-import response', { + imageUrl: data.imageUrl ?? null, + imageWarning: data.imageWarning ?? null, + markdownLength: (data.markdown ?? '').length, + }); sessionStorage.setItem('prefilled_markdown', data.markdown ?? ''); if (data.imageUrl) { sessionStorage.setItem('prefilled_image_url', data.imageUrl); } else { sessionStorage.removeItem('prefilled_image_url'); } + // eslint-disable-next-line no-console + console.log('[ImportFilePage:file] sessionStorage snapshot', { + prefilled_markdown: sessionStorage.getItem('prefilled_markdown')?.length ?? 0, + prefilled_image_url: sessionStorage.getItem('prefilled_image_url'), + }); router.push('/recipes/write'); } catch (err) { setError(err instanceof Error ? err.message : 'Importen misslyckades.'); @@ -77,12 +88,23 @@ export default function ImportFilePage() { } const data = await res.json(); + // eslint-disable-next-line no-console + console.log('[ImportFilePage:url] quick-import response', { + imageUrl: data.imageUrl ?? null, + imageWarning: data.imageWarning ?? null, + markdownLength: (data.markdown ?? '').length, + }); sessionStorage.setItem('prefilled_markdown', data.markdown ?? ''); if (data.imageUrl) { sessionStorage.setItem('prefilled_image_url', data.imageUrl); } else { sessionStorage.removeItem('prefilled_image_url'); } + // eslint-disable-next-line no-console + console.log('[ImportFilePage:url] sessionStorage snapshot', { + prefilled_markdown: sessionStorage.getItem('prefilled_markdown')?.length ?? 0, + prefilled_image_url: sessionStorage.getItem('prefilled_image_url'), + }); router.push('/recipes/write'); } catch (err) { setError(err instanceof Error ? err.message : 'Importen misslyckades.'); diff --git a/frontend/app/recipes/write/WriteRecipePage.tsx b/frontend/app/recipes/write/WriteRecipePage.tsx index 60ecb8b1..ec6d02c9 100644 --- a/frontend/app/recipes/write/WriteRecipePage.tsx +++ b/frontend/app/recipes/write/WriteRecipePage.tsx @@ -62,6 +62,11 @@ export default function WriteRecipePage() { // Kontrollera om det finns förifylld Markdown från snabbimport const prefilledMarkdown = sessionStorage.getItem('prefilled_markdown'); const prefilledImageUrl = sessionStorage.getItem('prefilled_image_url'); + // eslint-disable-next-line no-console + console.log('[WriteRecipePage] prefilled values', { + prefilledMarkdownLength: prefilledMarkdown?.length ?? 0, + prefilledImageUrl, + }); if (prefilledImageUrl) { setImageUrl(prefilledImageUrl); sessionStorage.removeItem('prefilled_image_url'); @@ -191,6 +196,8 @@ export default function WriteRecipePage() { note: ing.editedNote || undefined, })), }; + // eslint-disable-next-line no-console + console.log('[WriteRecipePage] create payload imageUrl', body.imageUrl ?? null); try { const res = await authFetch('/api/recipes', { @@ -203,6 +210,9 @@ export default function WriteRecipePage() { throw new Error(errorMessage); } + // eslint-disable-next-line no-console + console.log('[WriteRecipePage] recipe create success'); + setStep('saved'); router.refresh(); setTimeout(() => router.push('/recipes'), 2000);