iOS Safari can discard page JS state when backgrounding to open the camera. Saving gpsCoords to sessionStorage ensures the coords survive the round-trip and are still available when handleUpload runs after the photo is taken. Also change the server-side geocode fallback from empty string to raw lat/lon so the location column in the Excel sheet is never silently empty. https://claude.ai/code/session_015myTTMs6yDsAGarATe5ePZ
277 lines
8 KiB
Rust
277 lines
8 KiB
Rust
use axum::{
|
|
body::Body,
|
|
extract::{Multipart, Query, State},
|
|
http::{header, StatusCode},
|
|
response::{Html, Json, Response},
|
|
routing::{get, post},
|
|
Router,
|
|
};
|
|
use chrono::Local;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::sync::{Arc, Mutex};
|
|
use tokio::fs;
|
|
|
|
mod claude;
|
|
mod excel;
|
|
mod geocode;
|
|
|
|
#[derive(Default)]
|
|
struct Session {
|
|
reading_1: Option<u32>,
|
|
reading_2: Option<u32>,
|
|
date: Option<String>,
|
|
time_1: Option<String>, // start of ride 1
|
|
time_2: Option<String>, // end of ride 1 / start of ride 2
|
|
location: Option<String>,
|
|
}
|
|
|
|
type AppState = Arc<Mutex<Session>>;
|
|
|
|
#[derive(Serialize, Deserialize, Clone)]
|
|
pub struct LogEntry {
|
|
pub date: String,
|
|
pub time: String,
|
|
pub odometer_start: u32,
|
|
pub odometer_end: u32,
|
|
pub trip_km: u32,
|
|
pub location: String,
|
|
pub notes: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct UploadQuery {
|
|
step: u8,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct UploadResponse {
|
|
step: u8,
|
|
reading: u32,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
location: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
ride1_km: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
ride2_km: Option<u32>,
|
|
done: bool,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
let state: AppState = Arc::new(Mutex::new(Session::default()));
|
|
|
|
let app = Router::new()
|
|
.route("/", get(index))
|
|
.route("/upload", post(upload))
|
|
.route("/download", get(download))
|
|
.route("/reset", post(reset_session))
|
|
.with_state(state);
|
|
|
|
let addr = "0.0.0.0:3000";
|
|
println!("Listening on http://{addr}");
|
|
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
|
axum::serve(listener, app).await.unwrap();
|
|
}
|
|
|
|
async fn index() -> Html<&'static str> {
|
|
Html(include_str!("../templates/index.html"))
|
|
}
|
|
|
|
async fn reset_session(State(state): State<AppState>) -> StatusCode {
|
|
*state.lock().unwrap() = Session::default();
|
|
StatusCode::OK
|
|
}
|
|
|
|
async fn upload(
|
|
State(state): State<AppState>,
|
|
Query(q): Query<UploadQuery>,
|
|
mut multipart: Multipart,
|
|
) -> Result<Json<UploadResponse>, (StatusCode, String)> {
|
|
let mut image_bytes: Vec<u8> = Vec::new();
|
|
let mut lat: Option<f64> = None;
|
|
let mut lon: Option<f64> = None;
|
|
|
|
while let Some(field) = multipart
|
|
.next_field()
|
|
.await
|
|
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
|
|
{
|
|
let name = field.name().unwrap_or("").to_string();
|
|
match name.as_str() {
|
|
"image" => {
|
|
image_bytes = field
|
|
.bytes()
|
|
.await
|
|
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
|
|
.to_vec();
|
|
}
|
|
"lat" => {
|
|
lat = field.text().await.ok().and_then(|t| t.parse().ok());
|
|
}
|
|
"lon" => {
|
|
lon = field.text().await.ok().and_then(|t| t.parse().ok());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
if image_bytes.is_empty() {
|
|
return Err((StatusCode::BAD_REQUEST, "No image provided".into()));
|
|
}
|
|
|
|
let reading = claude::read_odometer(&image_bytes)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let now = Local::now();
|
|
let date_str = now.format("%Y-%m-%d").to_string();
|
|
let time_str = now.format("%H:%M").to_string();
|
|
|
|
match q.step {
|
|
1 => {
|
|
// Reverse-geocode before acquiring the lock (async operation)
|
|
let location = if let (Some(la), Some(lo)) = (lat, lon) {
|
|
geocode::reverse_geocode(la, lo)
|
|
.await
|
|
.unwrap_or_else(|_| format!("{:.5}, {:.5}", la, lo))
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
{
|
|
let mut s = state.lock().unwrap();
|
|
s.reading_1 = Some(reading);
|
|
s.date = Some(date_str);
|
|
s.time_1 = Some(time_str);
|
|
s.location = Some(location.clone());
|
|
}
|
|
|
|
Ok(Json(UploadResponse {
|
|
step: 1,
|
|
reading,
|
|
location: Some(location),
|
|
ride1_km: None,
|
|
ride2_km: None,
|
|
done: false,
|
|
}))
|
|
}
|
|
|
|
2 => {
|
|
let (reading_1, location) = {
|
|
let mut s = state.lock().unwrap();
|
|
s.reading_2 = Some(reading);
|
|
s.time_2 = Some(time_str);
|
|
(s.reading_1, s.location.clone())
|
|
};
|
|
|
|
let ride1_km = reading_1.map(|r1| reading.saturating_sub(r1));
|
|
|
|
Ok(Json(UploadResponse {
|
|
step: 2,
|
|
reading,
|
|
location,
|
|
ride1_km,
|
|
ride2_km: None,
|
|
done: false,
|
|
}))
|
|
}
|
|
|
|
3 => {
|
|
// Read everything we need from the session and release the lock
|
|
let (r1, r2, date, time_1, time_2, location) = {
|
|
let s = state.lock().unwrap();
|
|
(
|
|
s.reading_1,
|
|
s.reading_2,
|
|
s.date.clone(),
|
|
s.time_1.clone(),
|
|
s.time_2.clone(),
|
|
s.location.clone().unwrap_or_default(),
|
|
)
|
|
};
|
|
|
|
let (r1, r2) = match (r1, r2) {
|
|
(Some(a), Some(b)) => (a, b),
|
|
_ => {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
"Session incomplete — restart from step 1".into(),
|
|
))
|
|
}
|
|
};
|
|
|
|
let r3 = reading;
|
|
let entries = vec![
|
|
LogEntry {
|
|
date: date.clone().unwrap_or_default(),
|
|
time: time_1.unwrap_or_default(),
|
|
odometer_start: r1,
|
|
odometer_end: r2,
|
|
trip_km: r2.saturating_sub(r1),
|
|
location: location.clone(),
|
|
notes: "Ride 1".into(),
|
|
},
|
|
LogEntry {
|
|
date: date.unwrap_or_default(),
|
|
time: time_2.unwrap_or_default(),
|
|
odometer_start: r2,
|
|
odometer_end: r3,
|
|
trip_km: r3.saturating_sub(r2),
|
|
location: location.clone(),
|
|
notes: "Ride 2".into(),
|
|
},
|
|
];
|
|
|
|
append_entries(&entries)
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
*state.lock().unwrap() = Session::default();
|
|
|
|
Ok(Json(UploadResponse {
|
|
step: 3,
|
|
reading: r3,
|
|
location: Some(location),
|
|
ride1_km: Some(r2.saturating_sub(r1)),
|
|
ride2_km: Some(r3.saturating_sub(r2)),
|
|
done: true,
|
|
}))
|
|
}
|
|
|
|
_ => Err((StatusCode::BAD_REQUEST, "step must be 1, 2, or 3".into())),
|
|
}
|
|
}
|
|
|
|
async fn download() -> Result<Response, (StatusCode, String)> {
|
|
let entries = load_entries().await.unwrap_or_default();
|
|
let bytes = excel::generate(&entries)
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let response = Response::builder()
|
|
.header(
|
|
header::CONTENT_TYPE,
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
)
|
|
.header(
|
|
header::CONTENT_DISPOSITION,
|
|
"attachment; filename=\"mileage_log.xlsx\"",
|
|
)
|
|
.body(Body::from(bytes))
|
|
.unwrap();
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
async fn append_entries(new_entries: &[LogEntry]) -> anyhow::Result<()> {
|
|
let mut all = load_entries().await.unwrap_or_default();
|
|
all.extend_from_slice(new_entries);
|
|
fs::write("mileage_log.json", serde_json::to_string_pretty(&all)?).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn load_entries() -> anyhow::Result<Vec<LogEntry>> {
|
|
match fs::read_to_string("mileage_log.json").await {
|
|
Ok(s) => Ok(serde_json::from_str(&s)?),
|
|
Err(_) => Ok(vec![]),
|
|
}
|
|
}
|