This commit is contained in:
Andrew Phillips
2026-02-19 13:57:39 -04:00
parent a72395fe83
commit fdeb5f7951
82 changed files with 2756 additions and 2018 deletions

View File

@@ -1,3 +1,4 @@
use crate::config::ColumnConfig;
use crate::db;
use crate::modes::server::AppState;
use anyhow::Result;
@@ -8,7 +9,6 @@ use axum::{
use log::debug;
use rusqlite::Connection;
use serde::Deserialize;
use crate::config::ColumnConfig;
use std::collections::HashMap;
#[derive(Deserialize)]
@@ -72,8 +72,7 @@ fn default_count() -> usize {
/// let app = pages::add_routes(axum::Router::new());
/// ```
pub fn add_routes(app: axum::Router<AppState>) -> axum::Router<AppState> {
app
.route("/", axum::routing::get(list_items))
app.route("/", axum::routing::get(list_items))
.route("/item/{item_id}", axum::routing::get(show_item))
.route("/style.css", axum::routing::get(style_css))
}
@@ -84,9 +83,9 @@ async fn list_items(
) -> Result<Response, Html<String>> {
let conn = state.db.lock().await;
let settings = &state.settings;
let result = build_item_list(&conn, &params, &settings.list_format);
match result {
Ok(html) => {
// Build response with explicit Content-Length
@@ -96,23 +95,28 @@ async fn list_items(
.body(axum::body::Body::from(html))
.map_err(|_| Html("<html><body>Internal Server Error</body></html>".to_string()))?;
Ok(response)
},
}
Err(e) => Err(Html(format!("<html><body>Error: {}</body></html>", e))),
}
}
fn build_item_list(conn: &Connection, params: &ListQueryParams, columns: &[ColumnConfig]) -> Result<String> {
let tags: Vec<String> = params.tags
fn build_item_list(
conn: &Connection,
params: &ListQueryParams,
columns: &[ColumnConfig],
) -> Result<String> {
let tags: Vec<String> = 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" {
@@ -120,7 +124,7 @@ fn build_item_list(conn: &Connection, params: &ListQueryParams, columns: &[Colum
} else {
sorted_items.sort_by(|a, b| a.id.cmp(&b.id));
}
// Apply pagination
let start = params.start;
let end = std::cmp::min(start + params.count, sorted_items.len());
@@ -129,29 +133,29 @@ fn build_item_list(conn: &Connection, params: &ListQueryParams, columns: &[Colum
} else {
vec![]
};
// Get tags and meta for all items in the page
let item_ids: Vec<i64> = 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("<html><head><title>Keep - Items</title>");
html.push_str("<link rel=\"stylesheet\" href=\"/style.css\">");
html.push_str("</head><body>");
html.push_str("<h1>Items</h1>");
html.push_str("<p><a href=\"/swagger\">API Documentation</a></p>");
// Add recent tags section using the items we already have
html.push_str("<h2>Recent Tags</h2>");
// Collect all tags from all items, keeping track of their timestamps
let mut all_tags_with_time: Vec<(String, chrono::DateTime<chrono::Utc>)> = Vec::new();
for item in &sorted_items {
@@ -163,10 +167,10 @@ fn build_item_list(conn: &Connection, params: &ListQueryParams, columns: &[Colum
}
}
}
// 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();
@@ -179,20 +183,23 @@ fn build_item_list(conn: &Connection, params: &ListQueryParams, columns: &[Colum
}
}
}
if recent_tags.is_empty() {
html.push_str("<p>No tags found</p>");
} else {
html.push_str("<p>");
for tag in recent_tags {
html.push_str(&format!("<a href=\"/?tags={}\" style=\"margin-right: 8px;\">{}</a>", tag, tag));
html.push_str(&format!(
"<a href=\"/?tags={}\" style=\"margin-right: 8px;\">{}</a>",
tag, tag
));
}
html.push_str("</p>");
}
// Start table
html.push_str("<table>");
// Table headers
html.push_str("<tr>");
for column in columns {
@@ -200,19 +207,21 @@ fn build_item_list(conn: &Connection, params: &ListQueryParams, columns: &[Colum
}
html.push_str("<th>Actions</th>");
html.push_str("</tr>");
// 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<String, String> = meta_map.get(&item_id)
let meta: HashMap<String, String> = meta_map
.get(&item_id)
.map(|metas| {
metas.iter()
metas
.iter()
.map(|(name, value)| (name.clone(), value.clone()))
.collect()
})
.unwrap_or_default();
html.push_str("<tr>");
for column in columns {
let value = match column.name.as_str() {
@@ -220,16 +229,17 @@ fn build_item_list(conn: &Connection, params: &ListQueryParams, columns: &[Colum
let id_value = item.id.map(|id| id.to_string()).unwrap_or_default();
// Make the ID a link to the item details page
format!("<a href=\"/item/{}\">{}</a>", item_id, 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<String> = tags.iter()
let tag_links: Vec<String> = tags
.iter()
.map(|t| format!("<a href=\"/?tags={}\">{}</a>", t.name, t.name))
.collect();
tag_links.join(", ")
},
}
_ => {
if column.name.starts_with("meta:") {
let meta_key = &column.name[5..];
@@ -239,7 +249,7 @@ fn build_item_list(conn: &Connection, params: &ListQueryParams, columns: &[Colum
}
}
};
// Apply max_len if specified, but skip for tags column to avoid truncating HTML
let display_value = if column.name == "tags" {
value
@@ -257,36 +267,41 @@ fn build_item_list(conn: &Connection, params: &ListQueryParams, columns: &[Colum
} 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;",
};
html.push_str(&format!("<td style=\"{}\">{}</td>", align_style, display_value));
html.push_str(&format!(
"<td style=\"{}\">{}</td>",
align_style, display_value
));
}
// Actions column
html.push_str(&format!(
"<td><a href=\"/item/{}\">View</a> | <a href=\"/api/item/{}/content\">Download</a></td>",
item_id, item_id
));
html.push_str("</tr>");
}
html.push_str("</table>");
// Add pagination info
html.push_str(&format!("<p>Showing {} to {} of {} items</p>",
start + 1,
std::cmp::min(end, sorted_items.len()),
sorted_items.len()));
html.push_str(&format!(
"<p>Showing {} to {} of {} items</p>",
start + 1,
std::cmp::min(end, sorted_items.len()),
sorted_items.len()
));
html.push_str("</body></html>");
Ok(html)
}
@@ -344,9 +359,9 @@ async fn show_item(
Path(id): Path<i64>,
) -> Result<Response, Html<String>> {
let conn = state.db.lock().await;
let result = build_item_details(&conn, id);
match result {
Ok(html) => {
// Build response with explicit Content-Length
@@ -356,7 +371,7 @@ async fn show_item(
.body(axum::body::Body::from(html))
.map_err(|_| Html("<html><body>Internal Server Error</body></html>".to_string()))?;
Ok(response)
},
}
Err(e) => Err(Html(format!("<html><body>Error: {}</body></html>", e))),
}
}
@@ -366,51 +381,70 @@ fn build_item_details(conn: &Connection, id: i64) -> Result<String> {
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!("<html><head><title>Keep - Item #{}</title>", id));
html.push_str("<link rel=\"stylesheet\" href=\"/style.css\">");
html.push_str("</head><body>");
html.push_str(&format!("<h1>Item #{}</h1>", id));
// Single table for all details
html.push_str("<table>");
html.push_str(&format!("<tr><th>ID</th><td>{}</td></tr>", item.id.unwrap_or(0)));
html.push_str(&format!("<tr><th>Timestamp</th><td>{}</td></tr>", item.ts.format("%Y-%m-%d %H:%M:%S")));
html.push_str(&format!("<tr><th>Size</th><td>{}</td></tr>", item.size.unwrap_or(0)));
html.push_str(&format!("<tr><th>Compression</th><td>{}</td></tr>", item.compression));
html.push_str(&format!(
"<tr><th>ID</th><td>{}</td></tr>",
item.id.unwrap_or(0)
));
html.push_str(&format!(
"<tr><th>Timestamp</th><td>{}</td></tr>",
item.ts.format("%Y-%m-%d %H:%M:%S")
));
html.push_str(&format!(
"<tr><th>Size</th><td>{}</td></tr>",
item.size.unwrap_or(0)
));
html.push_str(&format!(
"<tr><th>Compression</th><td>{}</td></tr>",
item.compression
));
// Tags row
html.push_str("<tr><th>Tags</th><td>");
if tags.is_empty() {
html.push_str("No tags");
} else {
let tag_links: Vec<String> = tags.iter()
let tag_links: Vec<String> = tags
.iter()
.map(|t| format!("<a href=\"/?tags={}\">{}</a>", t.name, t.name))
.collect();
html.push_str(&tag_links.join(", "));
}
html.push_str("</td></tr>");
// Metadata rows
if metas.is_empty() {
html.push_str("<tr><th>Metadata</th><td>No metadata</td></tr>");
} else {
for meta in metas {
html.push_str(&format!("<tr><th>{}</th><td>{}</td></tr>", meta.name, meta.value));
html.push_str(&format!(
"<tr><th>{}</th><td>{}</td></tr>",
meta.name, meta.value
));
}
}
html.push_str("</table>");
// Links
html.push_str("<h2>Actions</h2>");
html.push_str(&format!("<p><a href=\"/api/item/{}/content\">Download Content</a></p>", id));
html.push_str(&format!(
"<p><a href=\"/api/item/{}/content\">Download Content</a></p>",
id
));
html.push_str("<p><a href=\"/\">Back to list</a></p>");
html.push_str("</body></html>");
Ok(html)
}