Initial microservice-importer setup with NestJS backend and Next.js frontend

This commit is contained in:
Nils-Johan Gynther
2026-04-12 16:58:23 +02:00
commit 1608eb4d70
32 changed files with 1619 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
# Byggas från projektets rot: docker build -f backend/Dockerfile -t recipe-importer-api:local .
# Stage 1: Bygg applikationen
FROM node:22-alpine AS builder
WORKDIR /app
# Kopiera backend-filer
COPY backend/package.json ./
COPY backend/src ./src
COPY backend/tsconfig.json ./
COPY backend/nest-cli.json ./
# Köra npm install
RUN npm install
RUN npm run build
# Stage 2: Kör applikationen
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
EXPOSE 3001
CMD ["node", "dist/main"]
+4
View File
@@ -0,0 +1,4 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "recipe-importer-api",
"version": "0.0.1",
"private": true,
"scripts": {
"build": "nest build",
"start": "node dist/main",
"start:dev": "nest start --watch"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.1",
"@types/express": "^4.17.21",
"@types/node": "^22.15.29",
"typescript": "^5.4.5"
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { QuickImportModule } from './quick-import/quick-import.module';
import { RecipesModule } from './recipes/recipes.module';
@Module({
imports: [
QuickImportModule,
RecipesModule,
],
})
export class AppModule {}
@@ -0,0 +1,66 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
interface ErrorResponse {
statusCode: number;
message: string;
timestamp: string;
path?: string;
}
/**
* Global exception filter som formaterar alla errors konsistent
* Returnerar tydliga, svenska felmeddelanden utan att läcka känslig info
*/
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: any, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
const path = request.url;
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Ett internt serverfel inträffade.';
// Hantera HTTP-exceptions (t.ex. NotFoundException)
if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'object' && 'message' in exceptionResponse) {
message = (exceptionResponse as any).message || message;
} else if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
}
} else if (exception instanceof Error) {
// Hantera vanliga Error-instanser
message = exception.message || message;
statusCode = HttpStatus.BAD_REQUEST;
// Log interna fel för debugging
this.logger.error(`Error: ${exception.message}`, exception.stack);
} else {
// Okänd typ av exception
this.logger.error('Unknown exception type', exception);
}
const errorResponse: ErrorResponse = {
statusCode,
message,
timestamp: new Date().toISOString(),
path,
};
response.status(statusCode).json(errorResponse);
}
}
@@ -0,0 +1,9 @@
export function normalizeName(value: string): string {
return value
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s]/g, '')
.replace(/\s+/g, '');
}
+26
View File
@@ -0,0 +1,26 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
// Registrera global exception filter
app.useGlobalFilters(new GlobalExceptionFilter());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
const port = process.env.PORT || 3001;
await app.listen(port);
console.log(`🚀 Microservice importer running on port ${port}`);
}
bootstrap();
@@ -0,0 +1,158 @@
/**
* Bas-parser för receptsidor
* Alla site-specifika parsers bör extenda denna
*/
export interface ParsedRecipe {
name: string;
description?: string;
ingredients: Array<{
quantity: number;
unit: string;
name: string;
note?: string;
}>;
instructions?: string;
}
export abstract class RecipeParser {
/**
* Kontrollera om denna parser kan hantera denna URL
*/
abstract canHandle(url: string): boolean;
/**
* Parsa HTML och extrahera receptdata
*/
abstract parse(html: string): ParsedRecipe;
/**
* Hjälpfunktion: parsa ingrediens-rad
* Hanterar format som:
* - "3 ägg"
* - "150 g lax"
* - "1/2 citron"
* - "1 msk senap"
* - "salt och peppar"
* - "1 förp handskalade räkor i lake (à 570 g)"
*/
protected parseIngredientLine(line: string): {
quantity: number;
unit: string;
name: string;
note?: string;
} | null {
let cleaned = line.replace(/<[^>]+>/g, '').trim();
if (!cleaned) return null;
// Kända enheter
const knownUnits = [
'g', 'kg', 'hg', 'mg', 'ml', 'dl', 'l', 'tl',
'st', 'tsk', 'msk', 'krm', 'matsked', 'tesked',
'pris', 'portion', 'port', 'burk', 'förp', 'paket', 'efter smak', 'klyfta',
];
// Extrahera parentetisk info
let parentheticalText = '';
const parentheteMatch = cleaned.match(/\s*\(([^)]*)\)/);
if (parentheteMatch) {
parentheticalText = parentheteMatch[1].trim();
cleaned = cleaned.replace(/\s*\([^)]*\)/, '').trim();
}
// Hantera bråkdelar: "1/2" eller "1 1/2" eller "1 1 / 2"
// Regex: (optional whole)? numerator / denominator
const fractionMatch = cleaned.match(/^(\d+)?\s*(\d+)\s*\/\s*([\d.]+)/);
let quantity = 0;
let remainingText = cleaned;
if (fractionMatch) {
if (fractionMatch[1]) {
// Heltal + bråk: "1 1/2"
const whole = parseFloat(fractionMatch[1]);
const numerator = parseFloat(fractionMatch[2]);
const denominator = parseFloat(fractionMatch[3]);
quantity = whole + (numerator / denominator);
} else {
// Bara bråk: "1/2"
const numerator = parseFloat(fractionMatch[2]);
const denominator = parseFloat(fractionMatch[3]);
quantity = numerator / denominator;
}
remainingText = cleaned.substring(fractionMatch[0].length).trim();
} else {
const numberMatch = remainingText.match(/^([\d.,]+)/);
if (numberMatch) {
quantity = parseFloat(numberMatch[1].replace(',', '.'));
remainingText = remainingText.substring(numberMatch[0].length).trim();
}
}
// Extrahera potentiell enhet
let potentialUnit = '';
let productName = remainingText;
if (remainingText) {
const unitMatch = remainingText.match(/^([a-zåäö]+)\b/i);
if (unitMatch) {
const candidateUnit = unitMatch[1].toLowerCase();
if (knownUnits.includes(candidateUnit)) {
potentialUnit = candidateUnit;
productName = remainingText.substring(candidateUnit.length).trim();
}
}
}
// Analysera parenthetical text för måttenhet
let parenthHasUnit = false;
if (parentheticalText) {
for (const unit of knownUnits) {
if (parentheticalText.toLowerCase().includes(unit)) {
parenthHasUnit = true;
break;
}
}
}
let note: string | undefined = undefined;
// Om vi hade quantity i huvuddelen och parenthetical innehåller unit
// → spara parenthetical som note
if (quantity > 0 && parenthHasUnit) {
note = parentheticalText;
}
// Om ingen mängd i huvuddelen men parenthetical hade både mängd och unit
// → parse parenthetical som quantity + unit
if (quantity === 0 && parentheticalText) {
const parenthMatch = parentheticalText.match(/^[\D]*?([\d.,]+)?\s*([a-zåäö]*)?\s*(.*)$/i);
if (parenthMatch) {
let pQuantity = parenthMatch[1] ? parseFloat(parenthMatch[1].replace(',', '.')) : 0;
let pUnit = parenthMatch[2]?.toLowerCase() || '';
let pRest = parenthMatch[3]?.trim() || '';
if (knownUnits.includes(pUnit) && pQuantity > 0) {
quantity = pQuantity;
potentialUnit = pUnit;
note = parentheticalText;
}
}
}
// Om ingen mängd och enhet, bara returna produktnamnet
if (quantity === 0) {
return {
quantity: 0,
unit: '',
name: cleaned,
note: parentheticalText || undefined,
};
}
return {
quantity,
unit: potentialUnit,
name: productName,
note: note,
};
}
}
@@ -0,0 +1,148 @@
import { RecipeParser, ParsedRecipe } from './base.parser';
/**
* Generisk parser för okända receptsidor
* Försöker JSON-LD först, sedan vanlig HTML-parsing
* Denna är mer permissiv än site-specifika parsers
*/
export class GenericRecipeParser extends RecipeParser {
canHandle(url: string): boolean {
// Denna parser hanterar alltid (är fallback)
return true;
}
parse(html: string): ParsedRecipe {
console.log('[GenericParser] Parsing recipe from unknown site...');
// Försöka extrahera JSON-LD recipe data
const jsonLdMatch = html.match(
/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/i
);
if (jsonLdMatch) {
try {
const jsonData = JSON.parse(jsonLdMatch[1]);
const recipe =
jsonData['@type'] === 'Recipe'
? jsonData
: jsonData['@graph']?.find((item: any) => item['@type'] === 'Recipe');
if (recipe) {
console.log('[GenericParser] ✓ JSON-LD data found');
return this.extractFromJsonLd(recipe);
}
} catch (err) {
console.log('[GenericParser] JSON-LD parsing failed');
}
}
console.log('[GenericParser] No JSON-LD found, using HTML parsing');
return this.parseFromHtml(html);
}
private extractFromJsonLd(recipe: any): ParsedRecipe {
const name = recipe.name || '';
const description = recipe.description || '';
const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = [];
if (recipe.recipeIngredient && Array.isArray(recipe.recipeIngredient)) {
for (const ing of recipe.recipeIngredient) {
const parsed = this.parseIngredientLine(ing);
if (parsed) {
ingredients.push(parsed);
}
}
}
let instructions = '';
if (recipe.recipeInstructions) {
if (typeof recipe.recipeInstructions === 'string') {
instructions = recipe.recipeInstructions;
} else if (Array.isArray(recipe.recipeInstructions)) {
instructions = recipe.recipeInstructions
.map((step: any) => {
if (typeof step === 'string') return step;
if (step.text) return step.text;
return '';
})
.filter((s: string) => s)
.join('\n\n');
}
}
return {
name,
description,
ingredients,
instructions,
};
}
private parseFromHtml(html: string): ParsedRecipe {
// Försöka hitta titel
let name = '';
// Prova olika selector-mönster
let titleMatch =
html.match(/<h1[^>]*>([^<]+)<\/h1>/i) ||
html.match(/<meta\s+property="og:title"\s+content="([^"]+)"/i) ||
html.match(/<title>([^<]+)<\/title>/i);
if (titleMatch) {
name = titleMatch[1].trim();
}
// Försöka extrahera beskrivning från meta-taggar
let description = '';
const descMatch = html.match(
/<meta\s+name="description"\s+content="([^"]+)"/i
);
if (descMatch) {
description = descMatch[1].trim();
}
// Försöka extrahera ingredienser från vanliga strukturer
const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = [];
// Testa olika ingredient-selectors
const ingredientPatterns = [
/<li[^>]*>(.*?)<\/li>/gi,
/<div[^>]*class="ingredient"[^>]*>(.*?)<\/div>/gi,
/<p[^>]*class="ingredient"[^>]*>(.*?)<\/p>/gi,
];
for (const pattern of ingredientPatterns) {
let match;
while ((match = pattern.exec(html)) !== null) {
const parsed = this.parseIngredientLine(match[1]);
if (parsed && parsed.name.length > 2) {
// Undvik mycket korta ingredienser (troligen brus)
ingredients.push(parsed);
}
}
if (ingredients.length > 0) break; // Om vi hittat några, använd dessa
}
// Försöka hitta instruktioner
let instructions = '';
const instructionsPatterns = [
/<(?:div|section)[^>]*class="[^"]*(?:instruction|method|step)[^"]*"[^>]*>(.*?)<\/(?:div|section)>/is,
/<ol[^>]*>(.*?)<\/ol>/i,
];
for (const pattern of instructionsPatterns) {
const match = html.match(pattern);
if (match) {
instructions = match[1].replace(/<[^>]+>/g, '').trim();
if (instructions.length > 10) break;
}
}
return {
name,
description,
ingredients,
instructions,
};
}
}
@@ -0,0 +1,138 @@
import { RecipeParser, ParsedRecipe } from './base.parser';
/**
* Parser för ica.se receptsidor
* Använder JSON-LD structured data som primär källa
*/
export class IcaRecipeParser extends RecipeParser {
canHandle(url: string): boolean {
return /ica\.se\/recept/i.test(url);
}
parse(html: string): ParsedRecipe {
console.log('[IcaParser] Parsing ICA recipe...');
// 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
);
if (jsonLdMatch) {
try {
const jsonData = JSON.parse(jsonLdMatch[1]);
// Hitta recipe-objektet
const recipe =
jsonData['@type'] === 'Recipe'
? jsonData
: jsonData['@graph']?.find((item: any) => item['@type'] === 'Recipe');
if (recipe) {
console.log('[IcaParser] ✓ JSON-LD recipe found');
return this.extractFromJsonLd(recipe);
}
} catch (err) {
console.log('[IcaParser] JSON-LD parsing failed:', err);
}
}
// Fallback: HTML parsing (sällan nödvändigt för ICA)
console.log('[IcaParser] Falling back to HTML parsing');
return this.parseFromHtml(html);
}
private extractFromJsonLd(recipe: any): ParsedRecipe {
// Extrahera titel
const name = recipe.name || '';
// Extrahera beskrivning
const description = recipe.description || '';
// Extrahera ingredienser
const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = [];
if (recipe.recipeIngredient && Array.isArray(recipe.recipeIngredient)) {
for (const ing of recipe.recipeIngredient) {
const parsed = this.parseIngredientLine(ing);
if (parsed) {
ingredients.push(parsed);
}
}
}
// Extrahera instruktioner
let instructions = '';
if (recipe.recipeInstructions) {
if (typeof recipe.recipeInstructions === 'string') {
instructions = recipe.recipeInstructions;
} else if (Array.isArray(recipe.recipeInstructions)) {
instructions = recipe.recipeInstructions
.map((step: any) => {
if (typeof step === 'string') return step;
if (step.text) return step.text;
return '';
})
.filter((s: string) => s)
.join('\n\n');
}
}
return {
name,
description,
ingredients,
instructions,
};
}
private parseFromHtml(html: string): ParsedRecipe {
let name = '';
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
if (titleMatch) {
name = titleMatch[1].trim();
}
if (!name) {
const ogTitleMatch = html.match(
/<meta\s+property="og:title"\s+content="([^"]+)"/i
);
if (ogTitleMatch) {
name = ogTitleMatch[1].trim();
}
}
// Extrahera beskrivning från meta-taggar
let description = '';
const descMatch = html.match(
/<meta\s+name="description"\s+content="([^"]+)"/i
);
if (descMatch) {
description = descMatch[1].trim();
}
const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = [];
const ingredientRegex =
/<li[^>]*class="[^"]*ingredient[^"]*"[^>]*>([^<]+)<\/li>/gi;
let match;
while ((match = ingredientRegex.exec(html)) !== null) {
const parsed = this.parseIngredientLine(match[1]);
if (parsed) {
ingredients.push(parsed);
}
}
let instructions = '';
const instructionsMatch = html.match(
/<(?:div|section)[^>]*class="[^"]*(?:instruction|howto)[^"]*"[^>]*>([^<]*)<\/(?:div|section)>/is
);
if (instructionsMatch) {
instructions = instructionsMatch[1].replace(/<[^>]+>/g, '').trim();
}
return {
name,
description,
ingredients,
instructions,
};
}
}
@@ -0,0 +1,14 @@
import { Controller, Post, Body } from '@nestjs/common';
import { QuickImportService, QuickImportResult } from './quick-import.service';
@Controller('quick-import')
export class QuickImportController {
constructor(private readonly quickImportService: QuickImportService) {}
@Post()
async importFromInput(
@Body() body: { input: string }
): Promise<QuickImportResult> {
return this.quickImportService.importFromInput(body.input);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { QuickImportController } from './quick-import.controller';
import { QuickImportService } from './quick-import.service';
@Module({
controllers: [QuickImportController],
providers: [QuickImportService],
})
export class QuickImportModule {}
@@ -0,0 +1,204 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { IcaRecipeParser } from './parsers/ica.parser';
import { GenericRecipeParser } from './parsers/generic.parser';
import { RecipeParser } from './parsers/base.parser';
export interface QuickImportResult {
markdown: string;
source: 'ica' | 'pdf' | 'other';
}
@Injectable()
export class QuickImportService {
/**
* Detekterar typ av input (URL eller filsökväg) och importerar från lämplig källa
*/
async importFromInput(input: string): Promise<QuickImportResult> {
input = input.trim();
console.log('[QuickImport] Mottog input:', input);
if (!input) {
throw new BadRequestException('Du måste ange en URL eller filsökväg');
}
// Detektera typ
const isUrl = this.isUrl(input);
const isPdf = this.isPdfPath(input);
console.log('[QuickImport] isUrl:', isUrl, 'isPdf:', isPdf);
if (isUrl) {
console.log('[QuickImport] Detekterade URL, försöker scrapa...');
return this.scrapeRecipeFromUrl(input);
} else if (isPdf) {
console.log('[QuickImport] Detekterade PDF-fil');
throw new BadRequestException(
'PDF-import under utveckling. Försök med en URL från ICA.se eller annat receptsida.'
);
} else {
console.log('[QuickImport] Input är inte URL eller PDF');
throw new BadRequestException(
'Ogültig input. Ange en gyltig URL (t.ex. ica.se/recept/...) eller filsökväg'
);
}
}
/**
* Kontrollerar om input är en URL
*/
private isUrl(input: string): boolean {
try {
new URL(input);
return true;
} catch {
return false;
}
}
/**
* Kontrollerar om input är en PDF-filsökväg
*/
private isPdfPath(input: string): boolean {
const normalized = input.toLowerCase();
return normalized.endsWith('.pdf');
}
/**
* Skrapar recept från en URL
*
* Använder site-specifika parsers om tillgängliga,
* annars fallback till generisk parser.
*
* @param url URL till receptsidan
* @returns Markdown-format
*/
private async scrapeRecipeFromUrl(url: string): Promise<QuickImportResult> {
try {
console.log('[QuickImport] Hämtar HTML från:', url);
// Hämta HTML från URL
const response = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
});
console.log('[QuickImport] HTTP status:', response.status);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const html = await response.text();
console.log('[QuickImport] HTML längd:', html.length, 'tecken');
// Välj lämplig parser
const parsers: RecipeParser[] = [
new IcaRecipeParser(),
new GenericRecipeParser(),
];
let recipe = null;
for (const parser of parsers) {
if (parser.canHandle(url)) {
console.log('[QuickImport] Använder parser:', parser.constructor.name);
recipe = parser.parse(html);
break;
}
}
if (!recipe) {
throw new Error('Ingen parserutrustning tillgänglig');
}
console.log('[QuickImport] Parsad recept:', {
name: recipe.name,
ingredienser: recipe.ingredients.length,
});
if (!recipe.name) {
throw new Error('Kunde inte hitta receptnamn på sidan. Försök med en annan länk.');
}
// Konvertera till Markdown-format
const markdown = this.recipeToMarkdown(recipe, url);
console.log('[QuickImport] Markdown genererad, längd:', markdown.length);
// Detektera källa från URL
let source: 'ica' | 'pdf' | 'other' = 'other';
if (/ica\.se/i.test(url)) {
source = 'ica';
}
return {
markdown,
source,
};
} catch (err) {
const message = err instanceof Error ? err.message : 'Okänt fel vid scraping';
console.error('[QuickImport] ERROR:', message);
throw new BadRequestException(
`Kunde inte hämta recept: ${message}. Kontrollera att länken är korrekt och försök igen.`
);
}
}
/**
* Konvertera receptobjekt till Markdown-format
*/
private recipeToMarkdown(
recipe: {
name: string;
description?: string;
ingredients: Array<{
quantity: number;
unit: string;
name: string;
note?: string;
}>;
instructions?: string;
},
sourceUrl?: string,
): string {
const lines: string[] = [];
// Titel
lines.push(`# ${recipe.name}`);
lines.push('');
// Beskrivning
if (recipe.description) {
lines.push(recipe.description);
lines.push('');
}
// Ingredienser
if (recipe.ingredients.length > 0) {
lines.push('## Ingredienser');
for (const ing of recipe.ingredients) {
const quantity = ing.quantity > 0 ? `${ing.quantity} ` : '';
const unit = ing.unit ? `${ing.unit} ` : '';
const note = ing.note ? ` (${ing.note})` : '';
lines.push(`- ${quantity}${unit}${ing.name}${note}`);
}
lines.push('');
}
// Instruktioner
if (recipe.instructions) {
lines.push('## Tillvägagångssätt');
lines.push(recipe.instructions);
lines.push('');
}
// Källa
if (sourceUrl) {
lines.push('---');
lines.push('');
lines.push(`Källa: [${sourceUrl}](${sourceUrl})`);
}
return lines.join('\n');
}
}
@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class ParseMarkdownDto {
@IsString()
@MinLength(1)
markdown!: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { Body, Controller, Post } from '@nestjs/common';
import { RecipesService } from './recipes.service';
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
@Controller('recipes')
export class RecipesController {
constructor(private readonly recipesService: RecipesService) {}
@Post('parse-markdown')
parseMarkdown(@Body() dto: ParseMarkdownDto) {
return this.recipesService.parseMarkdown(dto);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { RecipesController } from './recipes.controller';
import { RecipesService } from './recipes.service';
@Module({
controllers: [RecipesController],
providers: [RecipesService],
})
export class RecipesModule {}
+220
View File
@@ -0,0 +1,220 @@
import { Injectable } from '@nestjs/common';
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
// ============================================================================
// Local Type Definitions (previously from recipe-document-converter)
// ============================================================================
interface ParsedIngredient {
rawName: string;
quantity: number;
unit: string;
note: string | null;
}
interface ParsedRecipe {
name: string;
description: string;
instructions: string;
ingredients: ParsedIngredient[];
}
// ============================================================================
// Parser Functions
// ============================================================================
/**
* Parsar ett recept i Markdown-format och extraherar namn, beskrivning,
* instruktioner och ingredienser.
*
* Förväntat format:
* # Receptnamn
* Beskrivning (valfritt stycke efter titeln)
*
* ## Ingredienser
* - 400 g kycklingfilé
* - 2 dl grädde (eller crème fraiche)
*
* ## Instruktioner
* 1. Stek kycklingen …
*/
function parseRecipeMarkdown(markdown: string): ParsedRecipe {
const lines = markdown.split('\n');
let name = '';
let description = '';
let instructions = '';
const ingredients: ParsedIngredient[] = [];
let currentSection: 'none' | 'description' | 'ingredients' | 'instructions' = 'none';
const descriptionLines: string[] = [];
const instructionLines: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
// H1 — receptnamn
if (/^#\s+/.test(trimmed) && !trimmed.startsWith('##')) {
name = trimmed.replace(/^#\s+/, '').trim();
currentSection = 'description';
continue;
}
// H2 — sektionsrubriker
if (/^##\s+/.test(trimmed)) {
const heading = trimmed.replace(/^##\s+/, '').trim().toLowerCase();
if (/ingrediens/.test(heading)) {
currentSection = 'ingredients';
} else if (/instruktion|tillagning|gör så här|steg|tillväg|metod/.test(heading)) {
currentSection = 'instructions';
} else {
currentSection = 'none';
}
continue;
}
// Samla rader beroende på sektion
switch (currentSection) {
case 'description':
if (trimmed.length > 0) {
descriptionLines.push(trimmed);
}
break;
case 'ingredients':
if (/^[-*]\s+/.test(trimmed)) {
const ingredientText = trimmed.replace(/^[-*]\s+/, '');
ingredients.push(parseIngredientLine(ingredientText));
}
break;
case 'instructions':
if (trimmed.length > 0) {
instructionLines.push(trimmed);
}
break;
}
}
description = descriptionLines.join('\n');
instructions = instructionLines.join('\n');
return { name, description, instructions, ingredients };
}
/**
* Parsar en ingrediensrad, t.ex.:
* "400 g kycklingfilé"
* "2 dl grädde (eller crème fraiche)"
* "1 1/2 dl crème fraiche"
* "1 polka- eller gulbeta"
* "1 kruka basilika"
* "salt"
*/
function parseIngredientLine(text: string): ParsedIngredient {
const trimmed = text.trim();
// Kända enheter
const knownUnits = [
'g', 'kg', 'hg', 'mg', 'ml', 'dl', 'l', 'tl',
'st', 'tsk', 'msk', 'krm', 'matsled', 'tesled',
'pris', 'portion', 'port', 'burk', 'förp', 'paket', 'efter smak', 'klyfta',
];
// Extrahera eventuell parentes-not i slutet
let note: string | null = null;
let main = trimmed;
const parenMatch = trimmed.match(/\(([^)]+)\)\s*$/);
if (parenMatch) {
note = parenMatch[1].trim();
main = trimmed.slice(0, parenMatch.index).trim();
}
// Försök matcha bråk först: "1 1/2 dl crème fraiche" eller "1/2 dl"
const fractionMatch = main.match(/^(\d+)?\s*(\d+)\s*\/\s*([\d.]+)\s+(\S+)\s+(.*)$/);
if (fractionMatch) {
let quantity = 0;
if (fractionMatch[1]) {
quantity = parseFloat(fractionMatch[1]) + parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]);
} else {
quantity = parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]);
}
const candidateUnit = fractionMatch[4].toLowerCase();
if (knownUnits.includes(candidateUnit)) {
return {
quantity,
unit: candidateUnit,
rawName: fractionMatch[5].trim(),
note,
};
}
}
// Försök matcha "kvantitet enhet namn" — t.ex. "400 g kycklingfilé" eller "2.5 dl grädde"
const fullMatch = main.match(/^(\d+(?:[.,]\d+)?)\s+(\S+)\s+(.+)$/);
if (fullMatch) {
const candidateUnit = fullMatch[2].toLowerCase();
// Validera att det andra ordet är en känd enhet
if (knownUnits.includes(candidateUnit)) {
return {
quantity: parseNumber(fullMatch[1]),
unit: candidateUnit,
rawName: fullMatch[3].trim(),
note,
};
}
// Om inte känd enhet, behandla som "kvantitet namn" utan enhet
return {
quantity: parseNumber(fullMatch[1]),
unit: 'st',
rawName: fullMatch[2] + ' ' + fullMatch[3],
note,
};
}
// Försök matcha "kvantitet namn" utan enhet — t.ex. "3 ägg"
const noUnitMatch = main.match(/^(\d+(?:[.,]\d+)?)\s+(.+)$/);
if (noUnitMatch) {
return {
quantity: parseNumber(noUnitMatch[1]),
unit: 'st',
rawName: noUnitMatch[2].trim(),
note,
};
}
// Bara ett namn, ingen kvantitet — t.ex. "salt"
return {
quantity: 0,
unit: '',
rawName: main,
note,
};
}
function parseNumber(s: string): number {
return parseFloat(s.replace(',', '.'));
}
// ============================================================================
// Service
// ============================================================================
@Injectable()
export class RecipesService {
parseMarkdown(dto: ParseMarkdownDto) {
const parsed = parseRecipeMarkdown(dto.markdown);
return {
name: parsed.name,
description: parsed.description,
instructions: parsed.instructions,
ingredients: parsed.ingredients.map((ingredient: ParsedIngredient) => ({
rawName: ingredient.rawName,
quantity: ingredient.quantity,
unit: ingredient.unit,
note: ingredient.note,
})),
};
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}