diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3e48b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +mileage_log.json +mileage_log.xlsx +.env diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cd2bd98 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "driverthing" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "driverthing" +path = "src/main.rs" + +[dependencies] +axum = { version = "0.7", features = ["multipart"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", features = ["json"] } +base64 = "0.22" +chrono = "0.4" +rust_xlsxwriter = "0.75" +anyhow = "1" diff --git a/src/claude.rs b/src/claude.rs new file mode 100644 index 0000000..b4ccea2 --- /dev/null +++ b/src/claude.rs @@ -0,0 +1,69 @@ +use anyhow::Context; +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde_json::{json, Value}; + +pub async fn read_odometer(image_bytes: &[u8]) -> anyhow::Result { + let api_key = + std::env::var("ANTHROPIC_API_KEY").context("ANTHROPIC_API_KEY env var not set")?; + + let media_type = detect_media_type(image_bytes); + let b64 = STANDARD.encode(image_bytes); + + let client = reqwest::Client::new(); + let body = json!({ + "model": "claude-haiku-4-5-20251001", + "max_tokens": 64, + "messages": [{ + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": b64 + } + }, + { + "type": "text", + "text": "What is the odometer reading in this photo? Reply with only the number (digits only, no spaces, dots, or commas)." + } + ] + }] + }); + + let resp: Value = client + .post("https://api.anthropic.com/v1/messages") + .header("x-api-key", &api_key) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .json(&body) + .send() + .await + .context("Failed to reach Claude API")? + .json() + .await + .context("Failed to parse Claude API response")?; + + let text = resp["content"][0]["text"] + .as_str() + .context("No text in Claude response")?; + + // Keep only digits in case the model adds any punctuation + let digits: String = text.chars().filter(|c| c.is_ascii_digit()).collect(); + digits + .parse::() + .with_context(|| format!("Could not parse odometer number from: {text:?}")) +} + +fn detect_media_type(bytes: &[u8]) -> &'static str { + if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + "image/jpeg" + } else if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) { + "image/png" + } else if bytes.get(8..12) == Some(b"WEBP") { + "image/webp" + } else { + "image/jpeg" // safe fallback for phone cameras + } +} diff --git a/src/excel.rs b/src/excel.rs new file mode 100644 index 0000000..9f6654d --- /dev/null +++ b/src/excel.rs @@ -0,0 +1,37 @@ +use crate::LogEntry; +use rust_xlsxwriter::{Format, Workbook, XlsxError}; + +pub fn generate(entries: &[LogEntry]) -> Result, XlsxError> { + let mut workbook = Workbook::new(); + let sheet = workbook.add_worksheet(); + + let bold = Format::new().set_bold(); + + let headers = [ + "Date", + "Time", + "Odometer start (km)", + "Odometer end (km)", + "Trip (km)", + "Location", + "Notes", + ]; + for (col, h) in headers.iter().enumerate() { + sheet.write_with_format(0, col as u16, *h, &bold)?; + } + + for (row, e) in entries.iter().enumerate() { + let r = (row + 1) as u32; + sheet.write(r, 0, e.date.as_str())?; + sheet.write(r, 1, e.time.as_str())?; + sheet.write(r, 2, e.odometer_start as f64)?; + sheet.write(r, 3, e.odometer_end as f64)?; + sheet.write(r, 4, e.trip_km as f64)?; + sheet.write(r, 5, e.location.as_str())?; + sheet.write(r, 6, e.notes.as_str())?; + } + + sheet.autofit(); + + workbook.save_to_buffer() +} diff --git a/src/geocode.rs b/src/geocode.rs new file mode 100644 index 0000000..227d031 --- /dev/null +++ b/src/geocode.rs @@ -0,0 +1,41 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +struct NominatimResponse { + display_name: Option, + address: Option, +} + +#[derive(Deserialize)] +struct NominatimAddress { + city: Option, + town: Option, + village: Option, + country_code: Option, +} + +pub async fn reverse_geocode(lat: f64, lon: f64) -> anyhow::Result { + let client = reqwest::Client::new(); + let resp: NominatimResponse = client + .get("https://nominatim.openstreetmap.org/reverse") + .query(&[ + ("lat", lat.to_string()), + ("lon", lon.to_string()), + ("format", "json".to_string()), + ]) + .header("User-Agent", "Driverthing/1.0") + .send() + .await? + .json() + .await?; + + if let Some(addr) = resp.address { + let city = addr.city.or(addr.town).or(addr.village).unwrap_or_default(); + let cc = addr.country_code.unwrap_or_default().to_uppercase(); + if !city.is_empty() { + return Ok(format!("{city}, {cc}")); + } + } + + Ok(resp.display_name.unwrap_or_default()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..42c89c2 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,277 @@ +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, + reading_2: Option, + date: Option, + time_1: Option, // start of ride 1 + time_2: Option, // end of ride 1 / start of ride 2 + location: Option, +} + +type AppState = Arc>; + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + ride1_km: Option, + #[serde(skip_serializing_if = "Option::is_none")] + ride2_km: Option, + 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) -> StatusCode { + *state.lock().unwrap() = Session::default(); + StatusCode::OK +} + +async fn upload( + State(state): State, + Query(q): Query, + mut multipart: Multipart, +) -> Result, (StatusCode, String)> { + let mut image_bytes: Vec = Vec::new(); + let mut lat: Option = None; + let mut lon: Option = 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_default() + } 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 { + 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> { + match fs::read_to_string("mileage_log.json").await { + Ok(s) => Ok(serde_json::from_str(&s)?), + Err(_) => Ok(vec![]), + } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..2b5127c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,329 @@ + + + + + + Driverthing + + + +

Driverthing

+

Mileage tracker

+ +
+
+
+
+
+
+
+
+ + + +