use crate::config::ColumnConfig; use crate::db; use crate::modes::server::AppState; use anyhow::Result; use axum::{ extract::{Path, Query, State}, response::{Html, Response}, }; use html_escape::{encode_double_quoted_attribute, encode_text}; use log::debug; use rusqlite::Connection; use serde::Deserialize; use std::collections::HashMap; /// Escape text content for safe HTML insertion. fn esc(s: &str) -> String { encode_text(s).to_string() } /// Escape attribute values for safe HTML attribute insertion. fn esc_attr(s: &str) -> String { encode_double_quoted_attribute(s).to_string() } #[derive(Deserialize)] /// Query parameters for the item list endpoint. /// /// This struct defines the query parameters used to filter, sort, and paginate /// the list of items displayed on the main page. /// /// # Fields /// /// * `sort` - Sorting order, defaults to "newest". /// * `tags` - Optional comma-separated list of tags to filter by. /// * `count` - Number of items per page, defaults to 1000. /// * `start` - Starting index for pagination, defaults to 0. pub struct ListQueryParams { #[serde(default = "default_sort")] sort: String, #[serde(default)] tags: Option, #[serde(default = "default_count")] count: usize, #[serde(default)] start: usize, } fn default_sort() -> String { "newest".to_string() } /// Provides the default sorting order for item lists. /// /// # Returns /// /// A string representing the default sort order: "newest". fn default_count() -> usize { 1000 } /// Adds the web page routes to the Axum router. /// /// This function configures the routes for the web interface, including the /// main item list, individual item details, and static CSS styles. /// /// # Arguments /// /// * `app` - The existing Axum router with application state. /// /// # Returns /// /// The updated router with web routes added. /// /// # Examples /// /// ```ignore /// let app = pages::add_routes(axum::Router::new()); /// ``` pub fn add_routes(app: axum::Router) -> axum::Router { app.route("/", axum::routing::get(list_items)) .route("/item/{item_id}", axum::routing::get(show_item)) .route("/style.css", axum::routing::get(style_css)) } async fn list_items( State(state): State, Query(params): Query, ) -> Result> { let conn = state.db.lock().await; let settings = &state.settings; let result = build_item_list(&conn, ¶ms, &settings.list_format); match result { Ok(html) => { // Build response with explicit Content-Length let response = Response::builder() .header("content-type", "text/html") .header("content-length", html.len().to_string()) .body(axum::body::Body::from(html)) .map_err(|_| Html("Internal Server Error".to_string()))?; Ok(response) } Err(_e) => Err(Html( "An internal error occurred".to_string(), )), } } fn build_item_list( conn: &Connection, params: &ListQueryParams, columns: &[ColumnConfig], ) -> Result { let tags: Vec = params .tags .as_ref() .map(|t| t.split(',').map(|s| s.trim().to_string()).collect()) .unwrap_or_default(); let items = if tags.is_empty() { db::query_all_items(conn)? } else { db::query_tagged_items(conn, &tags)? }; // Sort items let mut sorted_items = items; if params.sort == "newest" { sorted_items.sort_by(|a, b| b.id.cmp(&a.id)); } else { sorted_items.sort_by(|a, b| a.id.cmp(&b.id)); } // Apply pagination let start = params.start; let count = params.count.min(10000); let end = std::cmp::min(start + count, sorted_items.len()); let page_items = if start < sorted_items.len() { sorted_items[start..std::cmp::min(end, sorted_items.len())].to_vec() } else { vec![] }; // Get tags and meta for all items in the page let item_ids: Vec = page_items.iter().filter_map(|item| item.id).collect(); let tags_map = db::get_tags_for_items(conn, &item_ids)?; let meta_map = db::get_meta_for_items(conn, &item_ids)?; // Debug: print number of tags per item for item_id in &item_ids { if let Some(tags) = tags_map.get(item_id) { debug!("Item {} has {} tags: {:?}", item_id, tags.len(), tags); } } let mut html = String::new(); html.push_str("Keep - Items"); html.push_str(""); html.push_str(""); html.push_str("

Items

"); html.push_str("

API Documentation

"); // Add recent tags section using the items we already have html.push_str("

Recent Tags

"); // Collect all tags from all items, keeping track of their timestamps let mut all_tags_with_time: Vec<(String, chrono::DateTime)> = Vec::new(); for item in &sorted_items { if let Some(item_id) = item.id && let Some(tags) = tags_map.get(&item_id) { for tag in tags { all_tags_with_time.push((tag.name.clone(), item.ts)); } } } // Sort by timestamp descending (most recent first) all_tags_with_time.sort_by(|a, b| b.1.cmp(&a.1)); // Get unique tags in order of most recent appearance let mut seen = std::collections::HashSet::new(); let mut recent_tags = Vec::new(); for (tag, _) in all_tags_with_time { if !seen.contains(&tag) { seen.insert(tag.clone()); recent_tags.push(tag); if recent_tags.len() >= 20 { break; } } } if recent_tags.is_empty() { html.push_str("

No tags found

"); } else { html.push_str("

"); for tag in recent_tags { html.push_str(&format!( "{}", esc_attr(&tag), esc(&tag) )); } html.push_str("

"); } // Start table html.push_str(""); // Table headers html.push_str(""); for column in columns { html.push_str(&format!("", esc(&column.label))); } html.push_str(""); html.push_str(""); // Table rows for item in page_items { let item_id = item.id.unwrap_or(0); let tags = tags_map.get(&item_id).cloned().unwrap_or_default(); let meta: HashMap = meta_map .get(&item_id) .map(|metas| { metas .iter() .map(|(name, value)| (name.clone(), value.clone())) .collect() }) .unwrap_or_default(); html.push_str(""); for column in columns { let value = match column.name.as_str() { "id" => { let id_value = item.id.map(|id| id.to_string()).unwrap_or_default(); // Make the ID a link to the item details page format!("{id_value}") } "time" => item.ts.format("%Y-%m-%d %H:%M:%S").to_string(), "size" => item.size.map(|s| s.to_string()).unwrap_or_default(), "tags" => { // Make sure we're using all tags for the item let tag_links: Vec = tags .iter() .map(|t| { format!( "{}", esc_attr(&t.name), esc(&t.name) ) }) .collect(); tag_links.join(", ") } _ => { if column.name.starts_with("meta:") { let meta_key = &column.name[5..]; meta.get(meta_key).cloned().unwrap_or_default() } else { String::new() } } }; // Apply max_len if specified, but skip for tags column to avoid truncating HTML let display_value = if column.name == "tags" { value } else if let Some(max_len_str) = &column.max_len { if let Ok(max_len) = max_len_str.parse::() { if value.chars().count() > max_len { let truncated: String = value.chars().take(max_len).collect(); format!("{truncated}...") } else { value } } else { value } } else { value }; // Apply alignment let align_style = match column.align { crate::config::ColumnAlignment::Left => "text-align: left;", crate::config::ColumnAlignment::Right => "text-align: right;", crate::config::ColumnAlignment::Center => "text-align: center;", }; let rendered_value = if column.name == "tags" { display_value // Already contains escaped HTML links } else { esc(&display_value) }; html.push_str(&format!( "" )); } // Actions column html.push_str(&format!( "" )); html.push_str(""); } html.push_str("
{}Actions
{rendered_value}View | Download
"); // Add pagination info html.push_str(&format!( "

Showing {} to {} of {} items

", start + 1, std::cmp::min(end, sorted_items.len()), sorted_items.len() )); html.push_str(""); Ok(html) } async fn style_css() -> &'static str { r#" body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; } h1, h2 { color: #333; } table { border-collapse: collapse; width: 100%; background-color: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } th, td { border: 1px solid #ddd; padding: 12px; text-align: left; } th { background-color: #f2f2f2; position: sticky; top: 0; } tr:nth-child(even) { background-color: #f9f9f9; } tr:hover { background-color: #f1f1f1; } a { color: #0066cc; text-decoration: none; } a:hover { text-decoration: underline; } .pagination { margin: 20px 0; } .actions { white-space: nowrap; } "# } async fn show_item( State(state): State, Path(id): Path, ) -> Result> { let conn = state.db.lock().await; let result = build_item_details(&conn, id); match result { Ok(html) => { // Build response with explicit Content-Length let response = Response::builder() .header("content-type", "text/html") .header("content-length", html.len().to_string()) .body(axum::body::Body::from(html)) .map_err(|_| Html("Internal Server Error".to_string()))?; Ok(response) } Err(_e) => Err(Html( "An internal error occurred".to_string(), )), } } fn build_item_details(conn: &Connection, id: i64) -> Result { let item = match db::get_item(conn, id)? { Some(item) => item, None => return Err(anyhow::anyhow!("Item not found")), }; let tags = db::get_item_tags(conn, &item)?; let metas = db::get_item_meta(conn, &item)?; let mut html = String::new(); html.push_str(&format!("Keep - Item #{id}")); html.push_str(""); html.push_str(""); html.push_str(&format!("

Item #{id}

")); // Single table for all details html.push_str(""); html.push_str(&format!( "", item.id.unwrap_or(0) )); html.push_str(&format!( "", item.ts.format("%Y-%m-%d %H:%M:%S") )); html.push_str(&format!( "", item.size.unwrap_or(0) )); html.push_str(&format!( "", esc(&item.compression) )); // Tags row html.push_str(""); // Metadata rows if metas.is_empty() { html.push_str(""); } else { for meta in metas { html.push_str(&format!( "", esc(&meta.name), esc(&meta.value) )); } } html.push_str("
ID{}
Timestamp{}
Size{}
Compression{}
Tags"); if tags.is_empty() { html.push_str("No tags"); } else { let tag_links: Vec = tags .iter() .map(|t| { format!( "{}", esc_attr(&t.name), esc(&t.name) ) }) .collect(); html.push_str(&tag_links.join(", ")); } html.push_str("
MetadataNo metadata
{}{}
"); // Links html.push_str("

Actions

"); html.push_str(&format!( "

Download Content

" )); html.push_str("

Back to list

"); html.push_str(""); Ok(html) }