From 8b8f8b7b6f9d4f4b193c9e36320287e471df5af6 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Tue, 19 May 2026 20:53:39 +0200 Subject: [PATCH] Update flyerimport. flutter timeout 300 sek --- .env | 2 + .env.example | 77 +- backend/package-lock.json | 8 +- backend/package.json | 2 +- .../services/ai-flyer-parser.service.ts | 121 ++- compose.yml | 2 + .../import/data/import_repository.dart | 722 +++++++++--------- 7 files changed, 523 insertions(+), 411 deletions(-) diff --git a/.env b/.env index f40315ff..82bdd132 100644 --- a/.env +++ b/.env @@ -21,3 +21,5 @@ JWT_SECRET=uK9yRQpyyWOcHYcYbpAdsJ7NJcEsyCYZcgF82OnBz2k= MISTRAL_API_KEY=JGPjLuNnzaLSYMxKbexLZohUOegrSLye FLYER_AI_TIMEOUT_MS=45000 FLYER_AI_RETRIES=2 +FLYER_AI_DEBUG=1 +FLYER_AI_DEBUG_DIR=/app/debug diff --git a/.env.example b/.env.example index a9477b3e..486fc230 100644 --- a/.env.example +++ b/.env.example @@ -1,42 +1,45 @@ -# Kopiera till .env och fyll i riktiga värden -# cp .env.example .env - -# MariaDB -MARIADB_ROOT_PASSWORD=byt-ut-mig -MARIADB_DATABASE=recipe_app -MARIADB_USER=recipe_user -MARIADB_PASSWORD=byt-ut-mig - -# Auth.js / NextAuth -# Generera med: openssl rand -base64 32 -AUTH_SECRET=byt-ut-mig - -# JWT (NestJS backend) -# Generera med: openssl rand -base64 32 -# OBS: Appen vägrar starta om detta saknas. -JWT_SECRET=byt-ut-mig - +# Kopiera till .env och fyll i riktiga värden +# cp .env.example .env + +# MariaDB +MARIADB_ROOT_PASSWORD=byt-ut-mig +MARIADB_DATABASE=recipe_app +MARIADB_USER=recipe_user +MARIADB_PASSWORD=byt-ut-mig + +# Auth.js / NextAuth +# Generera med: openssl rand -base64 32 +AUTH_SECRET=byt-ut-mig + +# JWT (NestJS backend) +# Generera med: openssl rand -base64 32 +# OBS: Appen vägrar starta om detta saknas. +JWT_SECRET=byt-ut-mig + # Mistral AI # Hämtas från: https://console.mistral.ai/ MISTRAL_API_KEY= FLYER_AI_TIMEOUT_MS=45000 FLYER_AI_RETRIES=2 - -# Publik URL (används av frontend) -NEXT_PUBLIC_APP_URL=https://recept.gynther.se -NEXT_PUBLIC_API_URL=https://recept.gynther.se -# CORS — tillåtna origins för backend-API (normalt samma som APP_URL) -ALLOWED_ORIGIN=https://recept.gynther.se - -# Importer integration -IMPORTER_SERVICE_URL=http://importer-api:3001 -RECEIPT_TRACE_DECISIONS=0 - -# Optional webhook hardening -GITEA_WEBHOOK_SECRET= - -# Bootstrap-användare (skapas/uppdateras vid appstart) -ADMIN_NADMIN_PASSWORD=byt-ut-mig -ADMIN_PADMIN_PASSWORD=byt-ut-mig -SEED_USER1_PASSWORD=byt-ut-mig -SEED_USER2_PASSWORD=byt-ut-mig +FLYER_AI_DEBUG=0 +# Linux-container: /app/debug, lokalt: ./debug +FLYER_AI_DEBUG_DIR=/app/debug + +# Publik URL (används av frontend) +NEXT_PUBLIC_APP_URL=https://recept.gynther.se +NEXT_PUBLIC_API_URL=https://recept.gynther.se +# CORS — tillåtna origins för backend-API (normalt samma som APP_URL) +ALLOWED_ORIGIN=https://recept.gynther.se + +# Importer integration +IMPORTER_SERVICE_URL=http://importer-api:3001 +RECEIPT_TRACE_DECISIONS=0 + +# Optional webhook hardening +GITEA_WEBHOOK_SECRET= + +# Bootstrap-användare (skapas/uppdateras vid appstart) +ADMIN_NADMIN_PASSWORD=byt-ut-mig +ADMIN_PADMIN_PASSWORD=byt-ut-mig +SEED_USER1_PASSWORD=byt-ut-mig +SEED_USER2_PASSWORD=byt-ut-mig diff --git a/backend/package-lock.json b/backend/package-lock.json index c5f0039e..126197d2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -39,7 +39,7 @@ "@types/express": "^5.0.5", "@types/jest": "^29.5.14", "@types/multer": "^1.4.12", - "@types/node": "^22.15.29", + "@types/node": "^22.19.19", "@types/passport-jwt": "^4.0.1", "@types/pdf-parse": "^1.1.5", "@types/supertest": "^7.2.0", @@ -2783,9 +2783,9 @@ } }, "node_modules/@types/node": { - "version": "22.19.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", - "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" diff --git a/backend/package.json b/backend/package.json index ff05bc7a..9c2e458d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -49,7 +49,7 @@ "@types/express": "^5.0.5", "@types/jest": "^29.5.14", "@types/multer": "^1.4.12", - "@types/node": "^22.15.29", + "@types/node": "^22.19.19", "@types/passport-jwt": "^4.0.1", "@types/pdf-parse": "^1.1.5", "@types/supertest": "^7.2.0", diff --git a/backend/src/flyer-import/services/ai-flyer-parser.service.ts b/backend/src/flyer-import/services/ai-flyer-parser.service.ts index 1a888182..7828f234 100644 --- a/backend/src/flyer-import/services/ai-flyer-parser.service.ts +++ b/backend/src/flyer-import/services/ai-flyer-parser.service.ts @@ -4,6 +4,8 @@ import { Logger, ServiceUnavailableException, } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; export interface AiFlyerParseResult { rawName: string; @@ -26,6 +28,8 @@ export class AiFlyerParserService { private readonly chunkSizeChars: number; private readonly chunkOverlapChars: number; private readonly maxChunks: number; + private readonly debugEnabled: boolean; + private readonly debugDirectory: string; private mistral: any; private apiKey: string; @@ -40,6 +44,8 @@ export class AiFlyerParserService { this.chunkSizeChars = this.readPositiveIntEnv('FLYER_AI_CHUNK_SIZE_CHARS', 3_000); this.chunkOverlapChars = this.readPositiveIntEnv('FLYER_AI_CHUNK_OVERLAP_CHARS', 300); this.maxChunks = this.readPositiveIntEnv('FLYER_AI_MAX_CHUNKS', 8); + this.debugEnabled = this.readBooleanEnv('FLYER_AI_DEBUG', false); + this.debugDirectory = process.env.FLYER_AI_DEBUG_DIR?.trim() || path.join(process.cwd(), 'debug'); } private async getClient(): Promise { @@ -60,19 +66,61 @@ export class AiFlyerParserService { throw new BadRequestException('Flyer-texten är tom. Kan inte fortsätta.'); } + const debugSession = this.createDebugSession('AI-flyerimporter'); + try { + if (debugSession) { + await this.writeDebugFile( + debugSession, + `${debugSession.baseName}-input.txt`, + text, + ); + } + const client = await this.getClient(); const chunks = this.splitIntoChunks(text); this.logger.debug(`Parsing flyer text in ${chunks.length} chunk(s)`); + if (debugSession) { + await this.writeDebugFile( + debugSession, + `${debugSession.baseName}-chunks.json`, + JSON.stringify(chunks, null, 2), + ); + } + const allItems: AiFlyerParseResult[] = []; for (let i = 0; i < chunks.length; i++) { - const chunkItems = await this.parseChunkWithRetry(client, chunks[i], i + 1, chunks.length); + const chunkItems = await this.parseChunkWithRetry( + client, + chunks[i], + i + 1, + chunks.length, + debugSession, + ); allItems.push(...chunkItems); } - return this.dedupeItems(allItems); + const deduped = this.dedupeItems(allItems); + + if (debugSession) { + await this.writeDebugFile( + debugSession, + `${debugSession.baseName}-result.json`, + JSON.stringify(deduped, null, 2), + ); + } + + return deduped; } catch (err) { + if (debugSession) { + await this.writeDebugFile( + debugSession, + `${debugSession.baseName}-error.txt`, + this.toErrorMessage(err), + ); + } + if (err instanceof SyntaxError) { this.logger.error(`JSON parse error: ${String(err)}`); throw new BadRequestException('AI returnerade ogiltigt JSON. Försök igen.'); @@ -155,11 +203,9 @@ Exempel på utdata: * Rensa AI-svaret för att kunna parse som JSON. */ private sanitizeJsonResponse(content: string): string { - // Ta bort markdown fences let cleaned = content.replace(/```json\n?/g, '').replace(/```\n?/g, ''); cleaned = cleaned.trim(); - // Försök att extrahera JSON om det finns omgivande text const jsonMatch = cleaned.match(/\[[\s\S]*\]/); if (jsonMatch) { cleaned = jsonMatch[0]; @@ -205,14 +251,11 @@ Exempel på utdata: comparisonPrice: toNumber(item.comparisonPrice), comparisonUnit: toString(item.comparisonUnit), offerText: toString(item.offer) || (toArray(item.offer).join(' ') || null), - confidence: 0.85, // AI-parse får medelhög confidence + confidence: 0.85, reasonCodes: ['ai_parsed'], }; } - /** - * Enkel normalisering av produktnamn. - */ private normalizeName(name: string): string { return name .toLowerCase() @@ -247,6 +290,7 @@ Exempel på utdata: chunkText: string, chunkIndex: number, totalChunks: number, + debugSession: { dirPath: string; baseName: string } | null, ): Promise { const textWindows = [3000, 2200, 1600]; const attempts = Math.max(1, Math.min(this.maxRetries + 1, textWindows.length)); @@ -261,6 +305,14 @@ Exempel på utdata: `Sending request to Mistral Tiny (chunk ${chunkIndex}/${totalChunks}, attempt ${i + 1}/${attempts}, timeout=${this.timeoutMs}ms, textWindow=${window})`, ); + if (debugSession) { + await this.writeDebugFile( + debugSession, + `${debugSession.baseName}-chunk-${chunkIndex}-attempt-${i + 1}-prompt.txt`, + prompt, + ); + } + const response = await this.withTimeout( client.chat({ model: 'mistral-tiny', @@ -278,6 +330,14 @@ Exempel på utdata: this.logger.debug(`Mistral response length: ${content.length} chars`); + if (debugSession) { + await this.writeDebugFile( + debugSession, + `${debugSession.baseName}-chunk-${chunkIndex}-attempt-${i + 1}-response.txt`, + String(content), + ); + } + const jsonString = this.sanitizeJsonResponse(content); const items = JSON.parse(jsonString) as Array>; @@ -288,6 +348,13 @@ Exempel på utdata: return items.map((item, idx) => this.normalizeAiItem(item, idx)); } catch (attemptErr) { lastError = attemptErr; + if (debugSession) { + await this.writeDebugFile( + debugSession, + `${debugSession.baseName}-chunk-${chunkIndex}-attempt-${i + 1}-error.txt`, + this.toErrorMessage(attemptErr), + ); + } if (!this.isRetryableError(attemptErr) || i === attempts - 1) { throw attemptErr; } @@ -332,6 +399,44 @@ Exempel på utdata: return parsed; } + private readBooleanEnv(key: string, fallback: boolean): boolean { + const raw = process.env[key]; + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase()); + } + + private createDebugSession(prefix: string): { dirPath: string; baseName: string } | null { + if (!this.debugEnabled) return null; + const now = new Date(); + const y = String(now.getFullYear()).slice(-2); + const m = String(now.getMonth() + 1).padStart(2, '0'); + const d = String(now.getDate()).padStart(2, '0'); + const hh = String(now.getHours()).padStart(2, '0'); + const mm = String(now.getMinutes()).padStart(2, '0'); + const ss = String(now.getSeconds()).padStart(2, '0'); + const datePart = `${y}${m}${d}`; + const timePart = `${hh}${mm}${ss}`; + const baseName = `${prefix}-${datePart}-${timePart}`; + const dirPath = path.join(this.debugDirectory, baseName); + return { dirPath, baseName }; + } + + private async writeDebugFile( + debugSession: { dirPath: string; baseName: string } | null, + filename: string, + content: string, + ): Promise { + if (!debugSession) return; + + try { + await fs.promises.mkdir(debugSession.dirPath, { recursive: true }); + const filePath = path.join(debugSession.dirPath, filename); + await fs.promises.writeFile(filePath, content, 'utf8'); + } catch (err) { + this.logger.warn(`Failed to write flyer debug file ${filename}: ${this.toErrorMessage(err)}`); + } + } + private isRetryableError(err: unknown): boolean { if (err instanceof ServiceUnavailableException) return true; const message = this.toErrorMessage(err).toLowerCase(); diff --git a/compose.yml b/compose.yml index 9b657693..8deb851a 100644 --- a/compose.yml +++ b/compose.yml @@ -12,6 +12,8 @@ services: MISTRAL_API_KEY: "${MISTRAL_API_KEY:-}" FLYER_AI_TIMEOUT_MS: "${FLYER_AI_TIMEOUT_MS:-30000}" FLYER_AI_RETRIES: "${FLYER_AI_RETRIES:-2}" + FLYER_AI_DEBUG: "${FLYER_AI_DEBUG:-0}" + FLYER_AI_DEBUG_DIR: "${FLYER_AI_DEBUG_DIR:-/app/debug}" JWT_SECRET: "${JWT_SECRET}" ALLOWED_ORIGIN: "${NEXT_PUBLIC_APP_URL}" ADMIN_NADMIN_PASSWORD: "${ADMIN_NADMIN_PASSWORD}" diff --git a/flutter/lib/features/import/data/import_repository.dart b/flutter/lib/features/import/data/import_repository.dart index c4a87763..1da9bedd 100644 --- a/flutter/lib/features/import/data/import_repository.dart +++ b/flutter/lib/features/import/data/import_repository.dart @@ -1,148 +1,148 @@ -import '../domain/parsed_receipt_item.dart'; -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:http/http.dart' as http; -import 'dart:developer' as developer; - +import '../domain/parsed_receipt_item.dart'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'dart:developer' as developer; + import '../../../core/api/api_paths.dart'; import '../../../core/api/api_exception.dart'; import '../domain/flyer_import_result.dart'; import '../domain/help_text_content.dart'; import '../domain/quick_import_result.dart'; - -/// Handles communication with the quick-import API endpoint. -/// -/// Two modes: -/// • [importFile] — multipart upload (PDF / image bytes, max 10 MB). -/// • [importUrl] — JSON body with `{ input: url }`. -class ImportRepository { - final http.Client _client; - final String _baseUrl; - - ImportRepository({http.Client? client}) - : _client = client ?? http.Client(), - _baseUrl = const String.fromEnvironment( - 'API_BASE_URL', - defaultValue: '/api', - ); - - Future fetchHelpTextByKey( - String key, { - String? token, - }) async { - final uri = Uri.parse('$_baseUrl${HelpTextApiPaths.byKey(key)}'); - final response = await _client.get( - uri, - headers: { - 'Content-Type': 'application/json', - if (token != null) 'Authorization': 'Bearer $token', - }, - ); - - if (response.statusCode < 200 || response.statusCode >= 300) { - throw ApiException( - type: _mapStatusCodeToErrorType(response.statusCode), - message: 'Kunde inte hämta hjälptext: ${response.body}', - statusCode: response.statusCode, - ); - } - - final parsed = _parseResponse(response); - if (parsed is! Map) { - throw ApiException( - type: ApiErrorType.unknown, - message: 'Felaktigt svar vid hämtning av hjälptext.', - ); - } - - return HelpTextContent.fromJson(parsed); - } - - /// Upload a receipt file for parsing (Fas 6b). - /// Returns a list of parsed receipt items. + +/// Handles communication with the quick-import API endpoint. +/// +/// Two modes: +/// • [importFile] — multipart upload (PDF / image bytes, max 10 MB). +/// • [importUrl] — JSON body with `{ input: url }`. +class ImportRepository { + final http.Client _client; + final String _baseUrl; + + ImportRepository({http.Client? client}) + : _client = client ?? http.Client(), + _baseUrl = const String.fromEnvironment( + 'API_BASE_URL', + defaultValue: '/api', + ); + + Future fetchHelpTextByKey( + String key, { + String? token, + }) async { + final uri = Uri.parse('$_baseUrl${HelpTextApiPaths.byKey(key)}'); + final response = await _client.get( + uri, + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Kunde inte hämta hjälptext: ${response.body}', + statusCode: response.statusCode, + ); + } + + final parsed = _parseResponse(response); + if (parsed is! Map) { + throw ApiException( + type: ApiErrorType.unknown, + message: 'Felaktigt svar vid hämtning av hjälptext.', + ); + } + + return HelpTextContent.fromJson(parsed); + } + + /// Upload a receipt file for parsing (Fas 6b). + /// Returns a list of parsed receipt items. Future> importReceiptFile({ - required Uint8List bytes, - required String filename, - String? token, - }) async { - try { - developer.log('Starting receipt import for file: $filename', name: 'ImportRepository'); - - final uri = Uri.parse('$_baseUrl/receipt-import'); - final request = http.MultipartRequest('POST', uri); - - if (token != null) { - request.headers['Authorization'] = 'Bearer $token'; - } - - request.files.add( - http.MultipartFile.fromBytes('file', bytes, filename: filename), - ); - - developer.log('Sending request to: ${request.url}', name: 'ImportRepository'); - - final streamed = await _client.send(request).timeout( - const Duration(seconds: 120), - onTimeout: () { - developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout'); - throw ApiException( - type: ApiErrorType.network, - message: 'Importen tog för lång tid. Försök igen.', - ); - }, - ); - - final response = await http.Response.fromStream(streamed); - developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository'); - - if (response.statusCode < 200 || response.statusCode >= 300) { - developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error'); - throw ApiException( - type: _mapStatusCodeToErrorType(response.statusCode), - message: 'Fel vid import: ${response.body}', - statusCode: response.statusCode, - ); - } - - final parsed = _parseResponse(response); - developer.log('Response parsed, keys: ${parsed is Map ? parsed.keys.toList() : "list[${(parsed as List?)?.length}]"}', name: 'ImportRepository'); - - // Backend returns ParsedReceiptItem[] directly as a JSON array - if (parsed is List) { - final items = parsed.map((e) => ParsedReceiptItem.fromJson(e as Map)).toList(); - developer.log('Successfully parsed ${items.length} items from array', name: 'ImportRepository'); - return items; - } - - if (parsed is Map) { - // Wrapped format: { items: [...] } - if (parsed.containsKey('items')) { - final items = (parsed['items'] as List?)?.map((e) => ParsedReceiptItem.fromJson(e as Map)).toList(); - if (items != null) { - developer.log('Successfully parsed ${items.length} items', name: 'ImportRepository'); - return items; - } - } - // Markdown-based receipt fallback — parse lines into items - if (parsed.containsKey('markdown')) { - developer.log('Got markdown receipt, parsing lines into items', name: 'ImportRepository'); - final lines = (parsed['markdown'] as String).split('\n'); - final items = lines - .where((l) => l.trim().isNotEmpty && !l.startsWith('#') && !l.startsWith('=') && !l.startsWith('-')) - .map((l) => ParsedReceiptItem(rawName: l.trim())) - .toList(); - developer.log('Extracted ${items.length} lines as items', name: 'ImportRepository'); - return items; - } - } - - developer.log('Invalid response format: ${response.body}', name: 'ImportRepository', error: 'Invalid Data'); - throw ApiException(type: ApiErrorType.unknown, message: 'Felaktigt svar från servern.'); - } catch (e) { - developer.log('Exception during receipt import: $e', name: 'ImportRepository', error: e); - rethrow; - } + required Uint8List bytes, + required String filename, + String? token, + }) async { + try { + developer.log('Starting receipt import for file: $filename', name: 'ImportRepository'); + + final uri = Uri.parse('$_baseUrl/receipt-import'); + final request = http.MultipartRequest('POST', uri); + + if (token != null) { + request.headers['Authorization'] = 'Bearer $token'; + } + + request.files.add( + http.MultipartFile.fromBytes('file', bytes, filename: filename), + ); + + developer.log('Sending request to: ${request.url}', name: 'ImportRepository'); + + final streamed = await _client.send(request).timeout( + const Duration(seconds: 120), + onTimeout: () { + developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout'); + throw ApiException( + type: ApiErrorType.network, + message: 'Importen tog för lång tid. Försök igen.', + ); + }, + ); + + final response = await http.Response.fromStream(streamed); + developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository'); + + if (response.statusCode < 200 || response.statusCode >= 300) { + developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error'); + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Fel vid import: ${response.body}', + statusCode: response.statusCode, + ); + } + + final parsed = _parseResponse(response); + developer.log('Response parsed, keys: ${parsed is Map ? parsed.keys.toList() : "list[${(parsed as List?)?.length}]"}', name: 'ImportRepository'); + + // Backend returns ParsedReceiptItem[] directly as a JSON array + if (parsed is List) { + final items = parsed.map((e) => ParsedReceiptItem.fromJson(e as Map)).toList(); + developer.log('Successfully parsed ${items.length} items from array', name: 'ImportRepository'); + return items; + } + + if (parsed is Map) { + // Wrapped format: { items: [...] } + if (parsed.containsKey('items')) { + final items = (parsed['items'] as List?)?.map((e) => ParsedReceiptItem.fromJson(e as Map)).toList(); + if (items != null) { + developer.log('Successfully parsed ${items.length} items', name: 'ImportRepository'); + return items; + } + } + // Markdown-based receipt fallback — parse lines into items + if (parsed.containsKey('markdown')) { + developer.log('Got markdown receipt, parsing lines into items', name: 'ImportRepository'); + final lines = (parsed['markdown'] as String).split('\n'); + final items = lines + .where((l) => l.trim().isNotEmpty && !l.startsWith('#') && !l.startsWith('=') && !l.startsWith('-')) + .map((l) => ParsedReceiptItem(rawName: l.trim())) + .toList(); + developer.log('Extracted ${items.length} lines as items', name: 'ImportRepository'); + return items; + } + } + + developer.log('Invalid response format: ${response.body}', name: 'ImportRepository', error: 'Invalid Data'); + throw ApiException(type: ApiErrorType.unknown, message: 'Felaktigt svar från servern.'); + } catch (e) { + developer.log('Exception during receipt import: $e', name: 'ImportRepository', error: e); + rethrow; + } } Future importFlyerFile({ @@ -162,7 +162,7 @@ class ImportRepository { ); final streamed = await _client.send(request).timeout( - const Duration(seconds: 120), + const Duration(seconds: 300), onTimeout: () { throw ApiException( type: ApiErrorType.network, @@ -220,225 +220,225 @@ class ImportRepository { if (parsed is! List) return const []; return parsed.cast>(); } - - /// Upload a file (PDF or image) for recipe extraction. - /// - /// [bytes] — raw file bytes from file_picker. - /// [filename] — original filename (used for MIME detection on the server). - /// [token] — JWT bearer token. - Future importFile({ - required Uint8List bytes, - required String filename, - String? token, - }) async { - try { - developer.log('Starting file import for file: $filename', name: 'ImportRepository'); - - final uri = Uri.parse('$_baseUrl/quick-import'); - final request = http.MultipartRequest('POST', uri); - - if (token != null) { - request.headers['Authorization'] = 'Bearer $token'; - } - - request.files.add( - http.MultipartFile.fromBytes('file', bytes, filename: filename), - ); - - developer.log('Sending request to: ${request.url}', name: 'ImportRepository'); - - final streamed = await _client.send(request).timeout( - const Duration(seconds: 120), - onTimeout: () { - developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout'); - throw ApiException( - type: ApiErrorType.network, - message: 'Importen tog för lång tid. Försök igen.', - ); - }, - ); - - final response = await http.Response.fromStream(streamed); - developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository'); - - if (response.statusCode != 200) { - developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error'); - throw ApiException( - type: _mapStatusCodeToErrorType(response.statusCode), - message: 'Fel vid import: ${response.body}', - statusCode: response.statusCode, - ); - } - - developer.log('Successfully imported file', name: 'ImportRepository'); - return QuickImportResult.fromJson(_parseResponse(response)); - } catch (e) { - developer.log('Exception during file import: $e', name: 'ImportRepository', error: e); - rethrow; - } - } - - /// Import a recipe from a URL. - Future importUrl({ - required String url, - String? token, - }) async { - try { - developer.log('Starting URL import for: $url', name: 'ImportRepository'); - - final uri = Uri.parse('$_baseUrl/quick-import'); - final response = await _client - .post( - uri, - headers: { - 'Content-Type': 'application/json', - if (token != null) 'Authorization': 'Bearer $token', - }, - body: jsonEncode({'input': url}), - ) - .timeout( - const Duration(seconds: 120), - onTimeout: () { - developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout'); - throw ApiException( - type: ApiErrorType.network, - message: 'Importen tog för lång tid. Försök igen.', - ); - }, - ); - - developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository'); - - if (response.statusCode != 200) { - developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error'); - throw ApiException( - type: _mapStatusCodeToErrorType(response.statusCode), - message: 'Fel vid import: ${response.body}', - statusCode: response.statusCode, - ); - } - - developer.log('Successfully imported URL', name: 'ImportRepository'); - return QuickImportResult.fromJson(_parseResponse(response)); - } catch (e) { - developer.log('Exception during URL import: $e', name: 'ImportRepository', error: e); - rethrow; - } - } - - Future upsertUnitMapping({ - required int productId, - required String originalUnit, - required String preferredUnit, - String? token, - }) async { - final normalizedOriginalUnit = originalUnit.trim().toLowerCase(); - final normalizedPreferredUnit = preferredUnit.trim().toLowerCase(); - - if (normalizedOriginalUnit.isEmpty || normalizedPreferredUnit.isEmpty) { - return; - } - if (normalizedOriginalUnit == normalizedPreferredUnit) { - return; - } - - final uri = Uri.parse('$_baseUrl${ReceiptImportApiPaths.unitMappings}'); - final response = await _client.post( - uri, - headers: { - 'Content-Type': 'application/json', - if (token != null) 'Authorization': 'Bearer $token', - }, - body: jsonEncode({ - 'productId': productId, - 'originalUnit': normalizedOriginalUnit, - 'preferredUnit': normalizedPreferredUnit, - }), - ); - - if (response.statusCode < 200 || response.statusCode >= 300) { - throw ApiException( - type: _mapStatusCodeToErrorType(response.statusCode), - message: 'Kunde inte spara enhetsmappning: ${response.body}', - statusCode: response.statusCode, - ); - } - } - - /// Save receipt items in a single atomic transaction. - /// - /// This endpoint handles: - /// - Creating/validating products - /// - Creating/merging inventory items - /// - Adding to pantry - /// - Learning aliases - /// - Learning unit mappings - Future> saveReceipt({ - required List> items, - String? token, - }) async { - try { - developer.log('Starting saveReceipt with ${items.length} items', name: 'ImportRepository'); - - final uri = Uri.parse('$_baseUrl/receipt-import/save'); - final response = await _client.post( - uri, - headers: { - 'Content-Type': 'application/json', - if (token != null) 'Authorization': 'Bearer $token', - }, - body: jsonEncode({ - 'items': items, - }), - ).timeout( - const Duration(seconds: 60), - onTimeout: () { - developer.log('saveReceipt request timed out', name: 'ImportRepository', error: 'Timeout'); - throw ApiException( - type: ApiErrorType.network, - message: 'Sparandet tok för lång tid. Försök igen.', - ); - }, - ); - - developer.log('saveReceipt response status: ${response.statusCode}', name: 'ImportRepository'); - - if (response.statusCode < 200 || response.statusCode >= 300) { - developer.log('saveReceipt error: ${response.body}', name: 'ImportRepository', error: 'HTTP Error'); - throw ApiException( - type: _mapStatusCodeToErrorType(response.statusCode), - message: 'Fel vid sparande: ${response.body}', - statusCode: response.statusCode, - ); - } - + + /// Upload a file (PDF or image) for recipe extraction. + /// + /// [bytes] — raw file bytes from file_picker. + /// [filename] — original filename (used for MIME detection on the server). + /// [token] — JWT bearer token. + Future importFile({ + required Uint8List bytes, + required String filename, + String? token, + }) async { + try { + developer.log('Starting file import for file: $filename', name: 'ImportRepository'); + + final uri = Uri.parse('$_baseUrl/quick-import'); + final request = http.MultipartRequest('POST', uri); + + if (token != null) { + request.headers['Authorization'] = 'Bearer $token'; + } + + request.files.add( + http.MultipartFile.fromBytes('file', bytes, filename: filename), + ); + + developer.log('Sending request to: ${request.url}', name: 'ImportRepository'); + + final streamed = await _client.send(request).timeout( + const Duration(seconds: 120), + onTimeout: () { + developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout'); + throw ApiException( + type: ApiErrorType.network, + message: 'Importen tog för lång tid. Försök igen.', + ); + }, + ); + + final response = await http.Response.fromStream(streamed); + developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository'); + + if (response.statusCode != 200) { + developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error'); + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Fel vid import: ${response.body}', + statusCode: response.statusCode, + ); + } + + developer.log('Successfully imported file', name: 'ImportRepository'); + return QuickImportResult.fromJson(_parseResponse(response)); + } catch (e) { + developer.log('Exception during file import: $e', name: 'ImportRepository', error: e); + rethrow; + } + } + + /// Import a recipe from a URL. + Future importUrl({ + required String url, + String? token, + }) async { + try { + developer.log('Starting URL import for: $url', name: 'ImportRepository'); + + final uri = Uri.parse('$_baseUrl/quick-import'); + final response = await _client + .post( + uri, + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + body: jsonEncode({'input': url}), + ) + .timeout( + const Duration(seconds: 120), + onTimeout: () { + developer.log('Request timed out', name: 'ImportRepository', error: 'Timeout'); + throw ApiException( + type: ApiErrorType.network, + message: 'Importen tog för lång tid. Försök igen.', + ); + }, + ); + + developer.log('Received response with status: ${response.statusCode}', name: 'ImportRepository'); + + if (response.statusCode != 200) { + developer.log('Error response: ${response.body}', name: 'ImportRepository', error: 'HTTP Error'); + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Fel vid import: ${response.body}', + statusCode: response.statusCode, + ); + } + + developer.log('Successfully imported URL', name: 'ImportRepository'); + return QuickImportResult.fromJson(_parseResponse(response)); + } catch (e) { + developer.log('Exception during URL import: $e', name: 'ImportRepository', error: e); + rethrow; + } + } + + Future upsertUnitMapping({ + required int productId, + required String originalUnit, + required String preferredUnit, + String? token, + }) async { + final normalizedOriginalUnit = originalUnit.trim().toLowerCase(); + final normalizedPreferredUnit = preferredUnit.trim().toLowerCase(); + + if (normalizedOriginalUnit.isEmpty || normalizedPreferredUnit.isEmpty) { + return; + } + if (normalizedOriginalUnit == normalizedPreferredUnit) { + return; + } + + final uri = Uri.parse('$_baseUrl${ReceiptImportApiPaths.unitMappings}'); + final response = await _client.post( + uri, + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + body: jsonEncode({ + 'productId': productId, + 'originalUnit': normalizedOriginalUnit, + 'preferredUnit': normalizedPreferredUnit, + }), + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Kunde inte spara enhetsmappning: ${response.body}', + statusCode: response.statusCode, + ); + } + } + + /// Save receipt items in a single atomic transaction. + /// + /// This endpoint handles: + /// - Creating/validating products + /// - Creating/merging inventory items + /// - Adding to pantry + /// - Learning aliases + /// - Learning unit mappings + Future> saveReceipt({ + required List> items, + String? token, + }) async { + try { + developer.log('Starting saveReceipt with ${items.length} items', name: 'ImportRepository'); + + final uri = Uri.parse('$_baseUrl/receipt-import/save'); + final response = await _client.post( + uri, + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + body: jsonEncode({ + 'items': items, + }), + ).timeout( + const Duration(seconds: 60), + onTimeout: () { + developer.log('saveReceipt request timed out', name: 'ImportRepository', error: 'Timeout'); + throw ApiException( + type: ApiErrorType.network, + message: 'Sparandet tok för lång tid. Försök igen.', + ); + }, + ); + + developer.log('saveReceipt response status: ${response.statusCode}', name: 'ImportRepository'); + + if (response.statusCode < 200 || response.statusCode >= 300) { + developer.log('saveReceipt error: ${response.body}', name: 'ImportRepository', error: 'HTTP Error'); + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Fel vid sparande: ${response.body}', + statusCode: response.statusCode, + ); + } + final result = _parseResponse(response) as Map; - developer.log('saveReceipt succeeded: ${result['created']} created, ${result['merged']} merged', name: 'ImportRepository'); - return result; - } catch (e) { - developer.log('Exception during saveReceipt: $e', name: 'ImportRepository', error: e); - rethrow; - } - } - - /// Helper method to map HTTP status codes to [ApiErrorType]. - ApiErrorType _mapStatusCodeToErrorType(int statusCode) { - if (statusCode == 401) return ApiErrorType.unauthorized; - if (statusCode == 403) return ApiErrorType.forbidden; - if (statusCode >= 500) return ApiErrorType.server; - return ApiErrorType.unknown; - } - - /// Parse the HTTP response and handle potential errors. - dynamic _parseResponse(http.Response response) { - try { - return jsonDecode(response.body); - } catch (e) { - developer.log('Failed to parse response: ${response.body}', name: 'ImportRepository', error: e); - throw ApiException( - type: ApiErrorType.unknown, - message: 'Felaktigt svar från servern: ${response.body}', - ); - } - } -} + developer.log('saveReceipt succeeded: ${result['created']} created, ${result['merged']} merged', name: 'ImportRepository'); + return result; + } catch (e) { + developer.log('Exception during saveReceipt: $e', name: 'ImportRepository', error: e); + rethrow; + } + } + + /// Helper method to map HTTP status codes to [ApiErrorType]. + ApiErrorType _mapStatusCodeToErrorType(int statusCode) { + if (statusCode == 401) return ApiErrorType.unauthorized; + if (statusCode == 403) return ApiErrorType.forbidden; + if (statusCode >= 500) return ApiErrorType.server; + return ApiErrorType.unknown; + } + + /// Parse the HTTP response and handle potential errors. + dynamic _parseResponse(http.Response response) { + try { + return jsonDecode(response.body); + } catch (e) { + developer.log('Failed to parse response: ${response.body}', name: 'ImportRepository', error: e); + throw ApiException( + type: ApiErrorType.unknown, + message: 'Felaktigt svar från servern: ${response.body}', + ); + } + } +}