diff --git a/README.md b/README.md index 81350e8..1309c73 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,30 @@ # 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 -### 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 - **Generisk parser** — Fallback för andra webbplatser -- **Automatisk extraction:** - - 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 +- **Automatisk extraction:** Namn, beskrivning, ingredienser, instruktioner ### 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,47 +35,53 @@ Tolka Markdown-format recepter utan databaskomplikationer. Användbar för API-i ``` src/ -├── app.module.ts # Root module (Quick-import + Recipes) -├── main.ts # Startpunkt +├── app.module.ts # Root module +├── main.ts # Startpunkt ├── common/ │ ├── filters/ -│ │ └── global-exception.filter.ts # Centraliserad felhantering (svenska meddelanden) +│ │ └── global-exception.filter.ts # Centraliserad felhantering (svenska meddelanden) │ └── utils/ -│ └── normalize-name.ts # Namnormalisering (åäö-handling) -├── quick-import/ # URL-scraping & parsing -│ ├── quick-import.controller.ts # POST /api/quick-import -│ ├── quick-import.service.ts # Scraping-logik, parser-selection +│ └── normalize-name.ts # Namnormalisering (åäö-handling) +├── 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.service.ts # Scraping-logik, parser-selection │ ├── quick-import.module.ts │ └── parsers/ -│ ├── base.parser.ts # Abstrakt bas-klass -│ ├── ica.parser.ts # ICA.se-specifik (JSON-LD prioritet) -│ └── generic.parser.ts # Fallback för alla webbplatser -└── recipes/ # Markdown-tolkning (SOM DB!) - ├── recipes.controller.ts # POST /api/recipes/parse-markdown - ├── recipes.service.ts # Enkel markdown-parsing +│ ├── base.parser.ts # Abstrakt bas-klass +│ ├── ica.parser.ts # ICA.se-specifik (JSON-LD prioritet) +│ └── generic.parser.ts # Fallback för alla webbplatser +└── recipes/ # Markdown-tolkning (utan DB) + ├── recipes.controller.ts # POST /api/recipes/parse-markdown + ├── recipes.service.ts ├── recipes.module.ts └── 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) **Port:** 3000 ``` app/ -├── layout.tsx # Root layout -├── page.tsx # Home page -├── Navigation.tsx # Minimal nav (Home + Import) -├── import/page.tsx # PRIMARY FEATURE — Import UI -│ └── Inmatningsfält för URL/filsökväg -│ └── Visa resultat i realtid +├── layout.tsx # Root layout +├── page.tsx # Home page +├── Navigation.tsx # Minimal nav (Home + Import) +├── import/page.tsx # PRIMARY FEATURE — Drag-and-drop filuppladdning ├── 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/ - ├── api.ts # Centraliserad API-access (fetchJson) - └── error-handler.ts # parseErrorResponse (svenska meddelanden) + ├── api.ts # Centraliserad API-access (fetchJson) + └── error-handler.ts # parseErrorResponse (svenska meddelanden) ``` --- @@ -124,9 +129,53 @@ docker compose down ## 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 -**Syfte:** Skrapa webbsida och returnera Markdown-recept +**Syfte:** Skrapa webbsida och returnera Markdown (sekundär funktion) **Request:** ```json @@ -138,51 +187,37 @@ docker compose down **Response (Success 200):** ```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" } ``` -**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:** -- `400` — Tomt input, inte en URL, inte en filsökväg -- `400` — HTML-parsing misslyckades (receptnamn/ingredienser inte hittade) -- `503` — Network-fel (t.ex. webbsidan nåbar, men HTTP 500 från server) +- `400` — Tomt input, inte en URL +- `400` — HTML-parsing misslyckades +- `503` — Network-fel --- ### POST /api/recipes/parse-markdown -**Syfte:** Tolka Markdown-receptformat utan database +**Syfte:** Tolka Markdown-format utan databas **Request:** ```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):** ```json { - "name": "Receptnamn", + "name": "Titel", "description": "", - "instructions": "Stek löken...", + "instructions": "Blanda...", "ingredients": [ - { - "rawName": "köttfärs", - "quantity": 500, - "unit": "g", - "note": null - } + { "rawName": "mjöl", "quantity": 500, "unit": "g", "note": null } ] } ``` @@ -191,116 +226,40 @@ docker compose down ## 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 -abstract class RecipeParser { - abstract canHandle(url: string): boolean; - abstract parse(html: string): ParsedRecipe; - - protected parseIngredientLine(line: string): ParsedIngredient | null { - // Shared logic för ingrediensparsning +abstract class DocumentParser { + abstract parse(buffer: Buffer, filename: string): Promise; + + protected textToMarkdown(text: string, title: string): string { + // Slår ihop sammanhängande textrader, bevarar stycken } } ``` -**parseIngredientLine-funktionen hanterar:** -- `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"}` +### PDF Parser (`pdf.parser.ts`) -**Kända enheter:** -``` -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. +Hanterar textbaserade PDFs via `pdf-parse`. **Strategi:** -1. Försök extrahera JSON-LD structured data (prioritet) -2. Fallback: HTML-regex parsing +1. Extrahera text med `pdf-parse` +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:** -- Receptnamn från `@type === "Recipe"` → `.name` -- Beskrivning från `.description` -- Ingredienser från `.recipeIngredient[]` -- Instruktioner från `.recipeInstructions[]` +**Felhantering:** +- Lösenordsskyddade PDFs → tydligt felmeddelande +- Skannade bild-PDFs → informativt felmeddelande om OCR -**HTML-fallback:** -- Titel: `

` eller `` -- Beskrivning: `` -- Ingredienser: `
  • ` regex -- Instruktioner: `
    ` regex +### Webb-parsers (`quick-import/parsers/`) -### Generic Parser (`generic.parser.ts`) +Abstrakt bas `RecipeParser` med `canHandle(url)` + `parse(html)`. Implementationer: -Fallback-parser för alla okända webbplatser. - -**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: `
  • `, `
    `, `

    ` -- Instruktioner: `

    `, `
      ` listor -- Titel: `

      `, ``, `` - ---- - -## 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} -``` +- **`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 --- @@ -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 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', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: url }) + body: formData, }); -const { markdown, source } = await response.json(); - -// Sedan kan recipe-app:s parse-markdown-endpoint -// använda Markdown för DB-matchning mot produkter +const { markdown, title, metadata } = await response.json(); ``` --- -## 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 ### Backend Stack - **NestJS** 10.3 — REST API & modular architecture - **TypeScript** 5.4.5 — Type safety - **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) - **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) - **React** 19.2 — UI components - **TypeScript** 5.4.5 -- **Inline CSS** — Minimal styling, no framework dependencies +- **Inline CSS** — Minimal styling, inga framework-beroenden ### Error Handling - Centraliserad `GlobalExceptionFilter` (svenska meddelanden) -- Konsistent JSON-responsformat +- Konsistent JSON-responsformat: `{ statusCode, message, timestamp, path }` - HTTP status codes: 200, 400, 503 --- ## Framtida utbyggnader -- [ ] PDF-import (stubben redan på plats) -- [ ] Stöd för fler webbplatser (mat.se, kokaihop.se, etc.) -- [ ] Caching av parsed recept -- [ ] Rate limiting för scraping -- [ ] WebSocket support för real-time parsing -- [ ] GraphQL endpoint +- [x] PDF-import — textbaserad extraction +- [ ] OCR för skannade bild-PDFs (Tesseract.js förberett) +- [ ] Word (.docx) import +- [ ] Batch-processing (flera filer samtidigt) +- [ ] Strukturerad data-extraction (tabeller, listor) +- [ ] Stöd för fler webbplatser i webb-skraparen (mat.se, kokaihop.se, etc.) +- [ ] Caching av konverterade dokument +- [ ] Rate limiting --- ## Licens -Samma som Recipe App — se [main repo](../recipe-app/) +MIT --- ## 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` diff --git a/backend/package.json b/backend/package.json index 3809871..c600b97 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,14 +13,19 @@ "@nestjs/platform-express": "^10.3.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "multer": "^1.4.5-lts.1", + "pdf-parse": "^1.1.1", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "tesseract.js": "^5.1.1" }, "devDependencies": { "@nestjs/cli": "^10.3.0", "@nestjs/schematics": "^10.1.1", "@types/express": "^4.17.21", + "@types/multer": "^1.4.12", "@types/node": "^22.15.29", + "@types/pdf-parse": "^1.1.4", "typescript": "^5.4.5" } } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6cc60d5..4da564e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { QuickImportModule } from './quick-import/quick-import.module'; import { RecipesModule } from './recipes/recipes.module'; +import { DocumentImportModule } from './document-import/document-import.module'; @Module({ imports: [ + DocumentImportModule, QuickImportModule, RecipesModule, ], diff --git a/backend/src/document-import/document-import.controller.ts b/backend/src/document-import/document-import.controller.ts new file mode 100644 index 0000000..a82b5a6 --- /dev/null +++ b/backend/src/document-import/document-import.controller.ts @@ -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); + } +} diff --git a/backend/src/document-import/document-import.module.ts b/backend/src/document-import/document-import.module.ts new file mode 100644 index 0000000..e44fcfb --- /dev/null +++ b/backend/src/document-import/document-import.module.ts @@ -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 {} diff --git a/backend/src/document-import/document-import.service.ts b/backend/src/document-import/document-import.service.ts new file mode 100644 index 0000000..f07936d --- /dev/null +++ b/backend/src/document-import/document-import.service.ts @@ -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}`); + } + } +} diff --git a/backend/src/document-import/parsers/document.parser.ts b/backend/src/document-import/parsers/document.parser.ts new file mode 100644 index 0000000..4b9b3db --- /dev/null +++ b/backend/src/document-import/parsers/document.parser.ts @@ -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}`; + } +} diff --git a/backend/src/document-import/parsers/pdf.parser.ts b/backend/src/document-import/parsers/pdf.parser.ts new file mode 100644 index 0000000..2278c14 --- /dev/null +++ b/backend/src/document-import/parsers/pdf.parser.ts @@ -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, + }, + }; + } +} diff --git a/frontend/app/api/document-import-proxy/route.ts b/frontend/app/api/document-import-proxy/route.ts new file mode 100644 index 0000000..c657295 --- /dev/null +++ b/frontend/app/api/document-import-proxy/route.ts @@ -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 }); +} diff --git a/frontend/app/import/page.tsx b/frontend/app/import/page.tsx index 5bb5f97..49467ec 100644 --- a/frontend/app/import/page.tsx +++ b/frontend/app/import/page.tsx @@ -1,148 +1,231 @@ 'use client'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import Navigation from '../Navigation'; 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() { - const [quickImportUrl, setQuickImportUrl] = useState(''); + const [selectedFile, setSelectedFile] = useState<File | null>(null); + const [isDragging, setIsDragging] = useState(false); const [isLoading, setIsLoading] = useState(false); 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(); + 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); setResult(null); setIsLoading(true); try { - const input = quickImportUrl.trim(); - if (!input) { - setError('Vänligen ange en URL eller filsökväg'); - setIsLoading(false); - return; - } + const formData = new FormData(); + formData.append('file', selectedFile); - // Försök importera från URL eller fil - const res = await fetch('/api/quick-import', { + const res = await fetch('/api/document-import-proxy', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input }), + body: formData, }); if (!res.ok) { const errorMessage = await parseErrorResponse(res); - setError(errorMessage || 'Importen misslyckades. Kontrollera att länken eller filsökvägen är korrekt.'); - setIsLoading(false); + setError(errorMessage || 'Importen misslyckades.'); return; } - const data = await res.json(); - if (data.markdown) { - setResult(data); - } + const data: ImportResult = await res.json(); + setResult(data); } 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}`); } finally { setIsLoading(false); } }; + const copyToClipboard = () => { + if (result?.markdown) { + navigator.clipboard.writeText(result.markdown); + } + }; + return ( <main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}> <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' }}> + Ladda upp en PDF-fil och konvertera den till Markdown-format. + </p> - {/* 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> + {/* UPLOAD-SEKTION */} + <form onSubmit={handleImport}> + <div + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + style={{ + border: `2px dashed ${isDragging ? '#3b82f6' : selectedFile ? '#10b981' : '#d1d5db'}`, + borderRadius: '8px', + padding: '2.5rem 1.5rem', + textAlign: 'center', + cursor: 'pointer', + background: isDragging ? '#eff6ff' : selectedFile ? '#f0fdf4' : '#f9fafb', + transition: 'all 0.15s ease', + marginBottom: '1rem', + }} + > + <input + ref={fileInputRef} + type="file" + accept=".pdf,application/pdf" + onChange={handleFileInputChange} + style={{ display: 'none' }} + /> + <div style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}> + {selectedFile ? '📄' : '⬆️'} </div> - - {error && ( - <p - style={{ - margin: '0.5rem 0 0 0', - color: '#991b1b', - background: '#fee2e2', - padding: '0.75rem', - borderRadius: '4px', - fontSize: '0.85rem', - }} - > - ⚠️ {error} - </p> + {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> + </> )} - </form> - </div> + </div> + + {error && ( + <p + style={{ + margin: '0 0 1rem 0', + color: '#991b1b', + background: '#fee2e2', + padding: '0.75rem', + borderRadius: '4px', + fontSize: '0.85rem', + }} + > + ⚠️ {error} + </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> {/* RESULT */} {result && ( <div style={{ - background: '#ecfdf5', + marginTop: '2rem', + background: '#f0fdf4', border: '2px solid #10b981', borderRadius: '8px', 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 style={{ background: '#fff', border: '1px solid #d1fae5', borderRadius: '4px', padding: '1rem', - maxHeight: '400px', + maxHeight: '500px', overflowY: 'auto', }} > @@ -158,9 +241,6 @@ export default function ImportPage() { {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>