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