feat: add image handling to recipes
- Implemented image downloading and optimization in QuickImportService. - Added imageUrl field to CreateRecipeDto for recipe creation. - Created an endpoint in RecipesController to update recipe images. - Enhanced RecipesService to handle image URL updates and optimizations. - Updated Docker Compose to mount a volume for recipe images. - Refactored frontend to display images in recipe grids and detail views. - Added a new utility function for downloading and optimizing images. - Created a new API route for handling image uploads. - Introduced RecipeGrid component for better recipe display. - Updated RecipeDetailClient to manage image updates and display. - Added migration for new imageUrl column in the Recipe table.
This commit is contained in:
@@ -12,6 +12,7 @@ export interface ParsedRecipe {
|
||||
note?: string;
|
||||
}>;
|
||||
instructions?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export abstract class RecipeParser {
|
||||
|
||||
@@ -14,6 +14,9 @@ export class GenericRecipeParser extends RecipeParser {
|
||||
parse(html: string): ParsedRecipe {
|
||||
console.log('[GenericParser] Parsing recipe from unknown site...');
|
||||
|
||||
// Extrahera og:image för bildurl-fallback
|
||||
const ogImage = this.extractOgImage(html);
|
||||
|
||||
// Försöka extrahera JSON-LD recipe data
|
||||
const jsonLdMatch = html.match(
|
||||
/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/i
|
||||
@@ -29,7 +32,7 @@ export class GenericRecipeParser extends RecipeParser {
|
||||
|
||||
if (recipe) {
|
||||
console.log('[GenericParser] ✓ JSON-LD data found');
|
||||
return this.extractFromJsonLd(recipe);
|
||||
return this.extractFromJsonLd(recipe, ogImage);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[GenericParser] JSON-LD parsing failed');
|
||||
@@ -37,13 +40,31 @@ export class GenericRecipeParser extends RecipeParser {
|
||||
}
|
||||
|
||||
console.log('[GenericParser] No JSON-LD found, using HTML parsing');
|
||||
return this.parseFromHtml(html);
|
||||
return this.parseFromHtml(html, ogImage);
|
||||
}
|
||||
|
||||
private extractFromJsonLd(recipe: any): ParsedRecipe {
|
||||
private extractOgImage(html: string): string | undefined {
|
||||
const match = html.match(/<meta[^>]+property="og:image"[^>]+content="([^"]+)"/i)
|
||||
|| html.match(/<meta[^>]+content="([^"]+)"[^>]+property="og:image"/i);
|
||||
return match ? match[1].trim() : undefined;
|
||||
}
|
||||
|
||||
private extractFromJsonLd(recipe: any, ogImage?: string): ParsedRecipe {
|
||||
const name = recipe.name || '';
|
||||
const description = recipe.description || '';
|
||||
|
||||
// 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 ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = [];
|
||||
if (recipe.recipeIngredient && Array.isArray(recipe.recipeIngredient)) {
|
||||
for (const ing of recipe.recipeIngredient) {
|
||||
@@ -75,10 +96,11 @@ export class GenericRecipeParser extends RecipeParser {
|
||||
description,
|
||||
ingredients,
|
||||
instructions,
|
||||
imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
private parseFromHtml(html: string): ParsedRecipe {
|
||||
private parseFromHtml(html: string, ogImage?: string): ParsedRecipe {
|
||||
// Försöka hitta titel
|
||||
let name = '';
|
||||
|
||||
@@ -143,6 +165,7 @@ export class GenericRecipeParser extends RecipeParser {
|
||||
description,
|
||||
ingredients,
|
||||
instructions,
|
||||
imageUrl: ogImage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ export class IcaRecipeParser extends RecipeParser {
|
||||
parse(html: string): ParsedRecipe {
|
||||
console.log('[IcaParser] Parsing ICA recipe...');
|
||||
|
||||
// Extrahera og:image för bildurl-fallback
|
||||
const ogImage = this.extractOgImage(html);
|
||||
|
||||
// Försöka extrahera JSON-LD recipe data (ICA använder detta)
|
||||
const jsonLdMatch = html.match(
|
||||
/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/i
|
||||
@@ -29,7 +32,7 @@ export class IcaRecipeParser extends RecipeParser {
|
||||
|
||||
if (recipe) {
|
||||
console.log('[IcaParser] ✓ JSON-LD recipe found');
|
||||
return this.extractFromJsonLd(recipe);
|
||||
return this.extractFromJsonLd(recipe, ogImage);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[IcaParser] JSON-LD parsing failed:', err);
|
||||
@@ -38,16 +41,34 @@ export class IcaRecipeParser extends RecipeParser {
|
||||
|
||||
// Fallback: HTML parsing (sällan nödvändigt för ICA)
|
||||
console.log('[IcaParser] Falling back to HTML parsing');
|
||||
return this.parseFromHtml(html);
|
||||
return this.parseFromHtml(html, ogImage);
|
||||
}
|
||||
|
||||
private extractFromJsonLd(recipe: any): ParsedRecipe {
|
||||
private extractOgImage(html: string): string | undefined {
|
||||
const match = html.match(/<meta[^>]+property="og:image"[^>]+content="([^"]+)"/i)
|
||||
|| html.match(/<meta[^>]+content="([^"]+)"[^>]+property="og:image"/i);
|
||||
return match ? match[1].trim() : undefined;
|
||||
}
|
||||
|
||||
private extractFromJsonLd(recipe: any, ogImage?: string): ParsedRecipe {
|
||||
// Extrahera titel
|
||||
const name = recipe.name || '';
|
||||
|
||||
// Extrahera beskrivning
|
||||
const description = recipe.description || '';
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Extrahera ingredienser
|
||||
const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = [];
|
||||
if (recipe.recipeIngredient && Array.isArray(recipe.recipeIngredient)) {
|
||||
@@ -81,10 +102,11 @@ export class IcaRecipeParser extends RecipeParser {
|
||||
description,
|
||||
ingredients,
|
||||
instructions,
|
||||
imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
private parseFromHtml(html: string): ParsedRecipe {
|
||||
private parseFromHtml(html: string, ogImage?: string): ParsedRecipe {
|
||||
let name = '';
|
||||
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
|
||||
if (titleMatch) {
|
||||
@@ -133,6 +155,7 @@ export class IcaRecipeParser extends RecipeParser {
|
||||
description,
|
||||
ingredients,
|
||||
instructions,
|
||||
imageUrl: ogImage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,14 @@ import { createWorker } from 'tesseract.js';
|
||||
import { IcaRecipeParser } from './parsers/ica.parser';
|
||||
import { GenericRecipeParser } from './parsers/generic.parser';
|
||||
import { RecipeParser } from './parsers/base.parser';
|
||||
import { downloadAndOptimizeImage } from '../common/utils/download-image';
|
||||
|
||||
const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images';
|
||||
|
||||
export interface QuickImportResult {
|
||||
markdown: string;
|
||||
source: 'ica' | 'pdf' | 'image' | 'other';
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
type UploadKind = 'pdf' | 'image';
|
||||
@@ -311,9 +315,21 @@ export class QuickImportService {
|
||||
source = 'ica';
|
||||
}
|
||||
|
||||
// Ladda ner och optimera bild om parser hittade en
|
||||
let imageUrl: string | undefined;
|
||||
if (recipe.imageUrl) {
|
||||
try {
|
||||
imageUrl = await downloadAndOptimizeImage(recipe.imageUrl, IMAGE_DEST_DIR);
|
||||
console.log('[QuickImport] Bild optimerad och sparad:', imageUrl);
|
||||
} catch (imgErr) {
|
||||
console.warn('[QuickImport] Kunde inte ladda ner bild:', imgErr);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
markdown,
|
||||
source,
|
||||
imageUrl,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Okänt fel vid scraping';
|
||||
|
||||
Reference in New Issue
Block a user