end of phase 5

This commit is contained in:
mohamad 2025-04-02 23:54:43 +02:00
parent 53c7382b88
commit 839487567a
12 changed files with 1199 additions and 252 deletions

View File

@ -8,6 +8,7 @@ from app.api.v1.endpoints import groups
from app.api.v1.endpoints import invites from app.api.v1.endpoints import invites
from app.api.v1.endpoints import lists from app.api.v1.endpoints import lists
from app.api.v1.endpoints import items from app.api.v1.endpoints import items
from app.api.v1.endpoints import ocr
api_router_v1 = APIRouter() 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(invites.router, prefix="/invites", tags=["Invites"])
api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"]) 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(items.router, tags=["Items"])
api_router_v1.include_router(ocr.router, prefix="/ocr", tags=["OCR"])
# Add other v1 endpoint routers here later # Add other v1 endpoint routers here later
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"]) # e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])

View File

@ -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()

View File

@ -2,11 +2,14 @@
import os import os
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from dotenv import load_dotenv from dotenv import load_dotenv
import logging
load_dotenv() load_dotenv()
logger = logging.getLogger(__name__)
class Settings(BaseSettings): class Settings(BaseSettings):
DATABASE_URL: str | None = None DATABASE_URL: str | None = None
GEMINI_API_KEY: str | None = None
# --- JWT Settings --- # --- JWT Settings ---
# Generate a strong secret key using: openssl rand -hex 32 # Generate a strong secret key using: openssl rand -hex 32
@ -35,3 +38,11 @@ if settings.SECRET_KEY == "a_very_insecure_default_secret_key_replace_me":
# Consider raising an error in a production environment check # Consider raising an error in a production environment check
# if os.getenv("ENVIRONMENT") == "production": # if os.getenv("ENVIRONMENT") == "production":
# raise ValueError("Default SECRET_KEY is not allowed in production!") # 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]}...).")

154
be/app/core/gemini.py Normal file
View File

@ -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

View File

@ -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)

6
be/app/schemas/ocr.py Normal file
View File

@ -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

View File

@ -9,3 +9,4 @@ python-dotenv>=1.0.0 # To load .env file for scripts/alembic
passlib[bcrypt]>=1.7.4 passlib[bcrypt]>=1.7.4
python-jose[cryptography]>=3.3.0 python-jose[cryptography]>=3.3.0
pydantic[email] pydantic[email]
google-generativeai>=0.5.0

View File

@ -39,177 +39,96 @@ export class ApiClientError extends Error {
// --- Request Options Interface --- // --- Request Options Interface ---
// Extends standard RequestInit but omits 'body' as we handle it separately // Extends standard RequestInit but omits 'body' as we handle it separately
interface RequestOptions extends Omit<RequestInit, 'body'> { interface RequestOptions extends Omit<RequestInit, 'body' | 'headers'> {
// Can add custom options here later, e.g.: headers?: HeadersInit;
// skipAuth?: boolean; // To bypass adding the Authorization header
} }
// --- Core Request Function --- // --- Core Request Function ---
// Uses generics <T> to allow specifying the expected successful response data type // Uses generics <T> to allow specifying the expected successful response data type
async function request<T = unknown>( async function request<T = unknown>(
method: string, method: string,
path: string, // Relative path to the API endpoint (e.g., /v1/users/me) path: string,
bodyData?: unknown, // Optional data for the request body (can be object, FormData, URLSearchParams, etc.) bodyData?: unknown,
options: RequestOptions = {} // Optional fetch options (headers, credentials, mode, etc.) options: RequestOptions = {}
): Promise<T> { ): Promise<T> {
// Runtime check for BASE_URL, in case it wasn't set or available during initial load
if (!BASE_URL) { if (!BASE_URL) {
// Depending on context (load function vs. component event), choose how to handle throw new Error('API Base URL (VITE_API_BASE_URL) is not configured.');
// 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
} }
// Construct the full URL safely const cleanBase = BASE_URL.replace(/\/$/, '');
const cleanBase = BASE_URL.replace(/\/$/, ''); // Remove trailing slash from base const cleanPath = path.replace(/^\//, '');
const cleanPath = path.replace(/^\//, ''); // Remove leading slash from path
const url = `${cleanBase}/${cleanPath}`; const url = `${cleanBase}/${cleanPath}`;
// Initialize headers, setting Accept to JSON by default // --- Refined Header Handling ---
const headers = new Headers({ const headers = new Headers({ Accept: 'application/json' });
Accept: 'application/json',
...options.headers // Spread custom headers provided in options early if (options.headers) {
}); new Headers(options.headers).forEach((value, key) => {
headers.set(key, value);
});
}
// --- Prepare Request Body and Set Content-Type --- // --- Prepare Request Body and Set Content-Type ---
let processedBody: BodyInit | null = null; let processedBody: BodyInit | null = null;
if (bodyData !== undefined && bodyData !== null) { if (bodyData !== undefined && bodyData !== null) {
if (bodyData instanceof URLSearchParams) { if (bodyData instanceof URLSearchParams) {
// Handle URL-encoded form data
headers.set('Content-Type', 'application/x-www-form-urlencoded'); headers.set('Content-Type', 'application/x-www-form-urlencoded');
processedBody = bodyData; processedBody = bodyData;
} else if (bodyData instanceof FormData) { } 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; processedBody = bodyData;
} else if (typeof bodyData === 'object') { } else if (typeof bodyData === 'object') {
// Handle plain JavaScript objects as JSON
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
try { try { processedBody = JSON.stringify(bodyData); }
processedBody = JSON.stringify(bodyData); catch (e) { throw new Error("Invalid JSON body data provided."); }
} catch (e) {
console.error("Failed to stringify JSON body data:", bodyData, e);
throw new Error("Invalid JSON body data provided.");
}
} else { } 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'); headers.set('Content-Type', 'application/json');
try { try { processedBody = JSON.stringify(bodyData); }
processedBody = JSON.stringify(bodyData) catch (e) { throw new Error("Invalid body data provided."); }
} catch (e) {
console.error("Failed to stringify primitive body data:", bodyData, e);
throw new Error("Invalid body data provided.");
}
} }
} }
// --- End Body Preparation ---
// --- Add Authorization Header --- // --- Add Authorization Header ---
const currentToken = getCurrentToken(); // Get token synchronously from auth store const currentToken = getCurrentToken();
// Add header if token exists and Authorization wasn't manually set in options.headers
if (currentToken && !headers.has('Authorization')) { if (currentToken && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${currentToken}`); headers.set('Authorization', `Bearer ${currentToken}`);
} }
// --- End Authorization Header ---
// Assemble final fetch options // --- Assemble fetch options carefully ---
const fetchOptions: RequestInit = { const fetchOptions: RequestInit = {
method: method.toUpperCase(), method: method.toUpperCase(),
headers, headers: headers,
body: processedBody, // Use the potentially processed body body: processedBody,
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
}; };
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 --- // --- Execute Fetch and Handle Response ---
try { 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); 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) { if (!response.ok) {
let errorJson: unknown = null; let errorJson: unknown = null;
// Attempt to parse error details from the response body try { errorJson = await response.json(); }
try { catch (e) { /* ignore */ }
errorJson = await response.json(); const errorToThrow = new ApiClientError(`HTTP Error ${response.status}`, response.status, errorJson);
// console.debug(`API Error Response Body:`, errorJson); if (response.status === 401) { logout(); }
} 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
throw errorToThrow; throw errorToThrow;
} }
if (response.status === 204) { return null as T; }
// Handle successful responses with no content (e.g., 204 No Content for DELETE) return (await response.json()) as T;
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;
} catch (err) { } catch (err) {
// Handle network errors (fetch throws TypeError) or errors thrown above if (err instanceof ApiClientError && err.status === 401) { logout(); }
console.error(`API Client request error during ${method} ${path}:`, err); if (err instanceof ApiClientError) { throw err; }
throw new ApiClientError('Unknown error occurred', 0, 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
);
} }
} }
// --- Convenience Methods (GET, POST, PUT, DELETE, PATCH) --- // --- Convenience Methods (GET, POST, PUT, DELETE, PATCH) ---
// Provide simple wrappers around the core 'request' function // Provide simple wrappers around the core 'request' function

View File

@ -0,0 +1,234 @@
<!-- src/lib/components/ImageOcrInput.svelte -->
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher<{
imageSelected: File; // Dispatch the selected file object
cancel: void; // Dispatch when user cancels
}>();
let selectedFile: File | null = null;
let previewUrl: string | null = null;
let inputKey = Date.now(); // Key to reset file input if needed
let error: string | null = null;
// Refs for the input elements
let fileInput: HTMLInputElement;
let captureInput: HTMLInputElement;
const MAX_FILE_SIZE_MB = 10; // Match backend limit
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
function handleFileChange(event: Event) {
error = null; // Clear previous error
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
// Basic Validation
if (!ALLOWED_TYPES.includes(file.type)) {
error = `Invalid file type. Please select JPEG, PNG, or WEBP. Type found: ${file.type}`;
resetInput();
return;
}
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
error = `File is too large (max ${MAX_FILE_SIZE_MB}MB). Size: ${(file.size / 1024 / 1024).toFixed(2)}MB`;
resetInput();
return;
}
selectedFile = file;
// Create a preview URL
if (previewUrl) URL.revokeObjectURL(previewUrl); // Revoke previous URL
previewUrl = URL.createObjectURL(file);
console.log('Image selected:', file.name, file.type, file.size);
} else {
// No file selected (e.g., user cancelled file picker)
// Optionally clear existing selection if needed
// clearSelection();
}
}
function clearSelection() {
selectedFile = null;
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
previewUrl = null;
}
error = null;
resetInput(); // Reset the input fields
}
function resetInput() {
// Changing the key forces Svelte to recreate the input, clearing its value
inputKey = Date.now();
// Also reset the value manually in case key trick doesn't work everywhere
if (fileInput) fileInput.value = '';
if (captureInput) captureInput.value = '';
}
function triggerFileInput() {
fileInput?.click(); // Programmatically click the hidden file input
}
function triggerCaptureInput() {
captureInput?.click(); // Programmatically click the hidden capture input
}
function handleConfirm() {
if (selectedFile) {
dispatch('imageSelected', selectedFile);
} else {
error = 'Please select or capture an image first.';
}
}
function handleCancel() {
clearSelection();
dispatch('cancel');
}
// Clean up object URL when component is destroyed
onDestroy(() => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
});
import { onDestroy } from 'svelte'; // Ensure onDestroy is imported
</script>
<!-- Basic Modal Structure (adapt styling as needed) -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="bg-opacity-60 fixed inset-0 z-50 flex items-center justify-center bg-black p-4"
on:click|self={handleCancel}
role="dialog"
aria-modal="true"
aria-labelledby="ocr-modal-title"
>
<div class="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl">
<h2 id="ocr-modal-title" class="mb-4 text-xl font-semibold text-gray-800">
Add Items via Photo
</h2>
{#if error}
<div
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700"
role="alert"
>
{error}
</div>
{/if}
<!-- Hidden Inputs -->
<input
type="file"
accept="image/jpeg, image/png, image/webp"
on:change={handleFileChange}
bind:this={fileInput}
key={inputKey}
class="hidden"
aria-hidden="true"
/>
<input
type="file"
accept="image/jpeg, image/png, image/webp"
capture="environment"
on:change={handleFileChange}
bind:this={captureInput}
key={inputKey}
class="hidden"
aria-hidden="true"
/>
{#if previewUrl}
<!-- Preview Section -->
<div class="mb-4 text-center">
<p class="mb-2 text-sm text-gray-600">Image Preview:</p>
<img
src={previewUrl}
alt="Selected list preview"
class="mx-auto max-h-60 w-auto rounded border border-gray-300 object-contain"
/>
<button
type="button"
on:click={clearSelection}
class="mt-2 text-xs text-red-600 hover:underline"
>
Clear Selection
</button>
</div>
{/if}
<!-- Action Buttons -->
<div class="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<button
type="button"
on:click={triggerCaptureInput}
class="flex w-full items-center justify-center rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none"
>
<!-- Basic Camera Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/><path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/></svg
>
Take Photo
</button>
<button
type="button"
on:click={triggerFileInput}
class="flex w-full items-center justify-center rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none"
>
<!-- Basic Upload Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/></svg
>
Upload File
</button>
</div>
<!-- Confirmation/Cancel -->
<div class="mt-6 flex justify-end space-x-3 border-t pt-4">
<button
type="button"
on:click={handleCancel}
class="rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
>
Cancel
</button>
<button
type="button"
on:click={handleConfirm}
disabled={!selectedFile}
class="rounded border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
Confirm Image
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,165 @@
<!-- src/lib/components/OcrReview.svelte -->
<script lang="ts">
import { createEventDispatcher, onMount, tick } from 'svelte'; // Added tick
import { fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
// Props
/** Initial list of item names extracted by OCR */
export let initialItems: string[] = [];
// Events
const dispatch = createEventDispatcher<{
confirm: string[]; // Final list of item names
cancel: void;
}>();
// Internal State
interface ReviewItem {
id: string; // Unique key for {#each} and focus management
name: string;
}
let reviewedItems: ReviewItem[] = [];
export let isLoading: boolean = false; // Add isLoading prop
let inputRefs: Record<string, HTMLInputElement> = {}; // To store references for focusing
// Initialize items with unique IDs when component mounts or prop changes
$: if (initialItems) {
reviewedItems = initialItems.map((name) => ({
id: crypto.randomUUID(), // Generate unique ID for each item
name: name
}));
console.log('OcrReview initialized with items:', reviewedItems);
}
/** Deletes an item from the review list */
function deleteItem(idToDelete: string) {
reviewedItems = reviewedItems.filter((item) => item.id !== idToDelete);
}
/** Adds a new, empty input field to the list */
async function addItemManually() {
const newItemId = crypto.randomUUID();
// Add a new empty item at the end
reviewedItems = [...reviewedItems, { id: newItemId, name: '' }];
// Wait for the DOM to update, then focus the new input
await tick();
if (inputRefs[newItemId]) {
inputRefs[newItemId].focus();
}
}
/** Dispatches the confirmed list of non-empty item names */
function handleConfirm() {
// Filter out empty items and extract just the names
const finalItemNames = reviewedItems
.map((item) => item.name.trim())
.filter((name) => name.length > 0);
console.log('OcrReview confirming items:', finalItemNames);
dispatch('confirm', finalItemNames);
}
/** Dispatches the cancel event */
function handleCancel() {
dispatch('cancel');
}
</script>
<!-- Modal Structure -->
<div
transition:fade={{ duration: 150 }}
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4"
on:click|self={handleCancel}
role="dialog"
aria-modal="true"
aria-labelledby="ocr-review-title"
>
<!-- Prevent backdrop click from closing when clicking inside modal content -->
<div
class="flex max-h-[85vh] w-full max-w-lg flex-col rounded-lg bg-white shadow-xl"
on:click|stopPropagation
>
<!-- Header -->
<div class="flex-shrink-0 border-b p-4">
<h2 id="ocr-review-title" class="text-xl font-semibold text-gray-800">
Review Extracted Items
</h2>
<p class="mt-1 text-sm text-gray-600">
Edit, delete, or add items below. Confirm to add them to your list.
</p>
</div>
<!-- Item List (Scrollable) -->
<div class="flex-grow overflow-y-auto p-4">
{#if reviewedItems.length > 0}
<ul class="space-y-2">
{#each reviewedItems as item (item.id)}
<li class="flex items-center gap-2" animate:flip={{ duration: 200 }}>
<!-- Bind input element reference using item.id as key -->
<input
type="text"
bind:value={item.name}
bind:this={inputRefs[item.id]}
placeholder="Enter item name..."
class="flex-grow rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
aria-label="Item name"
/>
<button
on:click={() => deleteItem(item.id)}
title="Remove item"
class="flex-shrink-0 rounded p-1 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-1 focus:ring-red-400"
aria-label="Remove item"
>
<!-- Basic 'X' icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
{/each}
</ul>
{:else}
<p class="py-4 text-center text-sm text-gray-500">
No items extracted. Add items manually below.
</p>
{/if}
</div>
<!-- Add Manually Button -->
<div class="flex-shrink-0 px-4 pb-4">
<button
on:click={addItemManually}
class="w-full rounded border border-dashed border-gray-400 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
+ Add Item Manually
</button>
</div>
<!-- Footer Actions -->
<div class="flex flex-shrink-0 justify-end space-x-3 border-t bg-gray-50 p-4">
<button
type="button"
on:click={handleCancel}
class="rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Cancel
</button>
<button
type="button"
on:click={handleConfirm}
class="rounded border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Adding...' : 'Confirm & Add Items'}
</button>
</div>
</div>
</div>

View File

@ -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
}

View File

@ -3,24 +3,27 @@
// Svelte/SvelteKit Imports // Svelte/SvelteKit Imports
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import type { PageData } from '../$types'; import type { PageData } from './$types';
// Component Imports // Component Imports
import ItemDisplay from '$lib/components/ItemDisplay.svelte'; 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 // Utility/Store Imports
import { apiClient, ApiClientError } from '$lib/apiClient'; import { apiClient, ApiClientError } from '$lib/apiClient';
import { authStore } from '$lib/stores/authStore'; // Get current user ID import { authStore } from '$lib/stores/authStore';
import { get, writable } from 'svelte/store'; // For local reactive list state import { writable, get } from 'svelte/store';
// Schema Imports // Schema Imports
import type { ItemPublic, ItemCreate, ItemUpdate } from '$lib/schemas/item'; import type { ItemPublic, ItemCreate, ItemUpdate } from '$lib/schemas/item';
import type { ListDetail, ListStatus } from '$lib/schemas/list'; 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 --- // --- DB and Sync Imports ---
import { import {
getListFromDb, getListFromDb,
getItemsByListIdFromDb,
putListToDb, putListToDb,
putItemToDb, putItemToDb,
deleteItemFromDb, deleteItemFromDb,
@ -46,7 +49,7 @@
// --- General Item Error Display --- // --- General Item Error Display ---
let itemUpdateError: string | null = null; let itemUpdateError: string | null = null;
let itemErrorTimeout: ReturnType<typeof setTimeout> | undefined = undefined; let itemErrorTimeout: ReturnType<typeof setTimeout>;
// --- Polling State --- // --- Polling State ---
let pollIntervalId: ReturnType<typeof setInterval> | null = null; let pollIntervalId: ReturnType<typeof setInterval> | null = null;
@ -59,12 +62,20 @@
let isRefreshing = false; let isRefreshing = false;
const POLLING_INTERVAL_MS = 15000; // Poll every 15 seconds 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 --- // --- Lifecycle ---
onMount(() => { onMount(() => {
let isMounted = true; let listId: number | null = null;
(async () => { (async () => {
let listId: number | null = null;
try { try {
listId = parseInt($page.params.listId, 10); listId = parseInt($page.params.listId, 10);
} catch { } catch {
@ -73,50 +84,39 @@
if (!listId) { if (!listId) {
console.error('List Detail Mount: Invalid or missing listId in params.'); console.error('List Detail Mount: Invalid or missing listId in params.');
// Optionally redirect or show permanent error
return; return;
} }
// 1. Load from IndexedDB first for faster initial display/offline // 1. Load from IndexedDB first
if (browser) { if (browser) {
console.log('List Detail Mount: Loading from IndexedDB for list', listId); console.log('List Detail Mount: Loading from IndexedDB for list', listId);
const listFromDb = await getListFromDb(listId); const listFromDb = await getListFromDb(listId);
if (listFromDb) { if (listFromDb) {
console.log('List Detail Mount: Found list in DB', listFromDb); console.log('List Detail Mount: Found list in DB', listFromDb);
// Items should be part of ListDetail object store localListStore.set(listFromDb);
if (isMounted) { initializePollingStatus(listFromDb);
localListStore.set(listFromDb);
initializePollingStatus(listFromDb);
}
} else { } else {
console.log('List Detail Mount: List not found in DB, using SSR/load data.'); console.log('List Detail Mount: List not found in DB, using SSR/load data.');
if (isMounted) { localListStore.set(data.list);
localListStore.set(data.list); // Fallback to initial data initializePollingStatus(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) { if (navigator.onLine) {
console.log('List Detail Mount: Online, fetching fresh data...'); console.log('List Detail Mount: Online, fetching fresh data...');
fetchAndUpdateList(listId); // Don't await, let it run in background fetchAndUpdateList(listId); // Don't await
// Also trigger sync queue processing
processSyncQueue(); // Don't await processSyncQueue(); // Don't await
} }
// 3. Start polling // 3. Start polling
startPolling(); startPolling();
} else { } else {
// Server side: Use data from load function directly localListStore.set(data.list);
if (isMounted) { initializePollingStatus(data.list);
localListStore.set(data.list);
initializePollingStatus(data.list);
}
} }
})(); })();
return () => { return () => {
isMounted = false;
stopPolling(); stopPolling();
clearTimeout(itemErrorTimeout); clearTimeout(itemErrorTimeout);
}; };
@ -124,15 +124,108 @@
// Helper to fetch from API and update DB + Store // Helper to fetch from API and update DB + Store
async function fetchAndUpdateList(listId: number) { 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 { try {
// Fetch the entire list detail (including items) from the API
const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`); const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
await putListToDb(freshList); // Update IndexedDB
localListStore.set(freshList); // Update the UI store // Update IndexedDB with the latest data
// No need to re-initialize polling status here, checkListStatus will update it 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); console.log('List Detail: Fetched and updated list', listId);
clearItemError(); // Clear any lingering item errors after a successful refresh
} catch (err) { } catch (err) {
console.error('List Detail: Failed to fetch fresh list data', 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<ListStatus>(`/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<ListDetail>(`/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( handleItemUpdateError(
new CustomEvent('updateError', { detail: 'Failed to refresh list data.' }) 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) { function initializePollingStatus(listData: ListDetail | null) {
if (!listData) { if (!listData) {
lastKnownStatus = null; 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<ListStatus>(`/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 --- // --- Event Handlers from ItemDisplay ---
async function handleItemUpdated(event: CustomEvent<ItemPublic>) { /** Handles the itemUpdated event from ItemDisplay */
function handleItemUpdated(event: CustomEvent<ItemPublic>) {
const updatedItem = event.detail; const updatedItem = event.detail;
console.log('Parent received itemUpdated:', updatedItem); console.log('Parent received itemUpdated:', updatedItem);
// Update DB (already done in ItemDisplay optimistic update)
// Update store for UI // Update store for UI
localListStore.update((currentList) => { localListStore.update((currentList) => {
if (!currentList) return null; if (!currentList) return null;
@ -244,15 +273,15 @@
if (index !== -1) { if (index !== -1) {
currentList.items[index] = updatedItem; currentList.items[index] = updatedItem;
} }
return { ...currentList, items: [...currentList.items] }; return { ...currentList, items: [...currentList.items] }; // Return new object
}); });
clearItemError(); clearItemError();
} }
async function handleItemDeleted(event: CustomEvent<number>) { /** Handles the itemDeleted event from ItemDisplay */
function handleItemDeleted(event: CustomEvent<number>) {
const deletedItemId = event.detail; const deletedItemId = event.detail;
console.log('Parent received itemDeleted:', deletedItemId); console.log('Parent received itemDeleted:', deletedItemId);
// Update DB (already done in ItemDisplay optimistic update)
// Update store for UI // Update store for UI
localListStore.update((currentList) => { localListStore.update((currentList) => {
if (!currentList) return null; if (!currentList) return null;
@ -264,33 +293,48 @@
clearItemError(); clearItemError();
} }
/** Handles the updateError event from ItemDisplay */
function handleItemUpdateError(event: CustomEvent<string>) { function handleItemUpdateError(event: CustomEvent<string>) {
/* ... (keep existing) ... */ const errorMsg = event.detail;
} console.log('Parent received updateError:', errorMsg);
function clearItemError() { itemUpdateError = errorMsg;
/* ... (keep existing) ... */ 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() { async function handleAddItem() {
const currentList = get(localListStore); // Use get for non-reactive access const currentList = get(localListStore);
if (!newItemName.trim() || !currentList) return; if (!newItemName.trim() || !currentList) {
addItemError = 'Item name cannot be empty.';
return;
}
if (isAddingItem) return; if (isAddingItem) return;
isAddingItem = true; isAddingItem = true;
addItemError = null; addItemError = null;
clearItemError(); clearItemError();
// 1. Optimistic UI Update with Temporary ID (Using negative random number for simplicity) // 1. Optimistic UI Update with Temporary ID
const tempId = Math.floor(Math.random() * -1000000); const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const currentUserId = get(authStore).user?.id; // Get current user ID synchronously const currentUserId = get(authStore).user?.id;
if (!currentUserId) { if (!currentUserId) {
addItemError = 'Cannot add item: User not identified.'; addItemError = 'Cannot add item: User not identified.';
isAddingItem = false; isAddingItem = false;
return; return;
} }
const optimisticItem: ItemPublic = { 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, list_id: currentList.id,
name: newItemName.trim(), name: newItemName.trim(),
quantity: newItemQuantity.trim() || null, quantity: newItemQuantity.trim() || null,
@ -305,8 +349,7 @@
localListStore.update((list) => localListStore.update((list) =>
list ? { ...list, items: [...list.items, optimisticItem] } : null list ? { ...list, items: [...list.items, optimisticItem] } : null
); );
// Note: Cannot add item with temp ID to IndexedDB if keyPath is 'id' and type is number. // Skip adding temp item to IndexedDB for simplicity in MVP
// For MVP, we skip adding temp items to DB and rely on sync + refresh.
// 2. Queue Sync Action // 2. Queue Sync Action
const actionPayload: ItemCreate = { const actionPayload: ItemCreate = {
@ -317,8 +360,8 @@
await addSyncAction({ await addSyncAction({
type: 'create_item', type: 'create_item',
payload: { listId: currentList.id, data: actionPayload }, payload: { listId: currentList.id, data: actionPayload },
timestamp: Date.now() timestamp: Date.now(),
// tempId: tempId // Optional: include tempId for mapping later tempId: tempId // Include tempId for potential mapping later
}); });
// 3. Trigger sync if online // 3. Trigger sync if online
@ -330,32 +373,170 @@
} catch (dbError) { } catch (dbError) {
console.error('Failed to queue add item action:', dbError); console.error('Failed to queue add item action:', dbError);
addItemError = 'Failed to save item for offline sync.'; addItemError = 'Failed to save item for offline sync.';
// Revert optimistic UI update? More complex. // Revert optimistic UI update
localListStore.update((list) => 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 { } finally {
isAddingItem = false; 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<File>) {
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<OcrExtractResponse>('/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<string[]>) {
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
}
}
</script> </script>
<!-- Template --> <!-- Template -->
{#if $localListStore} {#if $localListStore}
{@const list = $localListStore} {@const list = $localListStore}
<!-- Create local const for easier access --> <!-- Create local const for easier access in template -->
<div class="space-y-6"> <div class="space-y-6">
<!-- Sync Status Indicator --> <!-- Sync Status Indicator -->
{#if $syncStatus === 'syncing'} {#if $syncStatus === 'syncing'}
<div <div
class="fixed right-4 bottom-4 z-50 animate-pulse rounded bg-blue-100 p-3 text-sm text-blue-700 shadow" class="fixed bottom-4 right-4 z-50 animate-pulse rounded bg-blue-100 p-3 text-sm text-blue-700 shadow"
role="status" role="status"
> >
Syncing changes... Syncing changes...
</div> </div>
{:else if $syncStatus === 'error' && $syncError} {:else if $syncStatus === 'error' && $syncError}
<div <div
class="fixed right-4 bottom-4 z-50 rounded bg-red-100 p-3 text-sm text-red-700 shadow" class="fixed bottom-4 right-4 z-50 rounded bg-red-100 p-3 text-sm text-red-700 shadow"
role="alert" role="alert"
> >
Sync Error: {$syncError} Sync Error: {$syncError}
@ -381,20 +562,78 @@
).toLocaleString()} ).toLocaleString()}
</p> </p>
</div> </div>
<div class="flex-shrink-0 space-x-2"> <div class="flex flex-shrink-0 items-center space-x-2">
<!-- Action Buttons -->
{#if isRefreshing} {#if isRefreshing}
<span class="animate-pulse text-sm text-blue-600">Refreshing...</span> <span class="animate-pulse text-sm text-blue-600">Refreshing...</span>
{/if} {/if}
<!-- OCR Button with Progress Indication -->
<button
type="button"
on:click={openOcrModal}
disabled={isProcessingOcr || isConfirmingOcrItems}
class="inline-flex items-center rounded bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isProcessingOcr}
<svg
class="mr-2 h-4 w-4 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
><circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle><path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path></svg
>
Processing...
{:else if isConfirmingOcrItems}
<svg
class="mr-2 h-4 w-4 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
><circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle><path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path></svg
>
Adding Items...
{:else}
📷 Add via Photo
{/if}
</button>
<a <a
href="/lists/{list.id}/edit" href="/lists/{list.id}/edit"
class="rounded bg-yellow-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-yellow-600 focus:ring-2 focus:ring-yellow-400 focus:ring-offset-2 focus:outline-none" class="rounded bg-yellow-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-yellow-600 focus:outline-none focus:ring-2 focus:ring-yellow-400 focus:ring-offset-2"
> >
Edit List Details Edit List Details
</a> </a>
</div> </div>
</div> </div>
{#if ocrError || confirmOcrError}
<!-- Display OCR/Confirm errors -->
<div class="rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700" role="alert">
{ocrError || confirmOcrError}
</div>
{/if}
<!-- Add New Item Form --> <!-- Add New Item Form Section -->
<div class="rounded bg-white p-4 shadow"> <div class="rounded bg-white p-4 shadow">
<h2 class="mb-3 text-lg font-semibold text-gray-700">Add New Item</h2> <h2 class="mb-3 text-lg font-semibold text-gray-700">Add New Item</h2>
<form <form
@ -409,7 +648,7 @@
placeholder="Item name (required)" placeholder="Item name (required)"
required required
bind:value={newItemName} bind:value={newItemName}
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} disabled={isAddingItem}
/> />
</div> </div>
@ -420,13 +659,13 @@
id="new-item-qty" id="new-item-qty"
placeholder="Quantity (opt.)" placeholder="Quantity (opt.)"
bind:value={newItemQuantity} 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} disabled={isAddingItem}
/> />
</div> </div>
<button <button
type="submit" type="submit"
class="rounded bg-blue-600 px-4 py-2 font-medium whitespace-nowrap text-white shadow-sm transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" class="whitespace-nowrap rounded bg-blue-600 px-4 py-2 font-medium text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isAddingItem} disabled={isAddingItem}
> >
{isAddingItem ? 'Adding...' : 'Add Item'} {isAddingItem ? 'Adding...' : 'Add Item'}
@ -441,6 +680,7 @@
<div class="rounded bg-white p-6 shadow"> <div class="rounded bg-white p-6 shadow">
<h2 class="mb-4 text-xl font-semibold text-gray-700">Items ({list.items?.length ?? 0})</h2> <h2 class="mb-4 text-xl font-semibold text-gray-700">Items ({list.items?.length ?? 0})</h2>
{#if itemUpdateError} {#if itemUpdateError}
<!-- Display errors bubbled up from items -->
<div <div
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700" class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700"
role="alert" role="alert"
@ -450,6 +690,7 @@
{/if} {/if}
{#if list.items && list.items.length > 0} {#if list.items && list.items.length > 0}
<ul class="space-y-2"> <ul class="space-y-2">
<!-- Use {#key} block to help Svelte efficiently update the list when items are added/removed/reordered -->
{#each list.items as item (item.id)} {#each list.items as item (item.id)}
<ItemDisplay <ItemDisplay
{item} {item}
@ -473,3 +714,18 @@
<!-- Fallback if list data is somehow null/undefined after load function --> <!-- Fallback if list data is somehow null/undefined after load function -->
<p class="text-center text-gray-500">Loading list data...</p> <p class="text-center text-gray-500">Loading list data...</p>
{/if} {/if}
<!-- OCR Input Modal -->
{#if showOcrModal}
<ImageOcrInput on:imageSelected={handleImageSelected} on:cancel={closeOcrModal} />
{/if}
<!-- OCR Review Modal -->
{#if showOcrReview}
<OcrReview
initialItems={ocrResults}
on:confirm={handleOcrConfirm}
on:cancel={closeOcrReview}
bind:isLoading={isConfirmingOcrItems}
/>
{/if}