Files
keep/src/modes/server/pages.rs
Andrew Phillips b88daca131 fix: Handle ColumnAlignment::Center in server pages
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 19:06:24 -03:00

377 lines
12 KiB
Rust

use crate::db;
use crate::modes::server::AppState;
use anyhow::Result;
use axum::{
extract::{Path, Query, State},
response::{Html, Response},
};
use log::debug;
use rusqlite::Connection;
use serde::Deserialize;
use crate::config::ColumnConfig;
use std::collections::HashMap;
#[derive(Deserialize)]
pub struct ListQueryParams {
#[serde(default = "default_sort")]
sort: String,
#[serde(default)]
tags: Option<String>,
#[serde(default = "default_count")]
count: usize,
#[serde(default)]
start: usize,
}
fn default_sort() -> String {
"newest".to_string()
}
fn default_count() -> usize {
1000
}
pub fn add_routes(app: axum::Router<AppState>) -> axum::Router<AppState> {
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<AppState>,
Query(params): Query<ListQueryParams>,
) -> 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
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("<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
.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 end = std::cmp::min(start + params.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<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 {
if let Some(item_id) = item.id {
if 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("<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("</p>");
}
// Start table
html.push_str("<table>");
// Table headers
html.push_str("<tr>");
for column in columns {
html.push_str(&format!("<th>{}</th>", column.label));
}
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)
.map(|metas| {
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() {
"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!("<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()
.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..];
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::<usize>() {
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;",
};
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("</body></html>");
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<AppState>,
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
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("<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_details(conn: &Connection, id: i64) -> Result<String> {
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!("<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));
// 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()
.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("</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("<p><a href=\"/\">Back to list</a></p>");
html.push_str("</body></html>");
Ok(html)
}