diff --git a/backend/app/database/memories.py b/backend/app/database/memories.py new file mode 100644 index 000000000..1619ab4e3 --- /dev/null +++ b/backend/app/database/memories.py @@ -0,0 +1,502 @@ +# Standard library imports +import sqlite3 +import threading +from typing import List, Dict, Any, Optional +from datetime import datetime, timedelta +from collections import defaultdict + +# App-specific imports +from app.config.settings import DATABASE_PATH +from app.logging.setup_logging import get_logger + +# Initialize logger +logger = get_logger(__name__) + +# Module-level regeneration lock to prevent concurrent regenerations +_regeneration_lock = threading.Lock() +_is_regenerating = False + + +def _connect() -> sqlite3.Connection: + conn = sqlite3.connect(DATABASE_PATH) + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def db_create_memories_table() -> None: + """Create the memories table to cache generated memories.""" + conn = _connect() + cursor = conn.cursor() + + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + location TEXT, + latitude REAL, + longitude REAL, + image_count INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + # Junction table for memory-image relationships + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS memory_images ( + memory_id TEXT, + image_id TEXT, + is_representative BOOLEAN DEFAULT 0, + PRIMARY KEY (memory_id, image_id), + FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE, + FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE + ) + """ + ) + + conn.commit() + conn.close() + + +def db_generate_memories() -> List[Dict[str, Any]]: + """ + Generate memories by grouping images based on time and location. + Returns a list of memory objects with representative images. + """ + conn = _connect() + cursor = conn.cursor() + + try: + # Get all images with metadata + cursor.execute( + """ + SELECT id, path, thumbnailPath, metadata, folder_id + FROM images + ORDER BY CASE + WHEN json_valid(metadata) THEN json_extract(metadata, '$.date_created') + ELSE NULL + END DESC, id DESC + """ + ) + + images = cursor.fetchall() + + if not images: + return [] + + # Parse images and group by date and location + from app.utils.images import image_util_parse_metadata + + memories_by_group = defaultdict(list) + + for image_id, path, thumbnail, metadata_str, folder_id in images: + metadata = image_util_parse_metadata(metadata_str) + + # Extract date and location + date_created = metadata.get("date_created") + if not date_created: + continue + + # Parse date (format: YYYY-MM-DD HH:MM:SS or ISO format) + try: + if "T" in date_created: + date_obj = datetime.fromisoformat(date_created.replace("Z", "+00:00")) + else: + date_obj = datetime.strptime(date_created.split()[0], "%Y-%m-%d") + + # Normalize to date-only to avoid timezone issues + date_only = date_obj.date() + except Exception as e: + logger.warning(f"Could not parse date {date_created}: {e}") + continue + + location = metadata.get("location", "Unknown Location") + latitude = metadata.get("latitude") + longitude = metadata.get("longitude") + + # Create grouping key (year-month + location) + date_key = date_only.strftime("%Y-%m") + + # Group by similar locations (or "Unknown") + loc_key = location if location != "Unknown Location" else "no_location" + + group_key = f"{date_key}_{loc_key}" + + memories_by_group[group_key].append({ + "id": image_id, + "path": path, + "thumbnail": thumbnail, + "date": date_only.isoformat(), + "location": location, + "latitude": latitude, + "longitude": longitude, + "metadata": metadata + }) + + # Generate memory objects + memories = [] + current_date = datetime.now().date() # Normalize to date-only + + for group_key, images_in_group in memories_by_group.items(): + if len(images_in_group) < 3: # Skip groups with too few images + continue + + # Sort images by date (date is now ISO string) + images_in_group.sort(key=lambda x: x["date"]) + + # Parse first and last dates for memory metadata (date-only strings) + first_date = datetime.strptime(images_in_group[0]["date"], "%Y-%m-%d").date() + last_date = datetime.strptime(images_in_group[-1]["date"], "%Y-%m-%d").date() + location = images_in_group[0]["location"] + + # Calculate time difference using date objects + years_ago = current_date.year - first_date.year + + # Generate title based on time + if years_ago == 0: + month_name = first_date.strftime("%B") + title = f"{month_name} {first_date.year}" + elif years_ago == 1: + title = f"One Year Ago - {first_date.strftime('%B %Y')}" + else: + title = f"{years_ago} Years Ago - {first_date.strftime('%B %Y')}" + + # Add location to title if available + if location and location != "Unknown Location": + title = f"{title} • {location}" + + # Generate description + description = f"{len(images_in_group)} photos from {first_date.strftime('%b %d')} to {last_date.strftime('%b %d, %Y')}" + + # Select representative images (first 5) + representative_images = images_in_group[:5] + + memory = { + "id": group_key, + "title": title, + "description": description, + "start_date": first_date.isoformat(), + "end_date": last_date.isoformat(), + "location": location, + "latitude": images_in_group[0]["latitude"], + "longitude": images_in_group[0]["longitude"], + "image_count": len(images_in_group), + "images": representative_images, + "all_image_ids": [img["id"] for img in images_in_group] + } + + memories.append(memory) + + # Sort memories by most recent first + memories.sort(key=lambda x: x["start_date"], reverse=True) + + # Persist memories to database (best-effort, atomic refresh) + try: + # Ensure tables exist + db_create_memories_table() + + # Begin atomic transaction + cursor.execute("BEGIN IMMEDIATE") + + # Clear old memories + cursor.execute("DELETE FROM memory_images") + cursor.execute("DELETE FROM memories") + + # Insert new memories + for memory in memories: + cursor.execute( + """ + INSERT INTO memories + (id, title, description, start_date, end_date, location, latitude, longitude, image_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + memory["id"], + memory["title"], + memory["description"], + memory["start_date"], + memory["end_date"], + memory["location"], + memory["latitude"], + memory["longitude"], + memory["image_count"] + ) + ) + + # Insert memory-image associations + for idx, image_id in enumerate(memory["all_image_ids"]): + is_representative = idx < 5 # First 5 are representative + cursor.execute( + """ + INSERT INTO memory_images (memory_id, image_id, is_representative) + VALUES (?, ?, ?) + """, + (memory["id"], image_id, is_representative) + ) + + conn.commit() + logger.info(f"Successfully persisted {len(memories)} memories to database") + except Exception as e: + logger.error(f"Error persisting memories (continuing with in-memory data): {e}") + conn.rollback() + # Don't re-raise - treat persistence as best-effort + + return memories + + except Exception as e: + logger.error(f"Error generating memories: {e}") + return [] + finally: + conn.close() + + +def db_get_memory_images(memory_id: str) -> List[Dict[str, Any]]: + """ + Get all images associated with a specific memory. + """ + conn = _connect() + cursor = conn.cursor() + + try: + cursor.execute( + """ + SELECT i.id, i.path, i.thumbnailPath, i.metadata + FROM images i + JOIN memory_images mi ON i.id = mi.image_id + WHERE mi.memory_id = ? + ORDER BY CASE + WHEN json_valid(i.metadata) THEN json_extract(i.metadata, '$.date_created') + ELSE NULL + END ASC, i.id ASC + """, + (memory_id,) + ) + + images = [] + from app.utils.images import image_util_parse_metadata + + for image_id, path, thumbnail, metadata_str in cursor.fetchall(): + metadata = image_util_parse_metadata(metadata_str) + images.append({ + "id": image_id, + "path": path, + "thumbnail": thumbnail, + "metadata": metadata + }) + + return images + + except Exception as e: + logger.error(f"Error getting memory images: {e}") + return [] + finally: + conn.close() + + +def _regenerate_memories_background(): + """ + Background task to regenerate memories cache. + Uses global flag to prevent concurrent regenerations. + """ + global _is_regenerating + + try: + logger.warning("Starting background memory cache regeneration (this may take a while)...") + db_generate_memories() + logger.info("Background memory cache regeneration completed successfully") + except Exception as e: + logger.error(f"Error in background memory regeneration: {e}") + finally: + with _regeneration_lock: + _is_regenerating = False + + +def db_get_memories_for_current_date() -> List[Dict[str, Any]]: + """ + Get memories that are relevant for the current date. + (e.g., "On this day" memories from previous years) + Queries the persisted memories table instead of regenerating. + """ + global _is_regenerating + + conn = _connect() + cursor = conn.cursor() + + try: + current_date = datetime.now().date() # Normalize to date-only + current_day_of_year = current_date.timetuple().tm_yday + current_year = current_date.year + + # Query persisted memories using Julian day for cross-month matching + # This handles month boundaries correctly (e.g., Dec 30 -> Jan 5) + cursor.execute( + """ + SELECT id, title, description, start_date, end_date, location, + latitude, longitude, image_count + FROM memories + WHERE ABS( + (CAST(strftime('%j', start_date) AS INTEGER) - ? + 365) % 365 + ) <= 7 + AND CAST(strftime('%Y', start_date) AS INTEGER) < ? + ORDER BY start_date DESC + """, + (current_day_of_year, current_year) + ) + + rows = cursor.fetchall() + + if not rows: + # Attempt to acquire lock for background regeneration + lock_acquired = _regeneration_lock.acquire(blocking=False) + + if lock_acquired: + try: + if not _is_regenerating: + _is_regenerating = True + # Spawn background thread for regeneration + regen_thread = threading.Thread( + target=_regenerate_memories_background, + daemon=True, + name="MemoryCacheRegeneration" + ) + regen_thread.start() + logger.info("Spawned background thread for memory cache regeneration") + finally: + _regeneration_lock.release() + else: + logger.info("Memory cache regeneration already in progress, skipping") + + # Return empty result immediately (don't block request) + logger.info("No memories found in cache, background regeneration initiated") + return [] + + relevant_memories = [] + + for row in rows: + memory_id, title, description, start_date_str, end_date_str, location, latitude, longitude, image_count = row + + # Parse start date to calculate years ago (date-only string) + start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date() + years_ago = current_date.year - start_date.year + + # Get representative images for this memory + cursor.execute( + """ + SELECT i.id, i.path, i.thumbnailPath, i.metadata + FROM images i + JOIN memory_images mi ON i.id = mi.image_id + WHERE mi.memory_id = ? AND mi.is_representative = 1 + ORDER BY CASE + WHEN json_valid(i.metadata) THEN json_extract(i.metadata, '$.date_created') + ELSE NULL + END ASC, i.id ASC + LIMIT 5 + """, + (memory_id,) + ) + + images = [] + from app.utils.images import image_util_parse_metadata + + for img_row in cursor.fetchall(): + image_id, path, thumbnail, metadata_str = img_row + metadata = image_util_parse_metadata(metadata_str) + + # Extract date from metadata + date_created = metadata.get("date_created", start_date_str) + + images.append({ + "id": image_id, + "path": path, + "thumbnail": thumbnail, + "date": date_created, + "location": metadata.get("location"), + "latitude": metadata.get("latitude"), + "longitude": metadata.get("longitude"), + "metadata": metadata + }) + + # Create a shallow copy to avoid mutating cached DB rows + memory_copy = { + "id": memory_id, + "title": f"On This Day {years_ago} Year{'s' if years_ago > 1 else ''} Ago", + "description": description, + "start_date": start_date_str, + "end_date": end_date_str, + "location": location, + "latitude": latitude, + "longitude": longitude, + "image_count": image_count, + "images": images + } + + relevant_memories.append(memory_copy) + + return relevant_memories + + except Exception as e: + logger.error(f"Error getting memories for current date: {e}") + return [] + finally: + conn.close() + + +def db_check_memories_cache_exists() -> bool: + """ + Check if the memories cache is populated. + Returns True if there are memories in the cache, False otherwise. + """ + conn = _connect() + cursor = conn.cursor() + + try: + cursor.execute("SELECT COUNT(*) FROM memories") + count = cursor.fetchone()[0] + return count > 0 + except Exception as e: + logger.error(f"Error checking memories cache: {e}") + return False + finally: + conn.close() + + +def db_prepopulate_memories_cache() -> None: + """ + Pre-populate the memories cache at startup if it's empty. + This prevents cache misses during initial requests. + Should be called during application startup. + """ + global _is_regenerating + + # Check if cache already exists + if db_check_memories_cache_exists(): + logger.info("Memories cache already populated, skipping pre-population") + return + + # Acquire lock to prevent concurrent regenerations + lock_acquired = _regeneration_lock.acquire(blocking=False) + + if lock_acquired: + try: + if not _is_regenerating: + _is_regenerating = True + logger.info("Pre-populating memories cache at startup...") + + # Spawn background thread for regeneration + regen_thread = threading.Thread( + target=_regenerate_memories_background, + daemon=True, + name="MemoryCacheStartupPrePopulation" + ) + regen_thread.start() + logger.info("Memories cache pre-population started in background thread") + finally: + _regeneration_lock.release() + else: + logger.info("Memory cache regeneration already in progress, skipping pre-population") diff --git a/backend/app/routes/memories.py b/backend/app/routes/memories.py new file mode 100644 index 000000000..63ee57d15 --- /dev/null +++ b/backend/app/routes/memories.py @@ -0,0 +1,195 @@ +from fastapi import APIRouter, HTTPException, status +from typing import List +from app.database.memories import ( + db_generate_memories, + db_get_memory_images, + db_get_memories_for_current_date +) +from app.schemas.memories import ( + Memory, + MemoryImage, + GetMemoriesResponse, + GetMemoryImagesResponse, + ErrorResponse +) +from app.logging.setup_logging import get_logger + +# Initialize logger +logger = get_logger(__name__) +router = APIRouter() + + +@router.get( + "/", + response_model=GetMemoriesResponse, + responses={500: {"model": ErrorResponse}}, +) +def get_all_memories(): + """ + Get all generated memories. + + Returns memories grouped by time and location, showing representative images. + """ + try: + memories_data = db_generate_memories() + + # Convert to response format + memories = [] + for memory in memories_data: + memory_images = [ + MemoryImage( + id=img["id"], + path=img["path"], + thumbnail=img["thumbnail"], + date=img["date"].isoformat() if hasattr(img["date"], 'isoformat') else str(img["date"]), + location=img.get("location") + ) + for img in memory.get("images", []) + ] + + memories.append( + Memory( + id=memory["id"], + title=memory["title"], + description=memory["description"], + start_date=memory["start_date"], + end_date=memory["end_date"], + location=memory.get("location"), + latitude=memory.get("latitude"), + longitude=memory.get("longitude"), + image_count=memory["image_count"], + images=memory_images + ) + ) + + return GetMemoriesResponse( + success=True, + message=f"Successfully retrieved {len(memories)} memories", + data=memories + ) + + except Exception as e: + logger.error(f"Error in get_all_memories: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, + error="Internal server error", + message=f"Unable to retrieve memories: {str(e)}" + ).model_dump() + ) + + +@router.get( + "/today", + response_model=GetMemoriesResponse, + responses={500: {"model": ErrorResponse}}, +) +def get_memories_for_today(): + """ + Get memories relevant to today's date. + + Returns "On this day" style memories from previous years. + """ + try: + memories_data = db_get_memories_for_current_date() + + # Convert to response format + memories = [] + for memory in memories_data: + memory_images = [ + MemoryImage( + id=img["id"], + path=img["path"], + thumbnail=img["thumbnail"], + date=img["date"].isoformat() if hasattr(img["date"], 'isoformat') else str(img["date"]), + location=img.get("location") + ) + for img in memory.get("images", []) + ] + + memories.append( + Memory( + id=memory["id"], + title=memory["title"], + description=memory["description"], + start_date=memory["start_date"], + end_date=memory["end_date"], + location=memory.get("location"), + latitude=memory.get("latitude"), + longitude=memory.get("longitude"), + image_count=memory["image_count"], + images=memory_images + ) + ) + + return GetMemoriesResponse( + success=True, + message=f"Successfully retrieved {len(memories)} memories for today", + data=memories + ) + + except Exception as e: + logger.error(f"Error in get_memories_for_today: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, + error="Internal server error", + message=f"Unable to retrieve memories: {str(e)}" + ).model_dump() + ) + + +@router.get( + "/{memory_id}/images", + response_model=GetMemoryImagesResponse, + responses={500: {"model": ErrorResponse}}, +) +def get_memory_detail(memory_id: str): + """ + Get all images for a specific memory. + + Args: + memory_id: The unique identifier of the memory + + Returns: + All images associated with the memory + """ + try: + images_data = db_get_memory_images(memory_id) + + # Convert to response format with defensive metadata handling + images = [] + for img in images_data: + # Safely extract metadata + metadata = img.get("metadata") or {} + if not isinstance(metadata, dict): + metadata = {} + + images.append( + MemoryImage( + id=img.get("id", ""), + path=img.get("path", ""), + thumbnail=img.get("thumbnail", ""), + date=metadata.get("date_created", ""), + location=metadata.get("location") + ) + ) + + return GetMemoryImagesResponse( + success=True, + message=f"Successfully retrieved {len(images)} images", + data=images + ) + + except Exception as e: + logger.error(f"Error in get_memory_detail: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, + error="Internal server error", + message=f"Unable to retrieve memory images: {str(e)}" + ).model_dump() + ) diff --git a/backend/app/schemas/memories.py b/backend/app/schemas/memories.py new file mode 100644 index 000000000..f163fe909 --- /dev/null +++ b/backend/app/schemas/memories.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel +from typing import List, Optional + + +class MemoryImage(BaseModel): + id: str + path: str + thumbnail: str + date: str + location: Optional[str] = None + + +class Memory(BaseModel): + id: str + title: str + description: str + start_date: str + end_date: str + location: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + image_count: int + images: List[MemoryImage] + + +class GetMemoriesResponse(BaseModel): + success: bool + message: str + data: List[Memory] + + +class GetMemoryImagesResponse(BaseModel): + success: bool + message: str + data: List[MemoryImage] + + +class ErrorResponse(BaseModel): + success: bool + error: str + message: str diff --git a/backend/main.py b/backend/main.py index 2c1f39e44..c17cc7693 100644 --- a/backend/main.py +++ b/backend/main.py @@ -19,6 +19,7 @@ from app.database.albums import db_create_album_images_table from app.database.folders import db_create_folders_table from app.database.metadata import db_create_metadata_table +from app.database.memories import db_create_memories_table, db_prepopulate_memories_cache from app.utils.microservice import microservice_util_start_sync_service from app.routes.folders import router as folders_router @@ -26,6 +27,7 @@ from app.routes.images import router as images_router from app.routes.face_clusters import router as face_clusters_router from app.routes.user_preferences import router as user_preferences_router +from app.routes.memories import router as memories_router from fastapi.openapi.utils import get_openapi from app.logging.setup_logging import ( configure_uvicorn_logging, @@ -52,6 +54,8 @@ async def lifespan(app: FastAPI): db_create_albums_table() db_create_album_images_table() db_create_metadata_table() + db_create_memories_table() + db_prepopulate_memories_cache() # Pre-populate memories cache at startup microservice_util_start_sync_service() # Create ProcessPoolExecutor and attach it to app.state app.state.executor = ProcessPoolExecutor(max_workers=1) @@ -132,6 +136,7 @@ async def root(): app.include_router( user_preferences_router, prefix="/user-preferences", tags=["User Preferences"] ) +app.include_router(memories_router, prefix="/memories", tags=["Memories"]) # Entry point for running with: python3 main.py diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3b7716121..4ea37173f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,14 +1,18 @@ import pytest import os +import json +import tempfile +from datetime import datetime, timedelta # Import database table creation functions from app.database.faces import db_create_faces_table -from app.database.images import db_create_images_table +from app.database.images import db_create_images_table, db_bulk_insert_images from app.database.face_clusters import db_create_clusters_table from app.database.yolo_mapping import db_create_YOLO_classes_table from app.database.albums import db_create_albums_table, db_create_album_images_table from app.database.folders import db_create_folders_table from app.database.metadata import db_create_metadata_table +from app.database.memories import db_create_memories_table @pytest.fixture(scope="session", autouse=True) @@ -29,6 +33,7 @@ def setup_before_all_tests(): db_create_album_images_table() db_create_images_table() db_create_metadata_table() + db_create_memories_table() print("All database tables created successfully") except Exception as e: print(f"Error creating database tables: {e}") @@ -42,3 +47,153 @@ def setup_before_all_tests(): # Cleanup code here if "TEST_MODE" in os.environ: del os.environ["TEST_MODE"] + + +@pytest.fixture(scope="function") +def test_db(): + """Create a temporary test database for each test.""" + db_fd, db_path = tempfile.mkstemp() + + import app.config.settings + + original_db_path = app.config.settings.DATABASE_PATH + app.config.settings.DATABASE_PATH = db_path + + yield db_path + + app.config.settings.DATABASE_PATH = original_db_path + os.close(db_fd) + os.unlink(db_path) + + +@pytest.fixture +def sample_images(): + """Fixture to create sample images with various dates for testing.""" + import sqlite3 + from app.config.settings import DATABASE_PATH + + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + + # Ensure tables exist + db_create_folders_table() + db_create_images_table() + + # Create a test folder + cursor.execute( + "INSERT OR IGNORE INTO folders (folder_id, folder_path, AI_Tagging) VALUES (999, '/test/path', 1)" + ) + + # Create sample images with different dates + sample_data = [] + base_date = datetime.now() + + for i in range(10): + image_date = base_date - timedelta(days=i*30) # Monthly intervals + metadata = { + "name": f"test_image_{i}.jpg", + "date_created": image_date.isoformat(), + "width": 1920, + "height": 1080, + "file_location": f"/test/path/image_{i}.jpg", + "file_size": 102400, + "item_type": "image/jpeg", + "latitude": 40.7128 + (i * 0.01), + "longitude": -74.0060 + (i * 0.01), + "location": "New York" + } + + sample_data.append({ + "id": f"test_image_{i}", + "path": f"/test/path/image_{i}.jpg", + "folder_id": 999, + "thumbnailPath": f"/test/path/thumb_{i}.jpg", + "metadata": json.dumps(metadata), + "isTagged": False + }) + + # Insert sample images + db_bulk_insert_images(sample_data) + conn.commit() + conn.close() + + yield sample_data + + # Cleanup + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + cursor.execute("DELETE FROM images WHERE folder_id = 999") + cursor.execute("DELETE FROM folders WHERE folder_id = 999") + conn.commit() + conn.close() + + +@pytest.fixture +def sample_images_with_location(): + """Fixture to create sample images with different locations for testing.""" + import sqlite3 + from app.config.settings import DATABASE_PATH + + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + + # Ensure tables exist + db_create_folders_table() + db_create_images_table() + + # Create a test folder + cursor.execute( + "INSERT OR IGNORE INTO folders (folder_id, folder_path, AI_Tagging) VALUES (998, '/test/location', 1)" + ) + + # Create sample images with different locations + locations = [ + ("New York", 40.7128, -74.0060), + ("Los Angeles", 34.0522, -118.2437), + ("Chicago", 41.8781, -87.6298), + ] + + sample_data = [] + base_date = datetime.now() + + for i, (location, lat, lon) in enumerate(locations): + for j in range(4): # 4 images per location + idx = i * 4 + j + image_date = base_date - timedelta(days=idx*15) + + metadata = { + "name": f"location_image_{idx}.jpg", + "date_created": image_date.isoformat(), + "width": 1920, + "height": 1080, + "file_location": f"/test/location/image_{idx}.jpg", + "file_size": 102400, + "item_type": "image/jpeg", + "latitude": lat, + "longitude": lon, + "location": location + } + + sample_data.append({ + "id": f"location_image_{idx}", + "path": f"/test/location/image_{idx}.jpg", + "folder_id": 998, + "thumbnailPath": f"/test/location/thumb_{idx}.jpg", + "metadata": json.dumps(metadata), + "isTagged": False + }) + + # Insert sample images + db_bulk_insert_images(sample_data) + conn.commit() + conn.close() + + yield sample_data + + # Cleanup + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + cursor.execute("DELETE FROM images WHERE folder_id = 998") + cursor.execute("DELETE FROM folders WHERE folder_id = 998") + conn.commit() + conn.close() diff --git a/backend/tests/test_folders.py b/backend/tests/test_folders.py index a0d26f0e5..04553cc9d 100644 --- a/backend/tests/test_folders.py +++ b/backend/tests/test_folders.py @@ -16,23 +16,6 @@ # ############################## -@pytest.fixture(scope="function") -def test_db(): - """Create a temporary test database for each test.""" - db_fd, db_path = tempfile.mkstemp() - - import app.config.settings - - original_db_path = app.config.settings.DATABASE_PATH - app.config.settings.DATABASE_PATH = db_path - - yield db_path - - app.config.settings.DATABASE_PATH = original_db_path - os.close(db_fd) - os.unlink(db_path) - - @pytest.fixture def temp_folder_structure(): """Create a temporary folder structure for testing.""" diff --git a/backend/tests/test_memories.py b/backend/tests/test_memories.py new file mode 100644 index 000000000..628bec00e --- /dev/null +++ b/backend/tests/test_memories.py @@ -0,0 +1,183 @@ +import pytest +from app.database.memories import ( + db_create_memories_table, + db_generate_memories, + db_get_memory_images, +) + + +def test_create_memories_table(test_db): + """Test that memories table is created successfully.""" + db_create_memories_table() + + # Verify table exists by querying it + import sqlite3 + from app.config.settings import DATABASE_PATH + + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='memories'" + ) + assert cursor.fetchone() is not None + + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='memory_images'" + ) + assert cursor.fetchone() is not None + + conn.close() + + +def test_generate_memories_empty(test_db): + """Test generating memories with no images.""" + db_create_memories_table() + memories = db_generate_memories() + assert isinstance(memories, list) + assert len(memories) == 0 + + +def test_generate_memories_with_data(test_db, sample_images): + """Test generating memories with sample images.""" + db_create_memories_table() + memories = db_generate_memories() + + assert isinstance(memories, list) + # Memories should group images by date and location + # Note: sample_images fixture creates 10 images with monthly intervals, + # but db_generate_memories requires at least 3 images per group + # This assertion ensures the test fails if no memories are generated + assert len(memories) > 0, "Expected memories to be generated from sample images, but got empty list" + + memory = memories[0] + assert "id" in memory, "Memory should have 'id' field" + assert "title" in memory, "Memory should have 'title' field" + assert "description" in memory, "Memory should have 'description' field" + assert "start_date" in memory, "Memory should have 'start_date' field" + assert "end_date" in memory, "Memory should have 'end_date' field" + assert "image_count" in memory, "Memory should have 'image_count' field" + assert "images" in memory, "Memory should have 'images' field" + assert isinstance(memory["images"], list), "Memory images should be a list" + + +def test_get_memory_images(test_db, sample_images): + """Test retrieving images for a specific memory.""" + import sqlite3 + from app.config.settings import DATABASE_PATH + + db_create_memories_table() + + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + + try: + # Insert a test memory + test_memory_id = "test_memory_2024_01" + cursor.execute( + """ + INSERT INTO memories + (id, title, description, start_date, end_date, location, latitude, longitude, image_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + test_memory_id, + "Test Memory", + "Test description", + "2024-01-15", + "2024-01-20", + "Test Location", + 40.7128, + -74.0060, + 3 + ) + ) + + # Insert memory-image associations using sample images + for idx in range(min(3, len(sample_images))): + image_id = sample_images[idx]["id"] + cursor.execute( + """ + INSERT INTO memory_images (memory_id, image_id, is_representative) + VALUES (?, ?, ?) + """, + (test_memory_id, image_id, True) + ) + + conn.commit() + + # Now test db_get_memory_images + images = db_get_memory_images(test_memory_id) + + # Assertions + assert isinstance(images, list), "Result should be a list" + assert len(images) > 0, "Should return at least one image" + assert len(images) <= 3, "Should return at most 3 images" + + # Verify required fields in each image + for image in images: + assert "id" in image, "Image should have 'id' field" + assert "path" in image, "Image should have 'path' field" + assert "thumbnail" in image, "Image should have 'thumbnail' field" + assert "metadata" in image, "Image should have 'metadata' field" + + # Verify the image ID is from our test data + assert image["id"] in [sample_images[i]["id"] for i in range(min(3, len(sample_images)))] + + # Clean up test memory + cursor.execute("DELETE FROM memory_images WHERE memory_id = ?", (test_memory_id,)) + cursor.execute("DELETE FROM memories WHERE id = ?", (test_memory_id,)) + conn.commit() + + finally: + conn.close() + + +def test_memory_title_generation(test_db, sample_images): + """Test that memory titles are generated correctly.""" + db_create_memories_table() + memories = db_generate_memories() + + # Explicitly assert memories were generated + assert len(memories) > 0, "Expected memories to be generated from sample images for title validation" + + for memory in memories: + # Title should not be empty + assert memory["title"], "Memory title should not be empty" + assert len(memory["title"]) > 0, "Memory title should have content" + + # Title should contain year or time reference + assert any( + indicator in memory["title"].lower() + for indicator in ["year", "ago", "2023", "2024", "2025"] + ), f"Memory title '{memory['title']}' should contain a time reference" + + +def test_memory_grouping_by_location(test_db, sample_images_with_location): + """Test that memories are grouped by location.""" + db_create_memories_table() + memories = db_generate_memories() + + # If we have multiple locations, they should be in separate memories + locations = set() + for memory in memories: + if memory.get("location"): + locations.add(memory["location"]) + + # Each memory should have a consistent location + for memory in memories: + if memory.get("location") and len(memory["images"]) > 0: + # All images in this memory should have similar locations + assert memory["location"] is not None + + +def test_memory_image_count(test_db, sample_images): + """Test that image count matches actual images.""" + db_create_memories_table() + memories = db_generate_memories() + + for memory in memories: + # Image count should match the total images in the group + assert memory["image_count"] >= len(memory.get("images", [])) + # Representative images should not exceed total count + assert len(memory.get("images", [])) <= 5 # Max 5 representative images diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index 44eb908b1..d77f0603a 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -1117,9 +1117,14 @@ "in": "query", "required": false, "schema": { - "$ref": "#/components/schemas/InputType", + "allOf": [ + { + "$ref": "#/components/schemas/InputType" + } + ], "description": "Choose input type: 'path' or 'base64'", - "default": "path" + "default": "path", + "title": "Input Type" }, "description": "Choose input type: 'path' or 'base64'" } @@ -1299,6 +1304,123 @@ } } } + }, + "/memories/": { + "get": { + "tags": [ + "Memories" + ], + "summary": "Get All Memories", + "description": "Get all generated memories.\n\nReturns memories grouped by time and location, showing representative images.", + "operationId": "get_all_memories_memories__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetMemoriesResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__memories__ErrorResponse" + } + } + } + } + } + } + }, + "/memories/today": { + "get": { + "tags": [ + "Memories" + ], + "summary": "Get Memories For Today", + "description": "Get memories relevant to today's date.\n\nReturns \"On this day\" style memories from previous years.", + "operationId": "get_memories_for_today_memories_today_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetMemoriesResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__memories__ErrorResponse" + } + } + } + } + } + } + }, + "/memories/{memory_id}/images": { + "get": { + "tags": [ + "Memories" + ], + "summary": "Get Memory Detail", + "description": "Get all images for a specific memory.\n\nArgs:\n memory_id: The unique identifier of the memory\n \nReturns:\n All images associated with the memory", + "operationId": "get_memory_detail_memories__memory_id__images_get", + "parameters": [ + { + "name": "memory_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Memory Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetMemoryImagesResponse" + } + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__memories__ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { @@ -2005,6 +2127,58 @@ ], "title": "GetClustersResponse" }, + "GetMemoriesResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success" + }, + "message": { + "type": "string", + "title": "Message" + }, + "data": { + "items": { + "$ref": "#/components/schemas/Memory" + }, + "type": "array", + "title": "Data" + } + }, + "type": "object", + "required": [ + "success", + "message", + "data" + ], + "title": "GetMemoriesResponse" + }, + "GetMemoryImagesResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success" + }, + "message": { + "type": "string", + "title": "Message" + }, + "data": { + "items": { + "$ref": "#/components/schemas/MemoryImage" + }, + "type": "array", + "title": "Data" + } + }, + "type": "object", + "required": [ + "success", + "message", + "data" + ], + "title": "GetMemoryImagesResponse" + }, "GetUserPreferencesResponse": { "properties": { "success": { @@ -2199,7 +2373,6 @@ "metadata": { "anyOf": [ { - "additionalProperties": true, "type": "object" }, { @@ -2262,6 +2435,124 @@ ], "title": "InputType" }, + "Memory": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "title": { + "type": "string", + "title": "Title" + }, + "description": { + "type": "string", + "title": "Description" + }, + "start_date": { + "type": "string", + "title": "Start Date" + }, + "end_date": { + "type": "string", + "title": "End Date" + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Location" + }, + "latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Latitude" + }, + "longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Longitude" + }, + "image_count": { + "type": "integer", + "title": "Image Count" + }, + "images": { + "items": { + "$ref": "#/components/schemas/MemoryImage" + }, + "type": "array", + "title": "Images" + } + }, + "type": "object", + "required": [ + "id", + "title", + "description", + "start_date", + "end_date", + "image_count", + "images" + ], + "title": "Memory" + }, + "MemoryImage": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "path": { + "type": "string", + "title": "Path" + }, + "thumbnail": { + "type": "string", + "title": "Thumbnail" + }, + "date": { + "type": "string", + "title": "Date" + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Location" + } + }, + "type": "object", + "required": [ + "id", + "path", + "thumbnail", + "date" + ], + "title": "MemoryImage" + }, "MetadataModel": { "properties": { "name": { @@ -2894,6 +3185,29 @@ ], "title": "ErrorResponse" }, + "app__schemas__memories__ErrorResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success" + }, + "error": { + "type": "string", + "title": "Error" + }, + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "success", + "error", + "message" + ], + "title": "ErrorResponse" + }, "app__schemas__user_preferences__ErrorResponse": { "properties": { "success": { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e1e1ddd5f..5c4674b22 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -128,6 +128,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -780,6 +781,7 @@ "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1705,6 +1707,7 @@ "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-module-imports": "^7.27.1", @@ -5354,8 +5357,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5533,6 +5535,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5550,6 +5553,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5560,6 +5564,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5689,6 +5694,7 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -5935,6 +5941,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6641,6 +6648,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -7349,8 +7357,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/domexception": { "version": "4.0.0", @@ -7709,6 +7716,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -9753,6 +9761,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -11237,7 +11246,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11924,6 +11932,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11956,6 +11965,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12059,7 +12069,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -12075,7 +12084,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -12195,6 +12203,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12253,6 +12262,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -12288,14 +12298,14 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -12457,7 +12467,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -13420,6 +13431,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13560,6 +13572,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13792,6 +13805,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14050,6 +14064,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -14182,6 +14197,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14476,20 +14492,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/frontend/public/placeholder.svg b/frontend/public/placeholder.svg new file mode 100644 index 000000000..cbe95ca57 --- /dev/null +++ b/frontend/public/placeholder.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/api/api-functions/index.ts b/frontend/src/api/api-functions/index.ts index 5d6f2fa8c..4e22ef925 100644 --- a/frontend/src/api/api-functions/index.ts +++ b/frontend/src/api/api-functions/index.ts @@ -4,3 +4,4 @@ export * from './images'; export * from './folders'; export * from './user_preferences'; export * from './health'; +export * from './memories'; diff --git a/frontend/src/api/api-functions/memories.ts b/frontend/src/api/api-functions/memories.ts new file mode 100644 index 000000000..e182bf8d0 --- /dev/null +++ b/frontend/src/api/api-functions/memories.ts @@ -0,0 +1,26 @@ +import { memoriesEndpoints } from '../apiEndpoints'; +import { apiClient } from '../axiosConfig'; +import { APIResponse } from '@/types/API'; + +export const fetchAllMemories = async (): Promise => { + const response = await apiClient.get( + memoriesEndpoints.getAllMemories, + ); + return response.data; +}; + +export const fetchTodayMemories = async (): Promise => { + const response = await apiClient.get( + memoriesEndpoints.getTodayMemories, + ); + return response.data; +}; + +export const fetchMemoryImages = async ( + memoryId: string, +): Promise => { + const response = await apiClient.get( + memoriesEndpoints.getMemoryImages(memoryId), + ); + return response.data; +}; diff --git a/frontend/src/api/apiEndpoints.ts b/frontend/src/api/apiEndpoints.ts index 69a7e570d..3ee64bb76 100644 --- a/frontend/src/api/apiEndpoints.ts +++ b/frontend/src/api/apiEndpoints.ts @@ -27,6 +27,12 @@ export const userPreferencesEndpoints = { updateUserPreferences: '/user-preferences/', }; +export const memoriesEndpoints = { + getAllMemories: '/memories/', + getTodayMemories: '/memories/today', + getMemoryImages: (memoryId: string) => `/memories/${memoryId}/images`, +}; + export const healthEndpoints = { healthCheck: '/health', }; diff --git a/frontend/src/pages/Memories/Memories.tsx b/frontend/src/pages/Memories/Memories.tsx index 92f232b51..bee377f9a 100644 --- a/frontend/src/pages/Memories/Memories.tsx +++ b/frontend/src/pages/Memories/Memories.tsx @@ -1,5 +1,461 @@ +import { useEffect, useState } from 'react'; +import { Calendar, MapPin, Image as ImageIcon, Sparkles, ArrowLeft, X } from 'lucide-react'; +import { usePictoQuery } from '@/hooks/useQueryExtension'; +import { fetchAllMemories, fetchMemoryImages } from '@/api/api-functions'; +import { useMutationFeedback } from '@/hooks/useMutationFeedback'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { convertFileSrc } from '@tauri-apps/api/core'; + +interface MemoryImage { + id: string; + path: string; + thumbnail?: string; + date: string; + location?: string; +} + +interface Memory { + id: string; + title: string; + description: string; + start_date: string; + end_date: string; + location?: string; + latitude?: number; + longitude?: number; + image_count: number; + images: MemoryImage[]; +} + +const MemoryCard = ({ memory, onClick }: { memory: Memory; onClick: () => void }) => { + const [imageLoadError, setImageLoadError] = useState>(new Set()); + + const handleImageError = (index: number) => { + setImageLoadError((prev) => new Set(prev).add(index)); + }; + + // Display up to 5 images in a grid layout + const displayImages = memory.images.slice(0, 5); + + return ( + + + {/* Image Grid - Fixed aspect ratio container for consistency */} +
+ {displayImages.length === 1 && ( + {memory.title} handleImageError(0)} + /> + )} + + {displayImages.length === 2 && ( +
+ {displayImages.map((img, idx) => ( +
+ {`Memory handleImageError(idx)} + /> +
+ ))} +
+ )} + + {displayImages.length === 3 && ( +
+
+ Memory 1 handleImageError(0)} + /> +
+ {displayImages.slice(1).map((img, idx) => ( +
+ {`Memory handleImageError(idx + 1)} + /> +
+ ))} +
+ )} + + {displayImages.length >= 4 && ( +
+
+ Memory 1 handleImageError(0)} + /> +
+ {displayImages.slice(1, 3).map((img, idx) => ( +
+ {`Memory handleImageError(idx + 1)} + /> +
+ ))} +
+ )} + + {/* Gradient overlay */} +
+ + {/* Image count badge */} +
+ + + {memory.image_count} + +
+
+ + {/* Memory Info */} +
+

{memory.title}

+

+ {memory.description} +

+ + {/* Metadata */} +
+
+ + + {new Date(memory.start_date).toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + })} + +
+ {memory.location && memory.location !== 'Unknown Location' && ( +
+ + {memory.location} +
+ )} +
+
+ + + ); +}; + +const MemoryCardSkeleton = () => ( + + + +
+ + +
+ + +
+
+
+
+); + +const EmptyMemoriesState = () => ( +
+
+ +
+

No Memories Yet

+

+ Your memories will appear here automatically as you add more photos with dates and locations. + Keep capturing moments! +

+
+); + +const MemoryDetailView = ({ + memory, + onBack +}: { + memory: Memory; + onBack: () => void; +}) => { + const [imageLoadError, setImageLoadError] = useState>(new Set()); + const [selectedImageIndex, setSelectedImageIndex] = useState(null); + const [allImages, setAllImages] = useState(memory.images); + const [isLoadingImages, setIsLoadingImages] = useState(true); + + // Fetch all images for this memory + useEffect(() => { + const loadAllImages = async () => { + setIsLoadingImages(true); + try { + const response = await fetchMemoryImages(memory.id); + if (response.success && response.data && Array.isArray(response.data)) { + // Map the response to MemoryImage format + const images: MemoryImage[] = response.data.map((img: any) => ({ + id: img.id, + path: img.path, + thumbnail: img.thumbnail, + date: img.metadata?.date_created || '', + location: img.metadata?.location, + })); + setAllImages(images); + } + } catch (error) { + console.error('Failed to load memory images:', error); + // Fall back to the preview images + setAllImages(memory.images); + } finally { + setIsLoadingImages(false); + } + }; + + loadAllImages(); + }, [memory.id, memory.images]); + + const handleImageError = (index: number) => { + setImageLoadError((prev) => new Set(prev).add(index)); + }; + + return ( +
+ {/* Header */} +
+ +
+ +

{memory.title}

+
+

{memory.description}

+
+
+ + + {new Date(memory.start_date).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + })} + {memory.start_date !== memory.end_date && ( + <> - {new Date(memory.end_date).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + })} + )} + +
+ {memory.location && memory.location !== 'Unknown Location' && ( +
+ + {memory.location} +
+ )} +
+ + {memory.image_count} photos +
+
+
+ + {/* Image Grid */} +
+ {isLoadingImages ? ( +
+ {[...Array(Math.min(memory.image_count, 12))].map((_, i) => ( + + ))} +
+ ) : ( +
+ {allImages.map((img, idx) => ( +
setSelectedImageIndex(idx)} + > + {`Photo handleImageError(idx)} + /> +
+ ))} +
+ )} +
+ + {/* Full Image Modal */} + {selectedImageIndex !== null && allImages[selectedImageIndex] && ( +
setSelectedImageIndex(null)} + > + + {`Photo e.stopPropagation()} + /> +
+ {selectedImageIndex + 1} / {allImages.length} +
+
+ )} +
+ ); +}; + const Memories = () => { - return <>; + const [memories, setMemories] = useState([]); + const [selectedMemory, setSelectedMemory] = useState(null); + + const { data, isLoading, isSuccess, isError, error } = usePictoQuery({ + queryKey: ['memories'], + queryFn: () => fetchAllMemories(), + }); + + useMutationFeedback( + { isPending: isLoading, isSuccess, isError, error }, + { + loadingMessage: 'Loading memories', + showSuccess: false, + errorTitle: 'Error', + errorMessage: 'Failed to load memories. Please try again later.', + }, + ); + + useEffect(() => { + if (isSuccess && data?.data) { + // Validate data structure before setting state + const memoriesData = data.data; + + if (Array.isArray(memoriesData)) { + // Filter and validate each memory object + const validMemories = memoriesData.filter((item: any) => { + return ( + item && + typeof item === 'object' && + typeof item.id === 'string' && + typeof item.title === 'string' && + Array.isArray(item.images) + ); + }) as Memory[]; + + setMemories(validMemories); + } + } + }, [data, isSuccess]); + + // If a memory is selected, show the detail view + if (selectedMemory) { + return ( + setSelectedMemory(null)} + /> + ); + } + + return ( +
+ {/* Header */} +
+
+ +

Memories

+
+

+ Relive your favorite moments, automatically organized by time and location +

+
+ + {/* Content */} +
+ {isLoading ? ( +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+ ) : memories.length > 0 ? ( +
+ {memories.map((memory) => ( + setSelectedMemory(memory)} + /> + ))} +
+ ) : ( + + )} +
+
+ ); }; export default Memories; diff --git a/frontend/src/routes/AppRoutes.tsx b/frontend/src/routes/AppRoutes.tsx index 22153edbb..7df16fcd1 100644 --- a/frontend/src/routes/AppRoutes.tsx +++ b/frontend/src/routes/AppRoutes.tsx @@ -9,6 +9,7 @@ import { MyFav } from '@/pages/Home/MyFav'; import { AITagging } from '@/pages/AITagging/AITagging'; import { PersonImages } from '@/pages/PersonImages/PersonImages'; import { ComingSoon } from '@/pages/ComingSoon/ComingSoon'; +import Memories from '@/pages/Memories/Memories'; export const AppRoutes: React.FC = () => { return ( @@ -21,7 +22,7 @@ export const AppRoutes: React.FC = () => { } /> } /> } /> - } /> + } /> } /> diff --git a/scripts/setup.sh b/scripts/setup.sh old mode 100644 new mode 100755