Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions backend/app/routes/memories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
from fastapi import APIRouter, HTTPException
from typing import List, Dict, Any
from datetime import datetime
import math

from app.database.images import db_get_all_images

router = APIRouter()

# -------------------------------------------------
# Helpers
# -------------------------------------------------


def haversine_distance(lat1, lon1, lat2, lon2) -> float:
R = 6371
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = (
math.sin(dlat / 2) ** 2
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
)
return 2 * R * math.asin(math.sqrt(a))


def decide_memory_type(mem: Dict[str, Any]) -> str:
now = datetime.now()
date = mem["anchor_date"]
lat = mem["lat"]
lon = mem["lon"]

if date.month == now.month and date.day == now.day and date.year != now.year:
return "on_this_day"

if lat is not None and lon is not None:
return "trip"

return "date_range"
Comment on lines +27 to +39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential timezone mismatch between datetime.now() and parsed dates.

The anchor_date is parsed with timezone info on line 79 (replace("Z", "+00:00")), but datetime.now() returns a naive datetime. Comparing timezone-aware and naive datetimes can raise TypeError or produce incorrect results.

Consider using timezone-aware now:

+from datetime import datetime, timezone
+
 def decide_memory_type(mem: Dict[str, Any]) -> str:
-    now = datetime.now()
+    now = datetime.now(timezone.utc)
     date = mem["anchor_date"]

Or alternatively, strip timezone info from parsed dates if local time comparison is intended.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In backend/app/routes/memories.py around lines 27-39, the function uses
datetime.now() (naive) while mem["anchor_date"] is parsed with timezone info,
which can raise TypeError or give incorrect comparisons; update the code to use
a timezone-aware "now" (e.g., datetime.now(timezone.utc)) and compare against
anchor_date converted to the same timezone
(anchor_date.astimezone(timezone.utc)), or alternatively strip tzinfo from
anchor_date before comparing if local naive comparison is intended; ensure you
perform the month/day/year comparison using consistently timezone-aware or
consistently naive datetimes (or compare .date() after normalizing both to the
same tz).



def generate_title(mem: Dict[str, Any]) -> str:
mtype = decide_memory_type(mem)
date = mem["anchor_date"]
now = datetime.now()

if mtype == "on_this_day":
years = now.year - date.year
return (
"On this day last year"
if years == 1
else f"On this day · {years} years ago"
)

if mtype == "trip":
return f"Trip · {date.strftime('%B %Y')}"

return f"Memories from {date.strftime('%B %Y')}"
Comment on lines +42 to +58
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same timezone issue applies here.

The datetime.now() call on line 45 has the same naive vs. aware datetime mismatch issue mentioned above. Apply the same fix to use datetime.now(timezone.utc).

🤖 Prompt for AI Agents
In backend/app/routes/memories.py around lines 42 to 58, the function uses
datetime.now() which produces a naive datetime and can mismatch against
timezone-aware anchor_date; change to use an aware UTC now (e.g.,
datetime.now(timezone.utc)) and ensure you import timezone from datetime; also
if anchor_date may be naive, normalize/convert it to UTC-aware before computing
year differences or formatting so all datetime comparisons are done with the
same tz-awareness.



# -------------------------------------------------
# Core Logic
# -------------------------------------------------


def group_images(images: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
memories: List[Dict[str, Any]] = []

for img in images:
metadata = img.get("metadata", {})
date_str = metadata.get("date_created")
lat = metadata.get("latitude")
lon = metadata.get("longitude")

if not date_str:
continue

try:
date = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
except Exception:
continue

matched = None

for mem in memories:
day_diff = abs((date - mem["anchor_date"]).days)

# 1️⃣ Same-day / near-day memory
if day_diff <= 1:
matched = mem
break

# 2️⃣ Trip memory (GPS + time proximity)
if (
day_diff <= 3
and lat is not None
and lon is not None
and mem["lat"] is not None
and mem["lon"] is not None
and haversine_distance(lat, lon, mem["lat"], mem["lon"]) <= 10
):
matched = mem
break

if not matched:
matched = {
"memory_id": f"memory_{len(memories)}",
"anchor_date": date,
"date_range_start": date,
"date_range_end": date,
"lat": lat,
"lon": lon,
"images": [],
}
memories.append(matched)

matched["images"].append(img)
matched["date_range_start"] = min(matched["date_range_start"], date)
matched["date_range_end"] = max(matched["date_range_end"], date)

# -------------------------------------------------
# Final formatting (IMPORTANT)
# -------------------------------------------------

result: List[Dict[str, Any]] = []

for mem in memories:
# ❗ Google Photos rule: at least 2 images
if len(mem["images"]) < 2:
continue

images_sorted = sorted(
mem["images"], key=lambda i: i.get("metadata", {}).get("date_created") or ""
)

highlights = images_sorted[:5]
cover = highlights[0]

result.append(
{
"id": mem["memory_id"],
"title": generate_title(mem),
"memory_type": decide_memory_type(mem),
"date_range_start": mem["date_range_start"].isoformat(),
"date_range_end": mem["date_range_end"].isoformat(),
"image_count": len(mem["images"]),
"representative_image": {
"id": cover["id"],
"path": cover["path"],
"thumbnailPath": cover["thumbnailPath"],
"metadata": cover["metadata"],
},
"images": mem["images"],
}
)

result.sort(key=lambda m: m["date_range_start"], reverse=True)
return result


# -------------------------------------------------
# API
# -------------------------------------------------


@router.get("/")
def get_memories():
try:
images = db_get_all_images()
return {
"success": True,
"data": group_images(images),
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


@router.get("/{memory_id}/images")
def get_memory_images(memory_id: str):
try:
images = db_get_all_images()
grouped_memories = group_images(images)

for mem in grouped_memories:
if mem["id"] == memory_id:
return {
"success": True,
"data": mem["images"],
}

raise HTTPException(status_code=404, detail="Memory not found")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
Comment on lines +178 to +193
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: HTTPException is caught by the broad except block, converting 404s to 500s.

The HTTPException raised on line 191 is caught by the except Exception on line 192, which re-raises it as a 500 error with str(e) as the detail. This means clients will never receive the intended 404 status.

 @router.get("/{memory_id}/images")
 def get_memory_images(memory_id: str):
     try:
         images = db_get_all_images()
         grouped_memories = group_images(images)
 
         for mem in grouped_memories:
             if mem["id"] == memory_id:
                 return {
                     "success": True,
                     "data": mem["images"],
                 }
 
         raise HTTPException(status_code=404, detail="Memory not found")
+    except HTTPException:
+        raise
     except Exception as e:
         raise HTTPException(status_code=500, detail=str(e))
🤖 Prompt for AI Agents
In backend/app/routes/memories.py around lines 178-193 the broad except
Exception catches the HTTPException(404) and converts it into a 500; update the
error handling so HTTPException passes through untouched (e.g., in the except
block check if isinstance(e, HTTPException) and re-raise it, otherwise raise a
new HTTPException(status_code=500, detail=str(e))) or replace the broad except
with more specific exception types to avoid swallowing intentional
HTTPExceptions.

2 changes: 2 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from app.routes.images import router as images_router
from app.routes.face_clusters import router as face_clusters_router
from app.routes.user_preferences import router as user_preferences_router
from app.routes.memories import router as memories_router
from fastapi.openapi.utils import get_openapi
from app.logging.setup_logging import (
configure_uvicorn_logging,
Expand Down Expand Up @@ -132,6 +133,7 @@ async def root():
app.include_router(
user_preferences_router, prefix="/user-preferences", tags=["User Preferences"]
)
app.include_router(memories_router, prefix="/memories", tags=["Memories"])


# Entry point for running with: python3 main.py
Expand Down
69 changes: 66 additions & 3 deletions docs/backend/backend_python/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1117,9 +1117,14 @@
"in": "query",
"required": false,
"schema": {
"$ref": "#/components/schemas/InputType",
"allOf": [
{
"$ref": "#/components/schemas/InputType"
}
],
"description": "Choose input type: 'path' or 'base64'",
"default": "path"
"default": "path",
"title": "Input Type"
},
"description": "Choose input type: 'path' or 'base64'"
}
Expand Down Expand Up @@ -1299,6 +1304,65 @@
}
}
}
},
"/memories/": {
"get": {
"tags": [
"Memories"
],
"summary": "Get Memories",
"operationId": "get_memories_memories__get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
Comment on lines +1308 to +1326
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Incomplete API documentation: empty response schema for GET /memories/.

The 200 response schema is empty ("schema": {}), which fails to document the structure of the Memories response. This creates ambiguity for API consumers about what fields, types, and structure to expect.

Replace the empty schema with a proper response schema that documents the memory objects. For example:

         "200": {
           "description": "Successful Response",
           "content": {
             "application/json": {
-              "schema": {}
+              "schema": {
+                "type": "array",
+                "items": {
+                  "$ref": "#/components/schemas/MemoryResponse"
+                }
+              }
             }
           }
         }

Also, add error response documentation (e.g., 500 for server errors) consistent with other endpoints in the spec.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In docs/backend/backend_python/openapi.json around lines 1308 to 1326 the 200
response schema for GET /memories/ is empty; replace the empty "schema": {} with
a concrete response definition that documents an array of memory objects (e.g.,
use an array of a Memory schema or inline object describing fields such as id
(string/integer), user_id (string/integer), content (string),
created_at/updated_at (string, date-time), optional tags (array of strings) and
any other fields your app returns), and add standard error responses (e.g., a
500 response with a reference to a common ErrorResponse schema consistent with
other endpoints or an inline object containing code/message) so clients can
understand both success and failure payloads.

"/memories/{memory_id}/images": {
"get": {
"tags": [
"Memories"
],
"summary": "Get Memory Images",
"operationId": "get_memory_images_memories__memory_id__images_get",
"parameters": [
{
"name": "memory_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Memory Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
}
Comment on lines +1327 to 1366
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Incomplete API documentation: empty response schema for GET /memories/{memory_id}/images.

The 200 response schema is empty, leaving API consumers without documentation of the image structure. Additionally, error responses beyond 422 Validation Error are not documented; comparable endpoints document 500, 400, or 404 responses.

Replace the empty schema and add comprehensive error handling:

         "200": {
           "description": "Successful Response",
           "content": {
             "application/json": {
-              "schema": {}
+              "schema": {
+                "type": "array",
+                "items": {
+                  "$ref": "#/components/schemas/ImageData"
+                }
+              }
             }
           }
         },
+        "404": {
+          "description": "Memory not found",
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/HTTPValidationError"
+              }
+            }
+          }
+        },
+        "500": {
+          "description": "Internal Server Error",
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/HTTPValidationError"
+              }
+            }
+          }
+        }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In docs/backend/backend_python/openapi.json around lines 1327 to 1366, the 200
response for GET /memories/{memory_id}/images uses an empty schema and lacks
other common error responses; update the 200 response to reference or inline the
correct image array schema (e.g., an array of Image objects with id, url,
caption, width, height, created_at) and add standard error responses mirrored
from comparable endpoints (400 Bad Request, 404 Not Found, 500 Internal Server
Error) with appropriate $ref schemas (e.g., HTTPError or ErrorModel) or inline
error object schemas so API consumers have a complete contract.

},
"components": {
Expand Down Expand Up @@ -2199,7 +2263,6 @@
"metadata": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/api-functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './images';
export * from './folders';
export * from './user_preferences';
export * from './health';
export * from './memories';
17 changes: 17 additions & 0 deletions frontend/src/api/api-functions/memories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { apiClient } from '../axiosConfig';
import {
MemoriesApiResponse,
MemoryImagesApiResponse,
} from '@/types/memories';

export const fetchAllMemories = async (): Promise<MemoriesApiResponse> => {
const response = await apiClient.get('/memories/');
return response.data;
};

export const fetchMemoryImages = async (
memoryId: string
): Promise<MemoryImagesApiResponse> => {
const response = await apiClient.get(`/memories/${memoryId}/images`);
return response.data;
};
4 changes: 2 additions & 2 deletions frontend/src/api/api-functions/togglefav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { imagesEndpoints } from '../apiEndpoints';
import { apiClient } from '../axiosConfig';
import { APIResponse } from '@/types/API';

export const togglefav = async (image_id: string): Promise<APIResponse> => {
export const togglefav = async (image_id: number): Promise<APIResponse> => {
const response = await apiClient.post<APIResponse>(
imagesEndpoints.setFavourite,
{ image_id },
{ image_id: image_id.toString() },
);
return response.data;
};
5 changes: 5 additions & 0 deletions frontend/src/api/apiEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ export const userPreferencesEndpoints = {
export const healthEndpoints = {
healthCheck: '/health',
};

export const memoriesEndpoints = {
getAllMemories: '/memories/',
getMemoryImages: (memoryId: string) => `/memories/${memoryId}/images`,
};
2 changes: 1 addition & 1 deletion frontend/src/components/Media/ChronologicalGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const ChronologicalGallery = ({
}, [sortedGrouped]);

const imageIndexMap = useMemo(() => {
const map = new Map<string, number>();
const map = new Map<number, number>();
chronologicallySortedImages.forEach((img, idx) => {
map.set(img.id, idx);
});
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Media/MediaThumbnails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { convertFileSrc } from '@tauri-apps/api/core';

interface MediaThumbnailsProps {
images: Array<{
id: string;
id: number;
path: string;
thumbnailPath: string;
}>;
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/hooks/useToggleFav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import { togglefav } from '@/api/api-functions/togglefav';

export const useToggleFav = () => {
const toggleFavouriteMutation = usePictoMutation({
mutationFn: async (image_id: string) => togglefav(image_id),
mutationFn: async (image_id: number) => togglefav(image_id),
autoInvalidateTags: ['images'],
});
useMutationFeedback(toggleFavouriteMutation, {
showLoading: false,
showSuccess: false,
});
return {
toggleFavourite: (id: any) => toggleFavouriteMutation.mutate(id),
toggleFavourite: (id: number) => toggleFavouriteMutation.mutate(id),
toggleFavouritePending: toggleFavouriteMutation.isPending,
};
};
Loading