From 839487567a258a42c7cabee6b43f5115ec5399d3 Mon Sep 17 00:00:00 2001 From: mohamad Date: Wed, 2 Apr 2025 23:54:43 +0200 Subject: [PATCH] end of phase 5 --- be/app/api/v1/api.py | 2 + be/app/api/v1/endpoints/ocr.py | 108 ++++ be/app/config.py | 13 +- be/app/core/gemini.py | 154 ++++++ be/app/core/test_gemini.py | 83 +++ be/app/schemas/ocr.py | 6 + be/requirements.txt | 3 +- fe/src/lib/apiClient.ts | 165 ++---- fe/src/lib/components/ImageOcrInput.svelte | 234 ++++++++ fe/src/lib/components/OcrReview.svelte | 165 ++++++ fe/src/lib/schemas/ocr.ts | 8 + .../routes/(app)/lists/[listId]/+page.svelte | 510 +++++++++++++----- 12 files changed, 1199 insertions(+), 252 deletions(-) create mode 100644 be/app/api/v1/endpoints/ocr.py create mode 100644 be/app/core/gemini.py create mode 100644 be/app/core/test_gemini.py create mode 100644 be/app/schemas/ocr.py create mode 100644 fe/src/lib/components/ImageOcrInput.svelte create mode 100644 fe/src/lib/components/OcrReview.svelte create mode 100644 fe/src/lib/schemas/ocr.ts diff --git a/be/app/api/v1/api.py b/be/app/api/v1/api.py index 84afb76..640c569 100644 --- a/be/app/api/v1/api.py +++ b/be/app/api/v1/api.py @@ -8,6 +8,7 @@ from app.api.v1.endpoints import groups from app.api.v1.endpoints import invites from app.api.v1.endpoints import lists from app.api.v1.endpoints import items +from app.api.v1.endpoints import ocr api_router_v1 = APIRouter() @@ -18,5 +19,6 @@ api_router_v1.include_router(groups.router, prefix="/groups", tags=["Groups"]) api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"]) api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"]) api_router_v1.include_router(items.router, tags=["Items"]) +api_router_v1.include_router(ocr.router, prefix="/ocr", tags=["OCR"]) # Add other v1 endpoint routers here later # e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"]) \ No newline at end of file diff --git a/be/app/api/v1/endpoints/ocr.py b/be/app/api/v1/endpoints/ocr.py new file mode 100644 index 0000000..166fa6c --- /dev/null +++ b/be/app/api/v1/endpoints/ocr.py @@ -0,0 +1,108 @@ +# app/api/v1/endpoints/ocr.py +import logging +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File +from google.api_core import exceptions as google_exceptions # Import Google API exceptions + +from app.api.dependencies import get_current_user +from app.models import User as UserModel +from app.schemas.ocr import OcrExtractResponse +from app.core.gemini import extract_items_from_image_gemini, gemini_initialization_error # Import helper + +logger = logging.getLogger(__name__) +router = APIRouter() + +# Allowed image MIME types +ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"] +MAX_FILE_SIZE_MB = 10 # Set a reasonable max file size + +@router.post( + "/extract-items", + response_model=OcrExtractResponse, + summary="Extract List Items via OCR (Gemini)", + tags=["OCR"] +) +async def ocr_extract_items( + current_user: UserModel = Depends(get_current_user), + # Use File(...) for better metadata handling than UploadFile directly as type hint + image_file: UploadFile = File(..., description="Image file (JPEG, PNG, WEBP) of the shopping list or receipt."), +): + """ + Accepts an image upload, sends it to Gemini Flash with a prompt + to extract shopping list items, and returns the parsed items. + """ + # Check if Gemini client initialized correctly + if gemini_initialization_error: + logger.error("OCR endpoint called but Gemini client failed to initialize.") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"OCR service unavailable: {gemini_initialization_error}" + ) + + logger.info(f"User {current_user.email} uploading image '{image_file.filename}' for OCR extraction.") + + # --- File Validation --- + if image_file.content_type not in ALLOWED_IMAGE_TYPES: + logger.warning(f"Invalid file type uploaded by {current_user.email}: {image_file.content_type}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid file type. Allowed types: {', '.join(ALLOWED_IMAGE_TYPES)}", + ) + + # Simple size check (FastAPI/Starlette might handle larger limits via config) + # Read content first to get size accurately + contents = await image_file.read() + if len(contents) > MAX_FILE_SIZE_MB * 1024 * 1024: + logger.warning(f"File too large uploaded by {current_user.email}: {len(contents)} bytes") + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"File size exceeds limit of {MAX_FILE_SIZE_MB} MB.", + ) + # --- End File Validation --- + + try: + # Call the Gemini helper function + extracted_items = await extract_items_from_image_gemini(image_bytes=contents) + + logger.info(f"Successfully extracted {len(extracted_items)} items for user {current_user.email}.") + return OcrExtractResponse(extracted_items=extracted_items) + + except ValueError as e: + # Handle errors from Gemini processing (blocked, empty response, etc.) + logger.warning(f"Gemini processing error for user {current_user.email}: {e}") + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, # Or 400 Bad Request? + detail=f"Could not extract items from image: {e}", + ) + except google_exceptions.ResourceExhausted as e: + # Specific handling for quota errors + logger.error(f"Gemini Quota Exceeded for user {current_user.email}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="OCR service quota exceeded. Please try again later.", + ) + except google_exceptions.GoogleAPIError as e: + # Handle other Google API errors (e.g., invalid key, permissions) + logger.error(f"Gemini API Error for user {current_user.email}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"OCR service error: {e}", + ) + except RuntimeError as e: + # Catch initialization errors from get_gemini_client() + logger.error(f"Gemini client runtime error during OCR request: {e}") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"OCR service configuration error: {e}" + ) + except Exception as e: + # Catch any other unexpected errors + logger.exception(f"Unexpected error during OCR extraction for user {current_user.email}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An unexpected error occurred during item extraction.", + ) + finally: + # Ensure file handle is closed (UploadFile uses SpooledTemporaryFile) + await image_file.close() \ No newline at end of file diff --git a/be/app/config.py b/be/app/config.py index 6201d25..849bdd4 100644 --- a/be/app/config.py +++ b/be/app/config.py @@ -2,11 +2,14 @@ import os from pydantic_settings import BaseSettings from dotenv import load_dotenv +import logging load_dotenv() +logger = logging.getLogger(__name__) class Settings(BaseSettings): DATABASE_URL: str | None = None + GEMINI_API_KEY: str | None = None # --- JWT Settings --- # Generate a strong secret key using: openssl rand -hex 32 @@ -34,4 +37,12 @@ if settings.SECRET_KEY == "a_very_insecure_default_secret_key_replace_me": print("*" * 80) # Consider raising an error in a production environment check # if os.getenv("ENVIRONMENT") == "production": - # raise ValueError("Default SECRET_KEY is not allowed in production!") \ No newline at end of file + # raise ValueError("Default SECRET_KEY is not allowed in production!") + +if settings.GEMINI_API_KEY is None: + print.error("CRITICAL: GEMINI_API_KEY environment variable not set. Gemini features will be unavailable.") + # You might raise an error here if Gemini is essential for startup + # raise ValueError("GEMINI_API_KEY must be set.") +else: + # Optional: Log partial key for confirmation (avoid logging full key) + logger.info(f"GEMINI_API_KEY loaded (starts with: {settings.GEMINI_API_KEY[:4]}...).") \ No newline at end of file diff --git a/be/app/core/gemini.py b/be/app/core/gemini.py new file mode 100644 index 0000000..2665901 --- /dev/null +++ b/be/app/core/gemini.py @@ -0,0 +1,154 @@ +# app/core/gemini.py +import logging +from typing import List +import google.generativeai as genai +from google.generativeai.types import HarmCategory, HarmBlockThreshold # For safety settings +from google.api_core import exceptions as google_exceptions + +from app.config import settings + +logger = logging.getLogger(__name__) + +# --- Global variable to hold the initialized model client --- +gemini_flash_client = None +gemini_initialization_error = None # Store potential init error + +# --- Configure and Initialize --- +try: + if settings.GEMINI_API_KEY: + genai.configure(api_key=settings.GEMINI_API_KEY) + # Initialize the specific model we want to use + gemini_flash_client = genai.GenerativeModel( + model_name="gemini-2.0-flash", + # Optional: Add default safety settings + # Adjust these based on your expected content and risk tolerance + safety_settings={ + HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, + HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, + HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, + HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, + }, + # Optional: Add default generation config (can be overridden per request) + # generation_config=genai.types.GenerationConfig( + # # candidate_count=1, # Usually default is 1 + # # stop_sequences=["\n"], + # # max_output_tokens=2048, + # # temperature=0.9, # Controls randomness (0=deterministic, >1=more random) + # # top_p=1, + # # top_k=1 + # ) + ) + logger.info("Gemini AI client initialized successfully for model 'gemini-1.5-flash-latest'.") + else: + # Store error if API key is missing + gemini_initialization_error = "GEMINI_API_KEY not configured. Gemini client not initialized." + logger.error(gemini_initialization_error) + +except Exception as e: + # Catch any other unexpected errors during initialization + gemini_initialization_error = f"Failed to initialize Gemini AI client: {e}" + logger.exception(gemini_initialization_error) # Log full traceback + gemini_flash_client = None # Ensure client is None on error + + +# --- Function to get the client (optional, allows checking error) --- +def get_gemini_client(): + """ + Returns the initialized Gemini client instance. + Raises an exception if initialization failed. + """ + if gemini_initialization_error: + raise RuntimeError(f"Gemini client could not be initialized: {gemini_initialization_error}") + if gemini_flash_client is None: + # This case should ideally be covered by the check above, but as a safeguard: + raise RuntimeError("Gemini client is not available (unknown initialization issue).") + return gemini_flash_client + +# Define the prompt as a constant +OCR_ITEM_EXTRACTION_PROMPT = """ +Extract the shopping list items from this image. +List each distinct item on a new line. +Ignore prices, quantities, store names, discounts, taxes, totals, and other non-item text. +Focus only on the names of the products or items to be purchased. +If the image does not appear to be a shopping list or receipt, state that clearly. +Example output for a grocery list: +Milk +Eggs +Bread +Apples +Organic Bananas +""" + +async def extract_items_from_image_gemini(image_bytes: bytes) -> List[str]: + """ + Uses Gemini Flash to extract shopping list items from image bytes. + + Args: + image_bytes: The image content as bytes. + + Returns: + A list of extracted item strings. + + Raises: + RuntimeError: If the Gemini client is not initialized. + google_exceptions.GoogleAPIError: For API call errors (quota, invalid key etc.). + ValueError: If the response is blocked or contains no usable text. + """ + client = get_gemini_client() # Raises RuntimeError if not initialized + + # Prepare image part for multimodal input + image_part = { + "mime_type": "image/jpeg", # Or image/png, image/webp etc. Adjust if needed or detect mime type + "data": image_bytes + } + + # Prepare the full prompt content + prompt_parts = [ + OCR_ITEM_EXTRACTION_PROMPT, # Text prompt first + image_part # Then the image + ] + + logger.info("Sending image to Gemini for item extraction...") + try: + # Make the API call + # Use generate_content_async for async FastAPI + response = await client.generate_content_async(prompt_parts) + + # --- Process the response --- + # Check for safety blocks or lack of content + if not response.candidates or not response.candidates[0].content.parts: + logger.warning("Gemini response blocked or empty.", extra={"response": response}) + # Check finish_reason if available + finish_reason = response.candidates[0].finish_reason if response.candidates else 'UNKNOWN' + safety_ratings = response.candidates[0].safety_ratings if response.candidates else 'N/A' + if finish_reason == 'SAFETY': + raise ValueError(f"Gemini response blocked due to safety settings. Ratings: {safety_ratings}") + else: + raise ValueError(f"Gemini response was empty or incomplete. Finish Reason: {finish_reason}") + + # Extract text - assumes the first part of the first candidate is the text response + raw_text = response.text # response.text is a shortcut for response.candidates[0].content.parts[0].text + logger.info("Received raw text from Gemini.") + # logger.debug(f"Gemini Raw Text:\n{raw_text}") # Optional: Log full response text + + # Parse the text response + items = [] + for line in raw_text.splitlines(): # Split by newline + cleaned_line = line.strip() # Remove leading/trailing whitespace + # Basic filtering: ignore empty lines and potential non-item lines + if cleaned_line and len(cleaned_line) > 1: # Ignore very short lines too? + # Add more sophisticated filtering if needed (e.g., regex, keyword check) + items.append(cleaned_line) + + logger.info(f"Extracted {len(items)} potential items.") + return items + + except google_exceptions.GoogleAPIError as e: + logger.error(f"Gemini API Error: {e}", exc_info=True) + # Re-raise specific Google API errors for endpoint to handle (e.g., quota) + raise e + except Exception as e: + # Catch other unexpected errors during generation or processing + logger.error(f"Unexpected error during Gemini item extraction: {e}", exc_info=True) + # Wrap in a generic ValueError or re-raise + raise ValueError(f"Failed to process image with Gemini: {e}") from e \ No newline at end of file diff --git a/be/app/core/test_gemini.py b/be/app/core/test_gemini.py new file mode 100644 index 0000000..26b32dc --- /dev/null +++ b/be/app/core/test_gemini.py @@ -0,0 +1,83 @@ +# be/tests/core/test_gemini.py +import pytest +import os +from unittest.mock import patch, MagicMock + +# Store original key if exists, then clear it for testing missing key scenario +original_api_key = os.environ.get("GEMINI_API_KEY") +if "GEMINI_API_KEY" in os.environ: + del os.environ["GEMINI_API_KEY"] + +# --- Test Module Import --- +# This forces the module-level initialization code in gemini.py to run +# We need to reload modules because settings might have been cached +from importlib import reload +from app.config import settings as app_settings +from app.core import gemini as gemini_core + +# Reload settings first to ensure GEMINI_API_KEY is None initially +reload(app_settings) +# Reload gemini core to trigger initialization logic with potentially missing key +reload(gemini_core) + + +def test_gemini_initialization_without_key(): + """Verify behavior when GEMINI_API_KEY is not set.""" + # Reload modules again to ensure clean state for this specific test + if "GEMINI_API_KEY" in os.environ: + del os.environ["GEMINI_API_KEY"] + reload(app_settings) + reload(gemini_core) + + assert gemini_core.gemini_flash_client is None + assert gemini_core.gemini_initialization_error is not None + assert "GEMINI_API_KEY not configured" in gemini_core.gemini_initialization_error + + with pytest.raises(RuntimeError, match="GEMINI_API_KEY not configured"): + gemini_core.get_gemini_client() + +@patch('google.generativeai.configure') +@patch('google.generativeai.GenerativeModel') +def test_gemini_initialization_with_key(mock_generative_model: MagicMock, mock_configure: MagicMock): + """Verify initialization logic is called when key is present (using mocks).""" + # Set a dummy key in the environment for this test + test_key = "TEST_API_KEY_123" + os.environ["GEMINI_API_KEY"] = test_key + + # Reload settings and gemini module to pick up the new key + reload(app_settings) + reload(gemini_core) + + # Assertions + mock_configure.assert_called_once_with(api_key=test_key) + mock_generative_model.assert_called_once_with( + model_name="gemini-1.5-flash-latest", + safety_settings=pytest.ANY, # Check safety settings were passed (ANY allows flexibility) + # generation_config=pytest.ANY # Check if you added default generation config + ) + assert gemini_core.gemini_flash_client is not None + assert gemini_core.gemini_initialization_error is None + + # Test get_gemini_client() success path + client = gemini_core.get_gemini_client() + assert client is not None # Should return the mocked client instance + + # Clean up environment variable after test + if original_api_key: + os.environ["GEMINI_API_KEY"] = original_api_key + else: + if "GEMINI_API_KEY" in os.environ: + del os.environ["GEMINI_API_KEY"] + # Reload modules one last time to restore state for other tests + reload(app_settings) + reload(gemini_core) + +# Restore original key after all tests in the module run (if needed) +def teardown_module(module): + if original_api_key: + os.environ["GEMINI_API_KEY"] = original_api_key + else: + if "GEMINI_API_KEY" in os.environ: + del os.environ["GEMINI_API_KEY"] + reload(app_settings) + reload(gemini_core) \ No newline at end of file diff --git a/be/app/schemas/ocr.py b/be/app/schemas/ocr.py new file mode 100644 index 0000000..0b4bba9 --- /dev/null +++ b/be/app/schemas/ocr.py @@ -0,0 +1,6 @@ +# app/schemas/ocr.py +from pydantic import BaseModel +from typing import List + +class OcrExtractResponse(BaseModel): + extracted_items: List[str] # A list of potential item names \ No newline at end of file diff --git a/be/requirements.txt b/be/requirements.txt index c6625a7..c72bf96 100644 --- a/be/requirements.txt +++ b/be/requirements.txt @@ -8,4 +8,5 @@ pydantic-settings>=2.0.0 # For loading settings from .env python-dotenv>=1.0.0 # To load .env file for scripts/alembic passlib[bcrypt]>=1.7.4 python-jose[cryptography]>=3.3.0 -pydantic[email] \ No newline at end of file +pydantic[email] +google-generativeai>=0.5.0 \ No newline at end of file diff --git a/fe/src/lib/apiClient.ts b/fe/src/lib/apiClient.ts index 8b91a34..2ed431d 100644 --- a/fe/src/lib/apiClient.ts +++ b/fe/src/lib/apiClient.ts @@ -39,177 +39,96 @@ export class ApiClientError extends Error { // --- Request Options Interface --- // Extends standard RequestInit but omits 'body' as we handle it separately -interface RequestOptions extends Omit { - // Can add custom options here later, e.g.: - // skipAuth?: boolean; // To bypass adding the Authorization header +interface RequestOptions extends Omit { + headers?: HeadersInit; } + // --- Core Request Function --- // Uses generics to allow specifying the expected successful response data type async function request( method: string, - path: string, // Relative path to the API endpoint (e.g., /v1/users/me) - bodyData?: unknown, // Optional data for the request body (can be object, FormData, URLSearchParams, etc.) - options: RequestOptions = {} // Optional fetch options (headers, credentials, mode, etc.) + path: string, + bodyData?: unknown, + options: RequestOptions = {} ): Promise { - - // Runtime check for BASE_URL, in case it wasn't set or available during initial load if (!BASE_URL) { - // Depending on context (load function vs. component event), choose how to handle - // error(500, 'API Base URL is not configured.'); // Use in load functions - throw new Error('API Base URL (VITE_API_BASE_URL) is not configured.'); // Throw for component events + throw new Error('API Base URL (VITE_API_BASE_URL) is not configured.'); } - // Construct the full URL safely - const cleanBase = BASE_URL.replace(/\/$/, ''); // Remove trailing slash from base - const cleanPath = path.replace(/^\//, ''); // Remove leading slash from path + const cleanBase = BASE_URL.replace(/\/$/, ''); + const cleanPath = path.replace(/^\//, ''); const url = `${cleanBase}/${cleanPath}`; - // Initialize headers, setting Accept to JSON by default - const headers = new Headers({ - Accept: 'application/json', - ...options.headers // Spread custom headers provided in options early - }); + // --- Refined Header Handling --- + const headers = new Headers({ Accept: 'application/json' }); + + if (options.headers) { + new Headers(options.headers).forEach((value, key) => { + headers.set(key, value); + }); + } // --- Prepare Request Body and Set Content-Type --- let processedBody: BodyInit | null = null; - if (bodyData !== undefined && bodyData !== null) { if (bodyData instanceof URLSearchParams) { - // Handle URL-encoded form data headers.set('Content-Type', 'application/x-www-form-urlencoded'); processedBody = bodyData; } else if (bodyData instanceof FormData) { - // Handle FormData (multipart/form-data) - // Let the browser set the Content-Type with the correct boundary - // Important: DO NOT set 'Content-Type' manually for FormData - // headers.delete('Content-Type'); // Ensure no manual Content-Type is set processedBody = bodyData; } else if (typeof bodyData === 'object') { - // Handle plain JavaScript objects as JSON headers.set('Content-Type', 'application/json'); - try { - processedBody = JSON.stringify(bodyData); - } catch (e) { - console.error("Failed to stringify JSON body data:", bodyData, e); - throw new Error("Invalid JSON body data provided."); - } + try { processedBody = JSON.stringify(bodyData); } + catch (e) { throw new Error("Invalid JSON body data provided."); } } else { - // Handle other primitives (string, number, boolean) - default to sending as JSON stringified - // Adjust this logic if you need to send plain text or other formats headers.set('Content-Type', 'application/json'); - try { - processedBody = JSON.stringify(bodyData) - } catch (e) { - console.error("Failed to stringify primitive body data:", bodyData, e); - throw new Error("Invalid body data provided."); - } + try { processedBody = JSON.stringify(bodyData); } + catch (e) { throw new Error("Invalid body data provided."); } } } - // --- End Body Preparation --- // --- Add Authorization Header --- - const currentToken = getCurrentToken(); // Get token synchronously from auth store - // Add header if token exists and Authorization wasn't manually set in options.headers + const currentToken = getCurrentToken(); if (currentToken && !headers.has('Authorization')) { headers.set('Authorization', `Bearer ${currentToken}`); } - // --- End Authorization Header --- - // Assemble final fetch options + // --- Assemble fetch options carefully --- const fetchOptions: RequestInit = { method: method.toUpperCase(), - headers, - body: processedBody, // Use the potentially processed body - credentials: options.credentials ?? 'same-origin', // Default credentials policy - mode: options.mode ?? 'cors', // Default mode - cache: options.cache ?? 'default', // Default cache policy - ...options // Spread remaining options, potentially overriding defaults if needed + headers: headers, + body: processedBody, }; + const { headers: _, ...restOfOptions } = options; + Object.assign(fetchOptions, restOfOptions); + + fetchOptions.credentials = fetchOptions.credentials ?? 'same-origin'; + fetchOptions.mode = fetchOptions.mode ?? 'cors'; + fetchOptions.cache = fetchOptions.cache ?? 'default'; + // --- Execute Fetch and Handle Response --- try { - // Optional: Log request details for debugging - // console.debug(`API Request: ${fetchOptions.method} ${url}`, { headers: Object.fromEntries(headers.entries()), body: bodyData }); const response = await fetch(url, fetchOptions); - // Optional: Log response status - // console.debug(`API Response Status: ${response.status} for ${fetchOptions.method} ${url}`); - - // Check if the response status code indicates failure (not 2xx) if (!response.ok) { let errorJson: unknown = null; - // Attempt to parse error details from the response body - try { - errorJson = await response.json(); - // console.debug(`API Error Response Body:`, errorJson); - } catch (e) { - // Ignore if response body isn't valid JSON or empty - console.warn(`API Error response for ${response.status} was not valid JSON or empty.`); - } - - // Create the custom error object - const errorToThrow = new ApiClientError( - `API request failed: ${response.status} ${response.statusText}`, - response.status, - errorJson - ); - - // --- Global 401 (Unauthorized) Handling --- - // If the server returns 401, assume the token is invalid/expired - // and automatically log the user out by clearing the auth store. - if (response.status === 401) { - console.warn(`API Client: Received 401 Unauthorized for ${method} ${path}. Logging out.`); - // Calling logout clears the token from store & localStorage - logout(); - // Optional: Trigger a redirect to login page. Often better handled - // by calling code or root layout based on application structure. - // import { goto } from '$app/navigation'; - // if (browser) await goto('/login?sessionExpired=true'); - } - // --- End Global 401 Handling --- - - // Throw the error regardless, so the calling code knows the request failed + try { errorJson = await response.json(); } + catch (e) { /* ignore */ } + const errorToThrow = new ApiClientError(`HTTP Error ${response.status}`, response.status, errorJson); + if (response.status === 401) { logout(); } throw errorToThrow; } - - // Handle successful responses with no content (e.g., 204 No Content for DELETE) - if (response.status === 204) { - // Assert type as T, assuming T can accommodate null or void if needed - return null as T; - } - - // Parse successful JSON response body - const responseData = await response.json(); - // Assert the response data matches the expected generic type T - return responseData as T; - + if (response.status === 204) { return null as T; } + return (await response.json()) as T; } catch (err) { - // Handle network errors (fetch throws TypeError) or errors thrown above - console.error(`API Client request error during ${method} ${path}:`, err); - - // Ensure logout is called even if the caught error is a 401 ApiClientError - // This handles cases where parsing a non-ok response might fail but status was 401 - if (err instanceof ApiClientError && err.status === 401) { - console.warn(`API Client: Caught ApiClientError 401 for ${method} ${path}. Ensuring logout.`); - // Ensure logout state is cleared even if error originated elsewhere - logout(); - } - - // Re-throw the error so the calling code can handle it appropriately - // If it's already our custom error, re-throw it directly - if (err instanceof ApiClientError) { - throw err; - } - - // Otherwise, wrap network or other unexpected errors in our custom error type - throw new ApiClientError( - `Network or unexpected error during API request: ${err instanceof Error ? err.message : String(err)}`, - 0, // Use 0 or a specific code (e.g., -1) for non-HTTP errors - err // Include the original error object as data - ); + if (err instanceof ApiClientError && err.status === 401) { logout(); } + if (err instanceof ApiClientError) { throw err; } + throw new ApiClientError('Unknown error occurred', 0, err); } } + // --- Convenience Methods (GET, POST, PUT, DELETE, PATCH) --- // Provide simple wrappers around the core 'request' function diff --git a/fe/src/lib/components/ImageOcrInput.svelte b/fe/src/lib/components/ImageOcrInput.svelte new file mode 100644 index 0000000..7c23158 --- /dev/null +++ b/fe/src/lib/components/ImageOcrInput.svelte @@ -0,0 +1,234 @@ + + + + + + + diff --git a/fe/src/lib/components/OcrReview.svelte b/fe/src/lib/components/OcrReview.svelte new file mode 100644 index 0000000..8088242 --- /dev/null +++ b/fe/src/lib/components/OcrReview.svelte @@ -0,0 +1,165 @@ + + + + + diff --git a/fe/src/lib/schemas/ocr.ts b/fe/src/lib/schemas/ocr.ts new file mode 100644 index 0000000..f52cd24 --- /dev/null +++ b/fe/src/lib/schemas/ocr.ts @@ -0,0 +1,8 @@ +export interface OcrExtractResponse { + extracted_items: string[]; // Matches the backend schema +} + +export interface OcrReviewItem { + id: number; // Temporary unique ID for the {#each} key + text: string; // The item name, editable +} \ No newline at end of file diff --git a/fe/src/routes/(app)/lists/[listId]/+page.svelte b/fe/src/routes/(app)/lists/[listId]/+page.svelte index f02c112..cbc9eb0 100644 --- a/fe/src/routes/(app)/lists/[listId]/+page.svelte +++ b/fe/src/routes/(app)/lists/[listId]/+page.svelte @@ -3,24 +3,27 @@ // Svelte/SvelteKit Imports import { page } from '$app/stores'; import { onMount, onDestroy } from 'svelte'; - import type { PageData } from '../$types'; + import type { PageData } from './$types'; // Component Imports import ItemDisplay from '$lib/components/ItemDisplay.svelte'; + import ImageOcrInput from '$lib/components/ImageOcrInput.svelte'; + import OcrReview from '$lib/components/OcrReview.svelte'; // Import Review Component // Utility/Store Imports import { apiClient, ApiClientError } from '$lib/apiClient'; - import { authStore } from '$lib/stores/authStore'; // Get current user ID - import { get, writable } from 'svelte/store'; // For local reactive list state + import { authStore } from '$lib/stores/authStore'; + import { writable, get } from 'svelte/store'; // Schema Imports import type { ItemPublic, ItemCreate, ItemUpdate } from '$lib/schemas/item'; import type { ListDetail, ListStatus } from '$lib/schemas/list'; + import type { OcrExtractResponse } from '$lib/schemas/ocr'; + import type { Message } from '$lib/schemas/message'; // --- DB and Sync Imports --- import { getListFromDb, - getItemsByListIdFromDb, putListToDb, putItemToDb, deleteItemFromDb, @@ -46,7 +49,7 @@ // --- General Item Error Display --- let itemUpdateError: string | null = null; - let itemErrorTimeout: ReturnType | undefined = undefined; + let itemErrorTimeout: ReturnType; // --- Polling State --- let pollIntervalId: ReturnType | null = null; @@ -59,12 +62,20 @@ let isRefreshing = false; const POLLING_INTERVAL_MS = 15000; // Poll every 15 seconds + // --- OCR State --- + let showOcrModal = false; + let isProcessingOcr = false; // Loading state for API call + let ocrError: string | null = null; // Error during API call + let showOcrReview = false; // Controls review modal visibility + let ocrResults: string[] = []; // Stores results from OCR API + let isConfirmingOcrItems = false; // Loading state for adding items after review + let confirmOcrError: string | null = null; // Error during final add after review + // --- End OCR State --- + // --- Lifecycle --- onMount(() => { - let isMounted = true; - + let listId: number | null = null; (async () => { - let listId: number | null = null; try { listId = parseInt($page.params.listId, 10); } catch { @@ -73,50 +84,39 @@ if (!listId) { console.error('List Detail Mount: Invalid or missing listId in params.'); - // Optionally redirect or show permanent error return; } - // 1. Load from IndexedDB first for faster initial display/offline + // 1. Load from IndexedDB first if (browser) { console.log('List Detail Mount: Loading from IndexedDB for list', listId); const listFromDb = await getListFromDb(listId); if (listFromDb) { console.log('List Detail Mount: Found list in DB', listFromDb); - // Items should be part of ListDetail object store - if (isMounted) { - localListStore.set(listFromDb); - initializePollingStatus(listFromDb); - } + localListStore.set(listFromDb); + initializePollingStatus(listFromDb); } else { console.log('List Detail Mount: List not found in DB, using SSR/load data.'); - if (isMounted) { - localListStore.set(data.list); // Fallback to initial data - initializePollingStatus(data.list); - } + localListStore.set(data.list); + initializePollingStatus(data.list); } - // 2. If online, trigger an API fetch in background to update DB/UI + // 2. If online, fetch fresh data in background if (navigator.onLine) { console.log('List Detail Mount: Online, fetching fresh data...'); - fetchAndUpdateList(listId); // Don't await, let it run in background - // Also trigger sync queue processing + fetchAndUpdateList(listId); // Don't await processSyncQueue(); // Don't await } // 3. Start polling startPolling(); } else { - // Server side: Use data from load function directly - if (isMounted) { - localListStore.set(data.list); - initializePollingStatus(data.list); - } + localListStore.set(data.list); + initializePollingStatus(data.list); } })(); return () => { - isMounted = false; stopPolling(); clearTimeout(itemErrorTimeout); }; @@ -124,15 +124,108 @@ // Helper to fetch from API and update DB + Store async function fetchAndUpdateList(listId: number) { - isRefreshing = true; + // Don't trigger multiple refreshes concurrently + if (isRefreshing) return; + + isRefreshing = true; // Show refresh indicator + console.log('List Detail: Fetching fresh data for list', listId); try { + // Fetch the entire list detail (including items) from the API const freshList = await apiClient.get(`/v1/lists/${listId}`); - await putListToDb(freshList); // Update IndexedDB - localListStore.set(freshList); // Update the UI store - // No need to re-initialize polling status here, checkListStatus will update it + + // Update IndexedDB with the latest data + await putListToDb(freshList); + + // Update the local Svelte store, which triggers UI updates + localListStore.set(freshList); + + // Reset the polling status based on this fresh data + // (This ensures the next poll compares against the latest fetched state) + initializePollingStatus(freshList); + console.log('List Detail: Fetched and updated list', listId); + clearItemError(); // Clear any lingering item errors after a successful refresh } catch (err) { console.error('List Detail: Failed to fetch fresh list data', err); + // Display an error message to the user via the existing error handling mechanism + handleItemUpdateError( + new CustomEvent('updateError', { detail: 'Failed to refresh list data.' }) + ); + // Note: If the error was 401/403, the apiClient or layout guard should handle logout/redirect + } finally { + isRefreshing = false; // Hide refresh indicator + } + } + + // --- Polling Logic --- + function startPolling() { + stopPolling(); + const currentList = get(localListStore); + if (!currentList) return; + console.log( + `Polling: Starting polling for list ${currentList.id} every ${POLLING_INTERVAL_MS}ms` + ); + pollIntervalId = setInterval(checkListStatus, POLLING_INTERVAL_MS); + } + + function stopPolling() { + if (pollIntervalId) { + clearInterval(pollIntervalId); + pollIntervalId = null; + } + } + + async function checkListStatus() { + const currentList = get(localListStore); + if (!currentList || isRefreshing || !lastKnownStatus || !navigator.onLine) { + if (!navigator.onLine) console.log('Polling: Offline, skipping status check.'); + return; + } + console.log(`Polling: Checking status for list ${currentList.id}`); + try { + const currentStatus = await apiClient.get(`/v1/lists/${currentList.id}/status`); + const currentListUpdatedAt = new Date(currentStatus.list_updated_at); + const currentLatestItemUpdatedAt = currentStatus.latest_item_updated_at + ? new Date(currentStatus.latest_item_updated_at) + : null; + + const listChanged = + currentListUpdatedAt.getTime() !== lastKnownStatus.list_updated_at.getTime(); + const itemsChanged = + currentLatestItemUpdatedAt?.getTime() !== + lastKnownStatus.latest_item_updated_at?.getTime() || + currentStatus.item_count !== lastKnownStatus.item_count; + + if (listChanged || itemsChanged) { + console.log('Polling: Change detected!', { listChanged, itemsChanged }); + await refreshListData(); + // Update known status AFTER successful refresh + lastKnownStatus = { + list_updated_at: currentListUpdatedAt, + latest_item_updated_at: currentLatestItemUpdatedAt, + item_count: currentStatus.item_count + }; + } else { + console.log('Polling: No changes detected.'); + } + } catch (err) { + console.error('Polling: Failed to fetch list status:', err); + } + } + + async function refreshListData() { + const listId = get(localListStore)?.id; + if (!listId) return; + isRefreshing = true; + console.log(`Polling: Refreshing full data for list ${listId}`); + try { + const freshList = await apiClient.get(`/v1/lists/${listId}`); + await putListToDb(freshList); + localListStore.set(freshList); + // No need to re-init polling status here, checkListStatus updates it after refresh + console.log('Polling: List data refreshed successfully.'); + } catch (err) { + console.error(`Polling: Failed to refresh list data for ${listId}:`, err); handleItemUpdateError( new CustomEvent('updateError', { detail: 'Failed to refresh list data.' }) ); @@ -141,7 +234,6 @@ } } - // Helper to initialize polling status from ListDetail data function initializePollingStatus(listData: ListDetail | null) { if (!listData) { lastKnownStatus = null; @@ -169,74 +261,11 @@ } } - // --- Polling Logic --- - function startPolling() { - stopPolling(); - if (!$localListStore) return; - console.log( - `Polling: Starting polling for list ${$localListStore.id} every ${POLLING_INTERVAL_MS}ms` - ); - pollIntervalId = setInterval(checkListStatus, POLLING_INTERVAL_MS); - } - - function stopPolling() { - if (pollIntervalId) { - clearInterval(pollIntervalId); - pollIntervalId = null; - } - } - - async function checkListStatus() { - const currentList = get(localListStore); // Use get for non-reactive access inside async - if (!currentList || isRefreshing || !lastKnownStatus || !navigator.onLine) { - if (!navigator.onLine) console.log('Polling: Offline, skipping status check.'); - return; - } - console.log(`Polling: Checking status for list ${currentList.id}`); - try { - const currentStatus = await apiClient.get(`/v1/lists/${currentList.id}/status`); - const currentListUpdatedAt = new Date(currentStatus.list_updated_at); - const currentLatestItemUpdatedAt = currentStatus.latest_item_updated_at - ? new Date(currentStatus.latest_item_updated_at) - : null; - - const listChanged = - currentListUpdatedAt.getTime() !== lastKnownStatus.list_updated_at.getTime(); - const itemsChanged = - currentLatestItemUpdatedAt?.getTime() !== - lastKnownStatus.latest_item_updated_at?.getTime() || - currentStatus.item_count !== lastKnownStatus.item_count; - - if (listChanged || itemsChanged) { - console.log('Polling: Change detected!', { listChanged, itemsChanged }); - await refreshListData(); // Fetch full data - // Update known status AFTER successful refresh - lastKnownStatus = { - list_updated_at: currentListUpdatedAt, - latest_item_updated_at: currentLatestItemUpdatedAt, - item_count: currentStatus.item_count - }; - } else { - console.log('Polling: No changes detected.'); - } - } catch (err) { - console.error('Polling: Failed to fetch list status:', err); - } - } - - async function refreshListData() { - // Refactored to use store value - const listId = get(localListStore)?.id; - if (listId) { - await fetchAndUpdateList(listId); - } - } - // --- Event Handlers from ItemDisplay --- - async function handleItemUpdated(event: CustomEvent) { + /** Handles the itemUpdated event from ItemDisplay */ + function handleItemUpdated(event: CustomEvent) { const updatedItem = event.detail; console.log('Parent received itemUpdated:', updatedItem); - // Update DB (already done in ItemDisplay optimistic update) // Update store for UI localListStore.update((currentList) => { if (!currentList) return null; @@ -244,15 +273,15 @@ if (index !== -1) { currentList.items[index] = updatedItem; } - return { ...currentList, items: [...currentList.items] }; + return { ...currentList, items: [...currentList.items] }; // Return new object }); clearItemError(); } - async function handleItemDeleted(event: CustomEvent) { + /** Handles the itemDeleted event from ItemDisplay */ + function handleItemDeleted(event: CustomEvent) { const deletedItemId = event.detail; console.log('Parent received itemDeleted:', deletedItemId); - // Update DB (already done in ItemDisplay optimistic update) // Update store for UI localListStore.update((currentList) => { if (!currentList) return null; @@ -264,33 +293,48 @@ clearItemError(); } + /** Handles the updateError event from ItemDisplay */ function handleItemUpdateError(event: CustomEvent) { - /* ... (keep existing) ... */ - } - function clearItemError() { - /* ... (keep existing) ... */ + const errorMsg = event.detail; + console.log('Parent received updateError:', errorMsg); + itemUpdateError = errorMsg; + clearTimeout(itemErrorTimeout); + itemErrorTimeout = setTimeout(() => { + itemUpdateError = null; + }, 5000); } - // --- Add Item Logic --- + /** Clears the general item update error message */ + function clearItemError() { + itemUpdateError = null; + clearTimeout(itemErrorTimeout); + } + + // --- Add Item Logic (Single Item) --- + /** Handles submission of the Add Item form */ async function handleAddItem() { - const currentList = get(localListStore); // Use get for non-reactive access - if (!newItemName.trim() || !currentList) return; + const currentList = get(localListStore); + if (!newItemName.trim() || !currentList) { + addItemError = 'Item name cannot be empty.'; + return; + } if (isAddingItem) return; isAddingItem = true; addItemError = null; clearItemError(); - // 1. Optimistic UI Update with Temporary ID (Using negative random number for simplicity) - const tempId = Math.floor(Math.random() * -1000000); - const currentUserId = get(authStore).user?.id; // Get current user ID synchronously + // 1. Optimistic UI Update with Temporary ID + const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const currentUserId = get(authStore).user?.id; if (!currentUserId) { addItemError = 'Cannot add item: User not identified.'; isAddingItem = false; return; } const optimisticItem: ItemPublic = { - id: tempId, // Use temporary ID + // Use temporary string ID for optimistic UI + id: tempId as any, // Cast needed as DB expects number, but temp is string list_id: currentList.id, name: newItemName.trim(), quantity: newItemQuantity.trim() || null, @@ -305,8 +349,7 @@ localListStore.update((list) => list ? { ...list, items: [...list.items, optimisticItem] } : null ); - // Note: Cannot add item with temp ID to IndexedDB if keyPath is 'id' and type is number. - // For MVP, we skip adding temp items to DB and rely on sync + refresh. + // Skip adding temp item to IndexedDB for simplicity in MVP // 2. Queue Sync Action const actionPayload: ItemCreate = { @@ -317,8 +360,8 @@ await addSyncAction({ type: 'create_item', payload: { listId: currentList.id, data: actionPayload }, - timestamp: Date.now() - // tempId: tempId // Optional: include tempId for mapping later + timestamp: Date.now(), + tempId: tempId // Include tempId for potential mapping later }); // 3. Trigger sync if online @@ -330,32 +373,170 @@ } catch (dbError) { console.error('Failed to queue add item action:', dbError); addItemError = 'Failed to save item for offline sync.'; - // Revert optimistic UI update? More complex. + // Revert optimistic UI update localListStore.update((list) => - list ? { ...list, items: list.items.filter((i) => i.id !== tempId) } : null + list ? { ...list, items: list.items.filter((i) => String(i.id) !== tempId) } : null ); } finally { isAddingItem = false; } } + + // --- OCR Handling --- + function openOcrModal() { + ocrError = null; + confirmOcrError = null; + showOcrModal = true; + } + function closeOcrModal() { + showOcrModal = false; + } + function closeOcrReview() { + showOcrReview = false; + ocrResults = []; + } + + /** Handles image selection from the modal, uploads it, and shows review modal */ + async function handleImageSelected(event: CustomEvent) { + const imageFile = event.detail; + closeOcrModal(); + isProcessingOcr = true; + ocrError = null; + confirmOcrError = null; + const formData = new FormData(); + formData.append('image_file', imageFile); + + try { + const result = await apiClient.post('/v1/ocr/extract-items', formData); + console.log('OCR Extraction successful:', result); + if (result.extracted_items && result.extracted_items.length > 0) { + ocrResults = result.extracted_items; + showOcrReview = true; // Show the review modal + } else { + ocrError = 'OCR processing finished, but no items were extracted.'; + } + } catch (err) { + console.error('OCR failed:', err); + if (err instanceof ApiClientError) { + let detail = 'Failed to process image for items.'; + if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) { + detail = (err.errorData as { detail: string }).detail; + } + if (err.status === 413) { + detail = `Image file too large.`; + } + if (err.status === 400) { + detail = `Invalid image file type or request.`; + } + ocrError = `OCR Error (${err.status}): ${detail}`; + } else if (err instanceof Error) { + ocrError = `OCR Network/Client Error: ${err.message}`; + } else { + ocrError = 'An unexpected OCR error occurred.'; + } + } finally { + isProcessingOcr = false; + } + } + + /** Handles confirmation from the OCR Review modal */ + async function handleOcrConfirm(event: CustomEvent) { + const itemNamesToAdd = event.detail; + closeOcrReview(); + + if (!itemNamesToAdd || itemNamesToAdd.length === 0) { + console.log('OCR Confirm: No items selected to add.'); + return; + } + + isConfirmingOcrItems = true; + confirmOcrError = null; + let successCount = 0; + let failCount = 0; + const currentList = get(localListStore); // Get current list state + const currentUserId = get(authStore).user?.id; + + if (!currentList || !currentUserId) { + confirmOcrError = 'Cannot add items: list or user data missing.'; + isConfirmingOcrItems = false; + return; + } + + console.log(`OCR Confirm: Attempting to add ${itemNamesToAdd.length} items...`); + + // Process items sequentially for clearer feedback/error handling in MVP + for (const name of itemNamesToAdd) { + if (!name.trim()) continue; // Skip empty names + + const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`; + // Optimistic UI update + const optimisticItem: ItemPublic = { + id: tempId as any, + list_id: currentList.id, + name: name.trim(), + quantity: null, + is_complete: false, + price: null, + added_by_id: currentUserId, + completed_by_id: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }; + localListStore.update((list) => + list ? { ...list, items: [...list.items, optimisticItem] } : null + ); + + // Queue Sync Action + const actionPayload: ItemCreate = { name: name.trim(), quantity: undefined }; + try { + await addSyncAction({ + type: 'create_item', + payload: { listId: currentList.id, data: actionPayload }, + timestamp: Date.now(), + tempId: tempId + }); + successCount++; + } catch (dbError) { + console.error(`Failed to queue item '${name}':`, dbError); + failCount++; + // Revert optimistic UI update for this specific item + localListStore.update((list) => + list ? { ...list, items: list.items.filter((i) => String(i.id) !== tempId) } : null + ); + } + } + + // Trigger sync if online + if (browser && navigator.onLine) processSyncQueue(); + + isConfirmingOcrItems = false; + + // Provide feedback + if (failCount > 0) { + confirmOcrError = `Added ${successCount} items. Failed to queue ${failCount} items for sync.`; + } else { + console.log(`Successfully queued ${successCount} items from OCR.`); + // Optionally show a temporary success toast/message + } + } {#if $localListStore} {@const list = $localListStore} - +
{#if $syncStatus === 'syncing'}
Syncing changes...
{:else if $syncStatus === 'error' && $syncError} -
+
+ {#if isRefreshing} Refreshing... {/if} + + Edit List Details
+ {#if ocrError || confirmOcrError} + + + {/if} - +

Add New Item

@@ -420,13 +659,13 @@ id="new-item-qty" placeholder="Quantity (opt.)" bind:value={newItemQuantity} - class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70" + class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-70" disabled={isAddingItem} />