diff --git a/CLAUDE.md b/CLAUDE.md index 2e3b17f..4ba6f71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -250,8 +250,31 @@ Optional: WATCH_QUICK_INTERVAL_SECONDS=60 # Quick scan interval WATCH_FULL_INTERVAL_SECONDS=3600 # Full scan interval OTLP_OTLS_ENDPOINT=http://... # OpenTelemetry collector (release builds) + +# AI Insights Configuration +OLLAMA_PRIMARY_URL=http://desktop:11434 # Primary Ollama server (e.g., desktop) +OLLAMA_FALLBACK_URL=http://server:11434 # Fallback Ollama server (optional, always-on) +OLLAMA_PRIMARY_MODEL=nemotron-3-nano:30b # Model for primary server (default: nemotron-3-nano:30b) +OLLAMA_FALLBACK_MODEL=llama3.2:3b # Model for fallback server (optional, uses primary if not set) +SMS_API_URL=http://localhost:8000 # SMS message API endpoint (default: localhost:8000) +SMS_API_TOKEN=your-api-token # SMS API authentication token (optional) ``` +**AI Insights Fallback Behavior:** +- Primary server is tried first with its configured model (5-second connection timeout) +- On connection failure, automatically falls back to secondary server with its model (if configured) +- If `OLLAMA_FALLBACK_MODEL` not set, uses same model as primary server on fallback +- Total request timeout is 120 seconds to accommodate slow LLM inference +- Logs indicate which server and model was used (info level) and failover attempts (warn level) +- Backwards compatible: `OLLAMA_URL` and `OLLAMA_MODEL` still supported as fallbacks + +**Model Discovery:** +The `OllamaClient` provides methods to query available models: +- `OllamaClient::list_models(url)` - Returns list of all models on a server +- `OllamaClient::is_model_available(url, model_name)` - Checks if a specific model exists + +This allows runtime verification of model availability before generating insights. + ## Dependencies of Note - **actix-web**: HTTP framework diff --git a/src/ai/handlers.rs b/src/ai/handlers.rs index 3b74a49..a7cfcc2 100644 --- a/src/ai/handlers.rs +++ b/src/ai/handlers.rs @@ -1,13 +1,16 @@ use actix_web::{HttpResponse, Responder, delete, get, post, web}; use serde::{Deserialize, Serialize}; -use crate::ai::InsightGenerator; +use crate::ai::{InsightGenerator, OllamaClient}; use crate::data::Claims; use crate::database::InsightDao; +use crate::utils::normalize_path; #[derive(Debug, Deserialize)] pub struct GeneratePhotoInsightRequest { pub file_path: String, + #[serde(default)] + pub model: Option, } #[derive(Debug, Deserialize)] @@ -25,6 +28,20 @@ pub struct PhotoInsightResponse { pub model_version: String, } +#[derive(Debug, Serialize)] +pub struct AvailableModelsResponse { + pub primary: ServerModels, + #[serde(skip_serializing_if = "Option::is_none")] + pub fallback: Option, +} + +#[derive(Debug, Serialize)] +pub struct ServerModels { + pub url: String, + pub models: Vec, + pub default_model: String, +} + /// POST /insights/generate - Generate insight for a specific photo #[post("/insights/generate")] pub async fn generate_insight_handler( @@ -32,14 +49,17 @@ pub async fn generate_insight_handler( request: web::Json, insight_generator: web::Data, ) -> impl Responder { + let normalized_path = normalize_path(&request.file_path); + log::info!( - "Manual insight generation triggered for photo: {}", - request.file_path + "Manual insight generation triggered for photo: {} with model: {:?}", + normalized_path, + request.model ); - // Generate insight + // Generate insight with optional custom model match insight_generator - .generate_insight_for_photo(&request.file_path) + .generate_insight_for_photo_with_model(&normalized_path, request.model.clone()) .await { Ok(()) => HttpResponse::Ok().json(serde_json::json!({ @@ -62,12 +82,13 @@ pub async fn get_insight_handler( query: web::Query, insight_dao: web::Data>>, ) -> impl Responder { - log::debug!("Fetching insight for {}", query.path); + let normalized_path = normalize_path(&query.path); + log::debug!("Fetching insight for {}", normalized_path); let otel_context = opentelemetry::Context::new(); let mut dao = insight_dao.lock().expect("Unable to lock InsightDao"); - match dao.get_insight(&otel_context, &query.path) { + match dao.get_insight(&otel_context, &normalized_path) { Ok(Some(insight)) => { let response = PhotoInsightResponse { id: insight.id, @@ -98,12 +119,13 @@ pub async fn delete_insight_handler( query: web::Query, insight_dao: web::Data>>, ) -> impl Responder { - log::info!("Deleting insight for {}", query.path); + let normalized_path = normalize_path(&query.path); + log::info!("Deleting insight for {}", normalized_path); let otel_context = opentelemetry::Context::new(); let mut dao = insight_dao.lock().expect("Unable to lock InsightDao"); - match dao.delete_insight(&otel_context, &query.path) { + match dao.delete_insight(&otel_context, &normalized_path) { Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "success": true, "message": "Insight deleted successfully" @@ -152,3 +174,53 @@ pub async fn get_all_insights_handler( } } } + +/// GET /insights/models - List available models from both servers +#[get("/insights/models")] +pub async fn get_available_models_handler( + _claims: Claims, + app_state: web::Data, +) -> impl Responder { + log::debug!("Fetching available models"); + + let ollama_client = &app_state.ollama; + + // Fetch models from primary server + let primary_models = match OllamaClient::list_models(&ollama_client.primary_url).await { + Ok(models) => models, + Err(e) => { + log::warn!("Failed to fetch models from primary server: {:?}", e); + vec![] + } + }; + + let primary = ServerModels { + url: ollama_client.primary_url.clone(), + models: primary_models, + default_model: ollama_client.primary_model.clone(), + }; + + // Fetch models from fallback server if configured + let fallback = if let Some(fallback_url) = &ollama_client.fallback_url { + match OllamaClient::list_models(fallback_url).await { + Ok(models) => Some(ServerModels { + url: fallback_url.clone(), + models, + default_model: ollama_client + .fallback_model + .clone() + .unwrap_or_else(|| ollama_client.primary_model.clone()), + }), + Err(e) => { + log::warn!("Failed to fetch models from fallback server: {:?}", e); + None + } + } + } else { + None + }; + + let response = AvailableModelsResponse { primary, fallback }; + + HttpResponse::Ok().json(response) +} diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index 9c1cac9..5394e2d 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -1,6 +1,7 @@ use anyhow::Result; -use chrono::Utc; +use chrono::{DateTime, Utc}; use serde::Deserialize; +use std::fs::File; use std::sync::{Arc, Mutex}; use crate::ai::ollama::OllamaClient; @@ -8,6 +9,7 @@ use crate::ai::sms_client::SmsApiClient; use crate::database::models::InsertPhotoInsight; use crate::database::{ExifDao, InsightDao}; use crate::memories::extract_date_from_filename; +use crate::utils::normalize_path; #[derive(Deserialize)] struct NominatimResponse { @@ -20,9 +22,7 @@ struct NominatimAddress { city: Option, town: Option, village: Option, - county: Option, state: Option, - country: Option, } #[derive(Clone)] @@ -31,6 +31,7 @@ pub struct InsightGenerator { sms_client: SmsApiClient, insight_dao: Arc>>, exif_dao: Arc>>, + base_path: String, } impl InsightGenerator { @@ -39,12 +40,14 @@ impl InsightGenerator { sms_client: SmsApiClient, insight_dao: Arc>>, exif_dao: Arc>>, + base_path: String, ) -> Self { Self { ollama, sms_client, insight_dao, exif_dao, + base_path, } } @@ -69,16 +72,35 @@ impl InsightGenerator { None } - /// Generate AI insight for a single photo - pub async fn generate_insight_for_photo(&self, file_path: &str) -> Result<()> { + /// Generate AI insight for a single photo with optional custom model + pub async fn generate_insight_for_photo_with_model( + &self, + file_path: &str, + custom_model: Option, + ) -> Result<()> { + // Normalize path to ensure consistent forward slashes in database + let file_path = normalize_path(file_path); log::info!("Generating insight for photo: {}", file_path); + // Create custom Ollama client if model is specified + let ollama_client = if let Some(model) = custom_model { + log::info!("Using custom model: {}", model); + OllamaClient::new( + self.ollama.primary_url.clone(), + self.ollama.fallback_url.clone(), + model.clone(), + Some(model), // Use the same custom model for fallback server + ) + } else { + self.ollama.clone() + }; + // 1. Get EXIF data for the photo let otel_context = opentelemetry::Context::new(); let exif = { let mut exif_dao = self.exif_dao.lock().expect("Unable to lock ExifDao"); exif_dao - .get_exif(&otel_context, file_path) + .get_exif(&otel_context, &file_path) .map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))? }; @@ -88,17 +110,33 @@ impl InsightGenerator { } else { log::warn!("No date_taken in EXIF for {}, trying filename", file_path); - extract_date_from_filename(file_path) + extract_date_from_filename(&file_path) .map(|dt| dt.timestamp()) + .or_else(|| { + // Combine base_path with file_path to get full path + let full_path = std::path::Path::new(&self.base_path).join(&file_path); + File::open(&full_path) + .and_then(|f| f.metadata()) + .and_then(|m| m.created().or(m.modified())) + .map(|t| DateTime::::from(t).timestamp()) + .inspect_err(|e| { + log::warn!( + "Failed to get file timestamp for insight {}: {}", + file_path, + e + ) + }) + .ok() + }) .unwrap_or_else(|| Utc::now().timestamp()) }; - let date_taken = chrono::DateTime::from_timestamp(timestamp, 0) + let date_taken = DateTime::from_timestamp(timestamp, 0) .map(|dt| dt.date_naive()) .unwrap_or_else(|| Utc::now().date_naive()); // 3. Extract contact name from file path - let contact = Self::extract_contact_from_path(file_path); + let contact = Self::extract_contact_from_path(&file_path); log::info!("Extracted contact from path: {:?}", contact); // 4. Fetch SMS messages for the contact (±1 day) @@ -124,7 +162,7 @@ impl InsightGenerator { let sms_summary = if !sms_messages.is_empty() { match self .sms_client - .summarize_context(&sms_messages, &self.ollama) + .summarize_context(&sms_messages, &ollama_client) .await { Ok(summary) => Some(summary), @@ -157,13 +195,11 @@ impl InsightGenerator { ); // 7. Generate title and summary with Ollama - let title = self - .ollama + let title = ollama_client .generate_photo_title(date_taken, location.as_deref(), sms_summary.as_deref()) .await?; - let summary = self - .ollama + let summary = ollama_client .generate_photo_summary(date_taken, location.as_deref(), sms_summary.as_deref()) .await?; @@ -176,7 +212,7 @@ impl InsightGenerator { title, summary, generated_at: Utc::now().timestamp(), - model_version: self.ollama.model.clone(), + model_version: ollama_client.primary_model.clone(), }; let mut dao = self.insight_dao.lock().expect("Unable to lock InsightDao"); diff --git a/src/ai/mod.rs b/src/ai/mod.rs index be1fb05..ef0d52b 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -4,7 +4,8 @@ pub mod ollama; pub mod sms_client; pub use handlers::{ - delete_insight_handler, generate_insight_handler, get_all_insights_handler, get_insight_handler, + delete_insight_handler, generate_insight_handler, get_all_insights_handler, + get_available_models_handler, get_insight_handler, }; pub use insight_generator::InsightGenerator; pub use ollama::OllamaClient; diff --git a/src/ai/ollama.rs b/src/ai/ollama.rs index 0c81028..bac4c4c 100644 --- a/src/ai/ollama.rs +++ b/src/ai/ollama.rs @@ -2,25 +2,60 @@ use anyhow::Result; use chrono::NaiveDate; use reqwest::Client; use serde::{Deserialize, Serialize}; - -use crate::memories::MemoryItem; +use std::time::Duration; #[derive(Clone)] pub struct OllamaClient { client: Client, - pub base_url: String, - pub model: String, + pub primary_url: String, + pub fallback_url: Option, + pub primary_model: String, + pub fallback_model: Option, } impl OllamaClient { - pub fn new(base_url: String, model: String) -> Self { + pub fn new( + primary_url: String, + fallback_url: Option, + primary_model: String, + fallback_model: Option, + ) -> Self { Self { - client: Client::new(), - base_url, - model, + client: Client::builder() + .connect_timeout(Duration::from_secs(5)) // Quick connection timeout + .timeout(Duration::from_secs(120)) // Total request timeout for generation + .build() + .unwrap_or_else(|_| Client::new()), + primary_url, + fallback_url, + primary_model, + fallback_model, } } + /// List available models on an Ollama server + pub async fn list_models(url: &str) -> Result> { + let client = Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(10)) + .build()?; + + let response = client.get(&format!("{}/api/tags", url)).send().await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!("Failed to list models from {}", url)); + } + + let tags_response: OllamaTagsResponse = response.json().await?; + Ok(tags_response.models.into_iter().map(|m| m.name).collect()) + } + + /// Check if a model is available on a server + pub async fn is_model_available(url: &str, model_name: &str) -> Result { + let models = Self::list_models(url).await?; + Ok(models.iter().any(|m| m == model_name)) + } + /// Extract final answer from thinking model output /// Handles ... tags and takes everything after fn extract_final_answer(&self, response: &str) -> String { @@ -38,17 +73,15 @@ impl OllamaClient { response.to_string() } - pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result { - log::debug!("=== Ollama Request ==="); - log::debug!("Model: {}", self.model); - if let Some(sys) = system { - log::debug!("System: {}", sys); - } - log::debug!("Prompt:\n{}", prompt); - log::debug!("====================="); - + async fn try_generate( + &self, + url: &str, + model: &str, + prompt: &str, + system: Option<&str>, + ) -> Result { let request = OllamaRequest { - model: self.model.clone(), + model: model.to_string(), prompt: prompt.to_string(), stream: false, system: system.map(|s| s.to_string()), @@ -56,7 +89,7 @@ impl OllamaClient { let response = self .client - .post(&format!("{}/api/generate", self.base_url)) + .post(&format!("{}/api/generate", url)) .json(&request) .send() .await?; @@ -64,7 +97,6 @@ impl OllamaClient { if !response.status().is_success() { let status = response.status(); let error_body = response.text().await.unwrap_or_default(); - log::error!("Ollama request failed: {} - {}", status, error_body); return Err(anyhow::anyhow!( "Ollama request failed: {} - {}", status, @@ -73,13 +105,77 @@ impl OllamaClient { } let result: OllamaResponse = response.json().await?; + Ok(result.response) + } + + pub async fn generate(&self, prompt: &str, system: Option<&str>) -> Result { + log::debug!("=== Ollama Request ==="); + log::debug!("Primary model: {}", self.primary_model); + if let Some(sys) = system { + log::debug!("System: {}", sys); + } + log::debug!("Prompt:\n{}", prompt); + log::debug!("====================="); + + // Try primary server first with primary model + log::info!( + "Attempting to generate with primary server: {} (model: {})", + self.primary_url, + self.primary_model + ); + let primary_result = self + .try_generate(&self.primary_url, &self.primary_model, prompt, system) + .await; + + let raw_response = match primary_result { + Ok(response) => { + log::info!("Successfully generated response from primary server"); + response + } + Err(e) => { + log::warn!("Primary server failed: {}", e); + + // Try fallback server if available + if let Some(fallback_url) = &self.fallback_url { + // Use fallback model if specified, otherwise use primary model + let fallback_model = + self.fallback_model.as_ref().unwrap_or(&self.primary_model); + + log::info!( + "Attempting to generate with fallback server: {} (model: {})", + fallback_url, + fallback_model + ); + match self + .try_generate(fallback_url, fallback_model, prompt, system) + .await + { + Ok(response) => { + log::info!("Successfully generated response from fallback server"); + response + } + Err(fallback_e) => { + log::error!("Fallback server also failed: {}", fallback_e); + return Err(anyhow::anyhow!( + "Both primary and fallback servers failed. Primary: {}, Fallback: {}", + e, + fallback_e + )); + } + } + } else { + log::error!("No fallback server configured"); + return Err(e); + } + } + }; log::debug!("=== Ollama Response ==="); - log::debug!("Raw response: {}", result.response.trim()); + log::debug!("Raw response: {}", raw_response.trim()); log::debug!("======================="); // Extract final answer from thinking model output - let cleaned = self.extract_final_answer(&result.response); + let cleaned = self.extract_final_answer(&raw_response); log::debug!("=== Cleaned Response ==="); log::debug!("Final answer: {}", cleaned); @@ -99,7 +195,7 @@ impl OllamaClient { let sms_str = sms_summary.unwrap_or("No messages"); let prompt = format!( - r#"Create a short title (maximum 8 words) for this photo: + r#"Create a short title (maximum 8 words) about this moment: Date: {} Location: {} @@ -113,8 +209,7 @@ Return ONLY the title, nothing else."#, sms_str ); - let system = - "You are a memory assistant. Use only the information provided. Do not invent details."; + let system = "You are my long term memory assistant. Use only the information provided. Do not invent details."; let title = self.generate(&prompt, Some(system)).await?; Ok(title.trim().trim_matches('"').to_string()) @@ -127,7 +222,7 @@ Return ONLY the title, nothing else."#, location: Option<&str>, sms_summary: Option<&str>, ) -> Result { - let location_str = location.unwrap_or("somewhere"); + let location_str = location.unwrap_or("Unknown"); let sms_str = sms_summary.unwrap_or("No messages"); let prompt = format!( @@ -137,7 +232,7 @@ Date: {} Location: {} Messages: {} -Use only the specific details provided above. Mention people's names, places, or activities if they appear in the context. Write in first person as Cam in a casual but fluent tone. If limited information is available, keep it simple and factual. If the location is unknown omit it"#, +Use only the specific details provided above. Mention people's names, places, or activities if they appear in the context. Write in first person as Cameron with the tone of a journal entry. If limited information is available, keep it simple and factual. If the location is unknown omit it"#, date.format("%B %d, %Y"), location_str, sms_str @@ -147,15 +242,6 @@ Use only the specific details provided above. Mention people's names, places, or self.generate(&prompt, Some(system)).await } - -} - -pub struct MemoryContext { - pub date: NaiveDate, - pub photos: Vec, - pub sms_summary: Option, - pub locations: Vec, - pub cameras: Vec, } #[derive(Serialize)] @@ -171,3 +257,13 @@ struct OllamaRequest { struct OllamaResponse { response: String, } + +#[derive(Deserialize)] +struct OllamaTagsResponse { + models: Vec, +} + +#[derive(Deserialize)] +struct OllamaModel { + name: String, +} diff --git a/src/lib.rs b/src/lib.rs index 61f1387..90ba68a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub mod state; pub mod tags; #[cfg(test)] pub mod testhelpers; +pub mod utils; pub mod video; // Re-export commonly used types diff --git a/src/main.rs b/src/main.rs index f90bdfc..8b68ad1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,7 +46,6 @@ use crate::tags::*; use crate::video::actors::{ ProcessMessage, ScanDirectoryMessage, create_playlist, generate_video_thumbnail, }; -use crate::video::generate_video_gifs; use log::{debug, error, info, trace, warn}; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use opentelemetry::{KeyValue, global}; @@ -62,6 +61,7 @@ mod files; mod geo; mod state; mod tags; +mod utils; mod video; mod memories; @@ -802,6 +802,7 @@ fn main() -> std::io::Result<()> { .service(ai::get_insight_handler) .service(ai::delete_insight_handler) .service(ai::get_all_insights_handler) + .service(ai::get_available_models_handler) .add_feature(add_tag_services::<_, SqliteTagDao>) .app_data(app_data.clone()) .app_data::>(Data::new(RealFileSystem::new( diff --git a/src/state.rs b/src/state.rs index 5f7753f..40f33af 100644 --- a/src/state.rs +++ b/src/state.rs @@ -65,10 +65,21 @@ impl AppState { impl Default for AppState { fn default() -> Self { // Initialize AI clients - let ollama_url = - env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); - let ollama_model = env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.2".to_string()); - let ollama = OllamaClient::new(ollama_url, ollama_model); + let ollama_primary_url = env::var("OLLAMA_PRIMARY_URL").unwrap_or_else(|_| { + env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()) + }); + let ollama_fallback_url = env::var("OLLAMA_FALLBACK_URL").ok(); + let ollama_primary_model = env::var("OLLAMA_PRIMARY_MODEL") + .or_else(|_| env::var("OLLAMA_MODEL")) + .unwrap_or_else(|_| "nemotron-3-nano:30b".to_string()); + let ollama_fallback_model = env::var("OLLAMA_FALLBACK_MODEL").ok(); + + let ollama = OllamaClient::new( + ollama_primary_url, + ollama_fallback_url, + ollama_primary_model, + ollama_fallback_model, + ); let sms_api_url = env::var("SMS_API_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()); @@ -81,17 +92,21 @@ impl Default for AppState { let exif_dao: Arc>> = Arc::new(Mutex::new(Box::new(SqliteExifDao::new()))); + // Load base path + let base_path = env::var("BASE_PATH").expect("BASE_PATH was not set in the env"); + // Initialize InsightGenerator let insight_generator = InsightGenerator::new( ollama.clone(), sms_client.clone(), insight_dao.clone(), exif_dao.clone(), + base_path.clone(), ); Self::new( Arc::new(StreamActor {}.start()), - env::var("BASE_PATH").expect("BASE_PATH was not set in the env"), + base_path, env::var("THUMBNAILS").expect("THUMBNAILS was not set in the env"), env::var("VIDEO_PATH").expect("VIDEO_PATH was not set in the env"), env::var("GIFS_DIRECTORY").expect("GIFS_DIRECTORY was not set in the env"), @@ -119,8 +134,12 @@ impl AppState { let gif_path = create_test_subdir(&base_path, "gifs"); // Initialize test AI clients - let ollama = - OllamaClient::new("http://localhost:11434".to_string(), "llama3.2".to_string()); + let ollama = OllamaClient::new( + "http://localhost:11434".to_string(), + None, + "llama3.2".to_string(), + None, + ); let sms_client = SmsApiClient::new("http://localhost:8000".to_string(), None); // Initialize test DAOs @@ -130,17 +149,19 @@ impl AppState { Arc::new(Mutex::new(Box::new(SqliteExifDao::new()))); // Initialize test InsightGenerator + let base_path_str = base_path.to_string_lossy().to_string(); let insight_generator = InsightGenerator::new( ollama.clone(), sms_client.clone(), insight_dao.clone(), exif_dao.clone(), + base_path_str.clone(), ); // Create the AppState with the temporary paths AppState::new( Arc::new(StreamActor {}.start()), - base_path.to_string_lossy().to_string(), + base_path_str, thumbnail_path.to_string_lossy().to_string(), video_path.to_string_lossy().to_string(), gif_path.to_string_lossy().to_string(), diff --git a/src/tags.rs b/src/tags.rs index f0b7df6..f9f3c55 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -1,5 +1,6 @@ use crate::data::GetTagsRequest; use crate::otel::{extract_context_from_request, global_tracer, trace_db_call}; +use crate::utils::normalize_path; use crate::{Claims, ThumbnailRequest, connect, data::AddTagRequest, error::IntoHttpError, schema}; use actix_web::dev::{ServiceFactory, ServiceRequest}; use actix_web::{App, HttpRequest, HttpResponse, Responder, web}; @@ -41,6 +42,7 @@ async fn add_tag( let span = tracer.start_with_context("add_tag", &context); let span_context = opentelemetry::Context::current_with_span(span); let tag_name = body.tag_name.clone(); + let normalized_path = normalize_path(&body.file_name); let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao"); @@ -52,12 +54,12 @@ async fn add_tag( } else { info!( "Creating missing tag: '{:?}' for file: '{}'", - tag_name, &body.file_name + tag_name, &normalized_path ); tag_dao.create_tag(&span_context, tag_name.trim()) } }) - .and_then(|tag| tag_dao.tag_file(&span_context, &body.file_name, tag.id)) + .and_then(|tag| tag_dao.tag_file(&span_context, &normalized_path, tag.id)) .map(|_| { span_context.span().set_status(Status::Ok); HttpResponse::Ok() @@ -74,9 +76,10 @@ async fn get_tags( let context = extract_context_from_request(&http_request); let span = global_tracer().start_with_context("get_tags", &context); let span_context = opentelemetry::Context::current_with_span(span); + let normalized_path = normalize_path(&request.path); let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao"); tag_dao - .get_tags_for_path(&span_context, &request.path) + .get_tags_for_path(&span_context, &normalized_path) .map(|tags| { span_context.span().set_status(Status::Ok); HttpResponse::Ok().json(tags) @@ -139,10 +142,11 @@ async fn remove_tagged_photo( let context = extract_context_from_request(&http_request); let span = global_tracer().start_with_context("remove_tagged_photo", &context); let span_context = opentelemetry::Context::current_with_span(span); + let normalized_path = normalize_path(&request.file_name); let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao"); tag_dao - .remove_tag(&span_context, &request.tag_name, &request.file_name) + .remove_tag(&span_context, &request.tag_name, &normalized_path) .map(|result| { span_context.span().set_status(Status::Ok); @@ -165,8 +169,9 @@ async fn update_tags( let context = extract_context_from_request(&http_request); let span = global_tracer().start_with_context("update_tags", &context); let span_context = opentelemetry::Context::current_with_span(span); + let normalized_path = normalize_path(&request.file_name); - dao.get_tags_for_path(&span_context, &request.file_name) + dao.get_tags_for_path(&span_context, &normalized_path) .and_then(|existing_tags| { dao.get_all_tags(&span_context, None) .map(|all| (existing_tags, all)) @@ -180,9 +185,9 @@ async fn update_tags( for tag in tags_to_remove { info!( "Removing tag {:?} from file: {:?}", - tag.name, request.file_name + tag.name, normalized_path ); - dao.remove_tag(&span_context, &tag.name, &request.file_name) + dao.remove_tag(&span_context, &tag.name, &normalized_path) .unwrap_or_else(|err| panic!("{:?} Unable to remove tag {:?}", err, &tag.name)); } @@ -194,14 +199,14 @@ async fn update_tags( for (_, new_tag) in new_tags { info!( "Adding tag {:?} to file: {:?}", - new_tag.name, request.file_name + new_tag.name, normalized_path ); - dao.tag_file(&span_context, &request.file_name, new_tag.id) + dao.tag_file(&span_context, &normalized_path, new_tag.id) .with_context(|| { format!( "Unable to tag file {:?} with tag: {:?}", - request.file_name, new_tag.name + normalized_path, new_tag.name ) }) .unwrap(); diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..1779c15 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,83 @@ +/// Normalize a file path to use forward slashes for cross-platform consistency +/// This ensures paths stored in the database always use `/` regardless of OS +/// +/// # Examples +/// ``` +/// use image_api::utils::normalize_path; +/// +/// assert_eq!(normalize_path("foo\\bar\\baz.jpg"), "foo/bar/baz.jpg"); +/// assert_eq!(normalize_path("foo/bar/baz.jpg"), "foo/bar/baz.jpg"); +/// ``` +pub fn normalize_path(path: &str) -> String { + path.replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_path_with_backslashes() { + assert_eq!(normalize_path("foo\\bar\\baz.jpg"), "foo/bar/baz.jpg"); + } + + #[test] + fn test_normalize_path_with_forward_slashes() { + assert_eq!(normalize_path("foo/bar/baz.jpg"), "foo/bar/baz.jpg"); + } + + #[test] + fn test_normalize_path_mixed() { + assert_eq!( + normalize_path("foo\\bar/baz\\qux.jpg"), + "foo/bar/baz/qux.jpg" + ); + } + + #[test] + fn test_normalize_path_empty() { + assert_eq!(normalize_path(""), ""); + } + + #[test] + fn test_normalize_path_absolute_windows() { + assert_eq!( + normalize_path("C:\\Users\\Photos\\image.jpg"), + "C:/Users/Photos/image.jpg" + ); + } + + #[test] + fn test_normalize_path_unc_path() { + assert_eq!( + normalize_path("\\\\server\\share\\folder\\file.jpg"), + "//server/share/folder/file.jpg" + ); + } + + #[test] + fn test_normalize_path_single_filename() { + assert_eq!(normalize_path("image.jpg"), "image.jpg"); + } + + #[test] + fn test_normalize_path_trailing_slash() { + assert_eq!(normalize_path("foo\\bar\\"), "foo/bar/"); + } + + #[test] + fn test_normalize_path_multiple_consecutive_backslashes() { + assert_eq!( + normalize_path("foo\\\\bar\\\\\\baz.jpg"), + "foo//bar///baz.jpg" + ); + } + + #[test] + fn test_normalize_path_deep_nesting() { + assert_eq!( + normalize_path("a\\b\\c\\d\\e\\f\\g\\file.jpg"), + "a/b/c/d/e/f/g/file.jpg" + ); + } +}