feat: Implement PDF document import functionality with Markdown conversion
- Added DocumentImportModule, DocumentImportController, and DocumentImportService for handling PDF uploads. - Integrated pdf-parse for extracting text from PDF files. - Created PdfParser for parsing PDF documents and converting them to Markdown format. - Updated frontend to support file uploads via drag-and-drop and file input for PDF documents. - Modified API routes to handle document import requests. - Enhanced error handling for unsupported file types and file size limits. - Updated README to reflect new features and usage instructions.
This commit is contained in:
@@ -1,31 +1,30 @@
|
|||||||
# Microservice Importer
|
# Microservice Importer
|
||||||
|
|
||||||
Standalone-tjänst för snabbimport av recept från webben. Extraherar receptdata (namn, beskrivning, ingredienser, instruktioner) från URL:er och konverterar till standardiserad Markdown-format.
|
Standalone-tjänst för import och konvertering av dokument till Markdown-format. Primärt fokus på PDF-filer — textbaserad extraction samt OCR för skannade dokument. Webb-skrapning finns som sekundär funktion för referens och framtida integration.
|
||||||
|
|
||||||
**Kopplat till:** [`recipe-app`](../recipe-app/) — men kan användas helt oberoende.
|
Kan användas helt oberoende som fristående microservice.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Quick-Import via URL
|
### Dokument-Import via PDF
|
||||||
|
- **Textbaserad extraction** — Snabb och precis för digitalt skapade PDFs
|
||||||
|
- **OCR-detektering** — Identifierar skannade bild-PDFs och rapporterar tydligt (OCR-stöd under utveckling)
|
||||||
|
- **Automatisk konvertering:**
|
||||||
|
- Titeln extraheras från filnamnet
|
||||||
|
- Texten struktureras till Markdown-stycken
|
||||||
|
- Metadata sparas (antal sidor, teckenantal, producent, skapelsedatum)
|
||||||
|
- **Filstorlek:** Max 50 MB per fil
|
||||||
|
- **Format:** Multipart/form-data uppladdning
|
||||||
|
|
||||||
|
### Webb-skrapning (sekundär funktion)
|
||||||
- **Skrapa från ICA.se** — JSON-LD structured data + HTML-parsing
|
- **Skrapa från ICA.se** — JSON-LD structured data + HTML-parsing
|
||||||
- **Generisk parser** — Fallback för andra webbplatser
|
- **Generisk parser** — Fallback för andra webbplatser
|
||||||
- **Automatisk extraction:**
|
- **Automatisk extraction:** Namn, beskrivning, ingredienser, instruktioner
|
||||||
- Receptnamn
|
|
||||||
- Beskrivning (från meta-taggar eller JSON-LD)
|
|
||||||
- Ingredienser med kvantitet, enhet och noter
|
|
||||||
- Instruktioner/tillvägagångssätt
|
|
||||||
- Källlänk (läggs till i footer)
|
|
||||||
|
|
||||||
### Stödd format
|
|
||||||
- **Bråkmängder:** `1 1/2 dl`, `1/2 tsk`
|
|
||||||
- **Enheter:** g, kg, ml, dl, msk, tsk, st, port, efter smak, förp, klyfta, m.fl.
|
|
||||||
- **Parentetiska noter:** `2 dl grädde (vispgrädde)` → note sparas separat
|
|
||||||
- **Markdown-output:** Strukturerat receptformat för vidare bearbetning
|
|
||||||
|
|
||||||
### Parse-Markdown endpoint
|
### Parse-Markdown endpoint
|
||||||
Tolka Markdown-format recepter utan databaskomplikationer. Användbar för API-integration utan lokal DB.
|
Tolka Markdown-format utan databaskomplikationer. Användbar för API-integration utan lokal DB.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -36,14 +35,21 @@ Tolka Markdown-format recepter utan databaskomplikationer. Användbar för API-i
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── app.module.ts # Root module (Quick-import + Recipes)
|
├── app.module.ts # Root module
|
||||||
├── main.ts # Startpunkt
|
├── main.ts # Startpunkt
|
||||||
├── common/
|
├── common/
|
||||||
│ ├── filters/
|
│ ├── filters/
|
||||||
│ │ └── global-exception.filter.ts # Centraliserad felhantering (svenska meddelanden)
|
│ │ └── global-exception.filter.ts # Centraliserad felhantering (svenska meddelanden)
|
||||||
│ └── utils/
|
│ └── utils/
|
||||||
│ └── normalize-name.ts # Namnormalisering (åäö-handling)
|
│ └── normalize-name.ts # Namnormalisering (åäö-handling)
|
||||||
├── quick-import/ # URL-scraping & parsing
|
├── document-import/ # PDF-import & konvertering (primär funktion)
|
||||||
|
│ ├── document-import.controller.ts # POST /api/document-import
|
||||||
|
│ ├── document-import.service.ts # Filvalidering, parser-routing
|
||||||
|
│ ├── document-import.module.ts
|
||||||
|
│ └── parsers/
|
||||||
|
│ ├── document.parser.ts # Abstrakt bas-klass
|
||||||
|
│ └── pdf.parser.ts # PDF-extraction via pdf-parse
|
||||||
|
├── quick-import/ # Webb-skrapning (sekundär funktion)
|
||||||
│ ├── quick-import.controller.ts # POST /api/quick-import
|
│ ├── quick-import.controller.ts # POST /api/quick-import
|
||||||
│ ├── quick-import.service.ts # Scraping-logik, parser-selection
|
│ ├── quick-import.service.ts # Scraping-logik, parser-selection
|
||||||
│ ├── quick-import.module.ts
|
│ ├── quick-import.module.ts
|
||||||
@@ -51,15 +57,15 @@ src/
|
|||||||
│ ├── base.parser.ts # Abstrakt bas-klass
|
│ ├── base.parser.ts # Abstrakt bas-klass
|
||||||
│ ├── ica.parser.ts # ICA.se-specifik (JSON-LD prioritet)
|
│ ├── ica.parser.ts # ICA.se-specifik (JSON-LD prioritet)
|
||||||
│ └── generic.parser.ts # Fallback för alla webbplatser
|
│ └── generic.parser.ts # Fallback för alla webbplatser
|
||||||
└── recipes/ # Markdown-tolkning (SOM DB!)
|
└── recipes/ # Markdown-tolkning (utan DB)
|
||||||
├── recipes.controller.ts # POST /api/recipes/parse-markdown
|
├── recipes.controller.ts # POST /api/recipes/parse-markdown
|
||||||
├── recipes.service.ts # Enkel markdown-parsing
|
├── recipes.service.ts
|
||||||
├── recipes.module.ts
|
├── recipes.module.ts
|
||||||
└── dto/
|
└── dto/
|
||||||
└── parse-markdown.dto.ts # Validering
|
└── parse-markdown.dto.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
**Viktigt:** Backend har _INGEN_ databaskonfiguration (@prisma/client inte installerat).
|
**Viktigt:** Backend har _INGEN_ databaskonfiguration — stateless service.
|
||||||
|
|
||||||
### Frontend (Next.js 16.2, React 19.2, TypeScript 5.4.5)
|
### Frontend (Next.js 16.2, React 19.2, TypeScript 5.4.5)
|
||||||
**Port:** 3000
|
**Port:** 3000
|
||||||
@@ -69,11 +75,10 @@ app/
|
|||||||
├── layout.tsx # Root layout
|
├── layout.tsx # Root layout
|
||||||
├── page.tsx # Home page
|
├── page.tsx # Home page
|
||||||
├── Navigation.tsx # Minimal nav (Home + Import)
|
├── Navigation.tsx # Minimal nav (Home + Import)
|
||||||
├── import/page.tsx # PRIMARY FEATURE — Import UI
|
├── import/page.tsx # PRIMARY FEATURE — Drag-and-drop filuppladdning
|
||||||
│ └── Inmatningsfält för URL/filsökväg
|
|
||||||
│ └── Visa resultat i realtid
|
|
||||||
├── api/
|
├── api/
|
||||||
│ └── parse-markdown-proxy/route.ts # API proxy till backend
|
│ ├── document-import-proxy/route.ts # Proxy: multipart/form-data → backend
|
||||||
|
│ └── parse-markdown-proxy/route.ts # Proxy: Markdown-tolkning → backend
|
||||||
└── lib/
|
└── lib/
|
||||||
├── api.ts # Centraliserad API-access (fetchJson)
|
├── api.ts # Centraliserad API-access (fetchJson)
|
||||||
└── error-handler.ts # parseErrorResponse (svenska meddelanden)
|
└── error-handler.ts # parseErrorResponse (svenska meddelanden)
|
||||||
@@ -124,9 +129,53 @@ docker compose down
|
|||||||
|
|
||||||
## API-dokumentation
|
## API-dokumentation
|
||||||
|
|
||||||
|
### POST /api/document-import
|
||||||
|
|
||||||
|
**Syfte:** Ladda upp en PDF och returnera Markdown-text
|
||||||
|
|
||||||
|
**Request:** `multipart/form-data` med fältet `file` (PDF, max 50 MB)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3001/api/document-import \
|
||||||
|
-F "file=@dokument.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (Success 200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"markdown": "# Dokument\n\nInnehåll från PDFen...",
|
||||||
|
"title": "Dokument",
|
||||||
|
"documentType": "pdf",
|
||||||
|
"metadata": {
|
||||||
|
"pageCount": 5,
|
||||||
|
"characterCount": 12400,
|
||||||
|
"producer": "Adobe PDF Library",
|
||||||
|
"creationDate": "D:20260101120000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (Error 400):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"statusCode": 400,
|
||||||
|
"message": "Kunde inte läsa dokumentet: PDFen verkar vara en skannad bild utan textlager.",
|
||||||
|
"timestamp": "2026-04-12T10:30:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error-scenarier:**
|
||||||
|
- `400` — Ingen fil bifogad
|
||||||
|
- `400` — Fel filtyp (ej PDF)
|
||||||
|
- `400` — Filen överstiger 50 MB
|
||||||
|
- `400` — Lösenordsskyddad PDF
|
||||||
|
- `400` — Skannad bild-PDF utan textlager (OCR ej implementerat ännu)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### POST /api/quick-import
|
### POST /api/quick-import
|
||||||
|
|
||||||
**Syfte:** Skrapa webbsida och returnera Markdown-recept
|
**Syfte:** Skrapa webbsida och returnera Markdown (sekundär funktion)
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
```json
|
```json
|
||||||
@@ -138,51 +187,37 @@ docker compose down
|
|||||||
**Response (Success 200):**
|
**Response (Success 200):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"markdown": "# Köttfärssås\n\nEn klassisk...\n\n## Ingredienser\n- 500 g köttfärs\n...",
|
"markdown": "# Köttfärssås\n\nEn klassisk...",
|
||||||
"source": "ica"
|
"source": "ica"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response (Error 400/503):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 400,
|
|
||||||
"message": "Kunde inte hämta recept: HTTP 404. Kontrollera att länken är korrekt och försök igen.",
|
|
||||||
"timestamp": "2026-04-12T10:30:00.000Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error-scenarier:**
|
**Error-scenarier:**
|
||||||
- `400` — Tomt input, inte en URL, inte en filsökväg
|
- `400` — Tomt input, inte en URL
|
||||||
- `400` — HTML-parsing misslyckades (receptnamn/ingredienser inte hittade)
|
- `400` — HTML-parsing misslyckades
|
||||||
- `503` — Network-fel (t.ex. webbsidan nåbar, men HTTP 500 från server)
|
- `503` — Network-fel
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### POST /api/recipes/parse-markdown
|
### POST /api/recipes/parse-markdown
|
||||||
|
|
||||||
**Syfte:** Tolka Markdown-receptformat utan database
|
**Syfte:** Tolka Markdown-format utan databas
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"markdown": "# Receptnamn\n\n## Ingredienser\n- 500 g köttfärs\n\n## Tillvägagångssätt\nStek löken..."
|
"markdown": "# Titel\n\n## Ingredienser\n- 500 g mjöl\n\n## Tillvägagångssätt\nBlanda..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response (Success 200):**
|
**Response (Success 200):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "Receptnamn",
|
"name": "Titel",
|
||||||
"description": "",
|
"description": "",
|
||||||
"instructions": "Stek löken...",
|
"instructions": "Blanda...",
|
||||||
"ingredients": [
|
"ingredients": [
|
||||||
{
|
{ "rawName": "mjöl", "quantity": 500, "unit": "g", "note": null }
|
||||||
"rawName": "köttfärs",
|
|
||||||
"quantity": 500,
|
|
||||||
"unit": "g",
|
|
||||||
"note": null
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -191,116 +226,40 @@ docker compose down
|
|||||||
|
|
||||||
## Parser-arkitektur
|
## Parser-arkitektur
|
||||||
|
|
||||||
### Bas-parser (`base.parser.ts`)
|
### Dokument-parsers (`document-import/parsers/`)
|
||||||
|
|
||||||
Abstrakt klass som alla parsers ärver från. Innehåller gemensam parsing-logik:
|
Abstrakt bas `DocumentParser` som alla dokumenttyp-specifika parsers ärver från:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
abstract class RecipeParser {
|
abstract class DocumentParser {
|
||||||
abstract canHandle(url: string): boolean;
|
abstract parse(buffer: Buffer, filename: string): Promise<ParsedDocument>;
|
||||||
abstract parse(html: string): ParsedRecipe;
|
|
||||||
|
|
||||||
protected parseIngredientLine(line: string): ParsedIngredient | null {
|
protected textToMarkdown(text: string, title: string): string {
|
||||||
// Shared logic för ingrediensparsning
|
// Slår ihop sammanhängande textrader, bevarar stycken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**parseIngredientLine-funktionen hanterar:**
|
### PDF Parser (`pdf.parser.ts`)
|
||||||
- `500 g köttfärs` → `{quantity: 500, unit: "g", name: "köttfärs"}`
|
|
||||||
- `1 1/2 dl grädde (vispgrädde)` → `{quantity: 1.5, unit: "dl", name: "grädde", note: "vispgrädde"}`
|
|
||||||
- `3 ägg` → `{quantity: 3, unit: "st", name: "ägg"}`
|
|
||||||
- `salt` → `{quantity: 0, unit: "", name: "salt"}`
|
|
||||||
|
|
||||||
**Kända enheter:**
|
Hanterar textbaserade PDFs via `pdf-parse`.
|
||||||
```
|
|
||||||
Vikt: g, kg, hg, mg
|
|
||||||
Volym: ml, dl, l, tl
|
|
||||||
Portioner: tsk, msk, krm
|
|
||||||
Övriga: st, port, burk, förp, paket, pris, portion, matsked, tesked, efter smak, klyfta
|
|
||||||
```
|
|
||||||
|
|
||||||
### ICA Parser (`ica.parser.ts`)
|
|
||||||
|
|
||||||
Optimerad för ICA.se receptsidor.
|
|
||||||
|
|
||||||
**Strategi:**
|
**Strategi:**
|
||||||
1. Försök extrahera JSON-LD structured data (prioritet)
|
1. Extrahera text med `pdf-parse`
|
||||||
2. Fallback: HTML-regex parsing
|
2. Kontrollera att text hittades (annars: skannad PDF-varning)
|
||||||
|
3. Konvertera text → Markdown via `textToMarkdown()`
|
||||||
|
4. Returnera titel (från filnamn), innehåll och metadata
|
||||||
|
|
||||||
**JSON-LD mål:**
|
**Felhantering:**
|
||||||
- Receptnamn från `@type === "Recipe"` → `.name`
|
- Lösenordsskyddade PDFs → tydligt felmeddelande
|
||||||
- Beskrivning från `.description`
|
- Skannade bild-PDFs → informativt felmeddelande om OCR
|
||||||
- Ingredienser från `.recipeIngredient[]`
|
|
||||||
- Instruktioner från `.recipeInstructions[]`
|
|
||||||
|
|
||||||
**HTML-fallback:**
|
### Webb-parsers (`quick-import/parsers/`)
|
||||||
- Titel: `<h1>` eller `<meta property="og:title">`
|
|
||||||
- Beskrivning: `<meta name="description">`
|
|
||||||
- Ingredienser: `<li class="...ingredient...">` regex
|
|
||||||
- Instruktioner: `<div class="...instruction...">` regex
|
|
||||||
|
|
||||||
### Generic Parser (`generic.parser.ts`)
|
Abstrakt bas `RecipeParser` med `canHandle(url)` + `parse(html)`. Implementationer:
|
||||||
|
|
||||||
Fallback-parser för alla okända webbplatser.
|
- **`ica.parser.ts`** — ICA.se, prioriterar JSON-LD structured data
|
||||||
|
- **`generic.parser.ts`** — Fallback för alla webbplatser, försöker JSON-LD sedan HTML
|
||||||
**Strategi:**
|
|
||||||
1. Försök JSON-LD structured data (alla webbplatser kan ha detta)
|
|
||||||
2. Fallback: Permissiv HTML-parsing
|
|
||||||
|
|
||||||
**HTML-parsing försöker flera selectors:**
|
|
||||||
- Ingredienser: `<li>`, `<div class="ingredient">`, `<p class="ingredient">`
|
|
||||||
- Instruktioner: `<div class="instruction">`, `<ol>` listor
|
|
||||||
- Titel: `<h1>`, `<meta property="og:title">`, `<title>`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Markdown-format och parsing
|
|
||||||
|
|
||||||
### Input-format
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Receptnamn
|
|
||||||
|
|
||||||
Valfri beskrivning av receptet (1+ stycken).
|
|
||||||
|
|
||||||
## Ingredienser
|
|
||||||
- 500 g köttfärs
|
|
||||||
- 1 st lök
|
|
||||||
- 2.5 msk tomatpuré
|
|
||||||
- 1 dl grädde (vispgrädde)
|
|
||||||
- salt
|
|
||||||
|
|
||||||
## Tillvägagångssätt
|
|
||||||
Steg 1: Stek löken i smör.
|
|
||||||
Steg 2: Tillsätt köttfärsen och stek tills den är genomstekt.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Parsningsregler
|
|
||||||
|
|
||||||
| Element | Tolkning |
|
|
||||||
|---------|----------|
|
|
||||||
| `# Rubrik` | Receptnamn (första H1) |
|
|
||||||
| Text mellan H1 och `## Ingredienser` | Beskrivning (flera rader OK, valfritt) |
|
|
||||||
| `## Ingredienser` | Ingredient-markerare (case-insensitive) |
|
|
||||||
| `- ANTAL ENHET NAMN` | Ingrediens med alla delar |
|
|
||||||
| `- ANTAL NAMN` | Ingrediens utan enhet (unit → "st") |
|
|
||||||
| `- NAMN` | Ingrediens utan kvantitet (quantity → 0) |
|
|
||||||
| `(text i parentes)` | Ingrediensnot (sparas separat) |
|
|
||||||
| `## Tillvägagångssätt` / `## Instruktioner` / `## Tillagning` | Instruktions-markerare |
|
|
||||||
| Text under instruktioner | Tillagningssteg (flera rader OK) |
|
|
||||||
|
|
||||||
**Exempel:**
|
|
||||||
```
|
|
||||||
Input: "- 1,5 dl grädde (vispgrädde)"
|
|
||||||
Output: {quantity: 1.5, unit: "dl", rawName: "grädde", note: "vispgrädde"}
|
|
||||||
|
|
||||||
Input: "- 3 ägg"
|
|
||||||
Output: {quantity: 3, unit: "st", rawName: "ägg", note: null}
|
|
||||||
|
|
||||||
Input: "- salt"
|
|
||||||
Output: {quantity: 0, unit: "", rawName: "salt", note: null}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -357,47 +316,36 @@ NEXT_PUBLIC_API_URL_INTERNAL=http://importer-api:3001
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Integration med Recipe App
|
## Integration med andra tjänster
|
||||||
|
|
||||||
Recipe App kan anropa denna microservice som extern API:
|
Microservicen kan anropas som extern API från andra applikationer:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const IMPORTER_URL = process.env.MICROSERVICE_IMPORTER_URL || 'http://localhost:3001';
|
const IMPORTER_URL = process.env.MICROSERVICE_IMPORTER_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
const response = await fetch(`${IMPORTER_URL}/api/quick-import`, {
|
// PDF-konvertering
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', pdfFile);
|
||||||
|
|
||||||
|
const response = await fetch(`${IMPORTER_URL}/api/document-import`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: formData,
|
||||||
body: JSON.stringify({ input: url })
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { markdown, source } = await response.json();
|
const { markdown, title, metadata } = await response.json();
|
||||||
|
|
||||||
// Sedan kan recipe-app:s parse-markdown-endpoint
|
|
||||||
// använda Markdown för DB-matchning mot produkter
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fil-struktur & Koddelning
|
|
||||||
|
|
||||||
Följande filer är **identiska** mellan recipe-app och microservice-importer:
|
|
||||||
|
|
||||||
- `backend/src/quick-import/parsers/base.parser.ts`
|
|
||||||
- `backend/src/quick-import/parsers/ica.parser.ts`
|
|
||||||
- `backend/src/quick-import/parsers/generic.parser.ts`
|
|
||||||
- `backend/src/common/filters/global-exception.filter.ts`
|
|
||||||
- `backend/src/common/utils/normalize-name.ts`
|
|
||||||
|
|
||||||
**Framtida improvement:** Dessa kan förpackas som separat npm-paket för bättre koddelning och versionering.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tekniska detaljer
|
## Tekniska detaljer
|
||||||
|
|
||||||
### Backend Stack
|
### Backend Stack
|
||||||
- **NestJS** 10.3 — REST API & modular architecture
|
- **NestJS** 10.3 — REST API & modular architecture
|
||||||
- **TypeScript** 5.4.5 — Type safety
|
- **TypeScript** 5.4.5 — Type safety
|
||||||
- **Node.js** 22.x — Runtime
|
- **Node.js** 22.x — Runtime
|
||||||
|
- **pdf-parse** 1.1.x — PDF text extraction
|
||||||
|
- **tesseract.js** 5.x — OCR (förberett, ej aktivt ännu)
|
||||||
|
- **multer** — Multipart file upload handling
|
||||||
- **class-validator** — DTO validation (svenska felmeddelanden)
|
- **class-validator** — DTO validation (svenska felmeddelanden)
|
||||||
- **Ingen databas** — Stateless service
|
- **Ingen databas** — Stateless service
|
||||||
|
|
||||||
@@ -405,34 +353,34 @@ Följande filer är **identiska** mellan recipe-app och microservice-importer:
|
|||||||
- **Next.js** 16.2 — React framework (App Router)
|
- **Next.js** 16.2 — React framework (App Router)
|
||||||
- **React** 19.2 — UI components
|
- **React** 19.2 — UI components
|
||||||
- **TypeScript** 5.4.5
|
- **TypeScript** 5.4.5
|
||||||
- **Inline CSS** — Minimal styling, no framework dependencies
|
- **Inline CSS** — Minimal styling, inga framework-beroenden
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
- Centraliserad `GlobalExceptionFilter` (svenska meddelanden)
|
- Centraliserad `GlobalExceptionFilter` (svenska meddelanden)
|
||||||
- Konsistent JSON-responsformat
|
- Konsistent JSON-responsformat: `{ statusCode, message, timestamp, path }`
|
||||||
- HTTP status codes: 200, 400, 503
|
- HTTP status codes: 200, 400, 503
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Framtida utbyggnader
|
## Framtida utbyggnader
|
||||||
|
|
||||||
- [ ] PDF-import (stubben redan på plats)
|
- [x] PDF-import — textbaserad extraction
|
||||||
- [ ] Stöd för fler webbplatser (mat.se, kokaihop.se, etc.)
|
- [ ] OCR för skannade bild-PDFs (Tesseract.js förberett)
|
||||||
- [ ] Caching av parsed recept
|
- [ ] Word (.docx) import
|
||||||
- [ ] Rate limiting för scraping
|
- [ ] Batch-processing (flera filer samtidigt)
|
||||||
- [ ] WebSocket support för real-time parsing
|
- [ ] Strukturerad data-extraction (tabeller, listor)
|
||||||
- [ ] GraphQL endpoint
|
- [ ] Stöd för fler webbplatser i webb-skraparen (mat.se, kokaihop.se, etc.)
|
||||||
|
- [ ] Caching av konverterade dokument
|
||||||
|
- [ ] Rate limiting
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Licens
|
## Licens
|
||||||
|
|
||||||
Samma som Recipe App — se [main repo](../recipe-app/)
|
MIT
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
Relaterade projekt:
|
|
||||||
- **Recipe App** — [`recipe-app`](../recipe-app/) (Full platform med databas + quick-import integrerad)
|
|
||||||
- **Git Repo** — Gitea på `192.168.50.2:2222/nilsjohan/microservice-importer`
|
- **Git Repo** — Gitea på `192.168.50.2:2222/nilsjohan/microservice-importer`
|
||||||
|
|||||||
@@ -13,14 +13,19 @@
|
|||||||
"@nestjs/platform-express": "^10.3.0",
|
"@nestjs/platform-express": "^10.3.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"pdf-parse": "^1.1.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1",
|
||||||
|
"tesseract.js": "^5.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.3.0",
|
"@nestjs/cli": "^10.3.0",
|
||||||
"@nestjs/schematics": "^10.1.1",
|
"@nestjs/schematics": "^10.1.1",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.15.29",
|
"@types/node": "^22.15.29",
|
||||||
|
"@types/pdf-parse": "^1.1.4",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { QuickImportModule } from './quick-import/quick-import.module';
|
import { QuickImportModule } from './quick-import/quick-import.module';
|
||||||
import { RecipesModule } from './recipes/recipes.module';
|
import { RecipesModule } from './recipes/recipes.module';
|
||||||
|
import { DocumentImportModule } from './document-import/document-import.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
DocumentImportModule,
|
||||||
QuickImportModule,
|
QuickImportModule,
|
||||||
RecipesModule,
|
RecipesModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { DocumentImportService, DocumentImportResult } from './document-import.service';
|
||||||
|
|
||||||
|
@Controller('api/document-import')
|
||||||
|
export class DocumentImportController {
|
||||||
|
constructor(private readonly documentImportService: DocumentImportService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/document-import
|
||||||
|
* Ladda upp en PDF-fil och konvertera till Markdown
|
||||||
|
* Förväntar multipart/form-data med fältet "file"
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB gräns på multer-nivå
|
||||||
|
})
|
||||||
|
)
|
||||||
|
async importDocument(
|
||||||
|
@UploadedFile() file: Express.Multer.File
|
||||||
|
): Promise<DocumentImportResult> {
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Ingen fil mottagen. Skicka en PDF-fil med fältet "file" i multipart/form-data.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.documentImportService.importFromFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DocumentImportController } from './document-import.controller';
|
||||||
|
import { DocumentImportService } from './document-import.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DocumentImportController],
|
||||||
|
providers: [DocumentImportService],
|
||||||
|
})
|
||||||
|
export class DocumentImportModule {}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
import { PdfParser } from './parsers/pdf.parser';
|
||||||
|
|
||||||
|
export interface DocumentImportResult {
|
||||||
|
markdown: string;
|
||||||
|
title: string;
|
||||||
|
documentType: 'pdf';
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
|
||||||
|
const ALLOWED_MIME_TYPES = ['application/pdf'];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DocumentImportService {
|
||||||
|
private readonly pdfParser = new PdfParser();
|
||||||
|
|
||||||
|
async importFromFile(file: Express.Multer.File): Promise<DocumentImportResult> {
|
||||||
|
console.log(
|
||||||
|
'[DocumentImport] Mottog fil:',
|
||||||
|
file.originalname,
|
||||||
|
'— Typ:',
|
||||||
|
file.mimetype,
|
||||||
|
'— Storlek:',
|
||||||
|
file.size,
|
||||||
|
'bytes'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Filtypen "${file.mimetype}" stöds inte. Endast PDF-filer accepteras för tillfället.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Filen är för stor (${Math.round(file.size / 1024 / 1024)} MB). Maximal filstorlek är 50 MB.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = await this.pdfParser.parse(file.buffer, file.originalname);
|
||||||
|
return {
|
||||||
|
markdown: parsed.content,
|
||||||
|
title: parsed.title,
|
||||||
|
documentType: 'pdf',
|
||||||
|
metadata: parsed.metadata,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Okänt fel vid parsning';
|
||||||
|
console.error('[DocumentImport] Parse-fel för', file.originalname, ':', message);
|
||||||
|
throw new BadRequestException(`Kunde inte läsa dokumentet: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Abstract bas för document parsers
|
||||||
|
* Alla dokumenttyp-specifika parsers bör extenda denna
|
||||||
|
*/
|
||||||
|
export interface ParsedDocument {
|
||||||
|
title: string;
|
||||||
|
content: string; // Markdown-format
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class DocumentParser {
|
||||||
|
/**
|
||||||
|
* Parsa document-buffer och returnera strukturerad data
|
||||||
|
*/
|
||||||
|
abstract parse(buffer: Buffer, filename: string): Promise<ParsedDocument>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konverterar fritext till Markdown
|
||||||
|
* Slår ihop sammanhängande textrader, bevarar stycken
|
||||||
|
*/
|
||||||
|
protected textToMarkdown(text: string, title: string): string {
|
||||||
|
const lines = text.split('\n').map(l => l.trim());
|
||||||
|
const paragraphs: string[] = [];
|
||||||
|
let currentParagraph: string[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.length === 0) {
|
||||||
|
if (currentParagraph.length > 0) {
|
||||||
|
paragraphs.push(currentParagraph.join(' '));
|
||||||
|
currentParagraph = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentParagraph.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentParagraph.length > 0) {
|
||||||
|
paragraphs.push(currentParagraph.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = paragraphs.filter(p => p.length > 0).join('\n\n');
|
||||||
|
return `# ${title}\n\n${body}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import * as pdfParse from 'pdf-parse';
|
||||||
|
import { DocumentParser, ParsedDocument } from './document.parser';
|
||||||
|
|
||||||
|
export class PdfParser extends DocumentParser {
|
||||||
|
async parse(buffer: Buffer, filename: string): Promise<ParsedDocument> {
|
||||||
|
console.log('[PdfParser] Parsing:', filename, '— Storlek:', buffer.length, 'bytes');
|
||||||
|
|
||||||
|
let data: Awaited<ReturnType<typeof pdfParse>>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
data = await pdfParse(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
// Lösenordsskyddade eller skadade PDFs
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.toLowerCase().includes('password')) {
|
||||||
|
throw new Error('PDF-filen är lösenordsskyddad och kan inte läsas');
|
||||||
|
}
|
||||||
|
throw new Error(`Kunde inte läsa PDF: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasText = data.text && data.text.trim().length > 20;
|
||||||
|
|
||||||
|
if (!hasText) {
|
||||||
|
// Textextraction gav ingenting — troligtvis en skannad bild-PDF
|
||||||
|
throw new Error(
|
||||||
|
'PDFen verkar vara en skannad bild utan textlager. OCR-stöd kommer i nästa version.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[PdfParser] Extraherade ${data.numpages} sidor, ${data.text.length} tecken från ${filename}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = filename.replace(/\.pdf$/i, '').replace(/[_-]+/g, ' ').trim();
|
||||||
|
const markdown = this.textToMarkdown(data.text, title);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
content: markdown,
|
||||||
|
metadata: {
|
||||||
|
pageCount: data.numpages,
|
||||||
|
producer: (data.info as Record<string, unknown>)?.Producer ?? null,
|
||||||
|
creationDate: (data.info as Record<string, unknown>)?.CreationDate ?? null,
|
||||||
|
characterCount: data.text.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy för POST /api/document-import
|
||||||
|
* Vidarebefordrar multipart/form-data till backend direkt (ingen JSON-omvandling)
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/document-import`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
// Sätt INTE Content-Type manuellt — browser sätter boundary automatiskt
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
}
|
||||||
+156
-76
@@ -1,117 +1,158 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import Navigation from '../Navigation';
|
import Navigation from '../Navigation';
|
||||||
import { parseErrorResponse } from '../../lib/error-handler';
|
import { parseErrorResponse } from '../../lib/error-handler';
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
markdown: string;
|
||||||
|
title: string;
|
||||||
|
documentType: 'pdf';
|
||||||
|
metadata?: {
|
||||||
|
pageCount?: number;
|
||||||
|
producer?: string;
|
||||||
|
creationDate?: string;
|
||||||
|
characterCount?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function ImportPage() {
|
export default function ImportPage() {
|
||||||
const [quickImportUrl, setQuickImportUrl] = useState('');
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [result, setResult] = useState<any>(null);
|
const [result, setResult] = useState<ImportResult | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleQuickImport = async (e: React.FormEvent) => {
|
const handleFileSelect = (file: File) => {
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
if (!file.name.toLowerCase().endsWith('.pdf')) {
|
||||||
|
setError('Endast PDF-filer stöds för tillfället.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => setIsDragging(false);
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) handleFileSelect(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleFileSelect(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const input = quickImportUrl.trim();
|
const formData = new FormData();
|
||||||
if (!input) {
|
formData.append('file', selectedFile);
|
||||||
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/document-import-proxy', {
|
||||||
const res = await fetch('/api/quick-import', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: formData,
|
||||||
body: JSON.stringify({ input }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorMessage = await parseErrorResponse(res);
|
const errorMessage = await parseErrorResponse(res);
|
||||||
setError(errorMessage || 'Importen misslyckades. Kontrollera att länken eller filsökvägen är korrekt.');
|
setError(errorMessage || 'Importen misslyckades.');
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data: ImportResult = await res.json();
|
||||||
if (data.markdown) {
|
|
||||||
setResult(data);
|
setResult(data);
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Något oväntad gick fel';
|
const message = err instanceof Error ? err.message : 'Något oväntat gick fel';
|
||||||
setError(`Fel: ${message}`);
|
setError(`Fel: ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
if (result?.markdown) {
|
||||||
|
navigator.clipboard.writeText(result.markdown);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}>
|
<main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<h1 style={{ marginBottom: '1.5rem' }}>Importera recept</h1>
|
<h1 style={{ marginBottom: '0.5rem' }}>Importera dokument</h1>
|
||||||
|
<p style={{ margin: '0 0 1.5rem 0', color: '#6b7280', fontSize: '0.95rem' }}>
|
||||||
{/* IMPORT-SEKTION */}
|
Ladda upp en PDF-fil och konvertera den till Markdown-format.
|
||||||
<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>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleQuickImport} style={{ display: 'grid', gap: '0.75rem' }}>
|
{/* UPLOAD-SEKTION */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0.5rem' }}>
|
<form onSubmit={handleImport}>
|
||||||
<input
|
<div
|
||||||
type="text"
|
onDragOver={handleDragOver}
|
||||||
value={quickImportUrl}
|
onDragLeave={handleDragLeave}
|
||||||
onChange={(e) => setQuickImportUrl(e.target.value)}
|
onDrop={handleDrop}
|
||||||
placeholder="https://www.ica.se/recept/..."
|
onClick={() => fileInputRef.current?.click()}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.75rem',
|
border: `2px dashed ${isDragging ? '#3b82f6' : selectedFile ? '#10b981' : '#d1d5db'}`,
|
||||||
border: '1px solid #d97706',
|
borderRadius: '8px',
|
||||||
borderRadius: '4px',
|
padding: '2.5rem 1.5rem',
|
||||||
fontSize: '0.95rem',
|
textAlign: 'center',
|
||||||
boxSizing: 'border-box',
|
cursor: 'pointer',
|
||||||
}}
|
background: isDragging ? '#eff6ff' : selectedFile ? '#f0fdf4' : '#f9fafb',
|
||||||
disabled={isLoading}
|
transition: 'all 0.15s ease',
|
||||||
/>
|
marginBottom: '1rem',
|
||||||
<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...' : '→'}
|
<input
|
||||||
</button>
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,application/pdf"
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>
|
||||||
|
{selectedFile ? '📄' : '⬆️'}
|
||||||
|
</div>
|
||||||
|
{selectedFile ? (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '0 0 0.25rem 0', fontWeight: 600, color: '#065f46' }}>
|
||||||
|
{selectedFile.name}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.85rem', color: '#6b7280' }}>
|
||||||
|
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB — Klicka för att byta fil
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '0 0 0.25rem 0', fontWeight: 600, color: '#374151' }}>
|
||||||
|
Dra och släpp din PDF här
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.85rem', color: '#6b7280' }}>
|
||||||
|
eller klicka för att välja fil (max 50 MB)
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
margin: '0.5rem 0 0 0',
|
margin: '0 0 1rem 0',
|
||||||
color: '#991b1b',
|
color: '#991b1b',
|
||||||
background: '#fee2e2',
|
background: '#fee2e2',
|
||||||
padding: '0.75rem',
|
padding: '0.75rem',
|
||||||
@@ -122,27 +163,69 @@ export default function ImportPage() {
|
|||||||
⚠️ {error}
|
⚠️ {error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !selectedFile}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 2rem',
|
||||||
|
background: '#3b82f6',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: isLoading || !selectedFile ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isLoading || !selectedFile ? 0.5 : 1,
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Konverterar...' : 'Konvertera till Markdown'}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RESULT */}
|
{/* RESULT */}
|
||||||
{result && (
|
{result && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: '#ecfdf5',
|
marginTop: '2rem',
|
||||||
|
background: '#f0fdf4',
|
||||||
border: '2px solid #10b981',
|
border: '2px solid #10b981',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
padding: '1.5rem',
|
padding: '1.5rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={{ margin: '0 0 1rem 0', color: '#059669' }}>✓ Recept importerat</h2>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<h2 style={{ margin: 0, color: '#065f46' }}>✓ {result.title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem 0.9rem',
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid #10b981',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: '#065f46',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Kopiera Markdown
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.metadata && (
|
||||||
|
<p style={{ margin: '0 0 1rem 0', fontSize: '0.85rem', color: '#6b7280' }}>
|
||||||
|
{result.metadata.pageCount} sidor
|
||||||
|
{result.metadata.characterCount ? ` · ${result.metadata.characterCount.toLocaleString('sv')} tecken` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
border: '1px solid #d1fae5',
|
border: '1px solid #d1fae5',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
maxHeight: '400px',
|
maxHeight: '500px',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -158,9 +241,6 @@ export default function ImportPage() {
|
|||||||
{result.markdown}
|
{result.markdown}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: '1rem 0 0 0', fontSize: '0.9rem', color: '#059669' }}>
|
|
||||||
Källa: {result.source === 'ica' ? 'ICA' : 'Annan webbsida'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user