diff --git a/Push to Gitea for Memories.sh b/Push to Gitea for Memories.sh new file mode 100755 index 0000000..8b245c5 --- /dev/null +++ b/Push to Gitea for Memories.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +# Memories Project: Automate Git Init, Commit, and Push to Gitea +# Run from /Users/maq/memories directory +# Usage: bash setup-git.sh + +set -e # Exit on any error + +echo "=== Memories Project Git Automation ===" +echo "Current directory: $(pwd)" +if [ "$(basename $(pwd))" != "memories" ]; then + echo "Error: Run this from /Users/maq/memories" + exit 1 +fi + +# Step 1: Initialize Git if needed +if [ ! -d ".git" ]; then + echo "Initializing Git repository..." + git init +else + echo "Git repository already exists." +fi + +# Step 2: Add and commit new files (docs and workflow) +echo "Adding and committing documentation files..." +git add .gitignore README.md .gitea/workflows/deploy.yml +git commit -m "Add .gitignore, README.md, and Gitea CI/CD workflow" || echo "No changes to commit (files may already be added)" + +# Step 3: Add and commit the full project +echo "Adding and committing full project files..." +git add . +git commit -m "Initial commit: Complete Memories app with Leaflet map, backend fixes, enhancements, and docs" || echo "Full project already committed" + +# Step 4: Prompt for Gitea repo URL +echo "" +echo "Enter your Gitea repository URL (e.g., http://localhost:3000/maq/memories.git or git@your-server:maq/memories.git):" +read -r GITEA_URL +if [ -z "$GITEA_URL" ]; then + echo "Error: URL is required." + exit 1 +fi + +# Step 5: Set up remote and push +echo "Setting up remote: $GITEA_URL" +git remote add origin $GITEA_URL 2>/dev/null || git remote set-url origin $GITEA_URL + +echo "Setting branch to main..." +git branch -M main + +echo "Pushing to Gitea (may prompt for credentials)..." +git push -u origin main + +echo "" +echo "=== Success! ===" +echo "Your Memories project is now in Gitea at: $GITEA_URL" +echo "Check the 'Actions' tab for CI/CD workflow results." +echo "Next: Visit the repo in your browser to verify." diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a6c8836 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.9-slim + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/analysis.py b/backend/analysis.py new file mode 100644 index 0000000..30b5048 --- /dev/null +++ b/backend/analysis.py @@ -0,0 +1,146 @@ +import datetime +import numpy as np +from geoalchemy2.elements import WKTElement +from models import Location, Visit, Trip + +class LocationAnalyzer: + def __init__(self, visit_radius_meters: float = 50.0, min_visit_duration_minutes: int = 10): + self.visit_radius_meters = visit_radius_meters + self.min_visit_duration_minutes = min_visit_duration_minutes + + def detect_visits(self, locations: list[Location]) -> list[Visit]: + visits = [] + if not locations: + return visits + + current_visit_locations = [] + for i in range(len(locations)): + if not current_visit_locations: + current_visit_locations.append(locations[i]) + else: + # Calculate distance from the first location of the current potential visit + first_loc = current_visit_locations[0] + dist = self._haversine_distance(first_loc.latitude, first_loc.longitude, + locations[i].latitude, locations[i].longitude) + + if dist <= self.visit_radius_meters: + current_visit_locations.append(locations[i]) + else: + # Check if the accumulated locations form a valid visit + if len(current_visit_locations) > 1: + duration = (current_visit_locations[-1].timestamp - current_visit_locations[0].timestamp).total_seconds() / 60 + if duration >= self.min_visit_duration_minutes: + # Create Visit object with computed fields + start_time = current_visit_locations[0].timestamp + end_time = current_visit_locations[-1].timestamp + + # Compute centroid + lats = np.array([loc.latitude for loc in current_visit_locations]) + lons = np.array([loc.longitude for loc in current_visit_locations]) + centroid_lat = np.mean(lats) + centroid_lon = np.mean(lons) + centroid_point = WKTElement(f'POINT({centroid_lon} {centroid_lat})', srid=4326) + + visit = Visit( + start_time=start_time, + end_time=end_time, + centroid_geom=centroid_point + ) + visits.append(visit) + + current_visit_locations = [locations[i]] # Start a new potential visit + + # Check for a visit at the very end of the locations list + if len(current_visit_locations) > 1: + duration = (current_visit_locations[-1].timestamp - current_visit_locations[0].timestamp).total_seconds() / 60 + if duration >= self.min_visit_duration_minutes: + start_time = current_visit_locations[0].timestamp + end_time = current_visit_locations[-1].timestamp + + lats = np.array([loc.latitude for loc in current_visit_locations]) + lons = np.array([loc.longitude for loc in current_visit_locations]) + centroid_lat = np.mean(lats) + centroid_lon = np.mean(lons) + centroid_point = WKTElement(f'POINT({centroid_lon} {centroid_lat})', srid=4326) + + visit = Visit( + start_time=start_time, + end_time=end_time, + centroid_geom=centroid_point + ) + visits.append(visit) + + return visits + + def detect_trips(self, locations: list[Location], visits: list[Visit]) -> list[Trip]: + trips = [] + if not locations: + return trips + + # For simplicity, identify trips as sequences between visits + # First, get all visit time ranges + visit_time_ranges = [] + for visit in visits: + visit_time_ranges.append((visit.start_time, visit.end_time)) + + current_trip_locations = [] + for loc in locations: + in_visit = False + for start, end in visit_time_ranges: + if start <= loc.timestamp <= end: + in_visit = True + break + + if not in_visit: + current_trip_locations.append(loc) + else: + if len(current_trip_locations) > 1: # At least 2 points for a trip + # Create Trip object + start_time = current_trip_locations[0].timestamp + end_time = current_trip_locations[-1].timestamp + + # Create LINESTRING + coords = [(loc.longitude, loc.latitude) for loc in current_trip_locations] + linestring_str = f'LINESTRING({", ".join([f"{lon} {lat}" for lon, lat in coords])})' + path_line = WKTElement(linestring_str, srid=4326) + + trip = Trip( + start_time=start_time, + end_time=end_time, + transport_mode="unknown", + path_geom=path_line + ) + trips.append(trip) + + current_trip_locations = [] + + # Check for final trip + if len(current_trip_locations) > 1: + start_time = current_trip_locations[0].timestamp + end_time = current_trip_locations[-1].timestamp + + coords = [(loc.longitude, loc.latitude) for loc in current_trip_locations] + linestring_str = f'LINESTRING({", ".join([f"{lon} {lat}" for lon, lat in coords])})' + path_line = WKTElement(linestring_str, srid=4326) + + trip = Trip( + start_time=start_time, + end_time=end_time, + transport_mode="unknown", + path_geom=path_line + ) + trips.append(trip) + + return trips + + def _haversine_distance(self, lat1, lon1, lat2, lon2): + R = 6371000 # Radius of Earth in meters + phi1 = np.radians(lat1) + phi2 = np.radians(lat2) + delta_phi = np.radians(lat2 - lat1) + delta_lambda = np.radians(lon2 - lon1) + + a = np.sin(delta_phi / 2)**2 + np.cos(phi1) * np.cos(phi2) * np.sin(delta_lambda / 2)**2 + c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a)) + + return R * c diff --git a/backend/data_importer.py b/backend/data_importer.py new file mode 100644 index 0000000..e37b2ae --- /dev/null +++ b/backend/data_importer.py @@ -0,0 +1,27 @@ +import json +import datetime +from models import Location + +class GoogleTakeoutImporter: + def import_locations(self, json_filepath: str) -> list[Location]: + locations = [] + try: + with open(json_filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + for loc_data in data.get('locations', []): + timestamp_ms = int(loc_data['timestampMs']) + timestamp = datetime.datetime.fromtimestamp(timestamp_ms / 1000.0) + latitude = loc_data['latitudeE7'] / 1e7 + longitude = loc_data['longitudeE7'] / 1e7 + locations.append({ + 'latitude': latitude, + 'longitude': longitude, + 'timestamp': timestamp + }) + except FileNotFoundError: + print(f"Error: File not found: {json_filepath}") + except json.JSONDecodeError: + print("Error: Invalid JSON format.") + except Exception as e: + print(f"An unexpected error occurred during import: {e}") + return locations diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..9ab8f84 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,172 @@ +from fastapi import FastAPI, UploadFile, File, Depends, HTTPException +from fastapi.responses import JSONResponse, StreamingResponse +from sqlalchemy.orm import Session +from geoalchemy2.elements import WKTElement +import os +import datetime +from datetime import datetime as dt +import json +import redis +from reportlab.lib.pagesizes import letter +from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.lib import colors +from io import BytesIO + +from data_importer import GoogleTakeoutImporter +from analysis import LocationAnalyzer +from models import SessionLocal, engine, Base, Location, Visit, Trip, create_db_tables + +app = FastAPI() + +# Redis connection +redis_client = redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379")) + +# Dependency to get the DB session +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +@app.on_event("startup") +async def startup_event(): + create_db_tables() + +@app.post("/upload") +async def upload_file(file: UploadFile = File(...), db: Session = Depends(get_db)): + if not file.filename: + raise HTTPException(status_code=400, detail="No selected file") + + file_location = f"/tmp/{file.filename}" + try: + with open(file_location, "wb+") as file_object: + file_object.write(await file.read()) + + importer = GoogleTakeoutImporter() + raw_locations = importer.import_locations(file_location) + + new_locations = [] + for loc_data in raw_locations: + if all(k in loc_data for k in ['latitude', 'longitude', 'timestamp']): + if isinstance(loc_data['timestamp'], str): + timestamp = dt.fromisoformat(loc_data['timestamp'].replace('Z', '+00:00')) + else: + timestamp = loc_data['timestamp'] + + point = WKTElement(f'POINT({loc_data["longitude"]} {loc_data["latitude"]})', srid=4326) + new_location = Location( + latitude=loc_data['latitude'], + longitude=loc_data['longitude'], + timestamp=timestamp, + geom=point + ) + new_locations.append(new_location) + + analyzer = LocationAnalyzer() + visits = analyzer.detect_visits(new_locations) + trips = analyzer.detect_trips(new_locations, visits) + + db.add_all(new_locations) + db.add_all(visits) + db.add_all(trips) + db.commit() + + # Invalidate cache + redis_client.flushdb() + + return JSONResponse(content={ + 'message': f'Successfully imported {len(new_locations)} locations, detected {len(visits)} visits and {len(trips)} trips.' + }, status_code=200) + except Exception as e: + if 'db' in locals(): + db.rollback() + raise HTTPException(status_code=500, detail=f"Failed to process file: {str(e)}") + finally: + if os.path.exists(file_location): + os.remove(file_location) + +@app.get("/data/locations") +def get_locations(db: Session = Depends(get_db)): + cache_key = "locations" + cached = redis_client.get(cache_key) + if cached: + return JSONResponse(content=json.loads(cached)) + + locations = db.query(Location).all() + data = {"locations": [{"id": l.id, "lat": l.latitude, "lon": l.longitude, "time": l.timestamp.isoformat()} for l in locations]} + + redis_client.setex(cache_key, 300, json.dumps(data)) # Cache for 5 min + return data + +@app.get("/data/visits") +def get_visits(db: Session = Depends(get_db)): + cache_key = "visits" + cached = redis_client.get(cache_key) + if cached: + return JSONResponse(content=json.loads(cached)) + + visits = db.query(Visit).all() + data = {"visits": [{"id": v.id, "start": v.start_time.isoformat(), "end": v.end_time.isoformat(), + "centroid": str(v.centroid_geom) if v.centroid_geom else None} for v in visits]} + + redis_client.setex(cache_key, 300, json.dumps(data)) + return data + +@app.get("/data/trips") +def get_trips(db: Session = Depends(get_db)): + cache_key = "trips" + cached = redis_client.get(cache_key) + if cached: + return JSONResponse(content=json.loads(cached)) + + trips = db.query(Trip).all() + data = {"trips": [{"id": t.id, "start": t.start_time.isoformat(), "end": t.end_time.isoformat(), + "mode": t.transport_mode, "path": str(t.path_geom) if t.path_geom else None} for t in trips]} + + redis_client.setex(cache_key, 300, json.dumps(data)) + return data + +@app.get("/export/report") +def export_report(db: Session = Depends(get_db)): + # Fetch data + visits = db.query(Visit).count() + trips = db.query(Trip).count() + locations = db.query(Location).count() + + buffer = BytesIO() + doc = SimpleDocTemplate(buffer, pagesize=letter) + styles = getSampleStyleSheet() + story = [] + + # Title + title = Paragraph("Travel Memories Report", styles['Title']) + story.append(title) + story.append(Spacer(1, 12)) + + # Stats + stats_data = [ + ['Metric', 'Count'], + ['Total Locations', locations], + ['Detected Visits', visits], + ['Detected Trips', trips] + ] + stats_table = Table(stats_data) + stats_table.setStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.grey), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 14), + ('BOTTOMPADDING', (0, 0), (-1, 0), 12), + ('BACKGROUND', (0, 1), (-1, -1), colors.beige), + ('GRID', (0, 0), (-1, -1), 1, colors.black) + ]) + story.append(stats_table) + + # Build PDF + doc.build(story) + buffer.seek(0) + + return StreamingResponse(buffer, media_type="application/pdf", headers={"Content-Disposition": "attachment; filename=travel-report.pdf"}) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..15e0e28 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,65 @@ +from sqlalchemy import create_engine, Column, Integer, String, DateTime, Float, ForeignKey +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, relationship +from geoalchemy2 import Geometry +import datetime +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://user:password@db:5432/memories_db") + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +class Location(Base): + __tablename__ = "locations" + + id = Column(Integer, primary_key=True, index=True) + latitude = Column(Float, nullable=False) + longitude = Column(Float, nullable=False) + timestamp = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) + geom = Column(Geometry(geometry_type='POINT', srid=4326), nullable=False) + + # Optional: Link to a user if multi-user is implemented + # user_id = Column(Integer, ForeignKey("users.id")) + # user = relationship("User") + + def __repr__(self): + return f"" + +class Visit(Base): + __tablename__ = "visits" + + id = Column(Integer, primary_key=True, index=True) + start_time = Column(DateTime, nullable=False) + end_time = Column(DateTime, nullable=False) + # A visit can be represented by a polygon or a centroid, for simplicity we'll use a point for now + centroid_geom = Column(Geometry(geometry_type='POINT', srid=4326), nullable=True) + + # Optional: Link to a user + # user_id = Column(Integer, ForeignKey("users.id")) + # user = relationship("User") + + def __repr__(self): + return f"" + +class Trip(Base): + __tablename__ = "trips" + + id = Column(Integer, primary_key=True, index=True) + start_time = Column(DateTime, nullable=False) + end_time = Column(DateTime, nullable=False) + transport_mode = Column(String, default="unknown") + # A trip can be represented by a LineString + path_geom = Column(Geometry(geometry_type='LINESTRING', srid=4326), nullable=True) + + # Optional: Link to a user + # user_id = Column(Integer, ForeignKey("users.id")) + # user = relationship("User") + + def __repr__(self): + return f"" + +# Function to create all tables +def create_db_tables(): + Base.metadata.create_all(bind=engine) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..e4db78b --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,13 @@ +fastapi +uvicorn +SQLAlchemy +psycopg2-binary +python-dotenv +pika +pandas +numpy +scipy +GeoAlchemy2 +python-multipart +redis +reportlab diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a361167 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,77 @@ +version: '3.8' + +services: + db: + image: postgis/postgis:16-3.4 + restart: always + environment: + POSTGRES_DB: memories_db + POSTGRES_USER: user + POSTGRES_PASSWORD: password + volumes: + - db_data:/var/lib/postgresql/data + ports: + - "5432:5432" + + rabbitmq: + image: rabbitmq:3-management + restart: always + ports: + - "5672:5672" + - "15672:15672" # Management UI + environment: + RABBITMQ_DEFAULT_USER: user + RABBITMQ_DEFAULT_PASS: password + + redis: + image: redis:alpine + restart: always + ports: + - "6379:6379" + command: redis-server --appendonly yes + volumes: + - redis_data:/data + + backend: + build: ./backend + restart: always + command: uvicorn main:app --host 0.0.0.0 --port 8000 + volumes: + - ./backend:/app + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql://user:password@db:5432/memories_db + RABBITMQ_URL: amqp://user:password@rabbitmq:5672/%2F + REDIS_URL: redis://redis:6379 + depends_on: + - db + - rabbitmq + - redis + + frontend: + build: ./frontend + restart: always + ports: + - "3000:80" # Serve React app on port 80 inside container, map to 3000 outside + depends_on: + - backend + + # Optional: Analysis worker for background tasks + worker: + build: ./backend + command: python worker.py # Need to create worker.py + volumes: + - ./backend:/app + environment: + DATABASE_URL: postgresql://user:password@db:5432/memories_db + RABBITMQ_URL: amqp://user:password@rabbitmq:5672/%2F + REDIS_URL: redis://redis:6379 + depends_on: + - db + - rabbitmq + - redis + +volumes: + db_data: + redis_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b0dd38b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine as build +WORKDIR /app +COPY package.json ./ +COPY . . +RUN npm install +RUN npm run build + +FROM nginx:stable-alpine +COPY --from=build /app/build /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c9a1dd7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,39 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-scripts": "5.0.1", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@mui/material": "^5.14.18", + "mapbox-gl": "^2.15.0", + "react-router-dom": "^6.20.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..0abc90b --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + Memories App + + + +
+ + diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..f7e46d6 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "Memories", + "name": "Travel Memories App", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/frontend/src/App.js b/frontend/src/App.js new file mode 100644 index 0000000..fb01ece --- /dev/null +++ b/frontend/src/App.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { Typography, Container, Box, AppBar, Toolbar, Button } from '@mui/material'; +import { Routes, Route, Link } from 'react-router-dom'; +import Login from './components/Login'; +import Register from './components/Register'; +import Dashboard from './components/Dashboard'; +import UploadForm from './components/UploadForm'; +import MapView from './components/MapView'; +import ExportView from './components/ExportView'; + +function App() { + return ( + + + + + Memories + + + + + + + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + Welcome to Memories! + + + Your journey to narrative-driven travel logs begins here. + + + + + + + } /> + + + + ); +} + +export default App; diff --git a/frontend/src/components/Dashboard.js b/frontend/src/components/Dashboard.js new file mode 100644 index 0000000..b6dfe83 --- /dev/null +++ b/frontend/src/components/Dashboard.js @@ -0,0 +1,77 @@ +import React, { useState, useEffect } from 'react'; +import { Box, Typography, Card, CardContent, Button, Grid } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import axios from 'axios'; + +const Dashboard = () => { + const [stats, setStats] = useState({ locations: 0, visits: 0, trips: 0 }); + const navigate = useNavigate(); + + useEffect(() => { + loadStats(); + }, []); + + const loadStats = async () => { + try { + const [locRes, visitsRes, tripsRes] = await Promise.all([ + axios.get('http://localhost:8000/data/locations'), // Assume endpoint exists or add it + axios.get('http://localhost:8000/data/visits'), + axios.get('http://localhost:8000/data/trips') + ]); + setStats({ + locations: locRes.data.locations?.length || 0, + visits: visitsRes.data.visits?.length || 0, + trips: tripsRes.data.trips?.length || 0 + }); + } catch (err) { + console.error('Failed to load stats:', err); + } + }; + + return ( + + + Dashboard + + + + + + Total Locations + {stats.locations} + + + + + + + Visits + {stats.visits} + + + + + + + Trips + {stats.trips} + + + + + + + + + + + ); +}; + +export default Dashboard; diff --git a/frontend/src/components/ExportView.js b/frontend/src/components/ExportView.js new file mode 100644 index 0000000..604ceae --- /dev/null +++ b/frontend/src/components/ExportView.js @@ -0,0 +1,42 @@ +import React from 'react'; +import { Box, Typography, Button, Alert } from '@mui/material'; +import axios from 'axios'; + +const ExportView = () => { + const [message, setMessage] = React.useState(''); + + const handleExport = async () => { + try { + const response = await axios.get('http://localhost:8000/export/report', { responseType: 'blob' }); + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `travel-report-${new Date().toISOString().split('T')[0]}.pdf`); + document.body.appendChild(link); + link.click(); + link.remove(); + setMessage('Report exported successfully!'); + } catch (err) { + setMessage(`Export failed: ${err.response?.data?.detail || err.message}`); + } + }; + + return ( + + + Export Travel Report + + + Generate a PDF report of your travels. + + + {message && + {message} + } + + ); +}; + +export default ExportView; diff --git a/frontend/src/components/Login.js b/frontend/src/components/Login.js new file mode 100644 index 0000000..da06d7a --- /dev/null +++ b/frontend/src/components/Login.js @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; +import { TextField, Button, Typography, Container, Box, Paper } from '@mui/material'; + +function Login() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const handleSubmit = (event) => { + event.preventDefault(); + console.log('Login attempt with:', { email, password }); + // Here you would typically send a request to your backend + }; + + return ( + + + + Sign in + + + setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + + + + + ); +} + +export default Login; diff --git a/frontend/src/components/MapView.js b/frontend/src/components/MapView.js new file mode 100644 index 0000000..84f40ee --- /dev/null +++ b/frontend/src/components/MapView.js @@ -0,0 +1,107 @@ +import React, { useEffect, useRef } from 'react'; +import mapboxgl from 'mapbox-gl'; +import { Box, Typography } from '@mui/material'; +import axios from 'axios'; + +mapboxgl.accessToken = 'YOUR_MAPBOX_TOKEN'; // Replace with your Mapbox token + +const MapView = () => { + const mapContainer = useRef(null); + const map = useRef(null); + const [dataLoaded, setDataLoaded] = React.useState(false); + + useEffect(() => { + if (map.current) return; // initialize map only once + + map.current = new mapboxgl.Map({ + container: mapContainer.current, + style: 'mapbox://styles/mapbox/streets-v12', + center: [-74.5, 40], // Initial center (NYC as example) + zoom: 9 + }); + + map.current.on('load', () => { + loadData(); + }); + }, []); + + const loadData = async () => { + try { + const [visitsRes, tripsRes] = await Promise.all([ + axios.get('http://localhost:8000/data/visits'), + axios.get('http://localhost:8000/data/trips') + ]); + + const visits = visitsRes.data.visits; + const trips = tripsRes.data.trips; + + // Add visits as circles + map.current.addSource('visits', { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: visits.map(v => { + const coords = v.centroid.match(/POINT\\(([^)]+)\\)/)[1].split(' ').map(Number); + return { + type: 'Feature', + geometry: { type: 'Point', coordinates: coords }, + properties: { start: v.start, end: v.end } + }; + }) + } + }); + + map.current.addLayer({ + id: 'visits', + type: 'circle', + source: 'visits', + paint: { + 'circle-radius': 10, + 'circle-color': '#007cbf' + } + }); + + // Add trips as lines + if (trips.length > 0) { + map.current.addSource('trips', { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: trips.map(t => { + // Parse LINESTRING + const coordsStr = t.path.match(/LINESTRING\\((.*)\\)/)[1]; + const coords = coordsStr.split(', ').map(pair => pair.split(' ').map(Number)); + return { + type: 'Feature', + geometry: { type: 'LineString', coordinates: coords }, + properties: { mode: t.mode, start: t.start, end: t.end } + }; + }).filter(f => f.geometry.coordinates.length > 1) + } + }); + + map.current.addLayer({ + id: 'trips', + type: 'line', + source: 'trips', + layout: { 'line-join': 'round', 'line-cap': 'round' }, + paint: { 'line-color': '#f03b20', 'line-width': 4 } + }); + } + + setDataLoaded(true); + } catch (err) { + console.error('Failed to load data:', err); + } + }; + + return ( + + Travel Map +
+ {!dataLoaded && Loading map data...} + + ); +}; + +export default MapView; diff --git a/frontend/src/components/Register.js b/frontend/src/components/Register.js new file mode 100644 index 0000000..46932d8 --- /dev/null +++ b/frontend/src/components/Register.js @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { TextField, Button, Typography, Container, Box, Paper } from '@mui/material'; + +function Register() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + const handleSubmit = (event) => { + event.preventDefault(); + if (password !== confirmPassword) { + alert("Passwords don't match!"); + return; + } + console.log('Register attempt with:', { email, password }); + // Here you would typically send a request to your backend + }; + + return ( + + + + Sign up + + + setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + setConfirmPassword(e.target.value)} + /> + + + + + ); +} + +export default Register; diff --git a/frontend/src/components/UploadForm.js b/frontend/src/components/UploadForm.js new file mode 100644 index 0000000..42965b3 --- /dev/null +++ b/frontend/src/components/UploadForm.js @@ -0,0 +1,55 @@ +import React, { useState } from 'react'; +import { Button, Box, Typography, TextField, Alert } from '@mui/material'; +import axios from 'axios'; + +const UploadForm = () => { + const [file, setFile] = useState(null); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + + const handleFileChange = (e) => { + setFile(e.target.files[0]); + }; + + const handleUpload = async () => { + if (!file) { + setError('Please select a file'); + return; + } + + const formData = new FormData(); + formData.append('file', file); + + try { + setError(''); + const response = await axios.post('http://localhost:8000/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + setMessage(response.data.message); + } catch (err) { + setError(`Upload failed: ${err.response?.data?.detail || err.message}`); + } + }; + + return ( + + + Upload Location Data + + + + {message && {message}} + {error && {error}} + + ); +}; + +export default UploadForm; diff --git a/frontend/src/index.js b/frontend/src/index.js new file mode 100644 index 0000000..6ab668a --- /dev/null +++ b/frontend/src/index.js @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import { BrowserRouter } from 'react-router-dom'; + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + + + +); diff --git a/location_visualizer/__pycache__/location_data_manager.cpython-312.pyc b/location_visualizer/__pycache__/location_data_manager.cpython-312.pyc new file mode 100644 index 0000000..776e240 Binary files /dev/null and b/location_visualizer/__pycache__/location_data_manager.cpython-312.pyc differ diff --git a/location_visualizer/__pycache__/location_history_visualizer.cpython-312.pyc b/location_visualizer/__pycache__/location_history_visualizer.cpython-312.pyc new file mode 100644 index 0000000..ae1f18a Binary files /dev/null and b/location_visualizer/__pycache__/location_history_visualizer.cpython-312.pyc differ diff --git a/location_visualizer/location_data_manager.py b/location_visualizer/location_data_manager.py new file mode 100644 index 0000000..97a3902 --- /dev/null +++ b/location_visualizer/location_data_manager.py @@ -0,0 +1,49 @@ +import googlemaps +import json +import datetime + +class LocationDataManager: + def __init__(self, api_key): + self.gmaps = googlemaps.Client(key=api_key) + + def get_coordinates(self, address): + try: + geocode_result = self.gmaps.geocode(address) + if geocode_result: + location = geocode_result[0]['geometry']['location'] + return location['lat'], location['lng'] + else: + return None, None + except Exception as e: + print(f"Error during geocoding: {e}") + return None, None + + def get_address(self, latitude, longitude): + try: + reverse_geocode_result = self.gmaps.reverse_geocode((latitude, longitude)) + if reverse_geocode_result: + return reverse_geocode_result[0]['formatted_address'] + else: + return None + except Exception as e: + print(f"Error during reverse geocoding: {e}") + return None + + def load_location_history(self, json_filepath): + locations = [] + try: + with open(json_filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + for location in data.get('locations', []): + timestamp_ms = int(location['timestampMs']) + timestamp = datetime.datetime.fromtimestamp(timestamp_ms / 1000.0) + lat = location['latitudeE7'] / 1e7 + lng = location['longitudeE7'] / 1e7 + locations.append({'timestamp': timestamp, 'lat': lat, 'lng': lng}) + except FileNotFoundError: + print(f"Error: File not found: {json_filepath}") + except json.JSONDecodeError: + print("Error: Invalid JSON format.") + except Exception as e: + print(f"An unexpected error occurred: {e}") + return locations \ No newline at end of file diff --git a/location_visualizer/location_history_visualizer.py b/location_visualizer/location_history_visualizer.py new file mode 100644 index 0000000..14bdf5c --- /dev/null +++ b/location_visualizer/location_history_visualizer.py @@ -0,0 +1,42 @@ +import tkinter as tk +from tkinter import ttk +from location_data_manager import LocationDataManager + +class LocationHistoryVisualizer: + def __init__(self, api_key, json_filepath): + self.root = tk.Tk() + self.root.title("Location History Visualizer") + + self.location_data_manager = LocationDataManager(api_key) + self.json_filepath = json_filepath + + self.create_widgets() + self.load_and_display_location_history() + + def create_widgets(self): + history_label = ttk.Label(self.root, text="Location History:") + history_label.grid(row=0, column=0, padx=5, pady=5) + + self.history_display = tk.Text(self.root, wrap=tk.WORD, width=60, height=20) + self.history_display.grid(row=1, column=0, columnspan=2, padx=5, pady=5) + + def load_and_display_location_history(self): + locations = self.location_data_manager.load_location_history(self.json_filepath) + + if not locations: + self.history_display.insert(tk.END, "No location history data found or an error occurred.") + return + + for location in locations: + try: + address = self.location_data_manager.get_address(location['lat'], location['lng']) + if address: + self.history_display.insert(tk.END, f"{location['timestamp']}: {address}\n") + else: + self.history_display.insert(tk.END, f"{location['timestamp']}: ({location['lat']}, {location['lng']})\n") + except Exception as e: + print(f"Error getting address: {e}") + self.history_display.insert(tk.END, f"{location['timestamp']}: ({location['lat']}, {location['lng']}) - Error getting address\n") + + def run(self): + self.root.mainloop() diff --git a/location_visualizer/main.py b/location_visualizer/main.py new file mode 100644 index 0000000..15e12b9 --- /dev/null +++ b/location_visualizer/main.py @@ -0,0 +1,12 @@ +import os +from location_history_visualizer import LocationHistoryVisualizer + +if __name__ == "__main__": + google_maps_api_key = os.environ.get("GOOGLE_MAPS_API_KEY") + json_filepath = "/Users/maq/Desktop/Desktop2.7.2025/Desktop/MAYTE FILES2/takeout folders/New Folder With Items/LocationHistory.json" # Replace with your actual file path + + if google_maps_api_key: + app = LocationHistoryVisualizer(google_maps_api_key, json_filepath) + app.run() + else: + print("Please set the GOOGLE_MAPS_API_KEY environment variable.") \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..e09cdd9 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,16 @@ + + + + + + Memories App + + +

Welcome to Memories!

+

Upload your Google Takeout Location History JSON file to get started.

+
+ + +
+ + \ No newline at end of file