From 1c8422fc9b01948c775bd8dd0af2d6783adab366 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Thu, 2 Oct 2025 10:39:41 +0530 Subject: [PATCH 01/13] Add image metadata handling & extraction in backend --- backend/app/database/images.py | 213 ++++++++++++++++++++++++++++++--- backend/app/routes/images.py | 4 +- 2 files changed, 196 insertions(+), 21 deletions(-) diff --git a/backend/app/database/images.py b/backend/app/database/images.py index 3b9e2647d..6361ebaff 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -1,6 +1,10 @@ # Standard library imports import sqlite3 -from typing import List, Tuple, TypedDict +import json +import os +import datetime +from PIL import Image, ExifTags +from typing import Any, List, Mapping, Tuple, TypedDict, Union # App-specific imports from app.config.settings import ( @@ -21,15 +25,130 @@ class ImageRecord(TypedDict): path: ImagePath folder_id: FolderId thumbnailPath: str - metadata: str + metadata: Union[Mapping[str, Any], str] isTagged: bool +class UntaggedImageRecord(TypedDict): + """Represents an image record returned for tagging.""" + + id: ImageId + path: ImagePath + folder_id: FolderId + thumbnailPath: str + metadata: Mapping[str, Any] + + ImageClassPair = Tuple[ImageId, ClassId] -def db_create_images_table() -> None: +def _connect() -> sqlite3.Connection: conn = sqlite3.connect(DATABASE_PATH) + # Ensure ON DELETE CASCADE and other FKs are enforced + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def extract_image_metadata(image_path: str) -> dict: + """Extract metadata for a given image file with detailed debug logging.""" + # print(f"[DEBUG] extract_image_metadata called for: {image_path}") + + if not os.path.exists(image_path): + return { + "name": os.path.basename(image_path), + "date_created": None, + "width": 0, + "height": 0, + "file_location": image_path, + "file_size": 0, + "item_type": "unknown", + } + + try: + stats = os.stat(image_path) + # print(f"[DEBUG] File exists. Size = {stats.st_size} bytes") + + try: + with Image.open(image_path) as img: + width, height = img.size + mime_type = Image.MIME.get(img.format, "unknown") + # print(f"[DEBUG] Pillow opened image: {width}x{height}, type={mime_type}") + + # Robust EXIF extraction with safe fallback + try: + exif_data = ( + img.getexif() + if hasattr(img, "getexif") + else getattr(img, "_getexif", lambda: None)() + ) + except Exception: + exif_data = None + + exif = dict(exif_data) if exif_data else {} + dt_original = None + for k, v in exif.items(): + if ExifTags.TAGS.get(k) == "DateTimeOriginal": + dt_original = ( + v.decode("utf-8", "ignore") + if isinstance(v, (bytes, bytearray)) + else str(v) + ) + break + + # Safe parse; fall back to mtime without losing width/height + if dt_original: + try: + date_created = datetime.datetime.strptime( + dt_original.strip().split("\x00", 1)[0], + "%Y:%m:%d %H:%M:%S", + ).isoformat() + except ValueError: + date_created = datetime.datetime.fromtimestamp( + stats.st_mtime + ).isoformat() + else: + date_created = datetime.datetime.fromtimestamp( + stats.st_mtime + ).isoformat() + + return { + "name": os.path.basename(image_path), + "date_created": date_created, + "width": width, + "height": height, + "file_location": image_path, + "file_size": stats.st_size, + "item_type": mime_type, + } + + except Exception: + # print(f"[ERROR] Pillow could not open image {image_path} -> {e}") + return { + "name": os.path.basename(image_path), + "date_created": datetime.datetime.fromtimestamp( + stats.st_mtime + ).isoformat(), + "file_location": image_path, + "file_size": stats.st_size, + "width": 0, + "height": 0, + "item_type": "unknown", + } + + except Exception: + return { + "name": os.path.basename(image_path), + "date_created": None, + "width": 0, + "height": 0, + "file_location": image_path, + "file_size": 0, + "item_type": "unknown", + } + + +def db_create_images_table() -> None: + conn = _connect() cursor = conn.cursor() # Create new images table with merged fields @@ -69,16 +188,49 @@ def db_bulk_insert_images(image_records: List[ImageRecord]) -> bool: if not image_records: return True - conn = sqlite3.connect(DATABASE_PATH) + conn = _connect() cursor = conn.cursor() try: + # Ensure metadata is properly filled and JSON stringified + prepared_records = [] + for record in image_records: + metadata = record.get("metadata") + + # Normalize: if metadata is a string, try to parse it + if isinstance(metadata, str): + try: + metadata = json.loads(metadata) + except Exception: + metadata = {} + + # print(f"[DEBUG] Incoming metadata for {record['path']}: {metadata} (type={type(metadata)})") + + # If no metadata provided or it's empty, extract it + if not metadata or metadata == {}: + metadata = extract_image_metadata(record["path"]) + + # Make sure it's stored as a JSON string in DB + record["metadata"] = json.dumps(metadata) + + prepared_records.append(record) + + # print("Prepared metadata:", prepared_records[0]["metadata"]) + cursor.executemany( """ - INSERT OR IGNORE INTO images (id, path, folder_id, thumbnailPath, metadata, isTagged) + INSERT INTO images (id, path, folder_id, thumbnailPath, metadata, isTagged) VALUES (:id, :path, :folder_id, :thumbnailPath, :metadata, :isTagged) - """, - image_records, + ON CONFLICT(path) DO UPDATE SET + folder_id=excluded.folder_id, + thumbnailPath=excluded.thumbnailPath, + metadata=excluded.metadata, + isTagged=CASE + WHEN excluded.isTagged THEN 1 + ELSE images.isTagged + END + """, + prepared_records, ) conn.commit() return True @@ -97,7 +249,7 @@ def db_get_all_images() -> List[dict]: Returns: List of dictionaries containing all image data including tags """ - conn = sqlite3.connect(DATABASE_PATH) + conn = _connect() cursor = conn.cursor() try: @@ -132,18 +284,31 @@ def db_get_all_images() -> List[dict]: tag_name, ) in results: if image_id not in images_dict: + # Safely parse metadata JSON -> dict + metadata_dict = {} + if metadata: + try: + parsed = ( + json.loads(metadata) + if isinstance(metadata, str) + else metadata + ) + metadata_dict = parsed if isinstance(parsed, dict) else {} + except (json.JSONDecodeError, TypeError, ValueError): + metadata_dict = {} + images_dict[image_id] = { "id": image_id, "path": path, - "folder_id": folder_id, + "folder_id": str(folder_id), "thumbnailPath": thumbnail_path, - "metadata": metadata, + "metadata": metadata_dict, "isTagged": bool(is_tagged), "tags": [], } - # Add tag if it exists - if tag_name: + # Add tag if it exists (avoid duplicates) + if tag_name and tag_name not in images_dict[image_id]["tags"]: images_dict[image_id]["tags"].append(tag_name) # Convert to list and set tags to None if empty @@ -165,7 +330,7 @@ def db_get_all_images() -> List[dict]: conn.close() -def db_get_untagged_images() -> List[ImageRecord]: +def db_get_untagged_images() -> List[UntaggedImageRecord]: """ Find all images that need AI tagging. Returns images where: @@ -175,7 +340,7 @@ def db_get_untagged_images() -> List[ImageRecord]: Returns: List of dictionaries containing image data: id, path, folder_id, thumbnailPath, metadata """ - conn = sqlite3.connect(DATABASE_PATH) + conn = _connect() cursor = conn.cursor() try: @@ -193,13 +358,23 @@ def db_get_untagged_images() -> List[ImageRecord]: untagged_images = [] for image_id, path, folder_id, thumbnail_path, metadata in results: + md: dict = {} + if metadata: + try: + md = ( + json.loads(metadata) + if isinstance(metadata, str) + else (metadata or {}) + ) + except Exception: + md = {} untagged_images.append( { "id": image_id, "path": path, "folder_id": folder_id, "thumbnailPath": thumbnail_path, - "metadata": metadata, + "metadata": md, } ) @@ -220,7 +395,7 @@ def db_update_image_tagged_status(image_id: ImageId, is_tagged: bool = True) -> Returns: True if update was successful, False otherwise """ - conn = sqlite3.connect(DATABASE_PATH) + conn = _connect() cursor = conn.cursor() try: @@ -251,7 +426,7 @@ def db_insert_image_classes_batch(image_class_pairs: List[ImageClassPair]) -> bo if not image_class_pairs: return True - conn = sqlite3.connect(DATABASE_PATH) + conn = _connect() cursor = conn.cursor() try: @@ -287,7 +462,7 @@ def db_get_images_by_folder_ids( if not folder_ids: return [] - conn = sqlite3.connect(DATABASE_PATH) + conn = _connect() cursor = conn.cursor() try: @@ -323,7 +498,7 @@ def db_delete_images_by_ids(image_ids: List[ImageId]) -> bool: if not image_ids: return True - conn = sqlite3.connect(DATABASE_PATH) + conn = _connect() cursor = conn.cursor() try: diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index ad387154d..c8add7380 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, HTTPException, status -from typing import List, Optional +from typing import Dict, Any, List, Optional from app.database.images import db_get_all_images from app.schemas.images import ErrorResponse from pydantic import BaseModel @@ -13,7 +13,7 @@ class ImageData(BaseModel): path: str folder_id: str thumbnailPath: str - metadata: str + metadata: Dict[str, Any] isTagged: bool tags: Optional[List[str]] = None From 4ed327b3dadf43f30b197681868ee577160ccd99 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:22:19 +0530 Subject: [PATCH 02/13] Add ChronologicalGallery & Timeline component --- docs/backend/backend_python/openapi.json | 2 +- frontend/jest.setup.ts | 8 + frontend/src/App.css | 16 + .../components/Media/ChronologicalGallery.tsx | 190 ++++++++ .../src/components/Media/MediaInfoPanel.tsx | 6 +- .../components/Timeline/TimelineScrollbar.tsx | 421 ++++++++++++++++++ frontend/src/layout/layout.tsx | 2 +- frontend/src/pages/AITagging/AITagging.tsx | 60 ++- frontend/src/pages/Home/Home.tsx | 61 +-- frontend/src/types/Media.ts | 12 +- frontend/src/utils/dateUtils.ts | 40 ++ frontend/src/utils/timelineUtils.ts | 79 ++++ 12 files changed, 843 insertions(+), 54 deletions(-) create mode 100644 frontend/src/components/Media/ChronologicalGallery.tsx create mode 100644 frontend/src/components/Timeline/TimelineScrollbar.tsx create mode 100644 frontend/src/utils/timelineUtils.ts diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index 321a53b23..99eb21ad5 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -1931,7 +1931,7 @@ "title": "Thumbnailpath" }, "metadata": { - "type": "string", + "type": "object", "title": "Metadata" }, "isTagged": { diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index eb18735ab..8e90c0a43 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -8,3 +8,11 @@ if (typeof global.TextEncoder === 'undefined') { if (typeof global.TextDecoder === 'undefined') { global.TextDecoder = TextDecoder as unknown as typeof global.TextDecoder; } + +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +(global as any).ResizeObserver = ResizeObserver; diff --git a/frontend/src/App.css b/frontend/src/App.css index 3c2c6926a..743649926 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -196,3 +196,19 @@ letter-spacing: var(--tracking-normal); } } + +.hide-scrollbar::-webkit-scrollbar { + display: none; +} + +.hide-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + +.no-select { + -webkit-user-select: none; /* Safari */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* IE10+/Edge */ + user-select: none; /* Standard */ +} diff --git a/frontend/src/components/Media/ChronologicalGallery.tsx b/frontend/src/components/Media/ChronologicalGallery.tsx new file mode 100644 index 000000000..92f0f453f --- /dev/null +++ b/frontend/src/components/Media/ChronologicalGallery.tsx @@ -0,0 +1,190 @@ +import { useMemo, useRef, useEffect, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { ImageCard } from '@/components/Media/ImageCard'; +import { Image } from '@/types/Media'; +import { selectImages } from '@/features/imageSelectors'; +import { + groupImagesByYearMonthFromMetadata, + createImageIndexMap, +} from '@/utils/dateUtils'; + +export type MonthMarker = { + offset: number; + month: string; + year: string; +}; + +type ChronologicalGalleryProps = { + images: Image[]; + showTitle?: boolean; + title?: string; + className?: string; + onMonthOffsetsChange?: (markers: MonthMarker[]) => void; + scrollContainerRef?: React.RefObject; +}; + +export const ChronologicalGallery = ({ + images, + showTitle = false, + title = 'Image Gallery', + className = '', + onMonthOffsetsChange, + scrollContainerRef, +}: ChronologicalGalleryProps) => { + const allImages = useSelector(selectImages); + const monthHeaderRefs = useRef>(new Map()); + const galleryRef = useRef(null); + + // Optimized grouping with proper date handling + const grouped = useMemo( + () => groupImagesByYearMonthFromMetadata(images), + [images], + ); + + // Optimized image index lookup + const imageIndexMap = useMemo( + () => createImageIndexMap(allImages), + [allImages], + ); + + const sortedGrouped = useMemo(() => { + return Object.entries(grouped) + .sort((a, b) => Number(b[0]) - Number(a[0])) + .map(([year, months]) => ({ + year, + months: Object.entries(months).sort( + (a, b) => Number(b[0]) - Number(a[0]), + ), + })); + }, [grouped]); + + const recomputeMarkers = useCallback(() => { + if (!onMonthOffsetsChange) return; + if (monthHeaderRefs.current.size === 0) { + onMonthOffsetsChange([]); + return; + } + + const scroller = scrollContainerRef?.current; + const scrollerTop = scroller ? scroller.getBoundingClientRect().top : 0; + + const entries = Array.from(monthHeaderRefs.current.entries()).flatMap( + ([key, el]) => { + if (!el) return []; + const [y, m] = key.split('-'); + const monthName = new Date(Number(y), Number(m) - 1).toLocaleString( + 'default', + { month: 'long' }, + ); + const offset = scroller + ? el.getBoundingClientRect().top - scrollerTop + scroller.scrollTop + : el.offsetTop; + return [{ offset, month: monthName, year: y }]; + }, + ); + entries.sort((a, b) => a.offset - b.offset); + onMonthOffsetsChange(entries); + }, [onMonthOffsetsChange, scrollContainerRef]); + + useEffect(() => { + recomputeMarkers(); + }, [images, recomputeMarkers]); + + useEffect(() => { + const elementToObserve = scrollContainerRef?.current ?? galleryRef.current; + if (!elementToObserve) return; + + const observer = new ResizeObserver(recomputeMarkers); + + observer.observe(elementToObserve); + + return () => { + observer.disconnect(); + }; + }, [recomputeMarkers, scrollContainerRef]); + + // Check if we have any images to display + if (!images.length) { + return ( +
+
+
😢
+
No images found
+
+ Add some photo library folders to get started +
+
+
+ ); + } + + return ( +
+ {/* Title */} + {showTitle && ( +
+

{title}

+
+ )} + + {/* Gallery Content */} + {sortedGrouped.map(({ year, months }) => ( +
+ {months.map(([month, imgs]) => { + const monthName = new Date( + Number(year), + Number(month) - 1, + ).toLocaleString('default', { month: 'long' }); + + return ( +
{ + const key = `${year}-${month}`; + if (el) { + monthHeaderRefs.current.set(key, el); + } else { + monthHeaderRefs.current.delete(key); + } + }} + > + {/* Sticky Month/Year Header */} +
+

+
+ {monthName} {year} +
+ {imgs.length} {imgs.length === 1 ? 'image' : 'images'} +
+

+
+ + {/* Images Grid */} +
+ {imgs.map((img) => { + const reduxIndex = imageIndexMap.get(img.id) ?? -1; + + return ( +
+ +
+ ); + })} +
+
+ ); + })} +
+ ))} +
+ ); +}; diff --git a/frontend/src/components/Media/MediaInfoPanel.tsx b/frontend/src/components/Media/MediaInfoPanel.tsx index 3869528a7..a6fbbf572 100644 --- a/frontend/src/components/Media/MediaInfoPanel.tsx +++ b/frontend/src/components/Media/MediaInfoPanel.tsx @@ -80,7 +80,11 @@ export const MediaInfoPanel: React.FC = ({

Location

- {currentImage?.metadata || 'No location data'} + {typeof currentImage?.metadata === 'string' + ? currentImage.metadata + : currentImage?.metadata + ? JSON.stringify(currentImage.metadata) + : 'No location data'}

diff --git a/frontend/src/components/Timeline/TimelineScrollbar.tsx b/frontend/src/components/Timeline/TimelineScrollbar.tsx new file mode 100644 index 000000000..f666f0406 --- /dev/null +++ b/frontend/src/components/Timeline/TimelineScrollbar.tsx @@ -0,0 +1,421 @@ +import { useEffect, useMemo, useRef, useState, RefObject } from 'react'; +import { + useScroll, + useWheel, + getMarkerForScrollPosition, + TooltipState, +} from '@/utils/timelineUtils'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { MonthMarker } from '@/components/Media/ChronologicalGallery'; + +const monthAbbreviations: Record = { + January: 'Jan', + February: 'Feb', + March: 'Mar', + April: 'Apr', + May: 'May', + June: 'Jun', + July: 'Jul', + August: 'Aug', + September: 'Sept', + October: 'Oct', + November: 'Nov', + December: 'Dec', +}; + +const abbreviateMonth = (month: string) => monthAbbreviations[month] ?? month; + +type TimelineScrollbarProps = { + className?: string; + scrollableRef: RefObject; + monthMarkers?: MonthMarker[]; +}; + +export default function TimelineScrollbar({ + className = '', + scrollableRef, + monthMarkers = [], +}: TimelineScrollbarProps) { + const containerRef = useRef(null); + const trackRef = useRef(null); + const [trackHeight, setTrackHeight] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const [isMarkerHovered, setIsMarkerHovered] = useState(false); + const [trackTooltip, setTrackTooltip] = useState(null); + const [isTrackTooltipVisible, setIsTrackTooltipVisible] = useState(false); + const [scrollTooltip, setScrollTooltip] = useState(null); + const [isScrollTooltipVisible, setIsScrollTooltipVisible] = useState(false); + const [dragTooltip, setDragTooltip] = useState< + (TooltipState & { visible: boolean }) | null + >(null); + const scrollProgress = useScroll(scrollableRef); + const scrollTooltipTimer = useRef | null>(null); + const [scrollableDimensions, setScrollableDimensions] = useState({ + scrollHeight: 0, + clientHeight: 0, + }); + + useEffect(() => { + const scrollable = scrollableRef.current; + if (!scrollable) return; + + const measure = () => { + setScrollableDimensions({ + scrollHeight: scrollable.scrollHeight, + clientHeight: scrollable.clientHeight, + }); + }; + + const resizeObserver = new ResizeObserver(measure); + resizeObserver.observe(scrollable); + + measure(); // Initial measurement + + return () => resizeObserver.disconnect(); + }, [scrollableRef]); + + const markerPositions = useMemo(() => { + if (!scrollableRef.current || !monthMarkers.length) { + return []; + } + + const { scrollHeight, clientHeight } = scrollableDimensions; + const scrollableHeight = scrollHeight - clientHeight; + + // Filter out markers that can't reach the top of the viewport + const visibleMarkers = monthMarkers.filter( + (marker) => marker.offset <= scrollableHeight, + ); + + const safeDenominator = Math.max(1, scrollableHeight); + const maxTop = trackHeight; + + return visibleMarkers.map((marker) => { + const markerTop = (marker.offset / safeDenominator) * trackHeight; + return { + ...marker, + markerTop: Math.max(0, Math.min(markerTop, maxTop)), + }; + }); + }, [monthMarkers, trackHeight, scrollableDimensions, scrollableRef]); + + useWheel(containerRef, (deltaY) => { + const scroller = scrollableRef.current; + if (scroller) scroller.scrollTop += deltaY; + }); + + // Effect to show/hide scroll tooltip + useEffect(() => { + if (isDragging || isMarkerHovered || !scrollableRef.current) { + setIsScrollTooltipVisible(false); + return; + } + + if (scrollTooltipTimer.current) { + clearTimeout(scrollTooltipTimer.current); + } + + const scrollable = scrollableRef.current; + const currentMarker = getMarkerForScrollPosition( + scrollable.scrollTop, + monthMarkers, + ); + + if (currentMarker) { + const tooltipHalfHeight = 14; + const top = Math.max( + tooltipHalfHeight, + Math.min(scrollProgress * trackHeight, trackHeight - tooltipHalfHeight), + ); + + setScrollTooltip({ + top, + month: currentMarker.month, + year: currentMarker.year, + }); + setIsScrollTooltipVisible(true); + } + + scrollTooltipTimer.current = setTimeout(() => { + setIsScrollTooltipVisible(false); + }, 1000); + + return () => { + if (scrollTooltipTimer.current) { + clearTimeout(scrollTooltipTimer.current); + } + }; + }, [ + scrollProgress, + isDragging, + isMarkerHovered, + monthMarkers, + scrollableRef, + trackHeight, + ]); + + // Measure parent height dynamically + useEffect(() => { + if (!containerRef.current?.parentElement) return; + + const parent = containerRef.current.parentElement; + const measure = () => setTrackHeight(parent.clientHeight); + + measure(); + + const resizeObserver = new ResizeObserver(measure); + resizeObserver.observe(parent); + + window.addEventListener('resize', measure); + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', measure); + }; + }, []); + + const handleScroll = (clientY: number) => { + if (!scrollableRef.current || !trackRef.current) return; + + const { top, height } = trackRef.current.getBoundingClientRect(); + const clickY = clientY - top; + const scrollPercentage = Math.min(Math.max(clickY / height, 0), 1); + + const scrollable = scrollableRef.current; + const scrollToY = + scrollPercentage * (scrollable.scrollHeight - scrollable.clientHeight); + + scrollable.scrollTo({ + top: scrollToY, + behavior: 'auto', + }); + }; + + const updateDragTooltip = (clientY: number) => { + if (!trackRef.current || !scrollableRef.current || !monthMarkers.length) + return; + + const { top, height } = trackRef.current.getBoundingClientRect(); + const dragY = clientY - top; + + const scrollable = scrollableRef.current; + const scrollPercentage = Math.min(Math.max(dragY / height, 0), 1); + const correspondingScrollTop = + scrollPercentage * (scrollable.scrollHeight - scrollable.clientHeight); + + const draggedMarker = getMarkerForScrollPosition( + correspondingScrollTop, + monthMarkers, + ); + + if (draggedMarker) { + const tooltipHalfHeight = 14; + const clampedDragY = Math.max( + tooltipHalfHeight, + Math.min(dragY, height - tooltipHalfHeight), + ); + setDragTooltip({ + visible: true, + top: clampedDragY, + month: draggedMarker.month, + year: draggedMarker.year, + }); + } + }; + + const handleMarkerClick = (offset: number) => { + if (!scrollableRef.current) return; + + scrollableRef.current.scrollTo({ + top: offset, + behavior: 'smooth', + }); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + setIsDragging(true); + document.body.classList.add('no-select'); + handleScroll(e.clientY); + updateDragTooltip(e.clientY); + + const handleMouseMove = (e: MouseEvent) => { + try { + handleScroll(e.clientY); + updateDragTooltip(e.clientY); + } catch (error) { + console.error('Error during drag:', error); + handleMouseUp(); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.body.classList.remove('no-select'); + setDragTooltip(null); + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + window.removeEventListener('blur', handleMouseUp); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + window.addEventListener('blur', handleMouseUp); + }; + + const handleTrackMouseMove = (e: React.MouseEvent) => { + if (isDragging || isMarkerHovered) { + setIsTrackTooltipVisible(false); + return; + } + + if (!trackRef.current) return; + const { clientY } = e; + const { top, height } = trackRef.current.getBoundingClientRect(); + const hoverY = clientY - top; + + const scrollable = scrollableRef.current; + if (!scrollable) return; + + const scrollPercentage = Math.min(Math.max(hoverY / height, 0), 1); + const correspondingScrollTop = + scrollPercentage * (scrollable.scrollHeight - scrollable.clientHeight); + + const hoveredMarker = getMarkerForScrollPosition( + correspondingScrollTop, + monthMarkers, + ); + + if (hoveredMarker) { + const tooltipHalfHeight = 14; + const top = Math.max( + tooltipHalfHeight, + Math.min(hoverY, height - tooltipHalfHeight), + ); + setTrackTooltip({ + top, + month: hoveredMarker.month, + year: hoveredMarker.year, + }); + setIsTrackTooltipVisible(true); + } else { + setIsTrackTooltipVisible(false); + } + }; + + const handleTrackMouseLeave = () => { + setIsTrackTooltipVisible(false); + }; + + const tooltipBaseClass = + 'text-primary-foreground bg-primary absolute left-[-75px] rounded-md px-2 py-1 text-xs shadow-md'; + + return ( +
+ {/* Timeline Track */} +
+ {/* Timeline Background Gradient */} +
+ + {/* Progress Fill */} +
+ + {/* Month Markers */} + + {markerPositions.map((marker, index) => { + return ( + + +
handleMarkerClick(marker.offset)} + onMouseEnter={() => !isDragging && setIsMarkerHovered(true)} + onMouseLeave={() => setIsMarkerHovered(false)} + /> + + +

{`${marker.month} ${marker.year}`}

+
+ + ); + })} + + + {/* Track Hover Tooltip */} + {trackTooltip && ( +
+ {`${abbreviateMonth(trackTooltip.month)} ${trackTooltip.year}`} +
+ )} + + {/* Scroll Tooltip */} + {scrollTooltip && !isTrackTooltipVisible && ( +
+ {`${abbreviateMonth(scrollTooltip.month)} ${scrollTooltip.year}`} +
+ )} + + {/* Drag Tooltip */} + {isDragging && dragTooltip && dragTooltip.visible && ( +
+ {`${abbreviateMonth(dragTooltip.month)} ${dragTooltip.year}`} +
+ )} +
+
+ ); +} diff --git a/frontend/src/layout/layout.tsx b/frontend/src/layout/layout.tsx index cfe363518..e3ba980ae 100644 --- a/frontend/src/layout/layout.tsx +++ b/frontend/src/layout/layout.tsx @@ -9,7 +9,7 @@ const Layout: React.FC = () => {
-
+
diff --git a/frontend/src/pages/AITagging/AITagging.tsx b/frontend/src/pages/AITagging/AITagging.tsx index 8e278918c..1f0f5c9d6 100644 --- a/frontend/src/pages/AITagging/AITagging.tsx +++ b/frontend/src/pages/AITagging/AITagging.tsx @@ -1,6 +1,5 @@ -import { useEffect } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { ImageCard } from '@/components/Media/ImageCard'; import { MediaView } from '@/components/Media/MediaView'; import { FaceCollections } from '@/components/FaceCollections'; import { Image } from '@/types/Media'; @@ -12,11 +11,18 @@ import { } from '@/features/imageSelectors'; import { usePictoQuery } from '@/hooks/useQueryExtension'; import { fetchAllImages } from '@/api/api-functions'; +import { + ChronologicalGallery, + MonthMarker, +} from '@/components/Media/ChronologicalGallery'; +import TimelineScrollbar from '@/components/Timeline/TimelineScrollbar'; export const AITagging = () => { const dispatch = useDispatch(); const isImageViewOpen = useSelector(selectIsImageViewOpen); const taggedImages = useSelector(selectTaggedImages); + const scrollableRef = useRef(null); + const [monthMarkers, setMonthMarkers] = useState([]); const { data: imagesData, @@ -41,31 +47,39 @@ export const AITagging = () => { }, [imagesData, imagesSuccess, imagesError, imagesLoading, dispatch]); return ( -
-

AI Tagging

+
+
+

AI Tagging

- {/* Face Collections Section */} -
- -
+ {/* Face Collections Section */} +
+ +
- {/* Image Grid */} -
-

All Images

-
- {taggedImages.map((image, index) => ( - - ))} + {/* Gallery Section */} +
+
-
- {/* Media Viewer Modal */} - {isImageViewOpen && } + {/* Media Viewer Modal */} + {isImageViewOpen && } +
+ {monthMarkers.length > 0 && ( + + )}
); }; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 7ba860849..44f288419 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -1,6 +1,10 @@ -import { useEffect } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { ImageCard } from '@/components/Media/ImageCard'; +import { + ChronologicalGallery, + MonthMarker, +} from '@/components/Media/ChronologicalGallery'; +import TimelineScrollbar from '@/components/Timeline/TimelineScrollbar'; import { MediaView } from '@/components/Media/MediaView'; import { Image } from '@/types/Media'; import { setImages } from '@/features/imageSlice'; @@ -13,19 +17,18 @@ import { showInfoDialog } from '@/features/infoDialogSlice'; export const Home = () => { const dispatch = useDispatch(); - const isImageViewOpen = useSelector(selectIsImageViewOpen); const images = useSelector(selectImages); - - const searchState = useSelector((state: RootState) => state.search); - const isSearchActive = searchState.active; - const searchResults = searchState.images; + const scrollableRef = useRef(null); + const [monthMarkers, setMonthMarkers] = useState([]); const { data, isLoading, isSuccess, isError } = usePictoQuery({ queryKey: ['images'], queryFn: fetchAllImages, - enabled: !isSearchActive, }); + const searchState = useSelector((state: RootState) => state.search); + const isSearchActive = searchState.active; + const searchResults = searchState.images; // Handle fetching lifecycle useEffect(() => { @@ -55,28 +58,32 @@ export const Home = () => { const displayImages = isSearchActive ? searchResults : images; - const title = - isSearchActive && searchResults.length > 0 - ? `Face Search Results (${searchResults.length} found)` - : 'Image Gallery'; - return ( -
-

{title}

- - {/* Image Grid */} -
- {displayImages.map((image, index) => ( - - ))} +
+ {/* Gallery Section */} +
+
- {/* Media Viewer Modal */} + {/* Timeline Scrollbar */} + {monthMarkers.length > 0 && ( + + )} + + {/* Media viewer modal */} {isImageViewOpen && ( )} diff --git a/frontend/src/types/Media.ts b/frontend/src/types/Media.ts index f5fa7158a..12459e998 100644 --- a/frontend/src/types/Media.ts +++ b/frontend/src/types/Media.ts @@ -1,10 +1,20 @@ +export interface ImageMetadata { + name: string; + date_created: string; + width: number; + height: number; + file_location: string; + file_size: number; + item_type: string; +} + export interface Image { id: string; path: string; thumbnailPath: string; folder_id: string; isTagged: boolean; - metadata?: string; + metadata?: ImageMetadata; tags?: string[]; bboxes?: { x: number; y: number; width: number; height: number }[]; } diff --git a/frontend/src/utils/dateUtils.ts b/frontend/src/utils/dateUtils.ts index 41012bfa8..bd8b7d30b 100644 --- a/frontend/src/utils/dateUtils.ts +++ b/frontend/src/utils/dateUtils.ts @@ -1,3 +1,5 @@ +import { Image } from '@/types/Media'; + export const getTimeAgo = (dateString: string): string => { const date = new Date(dateString); const now = new Date(); @@ -24,3 +26,41 @@ export const getTimeAgo = (dateString: string): string => { return 'a long time ago'; }; + +// To group Images from same Month & Year. +export const groupImagesByYearMonthFromMetadata = (images: Image[]) => { + const grouped: Record> = {}; + + images.forEach((image) => { + const dateStr = image.metadata?.date_created; // extract date from metadata.date_created + if (!dateStr) return; + + const date = new Date(dateStr); + if (isNaN(date.getTime())) return; + + const year = date.getFullYear().toString(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + + if (!grouped[year]) { + grouped[year] = {}; + } + if (!grouped[year][month]) { + grouped[year][month] = []; + } + + grouped[year][month].push(image); + }); + + return grouped; +}; + +// Build a fast lookup map between an image's id and its index in the global array +export const createImageIndexMap = ( + allImages: Image[], +): Map => { + const indexMap = new Map(); + allImages.forEach((image, index) => { + indexMap.set(image.id, index); + }); + return indexMap; +}; diff --git a/frontend/src/utils/timelineUtils.ts b/frontend/src/utils/timelineUtils.ts new file mode 100644 index 000000000..0eb21fe85 --- /dev/null +++ b/frontend/src/utils/timelineUtils.ts @@ -0,0 +1,79 @@ +import { useState, useEffect, RefObject } from 'react'; +import { MonthMarker } from '@/components/Media/ChronologicalGallery'; + +export type TooltipState = { + top: number; + month: string; + year: string; +}; + +export function useScroll(scrollableRef: RefObject) { + const [scrollProgress, setScrollProgress] = useState(0); + + useEffect(() => { + const scrollable = scrollableRef.current; + if (!scrollable) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = scrollable; + const denom = Math.max(scrollHeight - clientHeight, 1); + const progress = Math.min(1, Math.max(0, scrollTop / denom)); + setScrollProgress(progress); + }; + + scrollable.addEventListener('scroll', handleScroll, { passive: true }); + + return () => { + scrollable.removeEventListener('scroll', handleScroll); + }; + }, [scrollableRef]); + + return scrollProgress; +} + +export function useWheel( + ref: RefObject, + callback: (deltaY: number) => void, +) { + useEffect(() => { + const element = ref.current; + if (!element) return; + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + callback(e.deltaY); + }; + + element.addEventListener('wheel', handleWheel, { passive: false }); + + return () => { + element.removeEventListener('wheel', handleWheel); + }; + }, [ref, callback]); +} + +export const getMarkerForScrollPosition = ( + scrollPosition: number, + monthMarkers: MonthMarker[], +): MonthMarker | undefined => { + if (!monthMarkers.length) return undefined; + const sorted = monthMarkers.slice().sort((a, b) => a.offset - b.offset); + let result: MonthMarker | undefined = undefined; + for (const m of sorted) { + if (m.offset <= scrollPosition) result = m; + else break; + } + return result; +}; + +export const clamp = (value: number, min: number, max: number): number => { + return Math.max(min, Math.min(value, max)); +}; + +export const calculateClampedTooltipTop = ( + position: number, + trackHeight: number, + tooltipHalfHeight: number = 14, +): number => { + return clamp(position, tooltipHalfHeight, trackHeight - tooltipHalfHeight); +}; From 6e144fbf099cbb14f84894a3ddca09f6957f3a93 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Thu, 2 Oct 2025 12:22:24 +0530 Subject: [PATCH 03/13] Add search results grid to Home page --- frontend/src/pages/Home/Home.tsx | 43 ++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 44f288419..53227e89e 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -14,6 +14,7 @@ import { usePictoQuery } from '@/hooks/useQueryExtension'; import { fetchAllImages } from '@/api/api-functions'; import { RootState } from '@/app/store'; import { showInfoDialog } from '@/features/infoDialogSlice'; +import { ImageCard } from '@/components/Media/ImageCard'; export const Home = () => { const dispatch = useDispatch(); @@ -22,13 +23,15 @@ export const Home = () => { const scrollableRef = useRef(null); const [monthMarkers, setMonthMarkers] = useState([]); + const searchState = useSelector((state: RootState) => state.search); + const isSearchActive = searchState.active; + const searchResults = searchState.images; + const { data, isLoading, isSuccess, isError } = usePictoQuery({ queryKey: ['images'], queryFn: fetchAllImages, + enabled: !isSearchActive, }); - const searchState = useSelector((state: RootState) => state.search); - const isSearchActive = searchState.active; - const searchResults = searchState.images; // Handle fetching lifecycle useEffect(() => { @@ -58,6 +61,36 @@ export const Home = () => { const displayImages = isSearchActive ? searchResults : images; + const title = + isSearchActive && searchResults.length > 0 + ? `Face Search Results (${searchResults.length} found)` + : 'Image Gallery'; + + if (isSearchActive) { + return ( +
+

{title}

+ + {/* Image Grid */} +
+ {displayImages.map((image, index) => ( + + ))} +
+ + {/* Media Viewer Modal */} + {isImageViewOpen && ( + + )} +
+ ); + } + return (
{/* Gallery Section */} @@ -66,9 +99,9 @@ export const Home = () => { className="hide-scrollbar flex-1 overflow-x-hidden overflow-y-auto" > From f30bc22edb9b92bd59928f56142455ec7210f4e6 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Fri, 3 Oct 2025 22:18:24 +0530 Subject: [PATCH 04/13] Refactor image metadata handling as per Mr. Rahul's review and add location support Moved image metadata extraction logic to a utility module and standardized metadata parsing across backend endpoints. Updated backend and frontend types and models to support location, latitude, and longitude in image metadata. Improved frontend display of image location and date. --- backend/app/database/face_clusters.py | 4 +- backend/app/database/faces.py | 4 +- backend/app/database/images.py | 156 +------------ backend/app/routes/face_clusters.py | 4 +- backend/app/routes/images.py | 20 +- backend/app/schemas/face_clusters.py | 4 +- backend/app/utils/images.py | 217 +++++++++++++++++- backend/requirements.txt | Bin 2696 -> 1230 bytes docs/backend/backend_python/openapi.json | 88 ++++++- .../src/components/Media/MediaInfoPanel.tsx | 27 ++- frontend/src/pages/Home/Home.tsx | 26 --- frontend/src/types/Media.ts | 1 + 12 files changed, 350 insertions(+), 201 deletions(-) diff --git a/backend/app/database/face_clusters.py b/backend/app/database/face_clusters.py index c3c74a02b..5bfa956e2 100644 --- a/backend/app/database/face_clusters.py +++ b/backend/app/database/face_clusters.py @@ -275,6 +275,8 @@ def db_get_images_by_cluster_id( rows = cursor.fetchall() conn.close() + from app.utils.images import image_util_parse_metadata + images = [] for row in rows: ( @@ -299,7 +301,7 @@ def db_get_images_by_cluster_id( "image_id": image_id, "image_path": image_path, "thumbnail_path": thumbnail_path, - "metadata": metadata, + "metadata": image_util_parse_metadata(metadata), "face_id": face_id, "confidence": confidence, "bbox": bbox, diff --git a/backend/app/database/faces.py b/backend/app/database/faces.py index b44438315..9a8a0269a 100644 --- a/backend/app/database/faces.py +++ b/backend/app/database/faces.py @@ -159,6 +159,8 @@ def get_all_face_embeddings(): ) results = cursor.fetchall() + from app.utils.images import image_util_parse_metadata + images_dict = {} for ( embeddings, @@ -184,7 +186,7 @@ def get_all_face_embeddings(): "path": path, "folder_id": folder_id, "thumbnailPath": thumbnail_path, - "metadata": metadata, + "metadata": image_util_parse_metadata(metadata), "isTagged": bool(is_tagged), "tags": [], } diff --git a/backend/app/database/images.py b/backend/app/database/images.py index 6361ebaff..63005225a 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -1,9 +1,5 @@ # Standard library imports import sqlite3 -import json -import os -import datetime -from PIL import Image, ExifTags from typing import Any, List, Mapping, Tuple, TypedDict, Union # App-specific imports @@ -49,104 +45,6 @@ def _connect() -> sqlite3.Connection: return conn -def extract_image_metadata(image_path: str) -> dict: - """Extract metadata for a given image file with detailed debug logging.""" - # print(f"[DEBUG] extract_image_metadata called for: {image_path}") - - if not os.path.exists(image_path): - return { - "name": os.path.basename(image_path), - "date_created": None, - "width": 0, - "height": 0, - "file_location": image_path, - "file_size": 0, - "item_type": "unknown", - } - - try: - stats = os.stat(image_path) - # print(f"[DEBUG] File exists. Size = {stats.st_size} bytes") - - try: - with Image.open(image_path) as img: - width, height = img.size - mime_type = Image.MIME.get(img.format, "unknown") - # print(f"[DEBUG] Pillow opened image: {width}x{height}, type={mime_type}") - - # Robust EXIF extraction with safe fallback - try: - exif_data = ( - img.getexif() - if hasattr(img, "getexif") - else getattr(img, "_getexif", lambda: None)() - ) - except Exception: - exif_data = None - - exif = dict(exif_data) if exif_data else {} - dt_original = None - for k, v in exif.items(): - if ExifTags.TAGS.get(k) == "DateTimeOriginal": - dt_original = ( - v.decode("utf-8", "ignore") - if isinstance(v, (bytes, bytearray)) - else str(v) - ) - break - - # Safe parse; fall back to mtime without losing width/height - if dt_original: - try: - date_created = datetime.datetime.strptime( - dt_original.strip().split("\x00", 1)[0], - "%Y:%m:%d %H:%M:%S", - ).isoformat() - except ValueError: - date_created = datetime.datetime.fromtimestamp( - stats.st_mtime - ).isoformat() - else: - date_created = datetime.datetime.fromtimestamp( - stats.st_mtime - ).isoformat() - - return { - "name": os.path.basename(image_path), - "date_created": date_created, - "width": width, - "height": height, - "file_location": image_path, - "file_size": stats.st_size, - "item_type": mime_type, - } - - except Exception: - # print(f"[ERROR] Pillow could not open image {image_path} -> {e}") - return { - "name": os.path.basename(image_path), - "date_created": datetime.datetime.fromtimestamp( - stats.st_mtime - ).isoformat(), - "file_location": image_path, - "file_size": stats.st_size, - "width": 0, - "height": 0, - "item_type": "unknown", - } - - except Exception: - return { - "name": os.path.basename(image_path), - "date_created": None, - "width": 0, - "height": 0, - "file_location": image_path, - "file_size": 0, - "item_type": "unknown", - } - - def db_create_images_table() -> None: conn = _connect() cursor = conn.cursor() @@ -192,31 +90,6 @@ def db_bulk_insert_images(image_records: List[ImageRecord]) -> bool: cursor = conn.cursor() try: - # Ensure metadata is properly filled and JSON stringified - prepared_records = [] - for record in image_records: - metadata = record.get("metadata") - - # Normalize: if metadata is a string, try to parse it - if isinstance(metadata, str): - try: - metadata = json.loads(metadata) - except Exception: - metadata = {} - - # print(f"[DEBUG] Incoming metadata for {record['path']}: {metadata} (type={type(metadata)})") - - # If no metadata provided or it's empty, extract it - if not metadata or metadata == {}: - metadata = extract_image_metadata(record["path"]) - - # Make sure it's stored as a JSON string in DB - record["metadata"] = json.dumps(metadata) - - prepared_records.append(record) - - # print("Prepared metadata:", prepared_records[0]["metadata"]) - cursor.executemany( """ INSERT INTO images (id, path, folder_id, thumbnailPath, metadata, isTagged) @@ -230,7 +103,7 @@ def db_bulk_insert_images(image_records: List[ImageRecord]) -> bool: ELSE images.isTagged END """, - prepared_records, + image_records, ) conn.commit() return True @@ -285,17 +158,9 @@ def db_get_all_images() -> List[dict]: ) in results: if image_id not in images_dict: # Safely parse metadata JSON -> dict - metadata_dict = {} - if metadata: - try: - parsed = ( - json.loads(metadata) - if isinstance(metadata, str) - else metadata - ) - metadata_dict = parsed if isinstance(parsed, dict) else {} - except (json.JSONDecodeError, TypeError, ValueError): - metadata_dict = {} + from app.utils.images import image_util_parse_metadata + + metadata_dict = image_util_parse_metadata(metadata) images_dict[image_id] = { "id": image_id, @@ -358,16 +223,9 @@ def db_get_untagged_images() -> List[UntaggedImageRecord]: untagged_images = [] for image_id, path, folder_id, thumbnail_path, metadata in results: - md: dict = {} - if metadata: - try: - md = ( - json.loads(metadata) - if isinstance(metadata, str) - else (metadata or {}) - ) - except Exception: - md = {} + from app.utils.images import image_util_parse_metadata + + md = image_util_parse_metadata(metadata) untagged_images.append( { "id": image_id, diff --git a/backend/app/routes/face_clusters.py b/backend/app/routes/face_clusters.py index 351a7a2f0..9987b49d8 100644 --- a/backend/app/routes/face_clusters.py +++ b/backend/app/routes/face_clusters.py @@ -1,7 +1,7 @@ import logging import uuid import os -from typing import Optional, List +from typing import Optional, List, Dict, Any from pydantic import BaseModel from app.config.settings import CONFIDENCE_PERCENT, DEFAULT_FACENET_MODEL from fastapi import APIRouter, HTTPException, status @@ -42,7 +42,7 @@ class ImageData(BaseModel): path: str folder_id: str thumbnailPath: str - metadata: str + metadata: Dict[str, Any] isTagged: bool tags: Optional[List[str]] = None bboxes: BoundingBox diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index c8add7380..5f02aafcb 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -1,19 +1,33 @@ from fastapi import APIRouter, HTTPException, status -from typing import Dict, Any, List, Optional +from typing import List, Optional from app.database.images import db_get_all_images from app.schemas.images import ErrorResponse +from app.utils.images import image_util_parse_metadata from pydantic import BaseModel router = APIRouter() # Response Models +class MetadataModel(BaseModel): + name: str + date_created: Optional[str] + width: int + height: int + file_location: str + file_size: int + item_type: str + latitude: Optional[float] = None + longitude: Optional[float] = None + location: Optional[str] = None + + class ImageData(BaseModel): id: str path: str folder_id: str thumbnailPath: str - metadata: Dict[str, Any] + metadata: MetadataModel isTagged: bool tags: Optional[List[str]] = None @@ -42,7 +56,7 @@ def get_all_images(): path=image["path"], folder_id=image["folder_id"], thumbnailPath=image["thumbnailPath"], - metadata=image["metadata"], + metadata=image_util_parse_metadata(image["metadata"]), isTagged=image["isTagged"], tags=image["tags"], ) diff --git a/backend/app/schemas/face_clusters.py b/backend/app/schemas/face_clusters.py index 11803cce6..64e44593d 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, Dict, Union +from typing import List, Optional, Dict, Union, Any # Request Models @@ -50,7 +50,7 @@ class ImageInCluster(BaseModel): id: str path: str thumbnailPath: Optional[str] = None - metadata: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None face_id: int confidence: Optional[float] = None bbox: Optional[Dict[str, Union[int, float]]] = None diff --git a/backend/app/utils/images.py b/backend/app/utils/images.py index ec2cec79e..3dc2cba1e 100644 --- a/backend/app/utils/images.py +++ b/backend/app/utils/images.py @@ -1,8 +1,12 @@ import os import uuid -from typing import List, Tuple, Dict -from PIL import Image +import datetime +import json +from typing import List, Tuple, Dict, Any, Mapping +from PIL import Image, ExifTags from pathlib import Path +from geopy.geocoders import Nominatim +from geopy.extra.rate_limiter import RateLimiter from app.config.settings import THUMBNAIL_IMAGES_PATH from app.database.images import ( @@ -152,13 +156,15 @@ def image_util_prepare_image_records( # Generate thumbnail if image_util_generate_thumbnail(image_path, thumbnail_path): + metadata = image_util_extract_metadata(image_path) + # print(f"Extracted metadata for {image_path}: {metadata}") image_records.append( { "id": image_id, "path": image_path, "folder_id": folder_id, "thumbnailPath": thumbnail_path, - "metadata": "{}", # Empty JSON object as default + "metadata": json.dumps(metadata), "isTagged": False, } ) @@ -309,3 +315,208 @@ def image_util_is_valid_image(file_path: str) -> bool: return True except Exception: return False + + +def image_util_extract_metadata(image_path: str) -> dict: + """Extract metadata for a given image file with detailed debug logging.""" + # print(f"[DEBUG] extract_image_metadata called for: {image_path}") + + if not os.path.exists(image_path): + return { + "name": os.path.basename(image_path), + "date_created": None, + "width": 0, + "height": 0, + "file_location": image_path, + "file_size": 0, + "item_type": "unknown", + } + + try: + stats = os.stat(image_path) + # print(f"[DEBUG] File exists. Size = {stats.st_size} bytes") + + try: + with Image.open(image_path) as img: + width, height = img.size + mime_type = Image.MIME.get(img.format, "unknown") + # print(f"[DEBUG] Pillow opened image: {width}x{height}, type={mime_type}") + + # Robust EXIF extraction with safe fallback + try: + exif_data = ( + img.getexif() + if hasattr(img, "getexif") + else getattr(img, "_getexif", lambda: None)() + ) + except Exception: + exif_data = None + + exif = dict(exif_data) if exif_data else {} + dt_original = None + latitude = None + longitude = None + + # GPS Info Extraction + gps_info_tag = None + for tag, name in ExifTags.TAGS.items(): + if name == "GPSInfo": + gps_info_tag = tag + break + + if gps_info_tag and exif_data: + gps_data = exif_data.get_ifd(gps_info_tag) + if isinstance(gps_data, dict): + try: + + def _convert_to_degrees(value): + if hasattr(value[0], "numerator"): + d = float(value[0].numerator) / float( + value[0].denominator + ) + else: + d = float(value[0]) + if hasattr(value[1], "numerator"): + m = float(value[1].numerator) / float( + value[1].denominator + ) + else: + m = float(value[1]) + if hasattr(value[2], "numerator"): + s = float(value[2].numerator) / float( + value[2].denominator + ) + else: + s = float(value[2]) + return d + (m / 60.0) + (s / 3600.0) + + lat_dms = gps_data.get(2) + lat_ref = gps_data.get(1) + lon_dms = gps_data.get(4) + lon_ref = gps_data.get(3) + + if lat_dms and lat_ref and lon_dms and lon_ref: + if isinstance(lat_ref, bytes): + lat_ref = lat_ref.decode("ascii") + if isinstance(lon_ref, bytes): + lon_ref = lon_ref.decode("ascii") + + latitude = _convert_to_degrees(lat_dms) + if lat_ref.strip() != "N": + latitude = -latitude + + longitude = _convert_to_degrees(lon_dms) + if lon_ref.strip() != "E": + longitude = -longitude + except ( + KeyError, + IndexError, + TypeError, + ValueError, + AttributeError, + ): + latitude = None + longitude = None + else: + latitude = None + longitude = None + + for k, v in exif.items(): + if ExifTags.TAGS.get(k) == "DateTimeOriginal": + dt_original = ( + v.decode("utf-8", "ignore") + if isinstance(v, (bytes, bytearray)) + else str(v) + ) + break + + # Safe parse; fall back to mtime without losing width/height + if dt_original: + try: + date_created = datetime.datetime.strptime( + dt_original.strip().split("\x00", 1)[0], + "%Y:%m:%d %H:%M:%S", + ).isoformat() + except ValueError: + date_created = datetime.datetime.fromtimestamp( + stats.st_mtime + ).isoformat() + else: + date_created = datetime.datetime.fromtimestamp( + stats.st_mtime + ).isoformat() + + location = None + if latitude is not None and longitude is not None: + try: + geolocator = Nominatim(user_agent="pictopy_app") + reverse = RateLimiter(geolocator.reverse, min_delay_seconds=1) + location_data = reverse((latitude, longitude), exactly_one=True) + if location_data: + location = location_data.address + except Exception as e: + print(f"[WARN] Geocoding failed: {e}") + location = None + + metadata_dict = { + "name": os.path.basename(image_path), + "date_created": date_created, + "width": width, + "height": height, + "file_location": image_path, + "file_size": stats.st_size, + "item_type": mime_type, + } + + if latitude is not None and longitude is not None: + metadata_dict["latitude"] = latitude + metadata_dict["longitude"] = longitude + if location: + metadata_dict["location"] = location + + return metadata_dict + + except Exception as e: + print(f"[ERROR] Pillow could not open image {image_path}: {e}") + return { + "name": os.path.basename(image_path), + "date_created": datetime.datetime.fromtimestamp( + stats.st_mtime + ).isoformat(), + "file_location": image_path, + "file_size": stats.st_size, + "width": 0, + "height": 0, + "item_type": "unknown", + } + + except Exception: + return { + "name": os.path.basename(image_path), + "date_created": None, + "width": 0, + "height": 0, + "file_location": image_path, + "file_size": 0, + "item_type": "unknown", + } + + +def image_util_parse_metadata(db_metadata: Any) -> Mapping[str, Any]: + """ + Safely parses metadata from the database, which might be a JSON string or already a dict. + """ + if not db_metadata: + return {} + + if isinstance(db_metadata, str): + try: + parsed = json.loads(db_metadata) + return parsed if isinstance(parsed, dict) else {} + except (json.JSONDecodeError, TypeError): + return {} + + if isinstance(db_metadata, dict): + return db_metadata + + return {} diff --git a/backend/requirements.txt b/backend/requirements.txt index d8b54a1cc1fb547efbe919a24c298f976ea57c0e..e6ff0db754210d59e197dfc223c79d26fd05b274 100644 GIT binary patch literal 1230 zcmZ8hL5`y^5WM>@ARr`}9Edd6)s8gMoc1(wi~%Rcw(NE?{C=w;kyd+)8+TQ8RXfr- zPm*+B&=Pd&x~e>smAI(51nRn7+2EvfBr}+UwQz(K9t&*MbDUf@HNQ@_{HQ=w+^|L<{DQC_pLL zjzC^3LzFY_zzpayy_0OlQT#IQDgnF$a4W9oo!v?z9Q^=vy8qo$(QVGW1lc$R0x7L7#jiEkhNuNV4< zv`g@I1%%?)jr5S?sAG{$L2_j7BYzA)oR}L@h08OmMji(Y>Ye+E*^yySkQ?9kK|6KF zY+KB2dBTKmmGJSJICl_oPVBuLjROx-szfl>`Ug<(k&n^%Xb4moZ9RMP$hGVCOkInx z^b*91FEH)v{Jn(;6W?Im=Q7UP5w8LlfV;{ndJhloteJCs7Uyiq1R1g5yh|r^|9SuT z{YpC;#Wo;rv?OJ2NYHA@8-l>L+U8Bo1Eon@i}@xwZx>pToEZk=`N+0onWNQ2@D4*C zAvY~gck4!wOtUyeO|v}bVdwO7s}7G!5#c}aOq;P6gchH6^MAn=QW9@1zgHx>>2T& h@H&tfzzdlcr;I9U_Q@!cY_d$wvE<) z_INz@%kN*a(&)1-hceT%EQ?a<|8v>tds{x}^SZn$S9)HTm*qv7m%8l3ehBM@EYsdv z>(pN7AIhB`PEU1iS}r@UX~d|@TGqP!2#Hj2LOGTvyV&2PdTaJ2zH#n)* z-gk=B%5x^GtA(pce3GwopGwieki6o55!Kx3Jsp@U*PTA_X#UNfd43bVtH_WsCc&<| zvl2Sq!#7y-?M|_iZ*1gE+d)}(vYPX(5|7MgkApnu&ABSf#7Z%U^3+#0RX>Lu$LK?& zy7sDmDPHOAb9hfiXg+ny+jn@}Q?p7ubG7Qdh&NO0Wkj+{vpaOUk(G*I&-(hT{7re( zdozUvrY?ioM^!kF4$V7kSm-&5vl@@ec?>JK%rsV%wwIPqf6iblS5pU(<(KIf{tj$nZScVyEc!CFbh%fPbG|J|sA!{na(Qw!UG zZZS0=d*8>u3O4?KqSEQ7h{2S4maH6bn)Sb{sN{K4zWczt7w)4@vX6KN2KYkrBP zXa2EfWGlPT=}phRd4nH!n%QoIkX|;*@X)IW&&epuRxwa)tqtOCj`t1yhZ&H)>t>2c zdQ>gBlUywx)^AQDRiQ@L<~cWZ_Q^W*cIqng>71xtbcQ@=%=&UK#~y5T^vp(qANJ>T zq!EJ{k4kCEy+E(?d`){_+DZ1nOr!^YA>QcX1o=O zbB#~BUNAGQ>N&r^V9L*|!vVkCwKMf%CnRk6TKPQl;PI`?tfHokEP1m#mRZT)48B#} zL-b+A+iFH`mF?e~!MhZ;t(D{16uARN51OMiLqC~<;hwmN`izNPa)^$@GE5G4*Hqoi zJSSP^F|XW_@SU5=Gex$O-Z6EasqFkZ6Ye)=!0U31(tDD++)7=Z6ywmxn=L6K5VH9Hc;YhWoOpb;Q{w- nIsYQ?T)K)XboQ*unV)TV8*652h4!Fm{H7ZJg1G9KVXpoFVm^q& diff --git a/docs/backend/backend_python/openapi.json b/docs/backend/backend_python/openapi.json index 99eb21ad5..b65874f03 100644 --- a/docs/backend/backend_python/openapi.json +++ b/docs/backend/backend_python/openapi.json @@ -1931,8 +1931,7 @@ "title": "Thumbnailpath" }, "metadata": { - "type": "object", - "title": "Metadata" + "$ref": "#/components/schemas/MetadataModel" }, "isTagged": { "type": "boolean", @@ -2004,7 +2003,7 @@ "metadata": { "anyOf": [ { - "type": "string" + "type": "object" }, { "type": "null" @@ -2058,6 +2057,89 @@ "title": "ImageInCluster", "description": "Represents an image that contains faces from a specific cluster." }, + "MetadataModel": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "date_created": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Date Created" + }, + "width": { + "type": "integer", + "title": "Width" + }, + "height": { + "type": "integer", + "title": "Height" + }, + "file_location": { + "type": "string", + "title": "File Location" + }, + "file_size": { + "type": "integer", + "title": "File Size" + }, + "item_type": { + "type": "string", + "title": "Item Type" + }, + "latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Latitude" + }, + "longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Longitude" + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Location" + } + }, + "type": "object", + "required": [ + "name", + "date_created", + "width", + "height", + "file_location", + "file_size", + "item_type" + ], + "title": "MetadataModel" + }, "RenameClusterData": { "properties": { "cluster_id": { diff --git a/frontend/src/components/Media/MediaInfoPanel.tsx b/frontend/src/components/Media/MediaInfoPanel.tsx index 95a04a788..a29575a27 100644 --- a/frontend/src/components/Media/MediaInfoPanel.tsx +++ b/frontend/src/components/Media/MediaInfoPanel.tsx @@ -26,11 +26,17 @@ export const MediaInfoPanel: React.FC = ({ totalImages, }) => { const getFormattedDate = () => { - return new Date().toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); + if (currentImage?.metadata?.date_created) { + return new Date(currentImage.metadata.date_created).toLocaleDateString( + 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric', + }, + ); + } + return 'Date not available'; }; const getImageName = () => { @@ -86,12 +92,11 @@ export const MediaInfoPanel: React.FC = ({

Location

-

- {typeof currentImage?.metadata === 'string' - ? currentImage.metadata - : currentImage?.metadata - ? JSON.stringify(currentImage.metadata) - : 'No location data'} +

+ {currentImage?.metadata?.location || 'Location not available'}

diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 53227e89e..0bae40d12 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -14,7 +14,6 @@ import { usePictoQuery } from '@/hooks/useQueryExtension'; import { fetchAllImages } from '@/api/api-functions'; import { RootState } from '@/app/store'; import { showInfoDialog } from '@/features/infoDialogSlice'; -import { ImageCard } from '@/components/Media/ImageCard'; export const Home = () => { const dispatch = useDispatch(); @@ -66,31 +65,6 @@ export const Home = () => { ? `Face Search Results (${searchResults.length} found)` : 'Image Gallery'; - if (isSearchActive) { - return ( -
-

{title}

- - {/* Image Grid */} -
- {displayImages.map((image, index) => ( - - ))} -
- - {/* Media Viewer Modal */} - {isImageViewOpen && ( - - )} -
- ); - } - return (
{/* Gallery Section */} diff --git a/frontend/src/types/Media.ts b/frontend/src/types/Media.ts index 12459e998..dd7d75307 100644 --- a/frontend/src/types/Media.ts +++ b/frontend/src/types/Media.ts @@ -6,6 +6,7 @@ export interface ImageMetadata { file_location: string; file_size: number; item_type: string; + location?: string; } export interface Image { From 28025ac189eeabfdefa8e5389765b5982c577672 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Fri, 3 Oct 2025 22:47:06 +0530 Subject: [PATCH 05/13] fix(tests): Correct metadata type in face cluster test fixture, add coderabbit suggestions --- backend/app/database/images.py | 2 +- backend/tests/test_face_clusters.py | 4 ++-- frontend/src/types/Media.ts | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/app/database/images.py b/backend/app/database/images.py index 63005225a..361818947 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -230,7 +230,7 @@ def db_get_untagged_images() -> List[UntaggedImageRecord]: { "id": image_id, "path": path, - "folder_id": folder_id, + "folder_id": str(folder_id) if folder_id is not None else None, "thumbnailPath": thumbnail_path, "metadata": md, } diff --git a/backend/tests/test_face_clusters.py b/backend/tests/test_face_clusters.py index 977ee8c33..1e6f7c398 100644 --- a/backend/tests/test_face_clusters.py +++ b/backend/tests/test_face_clusters.py @@ -64,7 +64,7 @@ def sample_cluster_images(): "image_id": "img_1", "image_path": "/path/to/image1.jpg", "thumbnail_path": "/path/to/thumb1.jpg", - "metadata": "{'camera': 'Canon'}", + "metadata": {"camera": "Canon"}, "face_id": 101, "confidence": 0.95, "bbox": {"x": 100, "y": 200, "width": 150, "height": 200}, @@ -73,7 +73,7 @@ def sample_cluster_images(): "image_id": "img_2", "image_path": "/path/to/image2.jpg", "thumbnail_path": "/path/to/thumb2.jpg", - "metadata": "{'camera': 'Nikon'}", + "metadata": {"camera": "Nikon"}, "face_id": 102, "confidence": 0.87, "bbox": {"x": 50, "y": 100, "width": 120, "height": 160}, diff --git a/frontend/src/types/Media.ts b/frontend/src/types/Media.ts index dd7d75307..fa690c11a 100644 --- a/frontend/src/types/Media.ts +++ b/frontend/src/types/Media.ts @@ -1,12 +1,14 @@ export interface ImageMetadata { name: string; - date_created: string; + date_created: string | null; width: number; height: number; file_location: string; file_size: number; item_type: string; location?: string; + latitude?: number; + longitude?: number; } export interface Image { From 00f406d5ca251e0119bab3765ae33ca5e1c4fd7d Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Tue, 7 Oct 2025 02:04:17 +0530 Subject: [PATCH 06/13] Refactor: code review changes Moved GPS coordinate parsing logic into a dedicated extract_gps_coordinates() helper function for better readability and maintainability. Removed reverse geocoding using geopy as per review feedback to prevent external API overhead. Updated Python and TypeScript metadata models to only include latitude and longitude. Restored original dependencies (httptools, httpx, humanfriendly) and removed geopy from requirements.txt. Updated MediaInfoPanel.tsx to display latitude and longitude directly with Google Maps link integration via Tauri Opener. --- backend/app/utils/images.py | 149 ++++++++---------- backend/requirements.txt | 8 +- .../src/components/Media/MediaInfoPanel.tsx | 30 +++- frontend/src/types/Media.ts | 1 - 4 files changed, 96 insertions(+), 92 deletions(-) diff --git a/backend/app/utils/images.py b/backend/app/utils/images.py index 3dc2cba1e..84b68b54e 100644 --- a/backend/app/utils/images.py +++ b/backend/app/utils/images.py @@ -5,8 +5,6 @@ from typing import List, Tuple, Dict, Any, Mapping from PIL import Image, ExifTags from pathlib import Path -from geopy.geocoders import Nominatim -from geopy.extra.rate_limiter import RateLimiter from app.config.settings import THUMBNAIL_IMAGES_PATH from app.database.images import ( @@ -317,6 +315,72 @@ def image_util_is_valid_image(file_path: str) -> bool: return False +def _extract_gps_coordinates(exif_data: Any) -> Tuple[float | None, float | None]: + """ + Extracts GPS coordinates from EXIF data. + Args: + exif_data: The EXIF data from an image (PIL.Image.Exif object). + Returns: + A tuple containing latitude and longitude, or (None, None) if not found. + """ + latitude = None + longitude = None + GPS_INFO_TAG = 34853 + + if exif_data: + gps_data = exif_data.get_ifd(GPS_INFO_TAG) + if isinstance(gps_data, dict): + try: + + def _convert_to_degrees(value): + if hasattr(value[0], "numerator"): + d = float(value[0].numerator) / float(value[0].denominator) + else: + d = float(value[0]) + if hasattr(value[1], "numerator"): + m = float(value[1].numerator) / float(value[1].denominator) + else: + m = float(value[1]) + if hasattr(value[2], "numerator"): + s = float(value[2].numerator) / float(value[2].denominator) + else: + s = float(value[2]) + return d + (m / 60.0) + (s / 3600.0) + + lat_dms = gps_data.get(2) + lat_ref = gps_data.get(1) + lon_dms = gps_data.get(4) + lon_ref = gps_data.get(3) + + if lat_dms and lat_ref and lon_dms and lon_ref: + if isinstance(lat_ref, bytes): + lat_ref = lat_ref.decode("ascii") + if isinstance(lon_ref, bytes): + lon_ref = lon_ref.decode("ascii") + + latitude = _convert_to_degrees(lat_dms) + if lat_ref.strip() != "N": + latitude = -latitude + + longitude = _convert_to_degrees(lon_dms) + if lon_ref.strip() != "E": + longitude = -longitude + except ( + KeyError, + IndexError, + TypeError, + ValueError, + AttributeError, + ): + latitude = None + longitude = None + else: + latitude = None + longitude = None + + return latitude, longitude + + def image_util_extract_metadata(image_path: str) -> dict: """Extract metadata for a given image file with detailed debug logging.""" # print(f"[DEBUG] extract_image_metadata called for: {image_path}") @@ -354,72 +418,7 @@ def image_util_extract_metadata(image_path: str) -> dict: exif = dict(exif_data) if exif_data else {} dt_original = None - latitude = None - longitude = None - - # GPS Info Extraction - gps_info_tag = None - for tag, name in ExifTags.TAGS.items(): - if name == "GPSInfo": - gps_info_tag = tag - break - - if gps_info_tag and exif_data: - gps_data = exif_data.get_ifd(gps_info_tag) - if isinstance(gps_data, dict): - try: - - def _convert_to_degrees(value): - if hasattr(value[0], "numerator"): - d = float(value[0].numerator) / float( - value[0].denominator - ) - else: - d = float(value[0]) - if hasattr(value[1], "numerator"): - m = float(value[1].numerator) / float( - value[1].denominator - ) - else: - m = float(value[1]) - if hasattr(value[2], "numerator"): - s = float(value[2].numerator) / float( - value[2].denominator - ) - else: - s = float(value[2]) - return d + (m / 60.0) + (s / 3600.0) - - lat_dms = gps_data.get(2) - lat_ref = gps_data.get(1) - lon_dms = gps_data.get(4) - lon_ref = gps_data.get(3) - - if lat_dms and lat_ref and lon_dms and lon_ref: - if isinstance(lat_ref, bytes): - lat_ref = lat_ref.decode("ascii") - if isinstance(lon_ref, bytes): - lon_ref = lon_ref.decode("ascii") - - latitude = _convert_to_degrees(lat_dms) - if lat_ref.strip() != "N": - latitude = -latitude - - longitude = _convert_to_degrees(lon_dms) - if lon_ref.strip() != "E": - longitude = -longitude - except ( - KeyError, - IndexError, - TypeError, - ValueError, - AttributeError, - ): - latitude = None - longitude = None - else: - latitude = None - longitude = None + latitude, longitude = _extract_gps_coordinates(exif_data) for k, v in exif.items(): if ExifTags.TAGS.get(k) == "DateTimeOriginal": @@ -446,18 +445,6 @@ def _convert_to_degrees(value): stats.st_mtime ).isoformat() - location = None - if latitude is not None and longitude is not None: - try: - geolocator = Nominatim(user_agent="pictopy_app") - reverse = RateLimiter(geolocator.reverse, min_delay_seconds=1) - location_data = reverse((latitude, longitude), exactly_one=True) - if location_data: - location = location_data.address - except Exception as e: - print(f"[WARN] Geocoding failed: {e}") - location = None - metadata_dict = { "name": os.path.basename(image_path), "date_created": date_created, @@ -471,8 +458,6 @@ def _convert_to_degrees(value): if latitude is not None and longitude is not None: metadata_dict["latitude"] = latitude metadata_dict["longitude"] = longitude - if location: - metadata_dict["location"] = location return metadata_dict diff --git a/backend/requirements.txt b/backend/requirements.txt index e6ff0db75..00bec243a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,7 +13,10 @@ h11==0.14.0 h2==4.1.0 hpack==4.0.0 httpcore==1.0.5 -hypercorn==0.17.3 +httptools==0.6.1 +httpx==0.27.0 +humanfriendly==10.0 +Hypercorn==0.17.3 hyperframe==6.0.1 idna==3.7 Jinja2==3.1.4 @@ -67,5 +70,4 @@ black>=23.3.0 ruff>=0.0.241 psutil>=5.9.5 pytest-asyncio>=1.0.0 -setuptools==66.1.1 -geopy \ No newline at end of file +setuptools==66.1.1 \ No newline at end of file diff --git a/frontend/src/components/Media/MediaInfoPanel.tsx b/frontend/src/components/Media/MediaInfoPanel.tsx index a29575a27..3b873b277 100644 --- a/frontend/src/components/Media/MediaInfoPanel.tsx +++ b/frontend/src/components/Media/MediaInfoPanel.tsx @@ -45,6 +45,18 @@ export const MediaInfoPanel: React.FC = ({ return currentImage.path?.split(/[/\\]/).pop() || 'Image'; }; + const handleLocationClick = async () => { + if (currentImage?.metadata?.latitude && currentImage?.metadata?.longitude) { + const { latitude, longitude } = currentImage.metadata; + const url = `https://maps.google.com/?q=${latitude},${longitude}`; + try { + await open(url); + } catch (error) { + console.error('Failed to open map URL:', error); + } + } + }; + if (!show) return null; return ( @@ -92,12 +104,18 @@ export const MediaInfoPanel: React.FC = ({

Location

-

- {currentImage?.metadata?.location || 'Location not available'} -

+ {currentImage?.metadata?.latitude && + currentImage?.metadata?.longitude ? ( + + ) : ( +

Location not available

+ )}
diff --git a/frontend/src/types/Media.ts b/frontend/src/types/Media.ts index fa690c11a..02ea57771 100644 --- a/frontend/src/types/Media.ts +++ b/frontend/src/types/Media.ts @@ -6,7 +6,6 @@ export interface ImageMetadata { file_location: string; file_size: number; item_type: string; - location?: string; latitude?: number; longitude?: number; } From 3c3df4629a3cf1469b48b304a74ec320d408f923 Mon Sep 17 00:00:00 2001 From: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com> Date: Tue, 7 Oct 2025 12:13:08 +0530 Subject: [PATCH 07/13] Nitpicks: Implement coderabbitai suggestions --- backend/app/utils/images.py | 64 ++++++++++--------- .../src/components/Media/MediaInfoPanel.tsx | 1 + 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/backend/app/utils/images.py b/backend/app/utils/images.py index 84b68b54e..f3189071c 100644 --- a/backend/app/utils/images.py +++ b/backend/app/utils/images.py @@ -2,6 +2,7 @@ import uuid import datetime import json +import logging from typing import List, Tuple, Dict, Any, Mapping from PIL import Image, ExifTags from pathlib import Path @@ -18,6 +19,8 @@ from app.models.FaceDetector import FaceDetector from app.models.ObjectClassifier import ObjectClassifier +logger = logging.getLogger(__name__) + def image_util_process_folder_images(folder_data: List[Tuple[str, int, bool]]) -> bool: """Main function to process images in multiple folders based on provided folder data. @@ -57,7 +60,7 @@ def image_util_process_folder_images(folder_data: List[Tuple[str, int, bool]]) - all_image_records.extend(folder_image_records) except Exception as e: - print(f"Error processing folder {folder_path}: {e}") + logger.error(f"Error processing folder {folder_path}: {e}") continue # Continue with other folders even if one fails # Step 4: Remove obsolete images that no longer exist in filesystem @@ -70,7 +73,7 @@ def image_util_process_folder_images(folder_data: List[Tuple[str, int, bool]]) - return True # No images to process is not an error except Exception as e: - print(f"Error processing folders: {e}") + logger.error(f"Error processing folders: {e}") return False @@ -87,7 +90,7 @@ def image_util_process_untagged_images() -> bool: return True except Exception as e: - print(f"Error processing untagged images: {e}") + logger.error(f"Error processing untagged images: {e}") return False @@ -109,7 +112,7 @@ def image_util_classify_and_face_detect_images( if len(classes) > 0: # Create image-class pairs image_class_pairs = [(image_id, class_id) for class_id in classes] - print(image_class_pairs) + logger.debug(f"Image class pairs: {image_class_pairs}") # Insert the pairs into the database db_insert_image_classes_batch(image_class_pairs) @@ -155,7 +158,7 @@ def image_util_prepare_image_records( # Generate thumbnail if image_util_generate_thumbnail(image_path, thumbnail_path): metadata = image_util_extract_metadata(image_path) - # print(f"Extracted metadata for {image_path}: {metadata}") + logger.debug(f"Extracted metadata for {image_path}: {metadata}") image_records.append( { "id": image_id, @@ -199,7 +202,7 @@ def image_util_get_images_from_folder( if os.path.isfile(file_path) and image_util_is_valid_image(file_path): image_files.append(file_path) except OSError as e: - print(f"Error reading folder {folder_path}: {e}") + logger.error(f"Error reading folder {folder_path}: {e}") return image_files @@ -219,7 +222,7 @@ def image_util_generate_thumbnail( img.save(thumbnail_path, "JPEG") # Always save thumbnails as JPEG return True except Exception as e: - print(f"Error generating thumbnail for {image_path}: {e}") + logger.error(f"Error generating thumbnail for {image_path}: {e}") return False @@ -243,13 +246,13 @@ def image_util_remove_obsolete_images(folder_id_list: List[int]) -> int: if thumbnail_path and os.path.exists(thumbnail_path): try: os.remove(thumbnail_path) - print(f"Removed obsolete thumbnail: {thumbnail_path}") + logger.info(f"Removed obsolete thumbnail: {thumbnail_path}") except OSError as e: - print(f"Error removing thumbnail {thumbnail_path}: {e}") + logger.error(f"Error removing thumbnail {thumbnail_path}: {e}") if obsolete_images: db_delete_images_by_ids(obsolete_images) - print(f"Removed {len(obsolete_images)} obsolete image(s) from database") + logger.info(f"Removed {len(obsolete_images)} obsolete image(s) from database") return len(obsolete_images) @@ -315,6 +318,23 @@ def image_util_is_valid_image(file_path: str) -> bool: return False +def _convert_to_degrees(value): + """Converts a GPS coordinate value from DMS to decimal degrees.""" + if hasattr(value[0], "numerator"): + d = float(value[0].numerator) / float(value[0].denominator) + else: + d = float(value[0]) + if hasattr(value[1], "numerator"): + m = float(value[1].numerator) / float(value[1].denominator) + else: + m = float(value[1]) + if hasattr(value[2], "numerator"): + s = float(value[2].numerator) / float(value[2].denominator) + else: + s = float(value[2]) + return d + (m / 60.0) + (s / 3600.0) + + def _extract_gps_coordinates(exif_data: Any) -> Tuple[float | None, float | None]: """ Extracts GPS coordinates from EXIF data. @@ -331,22 +351,6 @@ def _extract_gps_coordinates(exif_data: Any) -> Tuple[float | None, float | None gps_data = exif_data.get_ifd(GPS_INFO_TAG) if isinstance(gps_data, dict): try: - - def _convert_to_degrees(value): - if hasattr(value[0], "numerator"): - d = float(value[0].numerator) / float(value[0].denominator) - else: - d = float(value[0]) - if hasattr(value[1], "numerator"): - m = float(value[1].numerator) / float(value[1].denominator) - else: - m = float(value[1]) - if hasattr(value[2], "numerator"): - s = float(value[2].numerator) / float(value[2].denominator) - else: - s = float(value[2]) - return d + (m / 60.0) + (s / 3600.0) - lat_dms = gps_data.get(2) lat_ref = gps_data.get(1) lon_dms = gps_data.get(4) @@ -383,7 +387,7 @@ def _convert_to_degrees(value): def image_util_extract_metadata(image_path: str) -> dict: """Extract metadata for a given image file with detailed debug logging.""" - # print(f"[DEBUG] extract_image_metadata called for: {image_path}") + logger.debug(f"extract_image_metadata called for: {image_path}") if not os.path.exists(image_path): return { @@ -398,13 +402,13 @@ def image_util_extract_metadata(image_path: str) -> dict: try: stats = os.stat(image_path) - # print(f"[DEBUG] File exists. Size = {stats.st_size} bytes") + logger.debug(f"File exists. Size = {stats.st_size} bytes") try: with Image.open(image_path) as img: width, height = img.size mime_type = Image.MIME.get(img.format, "unknown") - # print(f"[DEBUG] Pillow opened image: {width}x{height}, type={mime_type}") + logger.debug(f"Pillow opened image: {width}x{height}, type={mime_type}") # Robust EXIF extraction with safe fallback try: @@ -462,7 +466,7 @@ def image_util_extract_metadata(image_path: str) -> dict: return metadata_dict except Exception as e: - print(f"[ERROR] Pillow could not open image {image_path}: {e}") + logger.error(f"Pillow could not open image {image_path}: {e}") return { "name": os.path.basename(image_path), "date_created": datetime.datetime.fromtimestamp( diff --git a/frontend/src/components/Media/MediaInfoPanel.tsx b/frontend/src/components/Media/MediaInfoPanel.tsx index 3b873b277..bccfe18f7 100644 --- a/frontend/src/components/Media/MediaInfoPanel.tsx +++ b/frontend/src/components/Media/MediaInfoPanel.tsx @@ -107,6 +107,7 @@ export const MediaInfoPanel: React.FC = ({ {currentImage?.metadata?.latitude && currentImage?.metadata?.longitude ? ( ) : (

Location not available

From d882a974d0acfd36f5245cf91281619f6a9de670 Mon Sep 17 00:00:00 2001 From: Rahul Harpal <51887323+rahulharpal1603@users.noreply.github.com> Date: Wed, 8 Oct 2025 23:48:06 +0530 Subject: [PATCH 11/13] Remove no images found message in ChronologicalGallery Removed the empty state message for no images found in the gallery component. --- .../components/Media/ChronologicalGallery.tsx | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/frontend/src/components/Media/ChronologicalGallery.tsx b/frontend/src/components/Media/ChronologicalGallery.tsx index 92f0f453f..3460d8ba7 100644 --- a/frontend/src/components/Media/ChronologicalGallery.tsx +++ b/frontend/src/components/Media/ChronologicalGallery.tsx @@ -103,23 +103,6 @@ export const ChronologicalGallery = ({ }; }, [recomputeMarkers, scrollContainerRef]); - // Check if we have any images to display - if (!images.length) { - return ( -
-
-
😢
-
No images found
-
- Add some photo library folders to get started -
-
-
- ); - } - return (
{/* Title */} @@ -165,7 +148,7 @@ export const ChronologicalGallery = ({
{/* Images Grid */} -
+
{imgs.map((img) => { const reduxIndex = imageIndexMap.get(img.id) ?? -1; From 9655f1c1ed22d4630082edf4fe9db4bae98ebd4e Mon Sep 17 00:00:00 2001 From: Rahul Harpal <51887323+rahulharpal1603@users.noreply.github.com> Date: Wed, 8 Oct 2025 23:48:51 +0530 Subject: [PATCH 12/13] Lint fix --- frontend/src/App.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 743649926..609c22cfc 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -202,8 +202,8 @@ } .hide-scrollbar { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ } .no-select { From 9f7ee2cbe4dc5dadb3731e67abcc158ecfbce5b2 Mon Sep 17 00:00:00 2001 From: Rahul Harpal <51887323+rahulharpal1603@users.noreply.github.com> Date: Wed, 8 Oct 2025 23:50:52 +0530 Subject: [PATCH 13/13] Remove unused TooltipContent import --- frontend/src/components/Timeline/TimelineScrollbar.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/components/Timeline/TimelineScrollbar.tsx b/frontend/src/components/Timeline/TimelineScrollbar.tsx index 761626ae6..d2d144377 100644 --- a/frontend/src/components/Timeline/TimelineScrollbar.tsx +++ b/frontend/src/components/Timeline/TimelineScrollbar.tsx @@ -7,7 +7,6 @@ import { } from '@/utils/timelineUtils'; import { Tooltip, - TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip';