feat(import): enhance image URL extraction and logging during recipe import
This commit is contained in:
@@ -77,7 +77,41 @@ export class GenericRecipeParser extends RecipeParser {
|
|||||||
private extractOgImage(html: string): string | undefined {
|
private extractOgImage(html: string): string | undefined {
|
||||||
const match = html.match(/<meta[^>]+property="og:image"[^>]+content="([^"]+)"/i)
|
const match = html.match(/<meta[^>]+property="og:image"[^>]+content="([^"]+)"/i)
|
||||||
|| html.match(/<meta[^>]+content="([^"]+)"[^>]+property="og:image"/i);
|
|| html.match(/<meta[^>]+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 {
|
private extractFromJsonLd(recipe: any, ogImage?: string): ParsedRecipe {
|
||||||
@@ -86,14 +120,9 @@ export class GenericRecipeParser extends RecipeParser {
|
|||||||
|
|
||||||
// Extrahera bildurl från JSON-LD
|
// Extrahera bildurl från JSON-LD
|
||||||
let imageUrl: string | undefined = ogImage;
|
let imageUrl: string | undefined = ogImage;
|
||||||
if (recipe.image) {
|
const extractedImage = this.extractImageValue(recipe.image);
|
||||||
if (typeof recipe.image === 'string') {
|
if (extractedImage) {
|
||||||
imageUrl = recipe.image;
|
imageUrl = this.decodeHtmlEntities(extractedImage);
|
||||||
} 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 ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = [];
|
const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = [];
|
||||||
|
|||||||
@@ -76,7 +76,41 @@ export class IcaRecipeParser extends RecipeParser {
|
|||||||
private extractOgImage(html: string): string | undefined {
|
private extractOgImage(html: string): string | undefined {
|
||||||
const match = html.match(/<meta[^>]+property="og:image"[^>]+content="([^"]+)"/i)
|
const match = html.match(/<meta[^>]+property="og:image"[^>]+content="([^"]+)"/i)
|
||||||
|| html.match(/<meta[^>]+content="([^"]+)"[^>]+property="og:image"/i);
|
|| html.match(/<meta[^>]+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 {
|
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)
|
// Extrahera bildurl från JSON-LD (kan vara sträng eller array)
|
||||||
let imageUrl: string | undefined = ogImage;
|
let imageUrl: string | undefined = ogImage;
|
||||||
if (recipe.image) {
|
const extractedImage = this.extractImageValue(recipe.image);
|
||||||
if (typeof recipe.image === 'string') {
|
if (extractedImage) {
|
||||||
imageUrl = recipe.image;
|
imageUrl = this.decodeHtmlEntities(extractedImage);
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extrahera ingredienser
|
// Extrahera ingredienser
|
||||||
|
|||||||
@@ -320,7 +320,9 @@ export class QuickImportService {
|
|||||||
let imageUrl: string | undefined;
|
let imageUrl: string | undefined;
|
||||||
let imageWarning: string | undefined;
|
let imageWarning: string | undefined;
|
||||||
if (recipe.imageUrl) {
|
if (recipe.imageUrl) {
|
||||||
|
this.logger.log(`Bildkandidat från parser: ${recipe.imageUrl}`);
|
||||||
const normalizedImageUrl = this.normalizeImageUrl(recipe.imageUrl, url);
|
const normalizedImageUrl = this.normalizeImageUrl(recipe.imageUrl, url);
|
||||||
|
this.logger.log(`Normaliserad bild-URL: ${normalizedImageUrl ?? 'null'}`);
|
||||||
if (!normalizedImageUrl) {
|
if (!normalizedImageUrl) {
|
||||||
imageWarning = 'Receptbild kunde inte tolkas till en giltig URL.';
|
imageWarning = 'Receptbild kunde inte tolkas till en giltig URL.';
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -331,7 +333,9 @@ export class QuickImportService {
|
|||||||
imageUrl = await downloadAndOptimizeImage(normalizedImageUrl, IMAGE_DEST_DIR);
|
imageUrl = await downloadAndOptimizeImage(normalizedImageUrl, IMAGE_DEST_DIR);
|
||||||
this.logger.log(`Bild optimerad och sparad: ${imageUrl}`);
|
this.logger.log(`Bild optimerad och sparad: ${imageUrl}`);
|
||||||
} catch (imgErr) {
|
} 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(
|
this.logger.warn(
|
||||||
`Kunde inte ladda ner bild: ${imgErr} (källa: ${normalizedImageUrl})`,
|
`Kunde inte ladda ner bild: ${imgErr} (källa: ${normalizedImageUrl})`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
@@ -27,6 +27,8 @@ interface ParsedRecipe {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RecipesService {
|
export class RecipesService {
|
||||||
|
private readonly logger = new Logger(RecipesService.name);
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async getInventoryPreview(id: number) {
|
async getInventoryPreview(id: number) {
|
||||||
@@ -301,17 +303,25 @@ export class RecipesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(createRecipeDto: CreateRecipeDto, userId: number) {
|
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
|
// Om imageUrl är en extern URL — ladda ner och optimera
|
||||||
let imageUrl: string | null = createRecipeDto.imageUrl || null;
|
let imageUrl: string | null = createRecipeDto.imageUrl || null;
|
||||||
if (imageUrl && imageUrl.startsWith('http')) {
|
if (imageUrl && imageUrl.startsWith('http')) {
|
||||||
|
const externalImageUrl = imageUrl;
|
||||||
try {
|
try {
|
||||||
imageUrl = await downloadAndOptimizeImage(imageUrl, IMAGE_DEST_DIR);
|
imageUrl = await downloadAndOptimizeImage(imageUrl, IMAGE_DEST_DIR);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[RecipesService] Kunde inte ladda ner receptbild:', 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({
|
const recipe = await this.prisma.recipe.create({
|
||||||
data: {
|
data: {
|
||||||
name: createRecipeDto.name,
|
name: createRecipeDto.name,
|
||||||
|
|||||||
@@ -19,6 +19,22 @@ export const POST = withAuth(async (request, session) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
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, {
|
return new NextResponse(text, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
headers: { 'Content-Type': response.headers.get('content-type') ?? 'application/json' },
|
headers: { 'Content-Type': response.headers.get('content-type') ?? 'application/json' },
|
||||||
|
|||||||
@@ -83,12 +83,23 @@ function ReceptImport() {
|
|||||||
const res = await fetch('/api/quick-import-proxy', { method: 'POST', body: formData });
|
const res = await fetch('/api/quick-import-proxy', { method: 'POST', body: formData });
|
||||||
if (!res.ok) throw new Error(await parseErrorResponse(res));
|
if (!res.ok) throw new Error(await parseErrorResponse(res));
|
||||||
const data = await res.json();
|
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 ?? '');
|
sessionStorage.setItem('prefilled_markdown', data.markdown ?? '');
|
||||||
if (data.imageUrl) {
|
if (data.imageUrl) {
|
||||||
sessionStorage.setItem('prefilled_image_url', data.imageUrl);
|
sessionStorage.setItem('prefilled_image_url', data.imageUrl);
|
||||||
} else {
|
} else {
|
||||||
sessionStorage.removeItem('prefilled_image_url');
|
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');
|
router.push('/recipes/write');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Importen misslyckades.');
|
setError(err instanceof Error ? err.message : 'Importen misslyckades.');
|
||||||
@@ -110,12 +121,23 @@ function ReceptImport() {
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await parseErrorResponse(res));
|
if (!res.ok) throw new Error(await parseErrorResponse(res));
|
||||||
const data = await res.json();
|
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 ?? '');
|
sessionStorage.setItem('prefilled_markdown', data.markdown ?? '');
|
||||||
if (data.imageUrl) {
|
if (data.imageUrl) {
|
||||||
sessionStorage.setItem('prefilled_image_url', data.imageUrl);
|
sessionStorage.setItem('prefilled_image_url', data.imageUrl);
|
||||||
} else {
|
} else {
|
||||||
sessionStorage.removeItem('prefilled_image_url');
|
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');
|
router.push('/recipes/write');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Importen misslyckades.');
|
setError(err instanceof Error ? err.message : 'Importen misslyckades.');
|
||||||
|
|||||||
@@ -39,12 +39,23 @@ export default function ImportFilePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
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 ?? '');
|
sessionStorage.setItem('prefilled_markdown', data.markdown ?? '');
|
||||||
if (data.imageUrl) {
|
if (data.imageUrl) {
|
||||||
sessionStorage.setItem('prefilled_image_url', data.imageUrl);
|
sessionStorage.setItem('prefilled_image_url', data.imageUrl);
|
||||||
} else {
|
} else {
|
||||||
sessionStorage.removeItem('prefilled_image_url');
|
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');
|
router.push('/recipes/write');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Importen misslyckades.');
|
setError(err instanceof Error ? err.message : 'Importen misslyckades.');
|
||||||
@@ -77,12 +88,23 @@ export default function ImportFilePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
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 ?? '');
|
sessionStorage.setItem('prefilled_markdown', data.markdown ?? '');
|
||||||
if (data.imageUrl) {
|
if (data.imageUrl) {
|
||||||
sessionStorage.setItem('prefilled_image_url', data.imageUrl);
|
sessionStorage.setItem('prefilled_image_url', data.imageUrl);
|
||||||
} else {
|
} else {
|
||||||
sessionStorage.removeItem('prefilled_image_url');
|
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');
|
router.push('/recipes/write');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Importen misslyckades.');
|
setError(err instanceof Error ? err.message : 'Importen misslyckades.');
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ export default function WriteRecipePage() {
|
|||||||
// Kontrollera om det finns förifylld Markdown från snabbimport
|
// Kontrollera om det finns förifylld Markdown från snabbimport
|
||||||
const prefilledMarkdown = sessionStorage.getItem('prefilled_markdown');
|
const prefilledMarkdown = sessionStorage.getItem('prefilled_markdown');
|
||||||
const prefilledImageUrl = sessionStorage.getItem('prefilled_image_url');
|
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) {
|
if (prefilledImageUrl) {
|
||||||
setImageUrl(prefilledImageUrl);
|
setImageUrl(prefilledImageUrl);
|
||||||
sessionStorage.removeItem('prefilled_image_url');
|
sessionStorage.removeItem('prefilled_image_url');
|
||||||
@@ -191,6 +196,8 @@ export default function WriteRecipePage() {
|
|||||||
note: ing.editedNote || undefined,
|
note: ing.editedNote || undefined,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('[WriteRecipePage] create payload imageUrl', body.imageUrl ?? null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await authFetch('/api/recipes', {
|
const res = await authFetch('/api/recipes', {
|
||||||
@@ -203,6 +210,9 @@ export default function WriteRecipePage() {
|
|||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('[WriteRecipePage] recipe create success');
|
||||||
|
|
||||||
setStep('saved');
|
setStep('saved');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
setTimeout(() => router.push('/recipes'), 2000);
|
setTimeout(() => router.push('/recipes'), 2000);
|
||||||
|
|||||||
Reference in New Issue
Block a user