-
Notifications
You must be signed in to change notification settings - Fork 585
[Feat]: Implement memories features end to end backend and frontend both. #781
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
|
|
||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same timezone issue applies here. The 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| # ------------------------------------------------- | ||
| # 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 | ||
|
|
||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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"], | ||
| }, | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| "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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical: The @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 |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1117,9 +1117,14 @@ | |
| "in": "query", | ||
| "required": false, | ||
| "schema": { | ||
| "$ref": "#/components/schemas/InputType", | ||
| "allOf": [ | ||
| { | ||
| "$ref": "#/components/schemas/InputType" | ||
| } | ||
| ], | ||
| "description": "Choose input type: 'path' or 'base64'", | ||
| "default": "path" | ||
| "default": "path", | ||
| "title": "Input Type" | ||
| }, | ||
| "description": "Choose input type: 'path' or 'base64'" | ||
| } | ||
|
|
@@ -1299,6 +1304,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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Incomplete API documentation: empty response schema for GET /memories/. The 200 response schema is empty ( 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.
🤖 Prompt for AI Agents |
||
| "/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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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"
+ }
+ }
+ }
+ }
🤖 Prompt for AI Agents |
||
| }, | ||
| "components": { | ||
|
|
@@ -2199,7 +2263,6 @@ | |
| "metadata": { | ||
| "anyOf": [ | ||
| { | ||
| "additionalProperties": true, | ||
| "type": "object" | ||
| }, | ||
| { | ||
|
|
||
| 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; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential timezone mismatch between
datetime.now()and parsed dates.The
anchor_dateis parsed with timezone info on line 79 (replace("Z", "+00:00")), butdatetime.now()returns a naive datetime. Comparing timezone-aware and naive datetimes can raiseTypeErroror produce incorrect results.Consider using timezone-aware
now:Or alternatively, strip timezone info from parsed dates if local time comparison is intended.
🤖 Prompt for AI Agents