Initial microservice-importer setup with NestJS backend and Next.js frontend
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.next/
|
||||
.env.local
|
||||
.env*.local
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
build/
|
||||
coverage/
|
||||
@@ -0,0 +1,45 @@
|
||||
# Microservice Importer
|
||||
|
||||
Recipe import microservice för snabb-import av recept från webben.
|
||||
|
||||
## Features
|
||||
|
||||
- **Quick Import från URL**: Importera recept direkt från ICA.se eller andra webbsidor
|
||||
- **Automatisk parsing**: Extraherar receptnamn, beskrivning, ingredienser och instruktioner
|
||||
- **Markdown-format**: Returnerar recept i standardiserad Markdown-format
|
||||
- **Flersidig parsning**: Stöd för JSON-LD structured data och HTML-parsing
|
||||
|
||||
## Arkitektur
|
||||
|
||||
### Backend (NestJS)
|
||||
- `src/quick-import/` — URL-scraping och parsing
|
||||
- `src/recipes/` — Markdown-parsing service
|
||||
- Parsers för site-specifik extraction (ICA, Generic fallback)
|
||||
|
||||
### Frontend (Next.js)
|
||||
- `app/import/page.tsx` — Import UI
|
||||
- `app/api/parse-markdown-proxy/` — API proxy till backend
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Installera beroenden
|
||||
cd backend && npm install
|
||||
cd ../frontend && npm install
|
||||
|
||||
# Kör i development-läge
|
||||
cd backend && npm run start:dev
|
||||
cd ../frontend && npm run dev
|
||||
```
|
||||
|
||||
Backend: http://localhost:3001
|
||||
Frontend: http://localhost:3000
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
frontend: http://localhost:3000
|
||||
backend: http://localhost:3001
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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, '');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
importer-frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: importer-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NEXT_PUBLIC_API_URL_INTERNAL: "http://importer-api:3001"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
networks:
|
||||
- importer-network
|
||||
depends_on:
|
||||
- importer-api
|
||||
|
||||
importer-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
image: recipe-importer-api:local
|
||||
container_name: importer-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: "production"
|
||||
PORT: "3001"
|
||||
ports:
|
||||
- "3001:3001"
|
||||
networks:
|
||||
- importer-network
|
||||
|
||||
networks:
|
||||
importer-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,22 @@
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,49 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Navigation() {
|
||||
return (
|
||||
<nav
|
||||
style={{
|
||||
background: '#f9f9f9',
|
||||
borderBottom: '1px solid #ddd',
|
||||
padding: '0.75rem 1rem',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: '1.5rem',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: '#fff',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
textDecoration: 'none',
|
||||
color: '#0070f3',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
🏠 Hem
|
||||
</Link>
|
||||
<Link
|
||||
href="/import"
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: '#fff',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
textDecoration: 'none',
|
||||
color: '#0070f3',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
⚡ Snabbimport
|
||||
</Link>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://localhost:3001';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.text();
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/recipes/parse-markdown`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Navigation from '../Navigation';
|
||||
import { parseErrorResponse } from '../../lib/error-handler';
|
||||
|
||||
export default function ImportPage() {
|
||||
const [quickImportUrl, setQuickImportUrl] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
|
||||
const handleQuickImport = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const input = quickImportUrl.trim();
|
||||
if (!input) {
|
||||
setError('Vänligen ange en URL eller filsökväg');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Försök importera från URL eller fil
|
||||
const res = await fetch('/api/quick-import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = await parseErrorResponse(res);
|
||||
setError(errorMessage || 'Importen misslyckades. Kontrollera att länken eller filsökvägen är korrekt.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.markdown) {
|
||||
setResult(data);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Något oväntad gick fel';
|
||||
setError(`Fel: ${message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}>
|
||||
<Navigation />
|
||||
<h1 style={{ marginBottom: '1.5rem' }}>Importera recept</h1>
|
||||
|
||||
{/* IMPORT-SEKTION */}
|
||||
<div
|
||||
style={{
|
||||
background: '#fef3c7',
|
||||
border: '2px solid #f59e0b',
|
||||
borderRadius: '8px',
|
||||
padding: '1.5rem',
|
||||
marginBottom: '2rem',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: '0 0 0.5rem 0', fontSize: '1.1rem', color: '#92400e' }}>
|
||||
⚡ Snabbimport
|
||||
</h2>
|
||||
<p style={{ margin: '0 0 1rem 0', color: '#92400e', fontSize: '0.9rem' }}>
|
||||
Klistra in en receptlänk från ICA eller annan webbsida:
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleQuickImport} style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0.5rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={quickImportUrl}
|
||||
onChange={(e) => setQuickImportUrl(e.target.value)}
|
||||
placeholder="https://www.ica.se/recept/..."
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #d97706',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.95rem',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !quickImportUrl.trim()}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: '#f59e0b',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: isLoading || !quickImportUrl.trim() ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading || !quickImportUrl.trim() ? 0.6 : 1,
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Laddar...' : '→'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
style={{
|
||||
margin: '0.5rem 0 0 0',
|
||||
color: '#991b1b',
|
||||
background: '#fee2e2',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
⚠️ {error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* RESULT */}
|
||||
{result && (
|
||||
<div
|
||||
style={{
|
||||
background: '#ecfdf5',
|
||||
border: '2px solid #10b981',
|
||||
borderRadius: '8px',
|
||||
padding: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: '0 0 1rem 0', color: '#059669' }}>✓ Recept importerat</h2>
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
border: '1px solid #d1fae5',
|
||||
borderRadius: '4px',
|
||||
padding: '1rem',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '0.85rem',
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
{result.markdown}
|
||||
</pre>
|
||||
</div>
|
||||
<p style={{ margin: '1rem 0 0 0', fontSize: '0.9rem', color: '#059669' }}>
|
||||
Källa: {result.source === 'ica' ? 'ICA' : 'Annan webbsida'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Microservice Importer',
|
||||
description: 'Snabbimport av recept från webben',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="sv">
|
||||
<body style={{ fontFamily: 'Arial, sans-serif', margin: 0 }}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Link from 'next/link';
|
||||
import Navigation from './Navigation';
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main style={{ padding: '1rem', maxWidth: '700px', margin: '0 auto' }}>
|
||||
<Navigation />
|
||||
<h1 style={{ marginBottom: '1.5rem' }}>Microservice Importer</h1>
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
<Link href="/import" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}>
|
||||
Importera recept från URL
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://localhost:3001';
|
||||
|
||||
export async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
// Använd alltid relativ path i webbläsaren för att undvika mixed content
|
||||
const url = typeof window === 'undefined'
|
||||
? (path.startsWith('http') ? path : `${API_BASE}${path}`)
|
||||
: path;
|
||||
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`API ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export { API_BASE };
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Utility för att parse HTTP-responses och extrahera tydliga felmeddelanden
|
||||
*/
|
||||
export async function parseErrorResponse(response: Response): Promise<string> {
|
||||
const status = response.status;
|
||||
|
||||
try {
|
||||
const data = await response.json();
|
||||
|
||||
// Om backend skickade ett felmeddelande
|
||||
if (data.message) {
|
||||
return data.message;
|
||||
}
|
||||
if (data.error) {
|
||||
return data.error;
|
||||
}
|
||||
if (data.details) {
|
||||
return data.details;
|
||||
}
|
||||
} catch {
|
||||
// Inte JSON, försök text
|
||||
try {
|
||||
const text = await response.text();
|
||||
if (text && text.length < 200) {
|
||||
return text;
|
||||
}
|
||||
} catch {
|
||||
// Inget text-innehål
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback baserat på HTTP-status
|
||||
const defaultMessages: Record<number, string> = {
|
||||
400: 'Ogiltiga data. Kontrollera dina inmatningar.',
|
||||
401: 'Du är inte autentiserad. Logga in.',
|
||||
403: 'Du har inte behörighet till detta.',
|
||||
404: 'Resursen hittades inte.',
|
||||
409: 'Konflikten med befintlig data.',
|
||||
422: 'Valideringen misslyckades. Kontrollera dina inmatningar.',
|
||||
500: 'Serverfel. Försök igen senare.',
|
||||
503: 'Tjänsten är inte tillgänglig.',
|
||||
};
|
||||
|
||||
return defaultMessages[status] || `Fel (${status}). Försök igen senare.`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "recipe-importer-frontend",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.2",
|
||||
"react": "19.2",
|
||||
"react-dom": "19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.15.29",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"typescript": "5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{ "name": "next" }
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user