Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
377 lines
12 KiB
Rust
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, ¶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("<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)
|
|
}
|