diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py index 6fecb0179..75fee4e4d 100644 --- a/backend/app/config/settings.py +++ b/backend/app/config/settings.py @@ -1,6 +1,9 @@ # Model Exports Path MODEL_EXPORTS_PATH = "app/models/ONNX_Exports" +# Microservice URLs +SYNC_MICROSERVICE_URL = "http://localhost:8001/api/v1" + # Object Detection Models: SMALL_OBJ_DETECTION_MODEL = f"{MODEL_EXPORTS_PATH}/YOLOv11_Small.onnx" NANO_OBJ_DETECTION_MODEL = f"{MODEL_EXPORTS_PATH}/YOLOv11_Nano.onnx" diff --git a/backend/app/database/face_clusters.py b/backend/app/database/face_clusters.py index d39b48c99..c3c74a02b 100644 --- a/backend/app/database/face_clusters.py +++ b/backend/app/database/face_clusters.py @@ -12,6 +12,7 @@ class ClusterData(TypedDict): cluster_id: ClusterId cluster_name: Optional[ClusterName] + face_image_base64: Optional[str] ClusterMap = Dict[ClusterId, ClusterData] @@ -25,7 +26,8 @@ def db_create_clusters_table() -> None: """ CREATE TABLE IF NOT EXISTS face_clusters ( cluster_id TEXT PRIMARY KEY, - cluster_name TEXT + cluster_name TEXT, + face_image_base64 TEXT ) """ ) @@ -56,14 +58,15 @@ def db_insert_clusters_batch(clusters: List[ClusterData]) -> List[ClusterId]: for cluster in clusters: cluster_id = cluster.get("cluster_id") cluster_name = cluster.get("cluster_name") + face_image_base64 = cluster.get("face_image_base64") - insert_data.append((cluster_id, cluster_name)) + insert_data.append((cluster_id, cluster_name, face_image_base64)) cluster_ids.append(cluster_id) cursor.executemany( """ - INSERT INTO face_clusters (cluster_id, cluster_name) - VALUES (?, ?) + INSERT INTO face_clusters (cluster_id, cluster_name, face_image_base64) + VALUES (?, ?, ?) """, insert_data, ) @@ -88,7 +91,7 @@ def db_get_cluster_by_id(cluster_id: ClusterId) -> Optional[ClusterData]: cursor = conn.cursor() cursor.execute( - "SELECT cluster_id, cluster_name FROM face_clusters WHERE cluster_id = ?", + "SELECT cluster_id, cluster_name, face_image_base64 FROM face_clusters WHERE cluster_id = ?", (cluster_id,), ) @@ -96,7 +99,9 @@ def db_get_cluster_by_id(cluster_id: ClusterId) -> Optional[ClusterData]: conn.close() if row: - return ClusterData(cluster_id=row[0], cluster_name=row[1]) + return ClusterData( + cluster_id=row[0], cluster_name=row[1], face_image_base64=row[2] + ) return None @@ -111,7 +116,7 @@ def db_get_all_clusters() -> List[ClusterData]: cursor = conn.cursor() cursor.execute( - "SELECT cluster_id, cluster_name FROM face_clusters ORDER BY cluster_id" + "SELECT cluster_id, cluster_name, face_image_base64 FROM face_clusters ORDER BY cluster_id" ) rows = cursor.fetchall() @@ -119,7 +124,11 @@ def db_get_all_clusters() -> List[ClusterData]: clusters = [] for row in rows: - clusters.append(ClusterData(cluster_id=row[0], cluster_name=row[1])) + clusters.append( + ClusterData( + cluster_id=row[0], cluster_name=row[1], face_image_base64=row[2] + ) + ) return clusters @@ -190,20 +199,24 @@ def db_get_all_clusters_with_face_counts() -> List[ Dict[str, Union[str, Optional[str], int]] ]: """ - Retrieve all clusters with their face counts. + Retrieve all clusters with their face counts and stored face images. Returns: - List of dictionaries containing cluster_id, cluster_name, and face_count + List of dictionaries containing cluster_id, cluster_name, face_count, and face_image_base64 """ conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() cursor.execute( """ - SELECT fc.cluster_id, fc.cluster_name, COUNT(f.face_id) as face_count + SELECT + fc.cluster_id, + fc.cluster_name, + COUNT(f.face_id) as face_count, + fc.face_image_base64 FROM face_clusters fc LEFT JOIN faces f ON fc.cluster_id = f.cluster_id - GROUP BY fc.cluster_id, fc.cluster_name + GROUP BY fc.cluster_id, fc.cluster_name, fc.face_image_base64 ORDER BY fc.cluster_id """ ) @@ -213,8 +226,84 @@ def db_get_all_clusters_with_face_counts() -> List[ clusters = [] for row in rows: + cluster_id, cluster_name, face_count, face_image_base64 = row clusters.append( - {"cluster_id": row[0], "cluster_name": row[1], "face_count": row[2]} + { + "cluster_id": cluster_id, + "cluster_name": cluster_name, + "face_count": face_count, + "face_image_base64": face_image_base64, + } ) return clusters + + +def db_get_images_by_cluster_id( + cluster_id: ClusterId, +) -> List[Dict[str, Union[str, int]]]: + """ + Get all images that contain faces belonging to a specific cluster. + + Args: + cluster_id: The ID of the cluster to get images for + + Returns: + List of dictionaries containing image data with face information + """ + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + + cursor.execute( + """ + SELECT DISTINCT + i.id as image_id, + i.path as image_path, + i.thumbnailPath as thumbnail_path, + i.metadata, + f.face_id, + f.confidence, + f.bbox + FROM images i + INNER JOIN faces f ON i.id = f.image_id + WHERE f.cluster_id = ? + ORDER BY i.path + """, + (cluster_id,), + ) + + rows = cursor.fetchall() + conn.close() + + images = [] + for row in rows: + ( + image_id, + image_path, + thumbnail_path, + metadata, + face_id, + confidence, + bbox_json, + ) = row + + # Parse bbox JSON if it exists + bbox = None + if bbox_json: + import json + + bbox = json.loads(bbox_json) + + images.append( + { + "image_id": image_id, + "image_path": image_path, + "thumbnail_path": thumbnail_path, + "metadata": metadata, + "face_id": face_id, + "confidence": confidence, + "bbox": bbox, + } + ) + + return images diff --git a/backend/app/database/folders.py b/backend/app/database/folders.py index 150ce764c..383628d1c 100644 --- a/backend/app/database/folders.py +++ b/backend/app/database/folders.py @@ -382,6 +382,30 @@ def db_get_folder_ids_by_paths( conn.close() +def db_get_all_folder_details() -> List[ + Tuple[str, str, Optional[str], int, bool, Optional[bool]] +]: + """ + Get all folder details including folder_id, folder_path, parent_folder_id, + last_modified_time, AI_Tagging, and taggingCompleted. + Returns list of tuples with all folder information. + """ + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + + try: + cursor.execute( + """ + SELECT folder_id, folder_path, parent_folder_id, last_modified_time, AI_Tagging, taggingCompleted + FROM folders + ORDER BY folder_path + """ + ) + return cursor.fetchall() + finally: + conn.close() + + def db_get_direct_child_folders(parent_folder_id: str) -> List[Tuple[str, str]]: """ Get all direct child folders (not subfolders) for a given parent folder. diff --git a/backend/app/database/images.py b/backend/app/database/images.py index 15466753c..3b9e2647d 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -90,6 +90,81 @@ def db_bulk_insert_images(image_records: List[ImageRecord]) -> bool: conn.close() +def db_get_all_images() -> List[dict]: + """ + Get all images from the database with their tags. + + Returns: + List of dictionaries containing all image data including tags + """ + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + + try: + cursor.execute( + """ + SELECT + i.id, + i.path, + i.folder_id, + i.thumbnailPath, + i.metadata, + i.isTagged, + m.name as tag_name + FROM images i + LEFT JOIN image_classes ic ON i.id = ic.image_id + LEFT JOIN mappings m ON ic.class_id = m.class_id + ORDER BY i.path, m.name + """ + ) + + results = cursor.fetchall() + + # Group results by image ID + images_dict = {} + for ( + image_id, + path, + folder_id, + thumbnail_path, + metadata, + is_tagged, + tag_name, + ) in results: + if image_id not in images_dict: + images_dict[image_id] = { + "id": image_id, + "path": path, + "folder_id": folder_id, + "thumbnailPath": thumbnail_path, + "metadata": metadata, + "isTagged": bool(is_tagged), + "tags": [], + } + + # Add tag if it exists + if tag_name: + images_dict[image_id]["tags"].append(tag_name) + + # Convert to list and set tags to None if empty + images = [] + for image_data in images_dict.values(): + if not image_data["tags"]: + image_data["tags"] = None + images.append(image_data) + + # Sort by path + images.sort(key=lambda x: x["path"]) + + return images + + except Exception as e: + print(f"Error getting all images: {e}") + return [] + finally: + conn.close() + + def db_get_untagged_images() -> List[ImageRecord]: """ Find all images that need AI tagging. diff --git a/backend/app/models/FaceDetector.py b/backend/app/models/FaceDetector.py index 86831150b..0ccf1c3f2 100644 --- a/backend/app/models/FaceDetector.py +++ b/backend/app/models/FaceDetector.py @@ -26,13 +26,20 @@ def detect_faces(self, image_id: int, image_path: str): return None boxes, scores, class_ids = self.yolo_detector(img) + print(boxes) print(f"Detected {len(boxes)} faces in image {image_id}.") - processed_faces, embeddings = [], [] + processed_faces, embeddings, bboxes, confidences = [], [], [], [] for box, score in zip(boxes, scores): if score > self.yolo_detector.conf_threshold: x1, y1, x2, y2 = map(int, box) + + # Create bounding box dictionary in JSON format + bbox = {"x": x1, "y": y1, "width": x2 - x1, "height": y2 - y1} + bboxes.append(bbox) + confidences.append(float(score)) + padding = 20 face_img = img[ max(0, y1 - padding) : min(img.shape[0], y2 + padding), @@ -45,7 +52,9 @@ def detect_faces(self, image_id: int, image_path: str): embeddings.append(embedding) if embeddings: - db_insert_face_embeddings_by_image_id(image_id, embeddings) + db_insert_face_embeddings_by_image_id( + image_id, embeddings, confidence=confidences, bbox=bboxes + ) return { "ids": f"{class_ids}", diff --git a/backend/app/routes/face_clusters.py b/backend/app/routes/face_clusters.py index b0c818ca9..9249eb099 100644 --- a/backend/app/routes/face_clusters.py +++ b/backend/app/routes/face_clusters.py @@ -3,13 +3,19 @@ db_get_cluster_by_id, db_update_cluster, db_get_all_clusters_with_face_counts, + db_get_images_by_cluster_id, # Add this import ) from app.schemas.face_clusters import ( RenameClusterRequest, RenameClusterResponse, + RenameClusterData, ErrorResponse, GetClustersResponse, + GetClustersData, ClusterMetadata, + GetClusterImagesResponse, + GetClusterImagesData, + ImageInCluster, ) @@ -62,8 +68,10 @@ def rename_cluster(cluster_id: str, request: RenameClusterRequest): return RenameClusterResponse( success=True, message=f"Successfully renamed cluster to '{request.cluster_name}'", - cluster_id=cluster_id, - cluster_name=request.cluster_name.strip(), + data=RenameClusterData( + cluster_id=cluster_id, + cluster_name=request.cluster_name.strip(), + ), ) except ValueError as e: @@ -104,6 +112,7 @@ def get_all_clusters(): cluster_id=cluster["cluster_id"], cluster_name=cluster["cluster_name"], face_count=cluster["face_count"], + face_image_base64=cluster["face_image_base64"], ) for cluster in clusters_data ] @@ -111,7 +120,7 @@ def get_all_clusters(): return GetClustersResponse( success=True, message=f"Successfully retrieved {len(clusters)} cluster(s)", - clusters=clusters, + data=GetClustersData(clusters=clusters), ) except Exception as e: @@ -123,3 +132,65 @@ def get_all_clusters(): message=f"Unable to retrieve clusters: {str(e)}", ).model_dump(), ) + + +@router.get( + "/{cluster_id}/images", + response_model=GetClusterImagesResponse, + responses={code: {"model": ErrorResponse} for code in [404, 500]}, +) +def get_cluster_images(cluster_id: str): + """Get all images that contain faces belonging to a specific cluster.""" + try: + # Step 1: Validate cluster exists + cluster = db_get_cluster_by_id(cluster_id) + if not cluster: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ErrorResponse( + success=False, + error="Cluster Not Found", + message=f"Cluster with ID '{cluster_id}' does not exist.", + ).model_dump(), + ) + + # Step 2: Get images for this cluster + images_data = db_get_images_by_cluster_id(cluster_id) + + # Step 3: Convert to response models + images = [ + ImageInCluster( + id=img["image_id"], + path=img["image_path"], + thumbnailPath=img["thumbnail_path"], + metadata=img["metadata"], + face_id=img["face_id"], + confidence=img["confidence"], + bbox=img["bbox"], + ) + for img in images_data + ] + + return GetClusterImagesResponse( + success=True, + message=f"Successfully retrieved {len(images)} image(s) for cluster '{cluster_id}'", + data=GetClusterImagesData( + cluster_id=cluster_id, + cluster_name=cluster["cluster_name"], + images=images, + total_images=len(images), + ), + ) + + except HTTPException as e: + # Re-raise HTTPExceptions to preserve the status code and detail + raise e + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, + error="Internal server error", + message=f"Unable to retrieve images for cluster: {str(e)}", + ).model_dump(), + ) diff --git a/backend/app/routes/folders.py b/backend/app/routes/folders.py index 9ffe9ec86..8bc995760 100644 --- a/backend/app/routes/folders.py +++ b/backend/app/routes/folders.py @@ -9,17 +9,25 @@ db_delete_folders_batch, db_get_direct_child_folders, db_get_folder_ids_by_path_prefix, + db_get_all_folder_details, ) from app.schemas.folders import ( AddFolderRequest, AddFolderResponse, + AddFolderData, ErrorResponse, UpdateAITaggingRequest, UpdateAITaggingResponse, + UpdateAITaggingData, DeleteFoldersRequest, DeleteFoldersResponse, + DeleteFoldersData, SyncFolderRequest, SyncFolderResponse, + SyncFolderData, + GetAllFoldersResponse, + GetAllFoldersData, + FolderDetails, ) import os from app.utils.folders import ( @@ -34,6 +42,7 @@ image_util_process_untagged_images, ) from app.utils.face_clusters import cluster_util_face_clusters_sync +from app.utils.API import API_util_restart_sync_microservice_watcher router = APIRouter() @@ -58,6 +67,9 @@ def post_folder_add_sequence(folder_path: str, folder_id: int): # Process images in all folders image_util_process_folder_images(folder_data) + # Restart sync microservice watcher after processing images + API_util_restart_sync_microservice_watcher() + except Exception as e: print(f"Error in post processing after folder {folder_path} was added: {e}") return False @@ -101,6 +113,9 @@ def post_sync_folder_sequence( image_util_process_folder_images(folder_data) image_util_process_untagged_images() cluster_util_face_clusters_sync() + + # Restart sync microservice watcher after processing images + API_util_restart_sync_microservice_watcher() except Exception as e: print(f"Error in post processing after folder {folder_path} was synced: {e}") return False @@ -137,7 +152,7 @@ def add_folder(request: AddFolderRequest, app_state=Depends(get_state)): success=False, error="Permission denied", message="The app does not have read permission for the specified folder", - ), + ).model_dump(), ) request.folder_path = os.path.abspath(request.folder_path) @@ -174,9 +189,11 @@ def add_folder(request: AddFolderRequest, app_state=Depends(get_state)): executor.submit(post_folder_add_sequence, request.folder_path, root_folder_id) return AddFolderResponse( + data=AddFolderData( + folder_id=root_folder_id, folder_path=request.folder_path + ), success=True, message=f"Successfully added folder tree starting at: {request.folder_path}", - folder_id=root_folder_id, ) except ValueError as e: raise HTTPException( @@ -218,9 +235,11 @@ def enable_ai_tagging(request: UpdateAITaggingRequest, app_state=Depends(get_sta executor.submit(post_AI_tagging_enabled_sequence) return UpdateAITaggingResponse( + data=UpdateAITaggingData( + updated_count=updated_count, folder_ids=request.folder_ids + ), success=True, message=f"Successfully enabled AI tagging for {updated_count} folder(s)", - updated_count=updated_count, ) except ValueError as e: @@ -257,9 +276,11 @@ def disable_ai_tagging(request: UpdateAITaggingRequest): updated_count = db_disable_ai_tagging_batch(request.folder_ids) return UpdateAITaggingResponse( + data=UpdateAITaggingData( + updated_count=updated_count, folder_ids=request.folder_ids + ), success=True, message=f"Successfully disabled AI tagging for {updated_count} folder(s)", - updated_count=updated_count, ) except ValueError as e: @@ -296,9 +317,11 @@ def delete_folders(request: DeleteFoldersRequest): deleted_count = db_delete_folders_batch(request.folder_ids) return DeleteFoldersResponse( + data=DeleteFoldersData( + deleted_count=deleted_count, folder_ids=request.folder_ids + ), success=True, message=f"Successfully deleted {deleted_count} folder(s)", - deleted_count=deleted_count, ) except ValueError as e: @@ -364,12 +387,16 @@ def sync_folder(request: SyncFolderRequest, app_state=Depends(get_state)): ) # Step 4: Return comprehensive response return SyncFolderResponse( + data=SyncFolderData( + deleted_count=deleted_count, + deleted_folders=deleted_folders, + added_count=added_count, + added_folders=added_folders, + folder_id=request.folder_id, + folder_path=request.folder_path, + ), success=True, message=f"Successfully synced folder. Added {added_count} folder(s), deleted {deleted_count} folder(s)", - deleted_count=deleted_count, - deleted_folders=deleted_folders, - added_count=added_count, - added_folders=added_folders, ) except ValueError as e: @@ -393,3 +420,52 @@ def sync_folder(request: SyncFolderRequest, app_state=Depends(get_state)): message=f"Unable to sync folder: {str(e)}", ).model_dump(), ) + + +@router.get( + "/all-folders", + response_model=GetAllFoldersResponse, + responses={code: {"model": ErrorResponse} for code in [500]}, +) +def get_all_folders(): + """Get details of all folders in the database.""" + try: + folder_details_raw = db_get_all_folder_details() + + # Convert raw tuples to FolderDetails objects + folders = [] + for folder_data in folder_details_raw: + ( + folder_id, + folder_path, + parent_folder_id, + last_modified_time, + ai_tagging, + tagging_completed, + ) = folder_data + folders.append( + FolderDetails( + folder_id=folder_id, + folder_path=folder_path, + parent_folder_id=parent_folder_id, + last_modified_time=last_modified_time, + AI_Tagging=ai_tagging, + taggingCompleted=tagging_completed, + ) + ) + + return GetAllFoldersResponse( + data=GetAllFoldersData(folders=folders, total_count=len(folders)), + success=True, + message=f"Successfully retrieved {len(folders)} folder(s)", + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, + error="Internal server error", + message=f"Unable to retrieve folders: {str(e)}", + ).model_dump(), + ) diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index e69de29bb..ad387154d 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -0,0 +1,66 @@ +from fastapi import APIRouter, HTTPException, status +from typing import List, Optional +from app.database.images import db_get_all_images +from app.schemas.images import ErrorResponse +from pydantic import BaseModel + +router = APIRouter() + + +# Response Models +class ImageData(BaseModel): + id: str + path: str + folder_id: str + thumbnailPath: str + metadata: str + isTagged: bool + tags: Optional[List[str]] = None + + +class GetAllImagesResponse(BaseModel): + success: bool + message: str + data: List[ImageData] + + +@router.get( + "/", + response_model=GetAllImagesResponse, + responses={500: {"model": ErrorResponse}}, +) +def get_all_images(): + """Get all images from the database.""" + try: + # Get all images with tags from database (single query) + images = db_get_all_images() + + # Convert to response format + image_data = [ + ImageData( + id=image["id"], + path=image["path"], + folder_id=image["folder_id"], + thumbnailPath=image["thumbnailPath"], + metadata=image["metadata"], + isTagged=image["isTagged"], + tags=image["tags"], + ) + for image in images + ] + + return GetAllImagesResponse( + success=True, + message=f"Successfully retrieved {len(image_data)} images", + data=image_data, + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, + error="Internal server error", + message=f"Unable to retrieve images: {str(e)}", + ).model_dump(), + ) diff --git a/backend/app/schemas/face_clusters.py b/backend/app/schemas/face_clusters.py index 6ee42ace9..11803cce6 100644 --- a/backend/app/schemas/face_clusters.py +++ b/backend/app/schemas/face_clusters.py @@ -1,5 +1,5 @@ from pydantic import BaseModel -from typing import List, Optional +from typing import List, Optional, Dict, Union # Request Models @@ -8,26 +8,67 @@ class RenameClusterRequest(BaseModel): # Response Models -class RenameClusterResponse(BaseModel): - success: bool - message: str +class RenameClusterData(BaseModel): cluster_id: str cluster_name: str +class RenameClusterResponse(BaseModel): + success: bool + message: Optional[str] = None + error: Optional[str] = None + data: Optional[RenameClusterData] = None + + class ErrorResponse(BaseModel): success: bool = False - message: str - error: str + message: Optional[str] = None + error: Optional[str] = None class ClusterMetadata(BaseModel): cluster_id: str cluster_name: Optional[str] + face_image_base64: Optional[str] face_count: int +class GetClustersData(BaseModel): + clusters: List[ClusterMetadata] + + class GetClustersResponse(BaseModel): success: bool - message: str - clusters: List[ClusterMetadata] + message: Optional[str] = None + error: Optional[str] = None + data: Optional[GetClustersData] = None + + +class ImageInCluster(BaseModel): + """Represents an image that contains faces from a specific cluster.""" + + id: str + path: str + thumbnailPath: Optional[str] = None + metadata: Optional[str] = None + face_id: int + confidence: Optional[float] = None + bbox: Optional[Dict[str, Union[int, float]]] = None + + +class GetClusterImagesData(BaseModel): + """Data model for cluster images response.""" + + cluster_id: str + cluster_name: Optional[str] = None + images: List[ImageInCluster] + total_images: int + + +class GetClusterImagesResponse(BaseModel): + """Response model for getting images in a cluster.""" + + success: bool + message: Optional[str] = None + error: Optional[str] = None + data: Optional[GetClusterImagesData] = None diff --git a/backend/app/schemas/folders.py b/backend/app/schemas/folders.py index b21ce90d2..63045241b 100644 --- a/backend/app/schemas/folders.py +++ b/backend/app/schemas/folders.py @@ -22,35 +22,82 @@ class SyncFolderRequest(BaseModel): folder_id: str # UUID of the folder to sync +# Response Data Models (for the 'data' field) +class FolderDetails(BaseModel): + folder_id: str + folder_path: str + parent_folder_id: Optional[str] = None + last_modified_time: int + AI_Tagging: bool + taggingCompleted: Optional[bool] = None + + +class GetAllFoldersData(BaseModel): + folders: List[FolderDetails] + total_count: int + + +class AddFolderData(BaseModel): + folder_id: str # UUID as string + folder_path: str + + +class UpdateAITaggingData(BaseModel): + updated_count: int + folder_ids: List[str] + + +class DeleteFoldersData(BaseModel): + deleted_count: int + folder_ids: List[str] + + +class SyncFolderData(BaseModel): + deleted_count: int + deleted_folders: List[str] # List of folder paths that were deleted + added_count: int + added_folders: List[str] # List of folder paths that were added + folder_id: str + folder_path: str + + # Response Models +class GetAllFoldersResponse(BaseModel): + success: bool + message: Optional[str] = None + error: Optional[str] = None + data: Optional[GetAllFoldersData] = None + + class AddFolderResponse(BaseModel): success: bool - message: str - folder_id: Optional[str] = None # UUID as string + message: Optional[str] = None + error: Optional[str] = None + data: Optional[AddFolderData] = None class UpdateAITaggingResponse(BaseModel): success: bool - message: str - updated_count: int + message: Optional[str] = None + error: Optional[str] = None + data: Optional[UpdateAITaggingData] = None class DeleteFoldersResponse(BaseModel): success: bool - message: str - deleted_count: int + message: Optional[str] = None + error: Optional[str] = None + data: Optional[DeleteFoldersData] = None class SyncFolderResponse(BaseModel): success: bool - message: str - deleted_count: int - deleted_folders: List[str] # List of folder paths that were deleted - added_count: int - added_folders: List[str] # List of folder paths that were added + message: Optional[str] = None + error: Optional[str] = None + data: Optional[SyncFolderData] = None class ErrorResponse(BaseModel): success: bool = False - message: str - error: str + message: Optional[str] = None + error: Optional[str] = None diff --git a/backend/app/utils/API.py b/backend/app/utils/API.py new file mode 100644 index 000000000..32bb9a0fa --- /dev/null +++ b/backend/app/utils/API.py @@ -0,0 +1,33 @@ +import requests +from app.config.settings import SYNC_MICROSERVICE_URL +import logging + +logger = logging.getLogger(__name__) + + +def API_util_restart_sync_microservice_watcher(): + """ + Send a POST request to restart the sync microservice watcher. + + Returns: + bool: True if request was successful, False otherwise + """ + try: + url = f"{SYNC_MICROSERVICE_URL}/watcher/restart" + response = requests.post(url, timeout=30) + + if response.status_code == 200: + logger.info("Successfully restarted sync microservice watcher") + return True + else: + logger.warning( + f"Failed to restart sync microservice watcher. Status code: {response.status_code}" + ) + return False + + except requests.exceptions.RequestException as e: + logger.error(f"Error communicating with sync microservice: {e}") + return False + except Exception as e: + logger.error(f"Unexpected error restarting sync microservice watcher: {e}") + return False diff --git a/backend/app/utils/face_clusters.py b/backend/app/utils/face_clusters.py index 86b6bd4fe..98797ba5c 100644 --- a/backend/app/utils/face_clusters.py +++ b/backend/app/utils/face_clusters.py @@ -1,5 +1,8 @@ import numpy as np import uuid +import json +import base64 +import cv2 from datetime import datetime from sklearn.cluster import DBSCAN from collections import defaultdict, Counter @@ -88,11 +91,11 @@ def cluster_util_face_clusters_sync(): return 0 results = [result.to_dict() for result in results] - # Update database with new clusters - db_update_face_cluster_ids_batch(results) - # Clear old clusters + + # Clear old clusters first db_delete_all_clusters() - # Extract unique clusters with their names + + # Extract unique clusters with their names (without face images yet) unique_clusters = {} for result in results: cluster_id = result["cluster_id"] @@ -101,13 +104,24 @@ def cluster_util_face_clusters_sync(): unique_clusters[cluster_id] = { "cluster_id": cluster_id, "cluster_name": cluster_name, + "face_image_base64": None, # Will be updated later } # Convert to list for batch insert cluster_list = list(unique_clusters.values()) - # Update the database with new clusters + # Insert the new clusters into database first db_insert_clusters_batch(cluster_list) + # Now update face cluster assignments (foreign keys will be valid) + db_update_face_cluster_ids_batch(results) + + # Finally, generate and update face images for each cluster + for cluster_id in unique_clusters.keys(): + face_image_base64 = _generate_cluster_face_image(cluster_id) + if face_image_base64: + # Update the cluster with the generated face image + _update_cluster_face_image(cluster_id, face_image_base64) + # Update metadata with new reclustering time, preserving other values current_metadata = metadata or {} current_metadata["reclustering_time"] = datetime.now().timestamp() @@ -294,6 +308,247 @@ def _calculate_cosine_distances( return cosine_distances +def _update_cluster_face_image(cluster_id: str, face_image_base64: str) -> bool: + """ + Update the face image for a specific cluster. + + Args: + cluster_id: The UUID of the cluster + face_image_base64: Base64 encoded face image string + + Returns: + True if update was successful, False otherwise + """ + import sqlite3 + from app.config.settings import DATABASE_PATH + + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + + try: + cursor.execute( + "UPDATE face_clusters SET face_image_base64 = ? WHERE cluster_id = ?", + (face_image_base64, cluster_id), + ) + + updated = cursor.rowcount > 0 + conn.commit() + return updated + + except Exception as e: + print(f"Error updating face image for cluster {cluster_id}: {e}") + conn.rollback() + return False + finally: + conn.close() + + +def _get_cluster_face_data(cluster_uuid: str) -> Optional[tuple]: + """ + Get the image path and bounding box for the first face in a cluster. + + Args: + cluster_uuid: The UUID of the cluster + + Returns: + Tuple of (image_path, bbox_dict) or None if not found + """ + import sqlite3 + from app.config.settings import DATABASE_PATH + + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + + try: + cursor.execute( + """ + SELECT i.path, f.bbox + FROM faces f + JOIN images i ON f.image_id = i.id + WHERE f.cluster_id = ? + LIMIT 1 + """, + (cluster_uuid,), + ) + + face_data = cursor.fetchone() + if not face_data: + return None + + image_path, bbox_json = face_data + + if not bbox_json or not image_path: + return None + + try: + bbox = json.loads(bbox_json) + return (image_path, bbox) + except json.JSONDecodeError: + return None + + except Exception as e: + print(f"Error getting face data for cluster {cluster_uuid}: {e}") + return None + finally: + conn.close() + + +def _calculate_square_crop_bounds( + bbox: Dict, img_shape: tuple, padding: int = 50 +) -> tuple: + """ + Calculate square crop bounds centered on a face bounding box. + + Args: + bbox: Dictionary with x, y, width, height keys + img_shape: Tuple of (height, width, channels) from image + padding: Padding around the face in pixels + + Returns: + Tuple of (x_start, y_start, x_end, y_end) for square crop + """ + img_height, img_width = img_shape[:2] + + x = int(bbox.get("x", 0)) + y = int(bbox.get("y", 0)) + width = int(bbox.get("width", 100)) + height = int(bbox.get("height", 100)) + + # Add padding around the face + x_start = max(0, x - padding) + y_start = max(0, y - padding) + x_end = min(img_width, x + width + padding) + y_end = min(img_height, y + height + padding) + + # Calculate square crop dimensions centered on the face + crop_width = x_end - x_start + crop_height = y_end - y_start + + # Use the larger dimension to create a square crop + square_size = max(crop_width, crop_height) + + # Calculate center of the current crop + center_x = x_start + crop_width // 2 + center_y = y_start + crop_height // 2 + + # Calculate square crop bounds centered on the face + half_square = square_size // 2 + square_x_start = max(0, center_x - half_square) + square_y_start = max(0, center_y - half_square) + square_x_end = min(img_width, center_x + half_square) + square_y_end = min(img_height, center_y + half_square) + + # Adjust if we hit image boundaries to maintain square shape + actual_width = square_x_end - square_x_start + actual_height = square_y_end - square_y_start + actual_square_size = min(actual_width, actual_height) + + # Recalculate final square crop + square_x_start = center_x - actual_square_size // 2 + square_y_start = center_y - actual_square_size // 2 + square_x_end = square_x_start + actual_square_size + square_y_end = square_y_start + actual_square_size + + # Ensure bounds are within image + square_x_start = max(0, square_x_start) + square_y_start = max(0, square_y_start) + square_x_end = min(img_width, square_x_end) + square_y_end = min(img_height, square_y_end) + + return (square_x_start, square_y_start, square_x_end, square_y_end) + + +def _crop_and_resize_face( + img: np.ndarray, crop_bounds: tuple, target_size: int = 300 +) -> Optional[np.ndarray]: + """ + Crop and resize a face region from an image. + + Args: + img: Input image as numpy array + crop_bounds: Tuple of (x_start, y_start, x_end, y_end) + target_size: Target size for the output square image + + Returns: + Cropped and resized face image or None if cropping fails + """ + try: + x_start, y_start, x_end, y_end = crop_bounds + + # Crop the square region + face_crop = img[y_start:y_end, x_start:x_end] + + # Check if crop is valid + if face_crop.size == 0: + return None + + # Resize to target size (maintaining square aspect ratio) + face_crop = cv2.resize(face_crop, (target_size, target_size)) + + return face_crop + except Exception as e: + print(f"Error cropping and resizing face: {e}") + return None + + +def _encode_image_to_base64(img: np.ndarray, format: str = ".jpg") -> Optional[str]: + """ + Encode an image to base64 string. + + Args: + img: Image as numpy array + format: Image format for encoding (e.g., ".jpg", ".png") + + Returns: + Base64 encoded string or None if encoding fails + """ + try: + _, buffer = cv2.imencode(format, img) + return base64.b64encode(buffer).decode("utf-8") + except Exception as e: + print(f"Error encoding image to base64: {e}") + return None + + +def _generate_cluster_face_image(cluster_uuid: str) -> Optional[str]: + """ + Generate a base64 encoded face image for a cluster. + + Args: + cluster_uuid: The UUID of the cluster + + Returns: + Base64 encoded face image string, or None if generation fails + """ + try: + # Get face data from database + face_data = _get_cluster_face_data(cluster_uuid) + if not face_data: + return None + + image_path, bbox = face_data + + # Load the image + img = cv2.imread(image_path) + if img is None: + return None + + # Calculate square crop bounds + crop_bounds = _calculate_square_crop_bounds(bbox, img.shape) + + # Crop and resize the face + face_crop = _crop_and_resize_face(img, crop_bounds) + if face_crop is None: + return None + + # Encode to base64 + return _encode_image_to_base64(face_crop) + + except Exception as e: + print(f"Error generating face image for cluster {cluster_uuid}: {e}") + return None + + def _determine_cluster_name(faces_in_cluster: List[Dict]) -> Optional[str]: """ Determine cluster name using majority voting from existing cluster names. diff --git a/backend/app/utils/images.py b/backend/app/utils/images.py index 76eea5d7d..ec2cec79e 100644 --- a/backend/app/utils/images.py +++ b/backend/app/utils/images.py @@ -146,7 +146,9 @@ def image_util_prepare_image_records( image_id = str(uuid.uuid4()) thumbnail_name = f"thumbnail_{image_id}.jpg" - thumbnail_path = os.path.join(THUMBNAIL_IMAGES_PATH, thumbnail_name) + thumbnail_path = os.path.abspath( + os.path.join(THUMBNAIL_IMAGES_PATH, thumbnail_name) + ) # Generate thumbnail if image_util_generate_thumbnail(image_path, thumbnail_path): @@ -199,7 +201,7 @@ def image_util_get_images_from_folder( def image_util_generate_thumbnail( - image_path: str, thumbnail_path: str, size: Tuple[int, int] = (200, 200) + image_path: str, thumbnail_path: str, size: Tuple[int, int] = (600, 600) ) -> bool: """Generate thumbnail for a single image.""" try: diff --git a/backend/app/utils/microservice.py b/backend/app/utils/microservice.py new file mode 100644 index 000000000..995ef5b3f --- /dev/null +++ b/backend/app/utils/microservice.py @@ -0,0 +1,183 @@ +import os +import platform +import subprocess +import sys +from pathlib import Path +from typing import Optional + +import logging + +logger = logging.getLogger(__name__) + + +def microservice_util_start_sync_service( + sync_service_path: Optional[str] = None, +) -> bool: + """ + Start the sync microservice with automatic virtual environment management. + + Args: + sync_service_path: Path to the sync microservice directory. + If None, defaults to 'sync-microservice' relative to project root. + + Returns: + bool: True if service started successfully, False otherwise. + """ + try: + # Determine the sync service path + if sync_service_path is None: + # Get project root (assuming this file is in backend/app/utils/) + current_file = Path(__file__) + project_root = current_file.parent.parent.parent.parent + sync_service_path = project_root / "sync-microservice" + else: + sync_service_path = Path(sync_service_path) + + if not sync_service_path.exists(): + logger.error(f"Sync service directory not found: {sync_service_path}") + return False + + # Define virtual environment path + venv_path = sync_service_path / ".sync-env" + + # Check if virtual environment exists + if not venv_path.exists(): + logger.info("Virtual environment not found. Creating .sync-env...") + if not _create_virtual_environment(venv_path): + logger.error("Failed to create virtual environment") + return False + + # Get the Python executable path from the virtual environment + python_executable = _get_venv_python_executable(venv_path) + if not python_executable: + logger.error("Failed to locate Python executable in virtual environment") + return False + + # Install dependencies if requirements.txt exists + requirements_file = sync_service_path / "requirements.txt" + if requirements_file.exists(): + logger.info("Installing dependencies...") + if not _install_requirements(python_executable, requirements_file): + logger.warning("Failed to install requirements, but continuing...") + + # Start the FastAPI service + logger.info("Starting sync microservice on port 8001...") + return _start_fastapi_service(python_executable, sync_service_path) + + except Exception as e: + logger.error(f"Error starting sync microservice: {e}") + return False + + +def _create_virtual_environment(venv_path: Path) -> bool: + """Create a virtual environment at the specified path.""" + try: + # Use the current Python interpreter to create the virtual environment + cmd = [sys.executable, "-m", "venv", str(venv_path)] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300, # 5 minute timeout + ) + + if result.returncode == 0: + logger.info(f"Virtual environment created successfully at {venv_path}") + return True + else: + logger.error(f"Failed to create virtual environment: {result.stderr}") + return False + + except subprocess.TimeoutExpired: + logger.error("Virtual environment creation timed out") + return False + except Exception as e: + logger.error(f"Error creating virtual environment: {e}") + return False + + +def _get_venv_python_executable(venv_path: Path) -> Optional[Path]: + """Get the Python executable path from the virtual environment.""" + system = platform.system().lower() + + if system == "windows": + # Windows: .sync-env/Scripts/python.exe + python_exe = venv_path / "Scripts" / "python.exe" + else: + # Unix/Linux/macOS: .sync-env/bin/python + python_exe = venv_path / "bin" / "python" + + if python_exe.exists(): + return python_exe + else: + logger.error(f"Python executable not found at {python_exe}") + return None + + +def _install_requirements(python_executable: Path, requirements_file: Path) -> bool: + """Install requirements using pip in the virtual environment.""" + try: + cmd = [ + str(python_executable), + "-m", + "pip", + "install", + "-r", + str(requirements_file), + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=600, # 10 minute timeout for pip install + ) + + if result.returncode == 0: + logger.info("Requirements installed successfully") + return True + else: + logger.error(f"Failed to install requirements: {result.stderr}") + return False + + except subprocess.TimeoutExpired: + logger.error("Requirements installation timed out") + return False + except Exception as e: + logger.error(f"Error installing requirements: {e}") + return False + + +def _start_fastapi_service(python_executable: Path, service_path: Path) -> bool: + """Start the FastAPI service using the virtual environment Python.""" + try: + # Change to the service directory + original_cwd = os.getcwd() + os.chdir(service_path) + + # Command to start FastAPI dev server + print(python_executable) + cmd = [str(python_executable), "-m", "fastapi", "dev", "--port", "8001"] + + # Start the process (non-blocking) + process = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + + # Restore original working directory + os.chdir(original_cwd) + + logger.info(f"Sync microservice started with PID: {process.pid}") + logger.info("Service should be available at http://localhost:8001") + + return True + + except Exception as e: + logger.error(f"Error starting FastAPI service: {e}") + # Restore original working directory in case of error + try: + os.chdir(original_cwd) + except Exception: + pass + return False diff --git a/backend/main.py b/backend/main.py index a5ef8ef3d..a6fb86b8e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -19,9 +19,11 @@ 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.utils.microservice import microservice_util_start_sync_service from app.routes.folders import router as folders_router from app.routes.albums import router as albums_router +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 fastapi.openapi.utils import get_openapi @@ -40,6 +42,7 @@ async def lifespan(app: FastAPI): db_create_albums_table() db_create_album_images_table() db_create_metadata_table() + microservice_util_start_sync_service() # Create ProcessPoolExecutor and attach it to app.state app.state.executor = ProcessPoolExecutor(max_workers=1) @@ -56,26 +59,11 @@ async def lifespan(app: FastAPI): description="The API calls to PictoPy are done via HTTP requests. This backend is built using FastAPI.", contact={ "name": "PictoPy Postman Collection", - "url": "https://www.postman.com/cryosat-explorer-62744145/workspace/pictopy/overview", + "url": "https://www.postman.com/aossie-pictopy/pictopy/overview", }, servers=[ {"url": "http://localhost:8000", "description": "Local Development server"} ], - openapi_tags=[ - { - "name": "Albums", - "description": "We briefly discuss the endpoints related to albums, all of these fall under the /albums route", - }, - { - "name": "Images", - "description": "We briefly discuss the endpoints related to images, all of these fall under the /images route", - }, - { - "name": "Tagging", - "x-displayName": "Face recognition and Tagging", - "description": "We briefly discuss the endpoints related to face tagging and recognition, all of these fall under the /tag route", - }, - ], ) app.logger = CustomizeLogger.make_logger("app/logging_config.json") @@ -118,13 +106,14 @@ def generate_openapi_json(): # Basic health check endpoint -@app.get("/") +@app.get("/", tags=["Health"]) async def root(): return {"message": "PictoPy Server is up and running!"} app.include_router(folders_router, prefix="/folders", tags=["Folders"]) app.include_router(albums_router, prefix="/albums", tags=["Albums"]) +app.include_router(images_router, prefix="/images", tags=["Images"]) app.include_router( face_clusters_router, prefix="/face-clusters", tags=["Face Clusters"] ) diff --git a/backend/tests/test_albums.py b/backend/tests/test_albums.py index a1157f4a7..cec9f670e 100644 --- a/backend/tests/test_albums.py +++ b/backend/tests/test_albums.py @@ -4,7 +4,7 @@ import bcrypt from fastapi import FastAPI from fastapi.testclient import TestClient -from unittest.mock import patch, ANY +from unittest.mock import patch import uuid from app.routes import albums as albums_router @@ -68,7 +68,10 @@ class TestAlbumRoutes: ], ) def test_create_album_variants(self, album_data): - with patch("app.routes.albums.db_insert_album") as mock_insert: + with patch("app.routes.albums.db_get_album_by_name") as mock_get_by_name, patch( + "app.routes.albums.db_insert_album" + ) as mock_insert: + mock_get_by_name.return_value = None # No existing album mock_insert.return_value = None response = client.post("/albums/", json=album_data) @@ -78,14 +81,36 @@ def test_create_album_variants(self, album_data): assert json_response["success"] is True assert "album_id" in json_response - mock_insert.assert_called_once_with( - ANY, - album_data["name"], - album_data.get("description", ""), - album_data["is_hidden"], - album_data.get("password"), + mock_insert.assert_called_once() + # Verify that the album_id is a valid UUID + album_id = json_response["album_id"] + uuid.UUID(album_id) # This will raise ValueError if not a valid UUID + + def test_create_album_duplicate_name(self): + """Test creating album with duplicate name.""" + album_data = { + "name": "Existing Album", + "description": "This name already exists", + "is_hidden": False, + "password": None, + } + + with patch("app.routes.albums.db_get_album_by_name") as mock_get_by_name: + mock_get_by_name.return_value = ( + "existing-id", + "Existing Album", + "desc", + 0, + None, ) + response = client.post("/albums/", json=album_data) + assert response.status_code == 409 + + json_response = response.json() + assert json_response["detail"]["success"] is False + assert json_response["detail"]["error"] == "Album Already Exists" + def test_get_all_albums_public_only(self, mock_db_album): """ Test fetching only public albums (default behavior). @@ -108,6 +133,14 @@ def test_get_all_albums_public_only(self, mock_db_album): assert isinstance(json_response["albums"], list) assert len(json_response["albums"]) == 1 assert json_response["albums"][0]["album_id"] == mock_db_album["album_id"] + assert ( + json_response["albums"][0]["album_name"] == mock_db_album["album_name"] + ) + assert ( + json_response["albums"][0]["description"] + == mock_db_album["description"] + ) + assert json_response["albums"][0]["is_hidden"] == mock_db_album["is_hidden"] mock_get_all.assert_called_once_with(False) @@ -180,6 +213,8 @@ def test_get_album_by_id_success(self, mock_db_album): assert json_response["success"] is True assert json_response["data"]["album_id"] == mock_db_album["album_id"] assert json_response["data"]["album_name"] == mock_db_album["album_name"] + assert json_response["data"]["description"] == mock_db_album["description"] + assert json_response["data"]["is_hidden"] == mock_db_album["is_hidden"] mock_get_album.assert_called_once_with(mock_db_album["album_id"]) def test_get_album_by_id_not_found(self): diff --git a/backend/tests/test_face_clusters.py b/backend/tests/test_face_clusters.py index ae2a2659f..977ee8c33 100644 --- a/backend/tests/test_face_clusters.py +++ b/backend/tests/test_face_clusters.py @@ -35,9 +35,49 @@ def sample_cluster_data(): def sample_clusters_with_counts(): """Sample clusters data with face counts.""" return [ - {"cluster_id": "cluster_1", "cluster_name": "John Doe", "face_count": 15}, - {"cluster_id": "cluster_2", "cluster_name": "Jane Smith", "face_count": 8}, - {"cluster_id": "cluster_3", "cluster_name": "Unknown Person", "face_count": 3}, + { + "cluster_id": "cluster_1", + "cluster_name": "John Doe", + "face_count": 15, + "face_image_base64": "base64_string_1", + }, + { + "cluster_id": "cluster_2", + "cluster_name": "Jane Smith", + "face_count": 8, + "face_image_base64": "base64_string_2", + }, + { + "cluster_id": "cluster_3", + "cluster_name": "Unknown Person", + "face_count": 3, + "face_image_base64": "base64_string_3", + }, + ] + + +@pytest.fixture +def sample_cluster_images(): + """Sample images data for a cluster.""" + return [ + { + "image_id": "img_1", + "image_path": "/path/to/image1.jpg", + "thumbnail_path": "/path/to/thumb1.jpg", + "metadata": "{'camera': 'Canon'}", + "face_id": 101, + "confidence": 0.95, + "bbox": {"x": 100, "y": 200, "width": 150, "height": 200}, + }, + { + "image_id": "img_2", + "image_path": "/path/to/image2.jpg", + "thumbnail_path": "/path/to/thumb2.jpg", + "metadata": "{'camera': 'Nikon'}", + "face_id": 102, + "confidence": 0.87, + "bbox": {"x": 50, "y": 100, "width": 120, "height": 160}, + }, ] @@ -49,131 +89,186 @@ def sample_clusters_with_counts(): class TestFaceClustersAPI: """Test class for Face Clusters API endpoints.""" - @pytest.mark.parametrize( - "cluster_id,cluster_name", - [ - ("cluster_123", "John Doe"), - ("cluster_456", "Jane Smith"), - ("cluster_789", "Bob Johnson"), - ], - ) + # ============================================================================ + # PUT /face_clusters/{cluster_id} - Rename Cluster Tests + # ============================================================================ + @patch("app.routes.face_clusters.db_update_cluster") @patch("app.routes.face_clusters.db_get_cluster_by_id") def test_rename_cluster_success( - self, - mock_get_cluster, - mock_update_cluster, - cluster_id, - cluster_name, - sample_cluster_data, + self, mock_get_cluster, mock_update_cluster, sample_rename_request ): - """Test successful cluster rename.""" - mock_get_cluster.return_value = sample_cluster_data + """Test successfully renaming a cluster.""" + cluster_id = "cluster_123" + mock_get_cluster.return_value = { + "cluster_id": cluster_id, + "cluster_name": "Old Name", + } mock_update_cluster.return_value = True response = client.put( - f"/face_clusters/{cluster_id}", json={"cluster_name": cluster_name} + f"/face_clusters/{cluster_id}", json=sample_rename_request ) assert response.status_code == 200 - response_data = response.json() - - assert response_data["success"] is True - assert response_data["cluster_id"] == cluster_id - assert response_data["cluster_name"] == cluster_name - assert ( - f"Successfully renamed cluster to '{cluster_name}'" - in response_data["message"] - ) + data = response.json() + assert data["success"] is True + assert "Successfully renamed cluster" in data["message"] + assert data["data"]["cluster_id"] == cluster_id + assert data["data"]["cluster_name"] == sample_rename_request["cluster_name"] mock_get_cluster.assert_called_once_with(cluster_id) mock_update_cluster.assert_called_once_with( - cluster_id=cluster_id, cluster_name=cluster_name + cluster_id=cluster_id, cluster_name=sample_rename_request["cluster_name"] ) @patch("app.routes.face_clusters.db_get_cluster_by_id") def test_rename_cluster_not_found(self, mock_get_cluster): - """Test rename cluster when cluster doesn't exist.""" + """Test renaming a cluster that doesn't exist.""" + cluster_id = "non_existent_cluster" mock_get_cluster.return_value = None - response = client.put( - "/face_clusters/nonexistent_cluster", json={"cluster_name": "New Name"} - ) - - response_data = response.json() + request_data = {"cluster_name": "New Name"} + response = client.put(f"/face_clusters/{cluster_id}", json=request_data) assert response.status_code == 404 - assert response_data["detail"]["success"] is False - assert response_data["detail"]["error"] == "Cluster Not Found" - assert "nonexistent_cluster" in response_data["detail"]["message"] + data = response.json() + assert data["detail"]["success"] is False + assert data["detail"]["error"] == "Cluster Not Found" - @pytest.mark.parametrize("invalid_cluster_id", [""]) - def test_rename_cluster_invalid_path(self, invalid_cluster_id): - """Test rename cluster with invalid (empty) path param which results in 405.""" - response = client.put( - f"/face_clusters/{invalid_cluster_id}", json={"cluster_name": "Valid Name"} - ) + def test_rename_cluster_empty_name(self): + """Test renaming cluster with empty name.""" + cluster_id = "cluster_123" + request_data = {"cluster_name": " "} - assert response.status_code == 405 + response = client.put(f"/face_clusters/{cluster_id}", json=request_data) + + assert response.status_code == 400 + data = response.json() + assert data["detail"]["success"] is False + assert data["detail"]["error"] == "Validation Error" + assert "Cluster name cannot be empty" in data["detail"]["message"] + + def test_rename_cluster_empty_id(self): + """Test renaming cluster with empty ID.""" + cluster_id = " " + request_data = {"cluster_name": "Valid Name"} + + response = client.put(f"/face_clusters/{cluster_id}", json=request_data) + + assert response.status_code == 400 + data = response.json() + assert data["detail"]["success"] is False + assert data["detail"]["error"] == "Validation Error" + assert "Cluster ID cannot be empty" in data["detail"]["message"] + + @patch("app.routes.face_clusters.db_update_cluster") + @patch("app.routes.face_clusters.db_get_cluster_by_id") + def test_rename_cluster_update_failed(self, mock_get_cluster, mock_update_cluster): + """Test renaming cluster when database update fails.""" + cluster_id = "cluster_123" + mock_get_cluster.return_value = { + "cluster_id": cluster_id, + "cluster_name": "Old Name", + } + mock_update_cluster.return_value = False + + request_data = {"cluster_name": "New Name"} + response = client.put(f"/face_clusters/{cluster_id}", json=request_data) + + assert response.status_code == 500 + data = response.json() + assert data["detail"]["success"] is False + assert data["detail"]["error"] == "Update Failed" + + @patch("app.routes.face_clusters.db_get_cluster_by_id") + def test_rename_cluster_database_error(self, mock_get_cluster): + """Test renaming cluster when database raises an exception.""" + cluster_id = "cluster_123" + mock_get_cluster.side_effect = Exception("Database connection failed") - def test_rename_cluster_name_whitespace_trimming(self, sample_cluster_data): + request_data = {"cluster_name": "New Name"} + response = client.put(f"/face_clusters/{cluster_id}", json=request_data) + + assert response.status_code == 500 + data = response.json() + assert data["detail"]["success"] is False + assert data["detail"]["error"] == "Internal server error" + + @patch("app.routes.face_clusters.db_update_cluster") + @patch("app.routes.face_clusters.db_get_cluster_by_id") + def test_rename_cluster_name_whitespace_trimming( + self, mock_get_cluster, mock_update_cluster, sample_cluster_data + ): """Test that cluster names are properly trimmed of whitespace.""" - with patch("app.routes.face_clusters.db_get_cluster_by_id") as mock_get, patch( - "app.routes.face_clusters.db_update_cluster" - ) as mock_update: - mock_get.return_value = sample_cluster_data - mock_update.return_value = True + mock_get_cluster.return_value = sample_cluster_data + mock_update_cluster.return_value = True - response = client.put( - "/face_clusters/cluster_123", json={"cluster_name": " John Doe "} - ) + response = client.put( + "/face_clusters/cluster_123", json={"cluster_name": " John Doe "} + ) - assert response.status_code == 200 - response_data = response.json() + assert response.status_code == 200 + response_data = response.json() + + assert response_data["data"]["cluster_name"] == "John Doe" # Trimmed + mock_update_cluster.assert_called_once_with( + cluster_id="cluster_123", + cluster_name="John Doe", # Should be trimmed + ) - assert response_data["cluster_name"] == "John Doe" # Trimmed - mock_update.assert_called_once_with( - cluster_id="cluster_123", cluster_name="John Doe" # Should be trimmed - ) + # ============================================================================ + # GET /face_clusters/ - Get All Clusters Tests + # ============================================================================ @patch("app.routes.face_clusters.db_get_all_clusters_with_face_counts") def test_get_all_clusters_success( self, mock_get_clusters, sample_clusters_with_counts ): - """Test successful retrieval of all clusters.""" + """Test successfully retrieving all clusters.""" mock_get_clusters.return_value = sample_clusters_with_counts response = client.get("/face_clusters/") assert response.status_code == 200 - response_data = response.json() - - assert response_data["success"] is True - assert "Successfully retrieved 3 cluster(s)" in response_data["message"] - assert len(response_data["clusters"]) == 3 - - first_cluster = response_data["clusters"][0] - assert "cluster_id" in first_cluster - assert "cluster_name" in first_cluster - assert "face_count" in first_cluster + data = response.json() + assert data["success"] is True + assert "Successfully retrieved 3 cluster(s)" in data["message"] + assert len(data["data"]["clusters"]) == 3 + # Check first cluster details + first_cluster = data["data"]["clusters"][0] assert first_cluster["cluster_id"] == "cluster_1" assert first_cluster["cluster_name"] == "John Doe" assert first_cluster["face_count"] == 15 + assert first_cluster["face_image_base64"] == "base64_string_1" + + mock_get_clusters.assert_called_once() @patch("app.routes.face_clusters.db_get_all_clusters_with_face_counts") - def test_get_all_clusters_empty_result(self, mock_get_clusters): - """Test get all clusters when no clusters exist.""" + def test_get_all_clusters_empty(self, mock_get_clusters): + """Test retrieving clusters when none exist.""" mock_get_clusters.return_value = [] response = client.get("/face_clusters/") assert response.status_code == 200 - response_data = response.json() + data = response.json() + assert data["success"] is True + assert "Successfully retrieved 0 cluster(s)" in data["message"] + assert data["data"]["clusters"] == [] - assert response_data["success"] is True - assert "Successfully retrieved 0 cluster(s)" in response_data["message"] - assert response_data["clusters"] == [] + @patch("app.routes.face_clusters.db_get_all_clusters_with_face_counts") + def test_get_all_clusters_database_error(self, mock_get_clusters): + """Test handling database errors during cluster retrieval.""" + mock_get_clusters.side_effect = Exception("Database connection failed") + + response = client.get("/face_clusters/") + + assert response.status_code == 500 + data = response.json() + assert data["detail"]["success"] is False + assert data["detail"]["error"] == "Internal server error" def test_get_all_clusters_response_structure(self, sample_clusters_with_counts): """Test that get all clusters returns correct response structure.""" @@ -185,11 +280,12 @@ def test_get_all_clusters_response_structure(self, sample_clusters_with_counts): response = client.get("/face_clusters/") response_data = response.json() - required_fields = ["success", "message", "clusters"] + required_fields = ["success", "message", "data"] for field in required_fields: assert field in response_data - for cluster in response_data["clusters"]: + assert "clusters" in response_data["data"] + for cluster in response_data["data"]["clusters"]: cluster_fields = ["cluster_id", "cluster_name", "face_count"] for field in cluster_fields: assert field in cluster @@ -198,6 +294,94 @@ def test_get_all_clusters_response_structure(self, sample_clusters_with_counts): assert isinstance(cluster["cluster_name"], str) assert isinstance(cluster["face_count"], int) + # ============================================================================ + # GET /face_clusters/{cluster_id}/images - Get Cluster Images Tests + # ============================================================================ + + @patch("app.routes.face_clusters.db_get_images_by_cluster_id") + @patch("app.routes.face_clusters.db_get_cluster_by_id") + def test_get_cluster_images_success( + self, mock_get_cluster, mock_get_images, sample_cluster_images + ): + """Test successfully retrieving images for a cluster.""" + cluster_id = "cluster_123" + mock_get_cluster.return_value = { + "cluster_id": cluster_id, + "cluster_name": "John Doe", + } + mock_get_images.return_value = sample_cluster_images + + response = client.get(f"/face_clusters/{cluster_id}/images") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "Successfully retrieved 2 image(s)" in data["message"] + assert data["data"]["cluster_id"] == cluster_id + assert data["data"]["cluster_name"] == "John Doe" + assert data["data"]["total_images"] == 2 + assert len(data["data"]["images"]) == 2 + + # Check first image details + first_image = data["data"]["images"][0] + assert first_image["id"] == "img_1" + assert first_image["path"] == "/path/to/image1.jpg" + assert first_image["face_id"] == 101 + assert first_image["confidence"] == 0.95 + + mock_get_cluster.assert_called_once_with(cluster_id) + mock_get_images.assert_called_once_with(cluster_id) + + @patch("app.routes.face_clusters.db_get_cluster_by_id") + def test_get_cluster_images_cluster_not_found(self, mock_get_cluster): + """Test getting images for a cluster that doesn't exist.""" + cluster_id = "non_existent_cluster" + mock_get_cluster.return_value = None + + response = client.get(f"/face_clusters/{cluster_id}/images") + + assert response.status_code == 404 + data = response.json() + assert data["detail"]["success"] is False + assert data["detail"]["error"] == "Cluster Not Found" + + @patch("app.routes.face_clusters.db_get_images_by_cluster_id") + @patch("app.routes.face_clusters.db_get_cluster_by_id") + def test_get_cluster_images_empty(self, mock_get_cluster, mock_get_images): + """Test getting images for a cluster with no images.""" + cluster_id = "cluster_123" + mock_get_cluster.return_value = { + "cluster_id": cluster_id, + "cluster_name": "Empty Cluster", + } + mock_get_images.return_value = [] + + response = client.get(f"/face_clusters/{cluster_id}/images") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "Successfully retrieved 0 image(s)" in data["message"] + assert data["data"]["total_images"] == 0 + assert data["data"]["images"] == [] + + @patch("app.routes.face_clusters.db_get_cluster_by_id") + def test_get_cluster_images_database_error(self, mock_get_cluster): + """Test handling database errors during image retrieval.""" + cluster_id = "cluster_123" + mock_get_cluster.side_effect = Exception("Database connection failed") + + response = client.get(f"/face_clusters/{cluster_id}/images") + + assert response.status_code == 500 + data = response.json() + assert data["detail"]["success"] is False + assert data["detail"]["error"] == "Internal server error" + + # ============================================================================ + # Additional Edge Case Tests + # ============================================================================ + def test_rename_cluster_missing_request_body(self): """Test rename cluster with missing request body.""" response = client.put("/face_clusters/cluster_123") diff --git a/backend/tests/test_folders.py b/backend/tests/test_folders.py index 2b408a4ad..a0d26f0e5 100644 --- a/backend/tests/test_folders.py +++ b/backend/tests/test_folders.py @@ -88,6 +88,29 @@ def sample_add_folder_request(): } +@pytest.fixture +def sample_folder_details(): + """Sample folder details data.""" + return [ + ( + "folder-id-1", + "/home/user/photos", + None, + 1693526400, # timestamp + True, # AI_Tagging + False, # taggingCompleted + ), + ( + "folder-id-2", + "/home/user/documents", + None, + 1693526500, + False, + True, + ), + ] + + # ############################## # Test Classes # ############################## @@ -132,7 +155,8 @@ def test_add_folder_success( data = response.json() assert data["success"] is True assert "Successfully added folder tree" in data["message"] - assert data["folder_id"] == "test-folder-id-123" + assert data["data"]["folder_id"] == "test-folder-id-123" + assert data["data"]["folder_path"] == folder_path # Verify mocks were called correctly mock_folder_exists.assert_called_once_with(folder_path) @@ -227,7 +251,7 @@ def test_add_folder_with_parent_id( assert response.status_code == 200 data = response.json() assert data["success"] is True - assert data["folder_id"] == "child-folder-id" + assert data["data"]["folder_id"] == "child-folder-id" # Verify parent lookup was not called since parent_id was provided mock_find_parent.assert_not_called() @@ -316,7 +340,8 @@ def test_enable_ai_tagging_success(self, mock_enable_batch, client): data = response.json() assert data["success"] is True assert "Successfully enabled AI tagging for 3 folder(s)" in data["message"] - assert data["updated_count"] == 3 + assert data["data"]["updated_count"] == 3 + assert data["data"]["folder_ids"] == ["folder-1", "folder-2", "folder-3"] mock_enable_batch.assert_called_once_with(["folder-1", "folder-2", "folder-3"]) @@ -332,7 +357,7 @@ def test_enable_ai_tagging_single_folder(self, mock_enable_batch, client): assert response.status_code == 200 data = response.json() assert data["success"] is True - assert data["updated_count"] == 1 + assert data["data"]["updated_count"] == 1 def test_enable_ai_tagging_empty_list(self, client): """Test enabling AI tagging with empty folder_ids list.""" @@ -404,7 +429,14 @@ def test_disable_ai_tagging_success(self, mock_disable_batch, client): data = response.json() assert data["success"] is True assert "Successfully disabled AI tagging for 5 folder(s)" in data["message"] - assert data["updated_count"] == 5 + assert data["data"]["updated_count"] == 5 + assert data["data"]["folder_ids"] == [ + "folder-1", + "folder-2", + "folder-3", + "folder-4", + "folder-5", + ] mock_disable_batch.assert_called_once_with( ["folder-1", "folder-2", "folder-3", "folder-4", "folder-5"] @@ -422,7 +454,7 @@ def test_disable_ai_tagging_single_folder(self, mock_disable_batch, client): assert response.status_code == 200 data = response.json() assert data["success"] is True - assert data["updated_count"] == 1 + assert data["data"]["updated_count"] == 1 def test_disable_ai_tagging_empty_list(self, client): """Test disabling AI tagging with empty folder_ids list.""" @@ -475,6 +507,136 @@ def test_disable_ai_tagging_no_background_processing( app_state = client.app.state app_state.executor.submit.assert_not_called() + # ============================================================================ + # DELETE /folders/delete-folders - Delete Folders Tests + # ============================================================================ + + @patch("app.routes.folders.db_delete_folders_batch") + def test_delete_folders_success(self, mock_delete_batch, client): + """Test successfully deleting multiple folders.""" + mock_delete_batch.return_value = 3 + + response = client.request( + "DELETE", + "/folders/delete-folders", + content='{"folder_ids": ["folder-1", "folder-2", "folder-3"]}', + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "Successfully deleted 3 folder(s)" in data["message"] + assert data["data"]["deleted_count"] == 3 + assert data["data"]["folder_ids"] == ["folder-1", "folder-2", "folder-3"] + + mock_delete_batch.assert_called_once_with(["folder-1", "folder-2", "folder-3"]) + + @patch("app.routes.folders.db_delete_folders_batch") + def test_delete_folders_single_folder(self, mock_delete_batch, client): + """Test deleting a single folder.""" + mock_delete_batch.return_value = 1 + + response = client.request( + "DELETE", + "/folders/delete-folders", + content='{"folder_ids": ["single-folder-id"]}', + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["deleted_count"] == 1 + + def test_delete_folders_empty_list(self, client): + """Test deleting folders with empty folder_ids list.""" + response = client.request( + "DELETE", + "/folders/delete-folders", + content='{"folder_ids": []}', + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 400 + data = response.json() + assert data["detail"]["success"] is False + assert data["detail"]["error"] == "Validation Error" + assert "No folder IDs provided" in data["detail"]["message"] + + @patch("app.routes.folders.db_delete_folders_batch") + def test_delete_folders_database_error(self, mock_delete_batch, client): + """Test handling database errors during folder deletion.""" + mock_delete_batch.side_effect = Exception("Database connection failed") + + response = client.request( + "DELETE", + "/folders/delete-folders", + content='{"folder_ids": ["folder-1", "folder-2"]}', + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 500 + data = response.json() + assert data["detail"]["success"] is False + assert data["detail"]["error"] == "Internal server error" + + # ============================================================================ + # GET /folders/all-folders - Get All Folders Tests + # ============================================================================ + + @patch("app.routes.folders.db_get_all_folder_details") + def test_get_all_folders_success( + self, mock_get_all_folders, client, sample_folder_details + ): + """Test successfully retrieving all folders.""" + mock_get_all_folders.return_value = sample_folder_details + + response = client.get("/folders/all-folders") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "Successfully retrieved 2 folder(s)" in data["message"] + assert data["data"]["total_count"] == 2 + assert len(data["data"]["folders"]) == 2 + + # Check first folder details + first_folder = data["data"]["folders"][0] + assert first_folder["folder_id"] == "folder-id-1" + assert first_folder["folder_path"] == "/home/user/photos" + assert first_folder["parent_folder_id"] is None + assert first_folder["AI_Tagging"] is True + assert first_folder["taggingCompleted"] is False + + mock_get_all_folders.assert_called_once() + + @patch("app.routes.folders.db_get_all_folder_details") + def test_get_all_folders_empty(self, mock_get_all_folders, client): + """Test retrieving all folders when none exist.""" + mock_get_all_folders.return_value = [] + + response = client.get("/folders/all-folders") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "Successfully retrieved 0 folder(s)" in data["message"] + assert data["data"]["total_count"] == 0 + assert data["data"]["folders"] == [] + + @patch("app.routes.folders.db_get_all_folder_details") + def test_get_all_folders_database_error(self, mock_get_all_folders, client): + """Test handling database errors during folder retrieval.""" + mock_get_all_folders.side_effect = Exception("Database connection failed") + + response = client.get("/folders/all-folders") + + assert response.status_code == 500 + data = response.json() + assert data["detail"]["success"] is False + assert data["detail"]["error"] == "Internal server error" + # ============================================================================ # Edge Cases and Error Handling Tests # ============================================================================ @@ -507,7 +669,7 @@ def test_enable_ai_tagging_partial_update(self, mock_enable_batch, client): assert response.status_code == 200 data = response.json() assert data["success"] is True - assert data["updated_count"] == 2 + assert data["data"]["updated_count"] == 2 @patch("app.routes.folders.db_disable_ai_tagging_batch") def test_disable_ai_tagging_no_folders_updated(self, mock_disable_batch, client): @@ -523,7 +685,7 @@ def test_disable_ai_tagging_no_folders_updated(self, mock_disable_batch, client) assert response.status_code == 200 data = response.json() assert data["success"] is True - assert data["updated_count"] == 0 + assert data["data"]["updated_count"] == 0 # ============================================================================ # Integration & Workflow Tests @@ -562,13 +724,13 @@ def test_complete_folder_workflow( add_response = client.post("/folders/add-folder", json=add_request) assert add_response.status_code == 200 - folder_id = add_response.json()["folder_id"] + folder_id = add_response.json()["data"]["folder_id"] enable_request = {"folder_ids": [folder_id]} enable_response = client.post("/folders/enable-ai-tagging", json=enable_request) assert enable_response.status_code == 200 - assert enable_response.json()["updated_count"] == 1 + assert enable_response.json()["data"]["updated_count"] == 1 mock_add_folder_tree.assert_called_once() mock_enable_batch.assert_called_once_with([folder_id]) @@ -587,14 +749,14 @@ def test_ai_tagging_toggle_workflow( enable_request = {"folder_ids": folder_ids} enable_response = client.post("/folders/enable-ai-tagging", json=enable_request) assert enable_response.status_code == 200 - assert enable_response.json()["updated_count"] == 2 + assert enable_response.json()["data"]["updated_count"] == 2 disable_request = {"folder_ids": folder_ids} disable_response = client.post( "/folders/disable-ai-tagging", json=disable_request ) assert disable_response.status_code == 200 - assert disable_response.json()["updated_count"] == 2 + assert disable_response.json()["data"]["updated_count"] == 2 mock_enable_batch.assert_called_once_with(folder_ids) mock_disable_batch.assert_called_once_with(folder_ids) @@ -643,3 +805,31 @@ def mock_find_parent_side_effect(folder_path): assert child_response.status_code == 200 assert mock_find_parent.call_count >= 1 + + @patch("app.routes.folders.db_delete_folders_batch") + @patch("app.routes.folders.db_enable_ai_tagging_batch") + def test_complete_folder_lifecycle( + self, mock_enable_batch, mock_delete_batch, client + ): + """Test complete folder lifecycle: enable AI -> delete.""" + folder_ids = ["folder-1", "folder-2"] + + # Enable AI tagging + mock_enable_batch.return_value = 2 + enable_request = {"folder_ids": folder_ids} + enable_response = client.post("/folders/enable-ai-tagging", json=enable_request) + assert enable_response.status_code == 200 + + # Delete folders + mock_delete_batch.return_value = 2 + delete_response = client.request( + "DELETE", + "/folders/delete-folders", + content='{"folder_ids": ["folder-1", "folder-2"]}', + headers={"Content-Type": "application/json"}, + ) + assert delete_response.status_code == 200 + assert delete_response.json()["data"]["deleted_count"] == 2 + + mock_enable_batch.assert_called_once_with(folder_ids) + mock_delete_batch.assert_called_once_with(folder_ids) diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index 928957aff..a524e5186 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -6,7 +6,7 @@ "version": "0.1.0", "contact": { "name": "PictoPy Postman Collection", - "url": "https://www.postman.com/cryosat-explorer-62744145/workspace/pictopy/overview" + "url": "https://www.postman.com/aossie-pictopy/pictopy/overview" } }, "servers": [ @@ -18,6 +18,9 @@ "paths": { "/": { "get": { + "tags": [ + "Health" + ], "summary": "Root", "operationId": "root__get", "responses": { @@ -371,6 +374,38 @@ } } }, + "/folders/all-folders": { + "get": { + "tags": [ + "Folders" + ], + "summary": "Get All Folders", + "description": "Get details of all folders in the database.", + "operationId": "get_all_folders_folders_all_folders_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAllFoldersResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__folders__ErrorResponse" + } + } + } + } + } + } + }, "/albums/": { "get": { "tags": [ @@ -790,6 +825,38 @@ } } }, + "/images/": { + "get": { + "tags": [ + "Images" + ], + "summary": "Get All Images", + "description": "Get all images from the database.", + "operationId": "get_all_images_images__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAllImagesResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__images__ErrorResponse" + } + } + } + } + } + } + }, "/face-clusters/{cluster_id}": { "put": { "tags": [ @@ -905,6 +972,69 @@ } } }, + "/face-clusters/{cluster_id}/images": { + "get": { + "tags": [ + "Face Clusters" + ], + "summary": "Get Cluster Images", + "description": "Get all images that contain faces belonging to a specific cluster.", + "operationId": "get_cluster_images_face_clusters__cluster_id__images_get", + "parameters": [ + { + "name": "cluster_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Cluster Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetClusterImagesResponse" + } + } + } + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__face_clusters__ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__face_clusters__ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/user-preferences/": { "get": { "tags": [ @@ -1000,6 +1130,24 @@ }, "components": { "schemas": { + "AddFolderData": { + "properties": { + "folder_id": { + "type": "string", + "title": "Folder Id" + }, + "folder_path": { + "type": "string", + "title": "Folder Path" + } + }, + "type": "object", + "required": [ + "folder_id", + "folder_path" + ], + "title": "AddFolderData" + }, "AddFolderRequest": { "properties": { "folder_path": { @@ -1043,10 +1191,17 @@ "title": "Success" }, "message": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Message" }, - "folder_id": { + "error": { "anyOf": [ { "type": "string" @@ -1055,13 +1210,22 @@ "type": "null" } ], - "title": "Folder Id" + "title": "Error" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/AddFolderData" + }, + { + "type": "null" + } + ] } }, "type": "object", "required": [ - "success", - "message" + "success" ], "title": "AddFolderResponse" }, @@ -1110,6 +1274,17 @@ ], "title": "Cluster Name" }, + "face_image_base64": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Face Image Base64" + }, "face_count": { "type": "integer", "title": "Face Count" @@ -1119,6 +1294,7 @@ "required": [ "cluster_id", "cluster_name", + "face_image_base64", "face_count" ], "title": "ClusterMetadata" @@ -1183,6 +1359,27 @@ ], "title": "CreateAlbumResponse" }, + "DeleteFoldersData": { + "properties": { + "deleted_count": { + "type": "integer", + "title": "Deleted Count" + }, + "folder_ids": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Folder Ids" + } + }, + "type": "object", + "required": [ + "deleted_count", + "folder_ids" + ], + "title": "DeleteFoldersData" + }, "DeleteFoldersRequest": { "properties": { "folder_ids": { @@ -1206,22 +1403,94 @@ "title": "Success" }, "message": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Message" }, - "deleted_count": { - "type": "integer", - "title": "Deleted Count" + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeleteFoldersData" + }, + { + "type": "null" + } + ] } }, "type": "object", "required": [ - "success", - "message", - "deleted_count" + "success" ], "title": "DeleteFoldersResponse" }, + "FolderDetails": { + "properties": { + "folder_id": { + "type": "string", + "title": "Folder Id" + }, + "folder_path": { + "type": "string", + "title": "Folder Path" + }, + "parent_folder_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Folder Id" + }, + "last_modified_time": { + "type": "integer", + "title": "Last Modified Time" + }, + "AI_Tagging": { + "type": "boolean", + "title": "Ai Tagging" + }, + "taggingCompleted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Taggingcompleted" + } + }, + "type": "object", + "required": [ + "folder_id", + "folder_path", + "last_modified_time", + "AI_Tagging" + ], + "title": "FolderDetails" + }, "GetAlbumImagesRequest": { "properties": { "password": { @@ -1298,16 +1567,184 @@ ], "title": "GetAlbumsResponse" }, - "GetClustersResponse": { + "GetAllFoldersData": { "properties": { - "success": { - "type": "boolean", - "title": "Success" - }, - "message": { + "folders": { + "items": { + "$ref": "#/components/schemas/FolderDetails" + }, + "type": "array", + "title": "Folders" + }, + "total_count": { + "type": "integer", + "title": "Total Count" + } + }, + "type": "object", + "required": [ + "folders", + "total_count" + ], + "title": "GetAllFoldersData" + }, + "GetAllFoldersResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/GetAllFoldersData" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "success" + ], + "title": "GetAllFoldersResponse" + }, + "GetAllImagesResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success" + }, + "message": { + "type": "string", + "title": "Message" + }, + "data": { + "items": { + "$ref": "#/components/schemas/ImageData" + }, + "type": "array", + "title": "Data" + } + }, + "type": "object", + "required": [ + "success", + "message", + "data" + ], + "title": "GetAllImagesResponse" + }, + "GetClusterImagesData": { + "properties": { + "cluster_id": { "type": "string", + "title": "Cluster Id" + }, + "cluster_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cluster Name" + }, + "images": { + "items": { + "$ref": "#/components/schemas/ImageInCluster" + }, + "type": "array", + "title": "Images" + }, + "total_images": { + "type": "integer", + "title": "Total Images" + } + }, + "type": "object", + "required": [ + "cluster_id", + "images", + "total_images" + ], + "title": "GetClusterImagesData", + "description": "Data model for cluster images response." + }, + "GetClusterImagesResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Message" }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/GetClusterImagesData" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "success" + ], + "title": "GetClusterImagesResponse", + "description": "Response model for getting images in a cluster." + }, + "GetClustersData": { + "properties": { "clusters": { "items": { "$ref": "#/components/schemas/ClusterMetadata" @@ -1318,10 +1755,53 @@ }, "type": "object", "required": [ - "success", - "message", "clusters" ], + "title": "GetClustersData" + }, + "GetClustersResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/GetClustersData" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "success" + ], "title": "GetClustersResponse" }, "GetUserPreferencesResponse": { @@ -1360,6 +1840,58 @@ "type": "object", "title": "HTTPValidationError" }, + "ImageData": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "path": { + "type": "string", + "title": "Path" + }, + "folder_id": { + "type": "string", + "title": "Folder Id" + }, + "thumbnailPath": { + "type": "string", + "title": "Thumbnailpath" + }, + "metadata": { + "type": "string", + "title": "Metadata" + }, + "isTagged": { + "type": "boolean", + "title": "Istagged" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tags" + } + }, + "type": "object", + "required": [ + "id", + "path", + "folder_id", + "thumbnailPath", + "metadata", + "isTagged" + ], + "title": "ImageData" + }, "ImageIdsRequest": { "properties": { "image_ids": { @@ -1376,6 +1908,102 @@ ], "title": "ImageIdsRequest" }, + "ImageInCluster": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "path": { + "type": "string", + "title": "Path" + }, + "thumbnailPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Thumbnailpath" + }, + "metadata": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "face_id": { + "type": "integer", + "title": "Face Id" + }, + "confidence": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Confidence" + }, + "bbox": { + "anyOf": [ + { + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "number" + } + ] + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Bbox" + } + }, + "type": "object", + "required": [ + "id", + "path", + "face_id" + ], + "title": "ImageInCluster", + "description": "Represents an image that contains faces from a specific cluster." + }, + "RenameClusterData": { + "properties": { + "cluster_id": { + "type": "string", + "title": "Cluster Id" + }, + "cluster_name": { + "type": "string", + "title": "Cluster Name" + } + }, + "type": "object", + "required": [ + "cluster_id", + "cluster_name" + ], + "title": "RenameClusterData" + }, "RenameClusterRequest": { "properties": { "cluster_name": { @@ -1396,24 +2024,41 @@ "title": "Success" }, "message": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Message" }, - "cluster_id": { - "type": "string", - "title": "Cluster Id" + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" }, - "cluster_name": { - "type": "string", - "title": "Cluster Name" + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/RenameClusterData" + }, + { + "type": "null" + } + ] } }, "type": "object", "required": [ - "success", - "message", - "cluster_id", - "cluster_name" + "success" ], "title": "RenameClusterResponse" }, @@ -1435,6 +2080,50 @@ ], "title": "SuccessResponse" }, + "SyncFolderData": { + "properties": { + "deleted_count": { + "type": "integer", + "title": "Deleted Count" + }, + "deleted_folders": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Deleted Folders" + }, + "added_count": { + "type": "integer", + "title": "Added Count" + }, + "added_folders": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Added Folders" + }, + "folder_id": { + "type": "string", + "title": "Folder Id" + }, + "folder_path": { + "type": "string", + "title": "Folder Path" + } + }, + "type": "object", + "required": [ + "deleted_count", + "deleted_folders", + "added_count", + "added_folders", + "folder_id", + "folder_path" + ], + "title": "SyncFolderData" + }, "SyncFolderRequest": { "properties": { "folder_path": { @@ -1460,42 +2149,64 @@ "title": "Success" }, "message": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Message" }, - "deleted_count": { - "type": "integer", - "title": "Deleted Count" - }, - "deleted_folders": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Deleted Folders" + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" }, - "added_count": { + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/SyncFolderData" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "success" + ], + "title": "SyncFolderResponse" + }, + "UpdateAITaggingData": { + "properties": { + "updated_count": { "type": "integer", - "title": "Added Count" + "title": "Updated Count" }, - "added_folders": { + "folder_ids": { "items": { "type": "string" }, "type": "array", - "title": "Added Folders" + "title": "Folder Ids" } }, "type": "object", "required": [ - "success", - "message", - "deleted_count", - "deleted_folders", - "added_count", - "added_folders" + "updated_count", + "folder_ids" ], - "title": "SyncFolderResponse" + "title": "UpdateAITaggingData" }, "UpdateAITaggingRequest": { "properties": { @@ -1520,19 +2231,41 @@ "title": "Success" }, "message": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Message" }, - "updated_count": { - "type": "integer", - "title": "Updated Count" + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/UpdateAITaggingData" + }, + { + "type": "null" + } + ] } }, "type": "object", "required": [ - "success", - "message", - "updated_count" + "success" ], "title": "UpdateAITaggingResponse" }, @@ -1708,22 +2441,65 @@ "default": false }, "message": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Message" }, "error": { - "type": "string", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Error" } }, "type": "object", - "required": [ - "message", - "error" - ], "title": "ErrorResponse" }, "app__schemas__folders__ErrorResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success", + "default": false + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + } + }, + "type": "object", + "title": "ErrorResponse" + }, + "app__schemas__images__ErrorResponse": { "properties": { "success": { "type": "boolean", @@ -1771,20 +2547,5 @@ "description": "Error response model" } } - }, - "tags": [ - { - "name": "Albums", - "description": "We briefly discuss the endpoints related to albums, all of these fall under the /albums route" - }, - { - "name": "Images", - "description": "We briefly discuss the endpoints related to images, all of these fall under the /images route" - }, - { - "name": "Tagging", - "description": "We briefly discuss the endpoints related to face tagging and recognition, all of these fall under the /tag route", - "x-displayName": "Face recognition and Tagging" - } - ] + } } \ No newline at end of file diff --git a/frontend/api/api-functions/albums.ts b/frontend/api/api-functions/albums.ts deleted file mode 100644 index 55a1eff47..000000000 --- a/frontend/api/api-functions/albums.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { albumEndpoints } from '../apiEndpoints'; - -interface ViewAlbumParams { - album_name: string; - password?: string; -} - -export const createAlbums = async (payload: { - name: string; - description?: string; - is_hidden?: boolean; - password?: string; -}) => { - const response = await fetch(albumEndpoints.createAlbum, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify(payload), - }); - - const data = await response.json(); - return data; -}; - -export const viewYourAlbum = async ({ - album_name, - password, -}: ViewAlbumParams) => { - const queryParams = new URLSearchParams({ album_name }); - if (password) { - queryParams.append('password', password); - } - - const response = await fetch( - `${albumEndpoints.viewAlbum}?${queryParams.toString()}`, - { - headers: { - Accept: 'application/json', - }, - }, - ); - - const data = await response.json(); - return data; -}; - -export const deleteAlbums = async (payload: { name: string }) => { - const response = await fetch(albumEndpoints.deleteAlbum, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify(payload), - }); - - const data = await response.json(); - return data; -}; - -export const fetchAllAlbums = async (includeHidden: boolean = false) => { - const queryParams = new URLSearchParams(); - if (includeHidden) { - queryParams.append('include_hidden', 'true'); - } - - const url = `${albumEndpoints.viewAllAlbums}${ - includeHidden ? '?' + queryParams.toString() : '' - }`; - - const response = await fetch(url, { - headers: { - Accept: 'application/json', - }, - }); - - const data = await response.json(); - return data; -}; - -export const isAlbumHidden = (albumData: any): boolean => { - return albumData?.is_hidden || false; -}; - -export const addToAlbum = async (payload: { - album_name: string; - image_path: string; - password?: string; -}) => { - const response = await fetch(albumEndpoints.addToAlbum, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify(payload), - }); - - const data = await response.json(); - return data; -}; - -export const addMultipleToAlbum = async (payload: { - album_name: string; - paths: string[]; - password?: string; -}) => { - const response = await fetch(albumEndpoints.addMultipleToAlbum, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify(payload), - }); - - const data = await response.json(); - return data; -}; - -export const removeFromAlbum = async (payload: { - album_name: string; - path: string; - password?: string; -}) => { - const response = await fetch(albumEndpoints.removeFromAlbum, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify(payload), - }); - - const data = await response.json(); - return data; -}; - -export const editAlbumDescription = async (payload: { - album_name: string; - description: string; - password?: string; -}) => { - const response = await fetch(albumEndpoints.editAlbumDescription, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify(payload), - }); - - const data = await response.json(); - return data; -}; - -export const addMultipleToAlbums = async (payload: { paths: string[] }) => { - const response = await fetch(albumEndpoints.addMultipleToAlbums, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify(payload), - }); - - const data = await response.json(); - return data; -}; diff --git a/frontend/api/api-functions/faceTagging.ts b/frontend/api/api-functions/faceTagging.ts deleted file mode 100644 index 31cd39b0c..000000000 --- a/frontend/api/api-functions/faceTagging.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { faceTaggingEndpoints } from '../apiEndpoints'; - -export const searchByFace = async (file: File, threshold: number = 0.5) => { - const formData = new FormData(); - formData.append('file', file); - formData.append('threshold', threshold.toString()); - - try { - const response = await fetch(faceTaggingEndpoints.searchByFace, { - method: 'POST', - body: formData, - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error( - data.message || `Request failed with status ${response.status}`, - ); - } - - return data; - } catch (error) { - console.error('Error searching by face:', error); - throw error; - } -}; diff --git a/frontend/api/api-functions/face_clusters.ts b/frontend/api/api-functions/face_clusters.ts new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/api/api-functions/images.ts b/frontend/api/api-functions/images.ts deleted file mode 100644 index 895dc95cb..000000000 --- a/frontend/api/api-functions/images.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { imagesEndpoints } from '../apiEndpoints'; -import { convertFileSrc } from '@tauri-apps/api/core'; -import { APIResponse, Image } from '../../src/types/image'; -import { extractThumbnailPath } from '@/hooks/useImages'; - -export const fetchAllImages = async () => { - const response = await fetch(imagesEndpoints.allImages, { - headers: { - accept: 'application/json', - }, - }); - - const data: APIResponse = await response.json(); - return data; -}; - -export const delMultipleImages = async ( - paths: string[], - isFromDevice: boolean, -) => { - const response = await fetch(imagesEndpoints.deleteMultipleImages, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ paths, isFromDevice }), - }); - - const data: APIResponse = await response.json(); - return data; -}; - -const parseAndSortImageData = (data: APIResponse['data']): Image[] => { - const parsedImages: Image[] = Object.entries(data.images).map( - ([src, tags]) => { - const url = convertFileSrc(src); - const thumbnailUrl = convertFileSrc(extractThumbnailPath(src)); - return { - imagePath: src, - title: src.substring(src.lastIndexOf('\\') + 1), - thumbnailUrl, - url, - tags: tags, - }; - }, - ); - return parsedImages; -}; - -export const getAllImageObjects = async () => { - const response = await fetch(imagesEndpoints.allImageObjects); - const data: APIResponse = await response.json(); - const parsedAndSortedImages = parseAndSortImageData(data.data); - const newObj = { - data: parsedAndSortedImages, - success: data.success, - error: data?.error, - message: data?.message, - }; - - return newObj; -}; - -export const addFolder = async (folderPath: string[]) => { - const response = await fetch(imagesEndpoints.addFolder, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ folder_path: folderPath }), - }); - - const data: APIResponse = await response.json(); - return data; -}; - -export const addMultImages = async (paths: string[]) => { - const response = await fetch(imagesEndpoints.addMultipleImages, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ paths }), - }); - - const data = await response.json(); - return data; -}; - -export const generateThumbnails = async (folderPath: string[]) => { - const response = await fetch(imagesEndpoints.generateThumbnails, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ folder_paths: folderPath }), - }); - const data = await response.json(); - return data; -}; - -export const deleteThumbnails = async (folderPath: string) => { - const response = await fetch(imagesEndpoints.deleteThumbnails, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ folder_path: folderPath }), // Send as JSON body - }); - - const data = await response.json(); - return data; -}; - -export const getProgress = async () => { - const response = await fetch(imagesEndpoints.progress); - const data = await response.json(); - return data; -}; - -export const deleteFolder = async (folderPath: string) => { - const response = await fetch(imagesEndpoints.deleteFolder, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ folder_path: folderPath }), - }); - - const data = await response.json(); - return data; -}; diff --git a/frontend/api/api-functions/index.ts b/frontend/api/api-functions/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/api/apiEndpoints.ts b/frontend/api/apiEndpoints.ts index 3c1fbdaa5..e69de29bb 100644 --- a/frontend/api/apiEndpoints.ts +++ b/frontend/api/apiEndpoints.ts @@ -1,33 +0,0 @@ -import { BACKEND_URL } from '@/config/Backend'; - -export const imagesEndpoints = { - allImages: `${BACKEND_URL}/images/all-images`, - deleteMultipleImages: `${BACKEND_URL}/images/multiple-images`, - allImageObjects: `${BACKEND_URL}/images/all-image-objects`, - addFolder: `${BACKEND_URL}/images/add-folder`, - addMultipleImages: `${BACKEND_URL}/images/multiple-images`, - generateThumbnails: `${BACKEND_URL}/images/generate-thumbnails`, - deleteThumbnails: `${BACKEND_URL}/images/delete-thumbnails`, - progress: `${BACKEND_URL}/images/add-folder-progress`, - deleteFolder: `${BACKEND_URL}/images/delete-folder`, - getThumbnailPath: `${BACKEND_URL}/images/get-thumbnail-path`, -}; - -export const albumEndpoints = { - createAlbum: `${BACKEND_URL}/albums/create-album`, - deleteAlbum: `${BACKEND_URL}/albums/delete-album`, - viewAllAlbums: `${BACKEND_URL}/albums/view-all`, - addToAlbum: `${BACKEND_URL}/albums/add-to-album`, - addMultipleToAlbum: `${BACKEND_URL}/albums/add-multiple-to-album`, - removeFromAlbum: `${BACKEND_URL}/albums/remove-from-album`, - viewAlbum: `${BACKEND_URL}/albums/view-album`, - editAlbumDescription: `${BACKEND_URL}/albums/edit-album-description`, - addMultipleToAlbums: `${BACKEND_URL}/albums/multiple-images`, -}; - -export const faceTaggingEndpoints = { - match: `${BACKEND_URL}/tag/match`, - clusters: `${BACKEND_URL}/tag/clusters`, - relatedImages: `${BACKEND_URL}/tag/related-images`, - searchByFace: `${BACKEND_URL}/tag/search-by-face`, -}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a2544c260..879f059ef 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,13 +8,19 @@ "name": "PictoPy", "version": "0.0.1", "dependencies": { - "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-aspect-ratio": "^1.1.7", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.8", "@reduxjs/toolkit": "^2.8.2", "@tailwindcss/vite": "^4.1.8", "@tanstack/react-query": "^5.62.10", @@ -3388,6 +3394,56 @@ } } }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -3445,20 +3501,20 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", - "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.10", - "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", @@ -3480,6 +3536,78 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -3609,6 +3737,29 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-menu": { "version": "2.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz", @@ -3776,6 +3927,99 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", @@ -3912,6 +4156,164 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -3982,6 +4384,24 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -4048,6 +4468,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 677bb9fa4..ae35fc5ea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,13 +23,19 @@ ] }, "dependencies": { - "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-aspect-ratio": "^1.1.7", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.8", "@reduxjs/toolkit": "^2.8.2", "@tailwindcss/vite": "^4.1.8", "@tanstack/react-query": "^5.62.10", diff --git a/frontend/public/128x128.png b/frontend/public/128x128.png new file mode 100644 index 000000000..8ba2d4a08 Binary files /dev/null and b/frontend/public/128x128.png differ diff --git a/frontend/public/PictoPy_Logo.png b/frontend/public/PictoPy_Logo.png deleted file mode 100644 index ab0ac1faf..000000000 Binary files a/frontend/public/PictoPy_Logo.png and /dev/null differ diff --git a/frontend/public/avatars/avatar1.png b/frontend/public/avatars/avatar1.png new file mode 100644 index 000000000..1d96b66de Binary files /dev/null and b/frontend/public/avatars/avatar1.png differ diff --git a/frontend/public/avatars/avatar2.png b/frontend/public/avatars/avatar2.png new file mode 100644 index 000000000..f4c1d1bb3 Binary files /dev/null and b/frontend/public/avatars/avatar2.png differ diff --git a/frontend/public/avatars/avatar3.png b/frontend/public/avatars/avatar3.png new file mode 100644 index 000000000..99f70dc11 Binary files /dev/null and b/frontend/public/avatars/avatar3.png differ diff --git a/frontend/public/avatars/avatar4.png b/frontend/public/avatars/avatar4.png new file mode 100644 index 000000000..53c4de21e Binary files /dev/null and b/frontend/public/avatars/avatar4.png differ diff --git a/frontend/public/avatars/avatar5.png b/frontend/public/avatars/avatar5.png new file mode 100644 index 000000000..24013cd0c Binary files /dev/null and b/frontend/public/avatars/avatar5.png differ diff --git a/frontend/public/avatars/avatar6.png b/frontend/public/avatars/avatar6.png new file mode 100644 index 000000000..292d909cf Binary files /dev/null and b/frontend/public/avatars/avatar6.png differ diff --git a/frontend/public/avatars/avatar7.png b/frontend/public/avatars/avatar7.png new file mode 100644 index 000000000..9817b0b0e Binary files /dev/null and b/frontend/public/avatars/avatar7.png differ diff --git a/frontend/public/avatars/avatar8.png b/frontend/public/avatars/avatar8.png new file mode 100644 index 000000000..9705fa099 Binary files /dev/null and b/frontend/public/avatars/avatar8.png differ diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 6f6738fe7..c88e55d7e 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -60,7 +60,7 @@ "scope": ["**"], "enable": true }, - "csp": null + "csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost" } } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 38ddaa79c..106a6a9f6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,7 +23,12 @@ const App: React.FC = () => { - + ); diff --git a/frontend/src/api/api-functions/face_clusters.ts b/frontend/src/api/api-functions/face_clusters.ts new file mode 100644 index 000000000..cf814306d --- /dev/null +++ b/frontend/src/api/api-functions/face_clusters.ts @@ -0,0 +1,38 @@ +import { faceClustersEndpoints } from '../apiEndpoints'; +import { apiClient } from '../axiosConfig'; +import { APIResponse } from '@/types/API'; + +//Request Types +export interface RenameClusterRequest { + clusterId: string; + newName: string; +} +export interface FetchClusterImagesRequest { + clusterId: string; +} + +export const fetchAllClusters = async (): Promise => { + const response = await apiClient.get( + faceClustersEndpoints.getAllClusters, + ); + return response.data; +}; + +export const renameCluster = async ( + request: RenameClusterRequest, +): Promise => { + const response = await apiClient.put( + faceClustersEndpoints.renameCluster(request.clusterId), + { cluster_name: request.newName }, + ); + return response.data; +}; + +export const fetchClusterImages = async ( + request: FetchClusterImagesRequest, +): Promise => { + const response = await apiClient.get( + faceClustersEndpoints.getClusterImages(request.clusterId), + ); + return response.data; +}; diff --git a/frontend/src/api/api-functions/folders.ts b/frontend/src/api/api-functions/folders.ts new file mode 100644 index 000000000..edc7efba1 --- /dev/null +++ b/frontend/src/api/api-functions/folders.ts @@ -0,0 +1,71 @@ +import { foldersEndpoints } from '../apiEndpoints'; +import { apiClient } from '../axiosConfig'; +import { APIResponse } from '@/types/API'; + +// Request Types +export interface AddFolderRequest { + folder_path: string; + parent_folder_id?: string; + taggingCompleted?: boolean; +} + +export interface UpdateAITaggingRequest { + folder_ids: string[]; +} + +export interface DeleteFoldersRequest { + folder_ids: string[]; +} + +export interface SyncFolderRequest { + folder_path: string; + folder_id: string; +} + +// API Functions +export const getAllFolders = async (): Promise => { + const response = await apiClient.get( + foldersEndpoints.getAllFolders, + ); + return response.data; +}; + +export const addFolder = async ( + request: AddFolderRequest, +): Promise => { + const response = await apiClient.post( + foldersEndpoints.addFolder, + request, + ); + return response.data; +}; + +export const enableAITagging = async ( + request: UpdateAITaggingRequest, +): Promise => { + const response = await apiClient.post( + foldersEndpoints.enableAITagging, + request, + ); + return response.data; +}; + +export const disableAITagging = async ( + request: UpdateAITaggingRequest, +): Promise => { + const response = await apiClient.post( + foldersEndpoints.disableAITagging, + request, + ); + return response.data; +}; + +export const deleteFolders = async ( + request: DeleteFoldersRequest, +): Promise => { + const response = await apiClient.delete( + foldersEndpoints.deleteFolders, + { data: request }, + ); + return response.data; +}; diff --git a/frontend/src/api/api-functions/images.ts b/frontend/src/api/api-functions/images.ts new file mode 100644 index 000000000..8eac5fa7c --- /dev/null +++ b/frontend/src/api/api-functions/images.ts @@ -0,0 +1,10 @@ +import { imagesEndpoints } from '../apiEndpoints'; +import { apiClient } from '../axiosConfig'; +import { APIResponse } from '@/types/API'; + +export const fetchAllImages = async (): Promise => { + const response = await apiClient.get( + imagesEndpoints.getAllImages, + ); + return response.data; +}; diff --git a/frontend/src/api/api-functions/index.ts b/frontend/src/api/api-functions/index.ts new file mode 100644 index 000000000..55f583f2e --- /dev/null +++ b/frontend/src/api/api-functions/index.ts @@ -0,0 +1,4 @@ +// Export all API functions +export * from './face_clusters'; +export * from './images'; +export * from './folders'; diff --git a/frontend/src/api/apiEndpoints.ts b/frontend/src/api/apiEndpoints.ts new file mode 100644 index 000000000..06a38c597 --- /dev/null +++ b/frontend/src/api/apiEndpoints.ts @@ -0,0 +1,18 @@ +export const imagesEndpoints = { + getAllImages: '/images/', +}; + +export const faceClustersEndpoints = { + getAllClusters: '/face-clusters/', + renameCluster: (clusterId: string) => `/face-clusters/${clusterId}`, + getClusterImages: (clusterId: string) => `/face-clusters/${clusterId}/images`, +}; + +export const foldersEndpoints = { + getAllFolders: '/folders/all-folders', + addFolder: '/folders/add-folder', + enableAITagging: '/folders/enable-ai-tagging', + disableAITagging: '/folders/disable-ai-tagging', + deleteFolders: '/folders/delete-folders', + syncFolder: '/folders/sync-folder', +}; diff --git a/frontend/src/api/axiosConfig.ts b/frontend/src/api/axiosConfig.ts new file mode 100644 index 000000000..dc8bc975c --- /dev/null +++ b/frontend/src/api/axiosConfig.ts @@ -0,0 +1,12 @@ +import axios from 'axios'; +import { BACKEND_URL } from '@/config/Backend'; + +// Create simple axios instance with basic configuration +export const apiClient = axios.create({ + baseURL: BACKEND_URL, + timeout: 10000, // 10 seconds timeout + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, +}); diff --git a/frontend/src/app/store.ts b/frontend/src/app/store.ts index 4c4d02206..f190c9cb6 100644 --- a/frontend/src/app/store.ts +++ b/frontend/src/app/store.ts @@ -1,13 +1,19 @@ import { configureStore } from '@reduxjs/toolkit'; import loaderReducer from '@/features/loaderSlice'; import onboardingReducer from '@/features/onboardingSlice'; +import imageReducer from '@/features/imageSlice'; +import faceClustersReducer from '@/features/faceClustersSlice'; import infoDialogReducer from '@/features/infoDialogSlice'; +import folderReducer from '@/features/folderSlice'; export const store = configureStore({ reducer: { loader: loaderReducer, onboarding: onboardingReducer, + images: imageReducer, + faceClusters: faceClustersReducer, infoDialog: infoDialogReducer, + folders: folderReducer, }, }); diff --git a/frontend/src/components/AITagging/AIgallery.tsx b/frontend/src/components/AITagging/AIgallery.tsx deleted file mode 100644 index a790a8698..000000000 --- a/frontend/src/components/AITagging/AIgallery.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import FilterControls from './FilterControls'; -import MediaGrid from '../Media/Mediagrid'; -import { LoadingScreen } from '@/components/ui/LoadingScreen/LoadingScreen'; -import MediaView from '../Media/MediaView'; -import PaginationControls from '../ui/PaginationControls'; -import { usePictoQuery } from '@/hooks/useQueryExtensio'; -import { getAllImageObjects } from '../../../api/api-functions/images'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Button } from '@/components/ui/button'; -import ProgressiveFolderLoader from '../ui/ProgressiveLoader'; - -import { UserSearch } from 'lucide-react'; -import ErrorPage from '@/components/ui/ErrorPage/ErrorPage'; - -export default function AIGallery({ - title, - type, -}: { - title: string; - type: 'image' | 'video'; -}) { - const { - successData, - error, - isLoading: isGeneratingTags, - } = usePictoQuery({ - queryFn: async () => await getAllImageObjects(), - queryKey: ['ai-tagging-images', 'ai'], - }); - const [addedFolders, setAddedFolders] = useState([]); - let mediaItems = successData ?? []; - const [filterTag, setFilterTag] = useState([]); - const [currentPage, setCurrentPage] = useState(1); - const [showMediaViewer, setShowMediaViewer] = useState(false); - const [selectedMediaIndex, setSelectedMediaIndex] = useState(0); - const [isVisibleSelectedImage, setIsVisibleSelectedImage] = - useState(true); - const [faceSearchResults, setFaceSearchResults] = useState([]); - - const itemsPerRow: number = 3; - const noOfPages: number[] = Array.from( - { length: 41 }, - (_, index) => index + 10, - ); - - const filteredMediaItems = useMemo(() => { - let filtered = mediaItems; - if (faceSearchResults.length > 0) { - filtered = filtered.filter((item: any) => - faceSearchResults.includes(item.imagePath), - ); - } - - return filterTag.length > 0 - ? filtered.filter((mediaItem: any) => - filterTag.some((tag) => mediaItem.tags.includes(tag)), - ) - : filtered; - }, [filterTag, mediaItems, isGeneratingTags, faceSearchResults]); - - const [pageNo, setpageNo] = useState(20); - - const currentItems = useMemo(() => { - const indexOfLastItem = currentPage * pageNo; - const indexOfFirstItem = indexOfLastItem - pageNo; - return filteredMediaItems.slice(indexOfFirstItem, indexOfLastItem); - }, [filteredMediaItems, currentPage, pageNo]); - - const totalPages = Math.ceil(filteredMediaItems.length / pageNo); - - const openMediaViewer = useCallback((index: number) => { - setSelectedMediaIndex(index); - setShowMediaViewer(true); - }, []); - - const closeMediaViewer = useCallback(() => { - setShowMediaViewer(false); - }, []); - - const handleFolderAdded = useCallback(async (newPaths: string[]) => { - setAddedFolders(newPaths); - }, []); - - useEffect(() => { - setCurrentPage(1); - }, [filterTag, faceSearchResults]); - - if (error) { - return ( - window.location.reload()} - /> - ); - } - - return ( -
-
-
- {isVisibleSelectedImage && ( -
-

{title}

- {faceSearchResults.length > 0 && ( -
- - - Face filter active ({faceSearchResults.length} matches) - - -
- )} -
- )} - - -
- - {isVisibleSelectedImage && ( - <> - -
- - -
- - - - - - setpageNo(Number(value))} - > - {noOfPages.map((itemsPerPage) => ( - - {itemsPerPage} - - ))} - - - -
-
- - )} - - {isGeneratingTags ? ( - - ) : ( - showMediaViewer && ( - ({ - url: item.url, - path: item?.imagePath, - }))} - currentPage={currentPage} - itemsPerPage={pageNo} - type={type} - /> - ) - )} -
-
- ); -} diff --git a/frontend/src/components/AITagging/FilterControls.tsx b/frontend/src/components/AITagging/FilterControls.tsx deleted file mode 100644 index 8442ddc20..000000000 --- a/frontend/src/components/AITagging/FilterControls.tsx +++ /dev/null @@ -1,332 +0,0 @@ -import React, { useState, useRef } from 'react'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from '../ui/dropdown-menu'; -import { Button } from '@/components/ui/button'; -import { MediaItem } from '@/types/Media'; -import AITaggingFolderPicker from '../FolderPicker/AITaggingFolderPicker'; -import DeleteSelectedImagePage from '../FolderPicker/DeleteSelectedImagePage'; -import ErrorDialog from '../Album/Error'; -import { Trash2, Filter, UserSearch, Upload, Camera } from 'lucide-react'; -import { searchByFace } from '../../../api/api-functions/faceTagging'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '../ui/dialog'; -import WebcamCapture from './WebcamCapture'; -import { LoadingScreen } from '../LoadingScreen/LoadingScreen'; - -interface FilterControlsProps { - setFilterTag: (tag: string[]) => void; - mediaItems: MediaItem[]; - onFolderAdded: (newPaths: string[]) => Promise; - isLoading: boolean; - isVisibleSelectedImage: boolean; - setIsVisibleSelectedImage: (value: boolean) => void; - setFaceSearchResults: (paths: string[]) => void; -} - -export default function FilterControls({ - setFilterTag, - mediaItems, - onFolderAdded, - isVisibleSelectedImage, - setIsVisibleSelectedImage, - setFaceSearchResults, -}: FilterControlsProps) { - const uniqueTags = React.useMemo(() => { - const allTags = mediaItems.flatMap((item) => item.tags); - return Array.from(new Set(allTags)) - .filter((tag): tag is string => typeof tag === 'string') - .sort(); - }, [mediaItems]); - - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [isFaceDialogOpen, setIsFaceDialogOpen] = useState(false); - const [isSearching, setIsSearching] = useState(false); - const [searchError, setSearchError] = useState(null); - const [showCamera, setShowCamera] = useState(false); - - const fileInputRef = useRef(null); - - const [selectedFlags, setSelectedFlags] = useState< - { tag: string; isChecked: boolean }[] - >([ - { tag: 'All tags', isChecked: false }, - ...uniqueTags.map((ele) => ({ tag: ele, isChecked: false })), - ]); - - const handleAddFlag = (idx: number) => { - const updatedFlags = [...selectedFlags]; - updatedFlags[idx].isChecked = true; - setSelectedFlags(updatedFlags); - }; - - const handleRemoveFlag = (idx: number) => { - const updatedFlags = [...selectedFlags]; - updatedFlags[idx].isChecked = false; - setSelectedFlags(updatedFlags); - }; - - const handleFilterFlag = () => { - let flags: string[] = []; - if (selectedFlags[0].isChecked) { - setFilterTag([]); - return; - } - selectedFlags.forEach((ele) => { - if (ele.isChecked) flags.push(ele.tag); - }); - console.log('Updated Filter Flags = ', flags); - setFilterTag(flags); - }; - - const handleToggleDropdown = (event: Event) => { - event.preventDefault(); - setIsDropdownOpen((prevState) => !prevState); // Toggle dropdown visibility - }; - const handleFolderPick = async (paths: string[]) => { - //set addiitional paths here - try { - await onFolderAdded(paths); - } catch (error) { - console.error('Error adding folder:', error); - } - }; - - const [errorDialogContent, setErrorDialogContent] = useState<{ - title: string; - description: string; - } | null>(null); - - const showErrorDialog = (title: string, err: unknown) => { - setErrorDialogContent({ - title, - description: - err instanceof Error ? err.message : 'An unknown error occurred', - }); - }; - - const handleFileUpload = async ( - event: React.ChangeEvent, - ) => { - const file = event.target.files?.[0]; - if (!file) return; - - try { - setIsSearching(true); - setSearchError(null); - - const result = await searchByFace(file); - - if (result.success && result.data) { - const matchedPaths = result.data.matches.map( - (match: any) => match.path, - ); - setFaceSearchResults(matchedPaths); - setIsFaceDialogOpen(false); - } else { - setSearchError(result.message || 'Failed to search by face'); - } - } catch (error: any) { - console.error('Error in face search:', error); - if (error.message?.includes('400')) { - setSearchError('No person detected in the image'); - } else { - setSearchError(error.message || 'An unknown error occurred'); - } - } finally { - setIsSearching(false); - } - }; - - const triggerFileInput = () => { - fileInputRef.current?.click(); - }; - - const clearFaceSearch = () => { - setFaceSearchResults([]); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }; - - const handleCameraCapture = ( - matchedPaths: string[], - errorMessage?: string, - ) => { - if (errorMessage) { - setIsSearching(false); - setSearchError(errorMessage); - return; - } - - setFaceSearchResults(matchedPaths); - setShowCamera(false); - setIsFaceDialogOpen(false); - }; - - if (!isVisibleSelectedImage) { - return ( -
- -
- ); - } - - return ( - <> -
- - - - - - - - - - - Sort by Face - -
- {isSearching && } - {searchError && ( -
- {searchError} -
- )} - - {showCamera ? ( - setShowCamera(false)} - /> - ) : ( - <> - - - - - - - - - )} -
-
-
- - - - - - - - {selectedFlags.map((ele, index) => ( - { - selectedFlags[index].isChecked - ? handleRemoveFlag(index) - : handleAddFlag(index); - event.preventDefault(); - }} - className="cursor-pointer" - > - - {ele.tag} - - ))} - - - - - setErrorDialogContent(null)} - /> -
- - ); -} diff --git a/frontend/src/components/AITagging/WebcamCapture.tsx b/frontend/src/components/AITagging/WebcamCapture.tsx deleted file mode 100644 index b27731fea..000000000 --- a/frontend/src/components/AITagging/WebcamCapture.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Camera } from 'lucide-react'; -import { v4 as uuidv4 } from 'uuid'; -import { BACKEND_URL } from '@/config/Backend'; - -interface WebcamCaptureProps { - onCapture: (matchedPaths: string[], errorMessage?: string) => void; - onClose: () => void; -} - -const GlobalInstanceCount = { count: 0 }; - -const WebcamCapture: React.FC = ({ - onCapture, - onClose, -}) => { - const [isConnecting, setIsConnecting] = useState(true); - const [error, setError] = useState(null); - const [facesDetected, setFacesDetected] = useState(0); - const [isCapturing, setIsCapturing] = useState(false); - const [fps, setFps] = useState(null); - const socketRef = useRef(null); - const imageRef = useRef(null); - const framesReceived = useRef(0); - const instanceId = useRef(++GlobalInstanceCount.count); - const attemptRef = useRef(0); - const cleanupFnRef = useRef<(() => void) | null>(null); - - // Generate a unique client ID for each component instance - const clientId = useRef(uuidv4()); - - // Track whether component is mounted - const isMountedRef = useRef(true); - - // Delay sending the "close" message to ensure messages are processed in order - const sendCloseMessage = async () => { - if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { - console.log('Sending close action to server'); - try { - socketRef.current.send(JSON.stringify({ action: 'close' })); - // Small delay to allow the close message to be sent - await new Promise((resolve) => setTimeout(resolve, 100)); - } catch (e) { - console.error('Error sending close message:', e); - } - } - }; - - const cleanupResources = async () => { - console.log( - `Cleaning up WebSocket resources for instance ${instanceId.current}`, - ); - - try { - await sendCloseMessage(); - - if (socketRef.current) { - console.log('Closing WebSocket connection'); - socketRef.current.close(); - socketRef.current = null; - } - } catch (e) { - console.error('Error during cleanup:', e); - } - }; - - const handleClose = async () => { - console.log('handleClose called'); - await cleanupResources(); - onClose(); - }; - - const connectWebSocket = async () => { - // Don't attempt to connect if component is unmounted - if (!isMountedRef.current) return () => {}; - - // Increment attempt counter for each connection attempt - attemptRef.current += 1; - - // Create a different client ID for each connection attempt - const connectionClientId = `${clientId.current}-${attemptRef.current}`; - console.log( - `Starting connection attempt #${attemptRef.current} with ID: ${connectionClientId}`, - ); - - setIsConnecting(true); - setError(null); - framesReceived.current = 0; - - // Clean up any existing connection - BUT wait for it to complete - // This fixes the race condition where we're closing the old socket while opening a new one - if (socketRef.current) { - console.log( - 'Waiting for previous socket to close before creating a new one', - ); - await cleanupResources(); - - // Small delay to ensure the backend has time to fully clean up - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = BACKEND_URL.replace(/^https?:\/\//, ''); - const wsFullUrl = `${wsProtocol}//${wsUrl}/tag/webcam-feed/${connectionClientId}`; - - console.log(`Connecting to WebSocket at: ${wsFullUrl}`); - - try { - const socket = new WebSocket(wsFullUrl); - socketRef.current = socket; - - // Connection timeout 10 seconds - const connectionTimeout = setTimeout(() => { - if (!isMountedRef.current || socketRef.current !== socket) return; - - console.log('Connection timeout reached'); - setError('Connection timeout. Please try again.'); - setIsConnecting(false); - - // Close the socket - try { - socket.close(); - socketRef.current = null; - } catch (e) { - console.error('Error closing socket after timeout:', e); - } - }, 10000); // 10 seconds timeout - - socket.onopen = () => { - console.log( - `WebSocket connection opened (attempt #${attemptRef.current})`, - ); - // isConnecting will be set to false when 'connected' event is received - }; - - socket.onmessage = (event) => { - if (!isMountedRef.current || socketRef.current !== socket) return; - - try { - const data = JSON.parse(event.data); - console.log(`Received event: ${data.event}`); - - if (data.event === 'connected') { - console.log('Camera connected event received'); - setIsConnecting(false); - clearTimeout(connectionTimeout); // Clear timeout on successful connection - } else if (data.event === 'frame' && imageRef.current) { - if ( - framesReceived.current === 0 || - framesReceived.current % 30 === 0 - ) { - console.log(`Received frame #${framesReceived.current}`); - } - - framesReceived.current++; - imageRef.current.src = `data:image/jpeg;base64,${data.image}`; - setFacesDetected(data.faces_detected || 0); - if (data.fps) setFps(data.fps); - } else if (data.event === 'error') { - console.error('Server error:', data.message); - setError(data.message); - setIsConnecting(false); - } else if (data.event === 'search_result') { - setIsCapturing(false); - if (data.success) { - const matchedPaths = data.matches.map((match: any) => match.path); - onCapture(matchedPaths); - } else { - onCapture( - [], - data.message || 'No faces detected in captured image', - ); - - setError(data.message || 'No faces detected in captured image'); - setTimeout(() => { - if (isMountedRef.current) { - setError(null); - } - }, 3000); - } - } - } catch (err) { - console.error('Error parsing message:', err); - } - }; - - socket.onerror = (err) => { - if (!isMountedRef.current || socketRef.current !== socket) return; - - console.error('WebSocket error:', err); - setError('Connection error. Please try again.'); - setIsConnecting(false); - clearTimeout(connectionTimeout); - }; - - socket.onclose = (event) => { - if (!isMountedRef.current || socketRef.current !== socket) return; - - clearTimeout(connectionTimeout); - console.log( - `WebSocket closed: code=${event.code}, reason=${event.reason || 'none'}`, - ); - - if (framesReceived.current === 0) { - setError( - 'Connection closed before receiving camera feed. Please try again.', - ); - setIsConnecting(false); - } - }; - - return () => clearTimeout(connectionTimeout); - } catch (error) { - console.error('Error creating WebSocket:', error); - setError('Failed to create connection. Please try again.'); - setIsConnecting(false); - return () => {}; - } - }; - - useEffect(() => { - console.log('Component mounted'); - isMountedRef.current = true; - attemptRef.current = 0; - - (async () => { - if (isMountedRef.current) { - const cleanup = await connectWebSocket(); - - if (typeof cleanup === 'function') { - cleanupFnRef.current = cleanup; - } - } - })(); - - return () => { - console.log('Component unmounting'); - isMountedRef.current = false; - - if (typeof cleanupFnRef.current === 'function') { - cleanupFnRef.current(); - } - - cleanupResources(); - }; - }, []); - - const handleCapture = () => { - if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { - setIsCapturing(true); - socketRef.current.send(JSON.stringify({ action: 'capture' })); - } - }; - - const handleRetry = async () => { - console.log('Retrying connection'); - await connectWebSocket(); - }; - - useEffect(() => { - if ( - error === - 'Connection closed before receiving camera feed. Please try again.' && - attemptRef.current < 3 - ) { - console.log( - `Auto-retrying connection (attempt ${attemptRef.current + 1} of 3)...`, - ); - - const retryTimeout = setTimeout(() => { - if (isMountedRef.current) { - handleRetry(); - } - }, 1500); - - return () => clearTimeout(retryTimeout); - } - }, [error]); - - return ( -
-
-
- {isConnecting ? ( -
-

Connecting to camera...

-
- ) : error ? ( -
-

{error}

-
- ) : ( -
- Webcam feed -
- {facesDetected > 0 - ? `${facesDetected} ${facesDetected === 1 ? 'face' : 'faces'} detected` - : 'No faces detected'} - {fps && ` • ${fps} FPS`} -
-
- )} -
-
- -
- - - -
-
- ); -}; - -export default WebcamCapture; diff --git a/frontend/src/components/Album/Album.tsx b/frontend/src/components/Album/Album.tsx deleted file mode 100644 index a247c83bf..000000000 --- a/frontend/src/components/Album/Album.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import React, { useState } from 'react'; -import AlbumList from './AlbumList'; -import { Button } from '@/components/ui/button'; -import CreateAlbumForm from './AlbumForm'; -import EditAlbumDialog from './AlbumDialog'; -import ErrorDialog from './Error'; -import AlbumView from './Albumview'; -import { Album } from '@/types/Album'; -import { SquarePlus } from 'lucide-react'; -import { LoadingScreen } from '@/components/ui/LoadingScreen/LoadingScreen'; -import { usePictoMutation, usePictoQuery } from '@/hooks/useQueryExtensio'; -import { - deleteAlbums, - fetchAllAlbums, -} from '../../../api/api-functions/albums'; - -const AlbumsView: React.FC = () => { - const { successData: albums, isLoading } = usePictoQuery({ - queryFn: async () => await fetchAllAlbums(false), - queryKey: ['all-albums'], - }); - - const { mutate: deleteAlbum } = usePictoMutation({ - mutationFn: deleteAlbums, - autoInvalidateTags: ['all-albums'], - }); - - const [isCreateFormOpen, setIsCreateFormOpen] = useState(false); - const [editingAlbum, setEditingAlbum] = useState(null); - const [currentAlbum, setCurrentAlbum] = useState(null); - const [errorDialogContent, setErrorDialogContent] = useState<{ - title: string; - description: string; - } | null>(null); - - if (isLoading) - return ( -
- -
- ); - - const showErrorDialog = (title: string, err: unknown) => { - setErrorDialogContent({ - title, - description: - err instanceof Error ? err.message : 'An unknown error occurred', - }); - }; - - if (!albums || albums.length === 0) { - return ( -
-
-

- Albums -

- -
-
- No albums found. -
- setIsCreateFormOpen(false)} - onError={(title, err) => showErrorDialog(title, err)} - /> - setErrorDialogContent(null)} - /> -
- ); - } - //these funcion works when there are albums - const transformedAlbums = albums.map((album: Album) => ({ - id: album.album_name, - title: album.album_name, - coverImage: album.image_paths[0] || '', - imageCount: album.image_paths.length, - })); - - const handleAlbumClick = (albumId: string) => { - const album = albums.find((a: Album) => a.album_name === albumId); - if (album?.is_hidden) { - const password = prompt('Enter the password for this hidden album:'); - if (password === album.password) { - setCurrentAlbum(albumId); - } else { - alert('Incorrect password.'); - } - } else { - setCurrentAlbum(albumId); - } - }; - - const handleDeleteAlbum = async (albumId: string) => { - try { - await deleteAlbum({ name: albumId }); - } catch (err) { - showErrorDialog('Error Deleting Album', err); - } - }; - - return ( -
- {currentAlbum ? ( - { - setCurrentAlbum(null); - }} - onError={showErrorDialog} - /> - ) : ( - <> -
-

- Albums -

- -
- { - const album = albums.find((a: any) => a.album_name === albumId); - if (album) { - setEditingAlbum(album); - } - }} - onDeleteAlbum={handleDeleteAlbum} - /> - - )} - - setIsCreateFormOpen(false)} - onError={(title, err) => showErrorDialog(title, err)} - /> - - setEditingAlbum(null)} - onSuccess={() => { - setEditingAlbum(null); - }} - onError={showErrorDialog} - /> - - setErrorDialogContent(null)} - /> -
- ); -}; - -export default AlbumsView; diff --git a/frontend/src/components/Album/AlbumCard.tsx b/frontend/src/components/Album/AlbumCard.tsx deleted file mode 100644 index 27103c9c5..000000000 --- a/frontend/src/components/Album/AlbumCard.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Button } from '@/components/ui/button'; -import { MoreVertical, Images } from 'lucide-react'; -import { convertFileSrc } from '@tauri-apps/api/core'; -import { AlbumCardProps } from '@/types/Album'; -import { extractThumbnailPath } from '@/hooks/useImages'; -const AlbumCard: React.FC = ({ - album, - onClick, - onEdit, - onDelete, -}) => { - return ( -
-
- {album.isHidden && ( -
- Hidden -
- )} - {album.imageCount ? ( - {`Cover - ) : ( -
- -
- )} -
-
-

{album.title}

-

- {album.imageCount} image{album.imageCount !== 1 ? 's' : ''} -

-
-
-
-
- - - - - - Edit - - Delete - - - -
-
- ); -}; -export default AlbumCard; diff --git a/frontend/src/components/Album/AlbumDialog.tsx b/frontend/src/components/Album/AlbumDialog.tsx deleted file mode 100644 index dde9b9efa..000000000 --- a/frontend/src/components/Album/AlbumDialog.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Button } from '@/components/ui/button'; - -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Textarea } from '../ui/textarea'; -import { EditAlbumDialogProps } from '@/types/Album'; -import { queryClient, usePictoMutation } from '@/hooks/useQueryExtensio'; -import { editAlbumDescription } from '../../../api/api-functions/albums'; - -const EditAlbumDialog: React.FC = ({ - album, - onClose, - onSuccess, - onError, -}) => { - const { mutate: editDescription, isPending: isEditing } = usePictoMutation({ - mutationFn: editAlbumDescription, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['view-album', album?.album_name], - }); - }, - autoInvalidateTags: ['all-albums'], - }); - const [description, setDescription] = useState(album?.description || ''); - - useEffect(() => { - setDescription(album?.description || ''); - }, [album]); - - const handleEditAlbum = async () => { - if (album) { - try { - await editDescription({ album_name: album?.album_name, description }); - onSuccess(); - } catch (err) { - onError('Error Editing Album', err); - } - } - }; - - return ( - - - - Edit Album: {album?.album_name} - -