diff --git a/src/cmd/webui.rs b/src/cmd/webui.rs deleted file mode 100644 index 91d26849840d58835ad6bb0c9334b68d396d894c..0000000000000000000000000000000000000000 --- a/src/cmd/webui.rs +++ /dev/null @@ -1,1313 +0,0 @@ -use std::collections::HashSet; -use std::path::Path; -use std::sync::Arc; - -use anyhow::Result; -use askama::Template; -use axum::extract::{Path as AxumPath, Query, State}; -use axum::http::{HeaderMap, StatusCode}; -use axum::response::{Html, IntoResponse, Redirect, Response}; -use axum::routing::{get, post}; -use axum::{Form, Json, Router}; - -use crate::db::{self, Store, TaskId}; -use crate::ops; -use crate::score; - -const PAGE_SIZE: usize = 25; - -// --------------------------------------------------------------------------- -// Sort helpers -// --------------------------------------------------------------------------- - -/// Columns the task table can be sorted by. -#[derive(Clone, Copy, PartialEq, Eq)] -enum SortField { - Id, - Status, - Priority, - Effort, - Title, - Created, -} - -impl SortField { - fn parse(s: &str) -> Option { - match s { - "id" => Some(Self::Id), - "status" => Some(Self::Status), - "priority" => Some(Self::Priority), - "effort" => Some(Self::Effort), - "title" => Some(Self::Title), - "created" => Some(Self::Created), - _ => None, - } - } - - fn as_str(self) -> &'static str { - match self { - Self::Id => "id", - Self::Status => "status", - Self::Priority => "priority", - Self::Effort => "effort", - Self::Title => "title", - Self::Created => "created", - } - } - - /// Sensible default direction when the user first clicks a column. - fn default_order(self) -> SortOrder { - match self { - // Newest first, alphabetical ascending for text fields. - Self::Created => SortOrder::Desc, - Self::Title | Self::Id => SortOrder::Asc, - // Highest priority/effort first; open before closed. - Self::Priority | Self::Effort | Self::Status => SortOrder::Asc, - } - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum SortOrder { - Asc, - Desc, -} - -impl SortOrder { - fn parse(s: &str) -> Option { - match s { - "asc" => Some(Self::Asc), - "desc" => Some(Self::Desc), - _ => None, - } - } - - fn as_str(self) -> &'static str { - match self { - Self::Asc => "asc", - Self::Desc => "desc", - } - } -} - -/// Map a `Status` to a numeric value for semantic sorting. -/// Lower values sort first in ascending order: open → in_progress → closed. -fn status_sort_key(s: db::Status) -> i32 { - match s { - db::Status::Open => 1, - db::Status::InProgress => 2, - db::Status::Closed => 3, - } -} - -/// Apply the chosen sort field and direction to a filtered task list. -fn sort_tasks(tasks: &mut [&db::Task], field: SortField, order: SortOrder) { - tasks.sort_by(|a, b| { - let cmp = match field { - SortField::Id => a.id.as_str().cmp(b.id.as_str()), - SortField::Status => status_sort_key(a.status).cmp(&status_sort_key(b.status)), - SortField::Priority => a.priority.score().cmp(&b.priority.score()), - SortField::Effort => a.effort.score().cmp(&b.effort.score()), - SortField::Title => a - .title - .to_ascii_lowercase() - .cmp(&b.title.to_ascii_lowercase()), - SortField::Created => a.created_at.cmp(&b.created_at), - }; - match order { - SortOrder::Asc => cmp, - SortOrder::Desc => cmp.reverse(), - } - }); -} - -/// Return a human-friendly status label (e.g. "In progress" instead of -/// "in_progress"). -fn friendly_status(raw: &str) -> &'static str { - match raw { - "open" => "Open", - "in_progress" => "In progress", - "closed" => "Closed", - _ => "Open", - } -} - -/// Format an ISO 8601 timestamp into a human-friendly form (e.g. "15 Mar 2026") -/// for the noscript fallback. Returns the original string unchanged on parse -/// failure so the page still renders something sensible. -fn friendly_date(iso: &str) -> String { - chrono::NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%SZ") - .map(|dt| dt.format("%-d %b %Y %H:%M").to_string()) - .unwrap_or_else(|_| iso.to_string()) -} - -/// Render a markdown string to sanitised HTML. -/// -/// Uses pulldown-cmark for parsing and ammonia for sanitisation so that -/// untrusted user input can be displayed safely. Images are stripped -/// entirely — only structural/inline markup is allowed through. -fn render_markdown(src: &str) -> String { - use pulldown_cmark::{html::push_html, Parser}; - - let parser = Parser::new(src); - let mut raw_html = String::new(); - push_html(&mut raw_html, parser); - - ammonia::Builder::default() - .rm_tags(&["img"]) - .clean(&raw_html) - .to_string() -} - -// --------------------------------------------------------------------------- -// Shared state -// --------------------------------------------------------------------------- - -#[derive(Clone)] -struct AppState { - data_root: Arc, -} - -// --------------------------------------------------------------------------- -// Template view-models -// --------------------------------------------------------------------------- - -/// A project card on the root page — either healthy or failed. -enum ProjectCard { - Ok { - name: String, - open: usize, - in_progress: usize, - closed: usize, - total: usize, - }, - Err { - name: String, - error: String, - }, -} - -/// Minimal view-model for a scored task in the "Next Up" table. -struct ScoredEntry { - id: String, - short_id: String, - title: String, - score: String, - status: String, - status_display: &'static str, -} - -/// Minimal view-model for a task row in the project task table. -struct TaskRow { - full_id: String, - short_id: String, - status: String, - status_display: &'static str, - priority: String, - effort: String, - title: String, - created_at: String, - created_at_display: String, -} - -/// View-model for the task detail page. -struct TaskView { - full_id: String, - short_id: String, - title: String, - description: String, - task_type: String, - status: String, - priority: String, - effort: String, - created_at: String, - created_at_display: String, - updated_at: String, - updated_at_display: String, - labels: Vec, - logs: Vec, -} - -struct LogView { - timestamp: String, - timestamp_display: String, - message: String, -} - -/// A blocker reference for the task detail page. -struct BlockerRef { - full_id: String, - short_id: String, -} - -/// Sort context passed to the task_table macro. When present, column headers -/// become clickable links that set sort/order query params. -struct SortContext { - /// Base URL for sort links (e.g. `/projects/myproj`). - base_href: String, - /// Current sort field. - field: String, - /// Current sort order ("asc" or "desc"). - order: String, - /// Query string fragment for the current filters (without sort/order/page), - /// suitable for appending to hrefs. - filter_qs: String, -} - -impl SortContext { - /// Build the href for a column header link. Clicking the currently-active - /// column toggles direction; clicking a different column uses its default. - fn column_href(&self, col: &str) -> String { - let order = if col == self.field { - // Toggle current direction. - match self.order.as_str() { - "asc" => "desc", - _ => "asc", - } - } else { - // Use the column's sensible default. - SortField::parse(col) - .map(|f| f.default_order().as_str()) - .unwrap_or("asc") - }; - let mut qs = self.filter_qs.clone(); - if !qs.is_empty() { - qs.push('&'); - } - qs.push_str(&format!("sort={col}&order={order}")); - format!("{}?{qs}", self.base_href) - } - - /// Return the arrow indicator for the active column, or empty string. - fn arrow(&self, col: &str) -> &str { - if col == self.field { - match self.order.as_str() { - "asc" => " ↑", - "desc" => " ↓", - _ => "", - } - } else { - "" - } - } -} - -// --------------------------------------------------------------------------- -// Askama templates -// --------------------------------------------------------------------------- - -#[derive(Template)] -#[template(path = "index.html")] -struct IndexTemplate { - all_projects: Vec, - active_project: Option, - projects: Vec, -} - -#[derive(Template)] -#[template(path = "project.html")] -struct ProjectTemplate { - all_projects: Vec, - active_project: Option, - project_name: String, - stats_open: usize, - stats_in_progress: usize, - stats_closed: usize, - next_up: Vec, - page_tasks: Vec, - all_labels: Vec, - filter_status: Option, - filter_priority: Option, - filter_effort: Option, - filter_label: Option, - filter_search: String, - page: usize, - total_pages: usize, - pagination_pages: Vec, - sort_ctx: SortContext, -} - -impl ProjectTemplate { - /// Build a query-string fragment containing the current filters (no sort, - /// no page). Reused by both pagination and sort helpers. - fn filter_qs(&self) -> String { - let mut parts = Vec::new(); - if let Some(ref s) = self.filter_status { - parts.push(format!("status={s}")); - } - if let Some(ref p) = self.filter_priority { - parts.push(format!("priority={p}")); - } - if let Some(ref e) = self.filter_effort { - parts.push(format!("effort={e}")); - } - if let Some(ref l) = self.filter_label { - parts.push(format!("label={l}")); - } - if !self.filter_search.is_empty() { - parts.push(format!("q={}", self.filter_search)); - } - parts.join("&") - } - - /// Build a pagination link preserving current filter and sort params. - fn pagination_href(&self, target_page: &usize) -> String { - let target_page = *target_page; - let mut qs = self.filter_qs(); - if !qs.is_empty() { - qs.push('&'); - } - qs.push_str(&format!( - "sort={}&order={}", - self.sort_ctx.field, self.sort_ctx.order - )); - qs.push_str(&format!("&page={target_page}")); - format!("/projects/{}?{qs}", self.project_name) - } -} - -#[derive(Template)] -#[template(path = "task.html")] -struct TaskTemplate { - all_projects: Vec, - active_project: Option, - project_name: String, - task: TaskView, - blockers_open: Vec, - blockers_resolved: Vec, - subtasks: Vec, -} - -#[derive(Template)] -#[template(path = "error.html")] -struct ErrorTemplate { - all_projects: Vec, - active_project: Option, - status_code: u16, - message: String, -} - -// --------------------------------------------------------------------------- -// Response helpers -// --------------------------------------------------------------------------- - -fn render(tmpl: impl Template) -> Response { - match tmpl.render() { - Ok(html) => Html(html).into_response(), - Err(e) => error_response(500, &format!("template render failed: {e}"), &[]), - } -} - -fn error_response(code: u16, msg: &str, all_projects: &[String]) -> Response { - let body = ErrorTemplate { - all_projects: all_projects.to_vec(), - active_project: None, - status_code: code, - message: msg.to_string(), - }; - let html = body - .render() - .unwrap_or_else(|_| format!("

{code}

{msg}

")); - let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); - (status, Html(html)).into_response() -} - -fn list_projects_safe(root: &std::path::Path) -> Vec { - db::list_projects_in(root).unwrap_or_default() -} - -// --------------------------------------------------------------------------- -// Route handlers -// --------------------------------------------------------------------------- - -async fn index_handler(State(state): State) -> Response { - let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result { - let projects = list_projects_safe(&root); - let mut cards = Vec::with_capacity(projects.len()); - - for name in &projects { - match Store::open(&root, name) { - Ok(store) => { - let tasks = store.list_tasks()?; - let open = tasks - .iter() - .filter(|t| t.status == db::Status::Open) - .count(); - let in_progress = tasks - .iter() - .filter(|t| t.status == db::Status::InProgress) - .count(); - let closed = tasks - .iter() - .filter(|t| t.status == db::Status::Closed) - .count(); - cards.push(ProjectCard::Ok { - name: name.clone(), - open, - in_progress, - closed, - total: tasks.len(), - }); - } - Err(e) => { - cards.push(ProjectCard::Err { - name: name.clone(), - error: format!("{e}"), - }); - } - } - } - - Ok(IndexTemplate { - all_projects: projects, - active_project: None, - projects: cards, - }) - }) - .await; - - match result { - Ok(Ok(tmpl)) => render(tmpl), - Ok(Err(e)) => error_response(500, &format!("{e}"), &[]), - Err(e) => error_response(500, &format!("join error: {e}"), &[]), - } -} - -#[derive(serde::Deserialize)] -struct ProjectQuery { - status: Option, - priority: Option, - effort: Option, - label: Option, - q: Option, - page: Option, - sort: Option, - order: Option, -} - -async fn project_handler( - State(state): State, - AxumPath(name): AxumPath, - Query(mut query): Query, -) -> Response { - // Default to showing open tasks when no status filter is specified. - if query.status.is_none() { - query.status = Some("open".to_string()); - } - let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result { - let all_projects = list_projects_safe(&root); - let store = Store::open(&root, &name)?; - let tasks = store.list_tasks()?; - - // Stats from the full unfiltered set. - let stats_open = tasks - .iter() - .filter(|t| t.status == db::Status::Open) - .count(); - let stats_in_progress = tasks - .iter() - .filter(|t| t.status == db::Status::InProgress) - .count(); - let stats_closed = tasks - .iter() - .filter(|t| t.status == db::Status::Closed) - .count(); - - // Collect distinct labels for the filter dropdown. - let mut label_set: HashSet = HashSet::new(); - for t in &tasks { - for l in &t.labels { - label_set.insert(l.clone()); - } - } - let mut all_labels: Vec = label_set.into_iter().collect(); - all_labels.sort(); - - // Next-up scoring (top 5 open tasks). - let open_tasks: Vec = tasks - .iter() - .filter(|t| t.status == db::Status::Open) - .map(|t| score::TaskInput { - id: t.id.as_str().to_string(), - title: t.title.clone(), - priority_score: t.priority.score(), - effort_score: t.effort.score(), - priority_label: db::priority_label(t.priority).to_string(), - effort_label: db::effort_label(t.effort).to_string(), - }) - .collect(); - - let edges: Vec<(String, String)> = tasks - .iter() - .filter(|t| t.status == db::Status::Open) - .flat_map(|t| { - t.blockers - .iter() - .map(|b| (t.id.as_str().to_string(), b.as_str().to_string())) - .collect::>() - }) - .collect(); - - let parents_with_open_children: HashSet = tasks - .iter() - .filter(|t| t.status == db::Status::Open) - .filter_map(|t| t.parent.as_ref().map(|p| p.as_str().to_string())) - .collect(); - - let scored = score::rank( - &open_tasks, - &edges, - &parents_with_open_children, - score::Mode::Impact, - 5, - ); - - let next_up: Vec = scored - .into_iter() - .map(|s| ScoredEntry { - short_id: TaskId::display_id(&s.id), - id: s.id, - title: s.title, - score: format!("{:.2}", s.score), - status: "open".to_string(), - status_display: friendly_status("open"), - }) - .collect(); - - // Apply filters. - let mut filtered: Vec<&db::Task> = tasks.iter().collect(); - - if let Some(ref s) = query.status { - if !s.is_empty() { - if let Ok(parsed) = db::parse_status(s) { - filtered.retain(|t| t.status == parsed); - } - } - } - if let Some(ref p) = query.priority { - if !p.is_empty() { - if let Ok(parsed) = db::parse_priority(p) { - filtered.retain(|t| t.priority == parsed); - } - } - } - if let Some(ref e) = query.effort { - if !e.is_empty() { - if let Ok(parsed) = db::parse_effort(e) { - filtered.retain(|t| t.effort == parsed); - } - } - } - if let Some(ref l) = query.label { - if !l.is_empty() { - filtered.retain(|t| t.labels.iter().any(|x| x == l)); - } - } - let search_term = query.q.clone().unwrap_or_default(); - if !search_term.is_empty() { - let q = search_term.to_ascii_lowercase(); - filtered.retain(|t| t.title.to_ascii_lowercase().contains(&q)); - } - - // Sort: user-selected column, or priority+created as default. - let sort_field = query - .sort - .as_deref() - .and_then(SortField::parse) - .unwrap_or(SortField::Priority); - let sort_order = query - .order - .as_deref() - .and_then(SortOrder::parse) - .unwrap_or_else(|| sort_field.default_order()); - sort_tasks(&mut filtered, sort_field, sort_order); - - // Pagination. - let total = filtered.len(); - let total_pages = if total == 0 { - 1 - } else { - total.div_ceil(PAGE_SIZE) - }; - let page = query.page.unwrap_or(1).clamp(1, total_pages); - let start = (page - 1) * PAGE_SIZE; - let end = (start + PAGE_SIZE).min(total); - - let page_tasks: Vec = filtered[start..end] - .iter() - .map(|t| { - let status = db::status_label(t.status).to_string(); - TaskRow { - full_id: t.id.as_str().to_string(), - short_id: t.id.short(), - status_display: friendly_status(&status), - status, - priority: db::priority_label(t.priority).to_string(), - effort: db::effort_label(t.effort).to_string(), - title: t.title.clone(), - created_at_display: friendly_date(&t.created_at), - created_at: t.created_at.clone(), - } - }) - .collect(); - - // Build filter query string for sort links (excludes sort/order/page). - let filter_qs = { - let mut parts = Vec::new(); - if let Some(ref s) = query.status { - parts.push(format!("status={s}")); - } - if let Some(ref p) = query.priority { - parts.push(format!("priority={p}")); - } - if let Some(ref e) = query.effort { - parts.push(format!("effort={e}")); - } - if let Some(ref l) = query.label { - parts.push(format!("label={l}")); - } - if !search_term.is_empty() { - parts.push(format!("q={search_term}")); - } - parts.join("&") - }; - - let proj_name = store.project_name().to_string(); - let sort_ctx = SortContext { - base_href: format!("/projects/{proj_name}"), - field: sort_field.as_str().to_string(), - order: sort_order.as_str().to_string(), - filter_qs, - }; - - Ok(ProjectTemplate { - all_projects, - active_project: Some(name), - project_name: proj_name, - stats_open, - stats_in_progress, - stats_closed, - next_up, - page_tasks, - all_labels, - filter_status: query.status, - filter_priority: query.priority, - filter_effort: query.effort, - filter_label: query.label, - filter_search: search_term, - page, - total_pages, - pagination_pages: (1..=total_pages).collect(), - sort_ctx, - }) - }) - .await; - - match result { - Ok(Ok(tmpl)) => render(tmpl), - Ok(Err(e)) => error_response(500, &format!("{e}"), &[]), - Err(e) => error_response(500, &format!("join error: {e}"), &[]), - } -} - -async fn task_handler( - State(state): State, - AxumPath((name, id)): AxumPath<(String, String)>, -) -> Response { - let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result { - let all_projects = list_projects_safe(&root); - let store = Store::open(&root, &name)?; - - let task_id = db::resolve_task_id(&store, &id, false)?; - let task = store - .get_task(&task_id, false)? - .ok_or_else(|| anyhow::anyhow!("task '{id}' not found"))?; - - // Partition blockers. - let partition = db::partition_blockers(&store, &task.blockers)?; - let blockers_open: Vec = partition - .open - .iter() - .map(|b| BlockerRef { - full_id: b.as_str().to_string(), - short_id: b.short(), - }) - .collect(); - let blockers_resolved: Vec = partition - .resolved - .iter() - .map(|b| BlockerRef { - full_id: b.as_str().to_string(), - short_id: b.short(), - }) - .collect(); - - // Find subtasks. - let all_tasks = store.list_tasks()?; - let subtasks: Vec = all_tasks - .iter() - .filter(|t| t.parent.as_ref() == Some(&task_id)) - .map(|t| { - let status = db::status_label(t.status).to_string(); - TaskRow { - full_id: t.id.as_str().to_string(), - short_id: t.id.short(), - status_display: friendly_status(&status), - status, - priority: db::priority_label(t.priority).to_string(), - effort: db::effort_label(t.effort).to_string(), - title: t.title.clone(), - created_at_display: friendly_date(&t.created_at), - created_at: t.created_at.clone(), - } - }) - .collect(); - - let task_view = TaskView { - full_id: task.id.as_str().to_string(), - short_id: task.id.short(), - title: task.title.clone(), - description: render_markdown(&task.description), - task_type: task.task_type.clone(), - status: db::status_label(task.status).to_string(), - priority: db::priority_label(task.priority).to_string(), - effort: db::effort_label(task.effort).to_string(), - created_at_display: friendly_date(&task.created_at), - created_at: task.created_at.clone(), - updated_at_display: friendly_date(&task.updated_at), - updated_at: task.updated_at.clone(), - labels: task.labels.clone(), - logs: task - .logs - .iter() - .map(|l| LogView { - timestamp_display: friendly_date(&l.timestamp), - timestamp: l.timestamp.clone(), - message: render_markdown(&l.message), - }) - .collect(), - }; - - Ok(TaskTemplate { - all_projects, - active_project: Some(name), - project_name: store.project_name().to_string(), - task: task_view, - blockers_open, - blockers_resolved, - subtasks, - }) - }) - .await; - - match result { - Ok(Ok(tmpl)) => render(tmpl), - Ok(Err(e)) => error_response(500, &format!("{e}"), &[]), - Err(e) => error_response(500, &format!("join error: {e}"), &[]), - } -} - -// --------------------------------------------------------------------------- -// Content negotiation -// --------------------------------------------------------------------------- - -/// Returns true when the client prefers a JSON response. -fn wants_json(headers: &HeaderMap) -> bool { - headers - .get("accept") - .and_then(|v| v.to_str().ok()) - .map(|v| v.contains("application/json")) - .unwrap_or(false) -} - -/// Build a redirect-or-JSON response after a successful mutation. -fn mutation_response( - headers: &HeaderMap, - redirect_to: &str, - json_body: serde_json::Value, -) -> Response { - if wants_json(headers) { - Json(json_body).into_response() - } else { - Redirect::to(redirect_to).into_response() - } -} - -/// Build an error response appropriate for the caller (JSON or HTML). -fn mutation_error(headers: &HeaderMap, code: u16, err: &anyhow::Error) -> Response { - let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); - if wants_json(headers) { - (status, Json(serde_json::json!({"error": err.to_string()}))).into_response() - } else { - error_response(code, &err.to_string(), &[]) - } -} - -// --------------------------------------------------------------------------- -// POST form/JSON input types -// --------------------------------------------------------------------------- - -#[derive(serde::Deserialize)] -struct CreateProjectForm { - name: String, - #[serde(default)] - bind_path: String, -} - -#[derive(serde::Deserialize)] -struct CreateForm { - title: String, - #[serde(default)] - description: String, - #[serde(default = "default_task_type")] - task_type: String, - #[serde(default = "default_priority")] - priority: String, - #[serde(default = "default_effort")] - effort: String, - #[serde(default)] - labels: String, - #[serde(default)] - parent: String, -} - -fn default_task_type() -> String { - "task".to_string() -} -fn default_priority() -> String { - "medium".to_string() -} -fn default_effort() -> String { - "medium".to_string() -} - -#[derive(serde::Deserialize)] -struct UpdateForm { - #[serde(default)] - title: Option, - #[serde(default)] - description: Option, - #[serde(default)] - status: Option, - #[serde(default)] - priority: Option, - #[serde(default)] - effort: Option, - #[serde(default)] - redirect: Option, -} - -#[derive(serde::Deserialize)] -struct LogForm { - message: String, -} - -#[derive(serde::Deserialize)] -struct LabelForm { - /// "add" or "rm" - action: String, - label: String, -} - -#[derive(serde::Deserialize)] -struct DepForm { - /// "add" or "rm" - action: String, - blocker: String, -} - -// --------------------------------------------------------------------------- -// POST handlers -// --------------------------------------------------------------------------- - -async fn create_project_handler( - State(state): State, - headers: HeaderMap, - Form(form): Form, -) -> Response { - let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result { - let bind = if form.bind_path.is_empty() { - None - } else { - Some(std::path::PathBuf::from(&form.bind_path)) - }; - ops::init_project(&root, &form.name, bind.as_deref())?; - Ok(form.name) - }) - .await; - - match result { - Ok(Ok(name)) => { - let redirect = format!("/projects/{name}"); - mutation_response(&headers, &redirect, serde_json::json!({"name": name})) - } - Ok(Err(e)) => mutation_error(&headers, 400, &e), - Err(e) => mutation_error(&headers, 500, &e.into()), - } -} - -async fn create_handler( - State(state): State, - AxumPath(name): AxumPath, - headers: HeaderMap, - Form(form): Form, -) -> Response { - let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result<(db::Task, String)> { - let store = Store::open(&root, &name)?; - - let parent = if form.parent.is_empty() { - None - } else { - Some(db::resolve_task_id(&store, &form.parent, false)?) - }; - - let labels: Vec = form - .labels - .split(',') - .map(str::trim) - .filter(|l| !l.is_empty()) - .map(String::from) - .collect(); - - let task = ops::create_task( - &store, - ops::CreateOpts { - title: form.title, - description: form.description, - task_type: form.task_type, - priority: db::parse_priority(&form.priority)?, - effort: db::parse_effort(&form.effort)?, - parent, - labels, - }, - )?; - - let redirect = format!("/projects/{}/tasks/{}", name, task.id.as_str()); - Ok((task, redirect)) - }) - .await; - - match result { - Ok(Ok((task, redirect))) => mutation_response( - &headers, - &redirect, - serde_json::to_value(&task).unwrap_or_default(), - ), - Ok(Err(e)) => mutation_error(&headers, 400, &e), - Err(e) => mutation_error(&headers, 500, &e.into()), - } -} - -async fn update_handler( - State(state): State, - AxumPath((name, id)): AxumPath<(String, String)>, - headers: HeaderMap, - Form(form): Form, -) -> Response { - let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result<(db::Task, String)> { - let store = Store::open(&root, &name)?; - let task_id = db::resolve_task_id(&store, &id, false)?; - - let task = ops::update_task( - &store, - &task_id, - ops::UpdateOpts { - status: form - .status - .as_deref() - .filter(|s| !s.is_empty()) - .map(db::parse_status) - .transpose()?, - priority: form - .priority - .as_deref() - .filter(|s| !s.is_empty()) - .map(db::parse_priority) - .transpose()?, - effort: form - .effort - .as_deref() - .filter(|s| !s.is_empty()) - .map(db::parse_effort) - .transpose()?, - title: form.title.filter(|s| !s.is_empty()), - description: form.description, - }, - )?; - - let redirect = form - .redirect - .filter(|r| r.starts_with('/')) - .unwrap_or_else(|| format!("/projects/{}/tasks/{}", name, task.id.as_str())); - Ok((task, redirect)) - }) - .await; - - match result { - Ok(Ok((task, redirect))) => mutation_response( - &headers, - &redirect, - serde_json::to_value(&task).unwrap_or_default(), - ), - Ok(Err(e)) => mutation_error(&headers, 400, &e), - Err(e) => mutation_error(&headers, 500, &e.into()), - } -} - -async fn log_handler( - State(state): State, - AxumPath((name, id)): AxumPath<(String, String)>, - headers: HeaderMap, - Form(form): Form, -) -> Response { - let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result<(db::LogEntry, String)> { - let store = Store::open(&root, &name)?; - let task_id = db::resolve_task_id(&store, &id, false)?; - let entry = ops::add_log(&store, &task_id, &form.message)?; - let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str()); - Ok((entry, redirect)) - }) - .await; - - match result { - Ok(Ok((entry, redirect))) => mutation_response( - &headers, - &redirect, - serde_json::to_value(&entry).unwrap_or_default(), - ), - Ok(Err(e)) => mutation_error(&headers, 400, &e), - Err(e) => mutation_error(&headers, 500, &e.into()), - } -} - -async fn done_handler( - State(state): State, - AxumPath((name, id)): AxumPath<(String, String)>, - headers: HeaderMap, -) -> Response { - let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result<(TaskId, String)> { - let store = Store::open(&root, &name)?; - let task_id = db::resolve_task_id(&store, &id, false)?; - ops::mark_done(&store, &task_id)?; - let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str()); - Ok((task_id, redirect)) - }) - .await; - - match result { - Ok(Ok((task_id, redirect))) => mutation_response( - &headers, - &redirect, - serde_json::json!({"id": task_id, "status": "closed"}), - ), - Ok(Err(e)) => mutation_error(&headers, 400, &e), - Err(e) => mutation_error(&headers, 500, &e.into()), - } -} - -async fn reopen_handler( - State(state): State, - AxumPath((name, id)): AxumPath<(String, String)>, - headers: HeaderMap, -) -> Response { - let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result<(TaskId, String)> { - let store = Store::open(&root, &name)?; - let task_id = db::resolve_task_id(&store, &id, false)?; - ops::reopen_task(&store, &task_id)?; - let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str()); - Ok((task_id, redirect)) - }) - .await; - - match result { - Ok(Ok((task_id, redirect))) => mutation_response( - &headers, - &redirect, - serde_json::json!({"id": task_id, "status": "open"}), - ), - Ok(Err(e)) => mutation_error(&headers, 400, &e), - Err(e) => mutation_error(&headers, 500, &e.into()), - } -} - -async fn label_handler( - State(state): State, - AxumPath((name, id)): AxumPath<(String, String)>, - headers: HeaderMap, - Form(form): Form, -) -> Response { - let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result { - let store = Store::open(&root, &name)?; - let task_id = db::resolve_task_id(&store, &id, false)?; - match form.action.as_str() { - "add" => ops::add_label(&store, &task_id, &form.label)?, - "rm" => ops::remove_label(&store, &task_id, &form.label)?, - other => anyhow::bail!("unknown label action '{other}'; expected 'add' or 'rm'"), - } - Ok(format!("/projects/{}/tasks/{}", name, task_id.as_str())) - }) - .await; - - match result { - Ok(Ok(redirect)) => mutation_response(&headers, &redirect, serde_json::json!({"ok": true})), - Ok(Err(e)) => mutation_error(&headers, 400, &e), - Err(e) => mutation_error(&headers, 500, &e.into()), - } -} - -async fn dep_handler( - State(state): State, - AxumPath((name, id)): AxumPath<(String, String)>, - headers: HeaderMap, - Form(form): Form, -) -> Response { - let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result { - let store = Store::open(&root, &name)?; - let child_id = db::resolve_task_id(&store, &id, false)?; - let blocker_id = db::resolve_task_id(&store, &form.blocker, form.action == "rm")?; - match form.action.as_str() { - "add" => ops::add_dep(&store, &child_id, &blocker_id)?, - "rm" => ops::remove_dep(&store, &child_id, &blocker_id)?, - other => anyhow::bail!("unknown dep action '{other}'; expected 'add' or 'rm'"), - } - Ok(format!("/projects/{}/tasks/{}", name, child_id.as_str())) - }) - .await; - - match result { - Ok(Ok(redirect)) => mutation_response(&headers, &redirect, serde_json::json!({"ok": true})), - Ok(Err(e)) => mutation_error(&headers, 400, &e), - Err(e) => mutation_error(&headers, 500, &e.into()), - } -} - -async fn delete_handler( - State(state): State, - AxumPath((name, id)): AxumPath<(String, String)>, - headers: HeaderMap, -) -> Response { - let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result<(ops::DeleteResult, String)> { - let store = Store::open(&root, &name)?; - let task_id = db::resolve_task_id(&store, &id, false)?; - let dr = ops::soft_delete(&store, &[task_id], false)?; - let redirect = format!("/projects/{}", name); - Ok((dr, redirect)) - }) - .await; - - match result { - Ok(Ok((dr, redirect))) => { - let json_body = serde_json::json!({ - "deleted_ids": dr.deleted_ids.iter().map(ToString::to_string).collect::>(), - "unblocked_ids": dr.unblocked_ids.iter().map(ToString::to_string).collect::>(), - }); - mutation_response(&headers, &redirect, json_body) - } - Ok(Err(e)) => mutation_error(&headers, 400, &e), - Err(e) => mutation_error(&headers, 500, &e.into()), - } -} - -async fn static_oat_css() -> impl IntoResponse { - ( - [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")], - include_bytes!("../../static/oat.min.css").as_slice(), - ) -} - -async fn static_td_css() -> impl IntoResponse { - ( - [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")], - include_bytes!("../../static/td.css").as_slice(), - ) -} - -async fn static_js() -> impl IntoResponse { - ( - [( - axum::http::header::CONTENT_TYPE, - "application/javascript; charset=utf-8", - )], - include_bytes!("../../static/oat.min.js").as_slice(), - ) -} - -async fn static_td_js() -> impl IntoResponse { - ( - [( - axum::http::header::CONTENT_TYPE, - "application/javascript; charset=utf-8", - )], - include_bytes!("../../static/td.js").as_slice(), - ) -} - -// --------------------------------------------------------------------------- -// Entry point -// --------------------------------------------------------------------------- - -pub fn run(cwd: &Path, host: &str, port: u16, explicit_project: Option<&str>) -> Result<()> { - let data_root = db::data_root()?; - let state = AppState { - data_root: Arc::new(data_root), - }; - - let app = Router::new() - .route("/", get(index_handler)) - .route("/projects", post(create_project_handler)) - .route("/projects/{name}", get(project_handler)) - .route("/projects/{name}/tasks", post(create_handler)) - .route( - "/projects/{name}/tasks/{id}", - get(task_handler).post(update_handler), - ) - .route("/projects/{name}/tasks/{id}/log", post(log_handler)) - .route("/projects/{name}/tasks/{id}/done", post(done_handler)) - .route("/projects/{name}/tasks/{id}/reopen", post(reopen_handler)) - .route("/projects/{name}/tasks/{id}/labels", post(label_handler)) - .route("/projects/{name}/tasks/{id}/deps", post(dep_handler)) - .route("/projects/{name}/tasks/{id}/delete", post(delete_handler)) - .route("/static/oat.min.css", get(static_oat_css)) - .route("/static/td.css", get(static_td_css)) - .route("/static/oat.min.js", get(static_js)) - .route("/static/td.js", get(static_td_js)) - .with_state(state); - - let addr = format!("{host}:{port}"); - let root_url = format!("http://{addr}"); - - // Resolve current project for a convenience URL. - let project_url = match explicit_project { - Some(p) => Some(format!("{root_url}/projects/{p}")), - None => db::try_open(cwd) - .ok() - .flatten() - .map(|s| format!("{root_url}/projects/{}", s.project_name())), - }; - - eprintln!("listening on {root_url}"); - if let Some(ref url) = project_url { - eprintln!("project: {url}"); - } - - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()? - .block_on(async { - let listener = tokio::net::TcpListener::bind(&addr).await?; - axum::serve(listener, app).await?; - Ok(()) - }) -} diff --git a/src/cmd/webui/handlers.rs b/src/cmd/webui/handlers.rs new file mode 100644 index 0000000000000000000000000000000000000000..2f743e767b3609d6df819889d7f5cc5d6997d460 --- /dev/null +++ b/src/cmd/webui/handlers.rs @@ -0,0 +1,410 @@ +use std::collections::HashSet; + +use anyhow::Result; +use axum::extract::{Path as AxumPath, Query, State}; +use axum::response::Response; + +use crate::db::{self, Store, TaskId}; +use crate::score; + +use super::helpers::{ + error_response, friendly_date, friendly_status, list_projects_safe, render, render_markdown, + sort_tasks, SortField, SortOrder, +}; +use super::views::{ + BlockerRef, IndexTemplate, LogView, ProjectCard, ProjectTemplate, ScoredEntry, SortContext, + TaskRow, TaskTemplate, TaskView, +}; +use super::AppState; + +const PAGE_SIZE: usize = 25; + +pub(super) async fn index_handler(State(state): State) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result { + let projects = list_projects_safe(&root); + let mut cards = Vec::with_capacity(projects.len()); + + for name in &projects { + match Store::open(&root, name) { + Ok(store) => { + let tasks = store.list_tasks()?; + let open = tasks + .iter() + .filter(|t| t.status == db::Status::Open) + .count(); + let in_progress = tasks + .iter() + .filter(|t| t.status == db::Status::InProgress) + .count(); + let closed = tasks + .iter() + .filter(|t| t.status == db::Status::Closed) + .count(); + cards.push(ProjectCard::Ok { + name: name.clone(), + open, + in_progress, + closed, + total: tasks.len(), + }); + } + Err(e) => { + cards.push(ProjectCard::Err { + name: name.clone(), + error: format!("{e}"), + }); + } + } + } + + Ok(IndexTemplate { + all_projects: projects, + active_project: None, + projects: cards, + }) + }) + .await; + + match result { + Ok(Ok(tmpl)) => render(tmpl), + Ok(Err(e)) => error_response(500, &format!("{e}"), &[]), + Err(e) => error_response(500, &format!("join error: {e}"), &[]), + } +} + +#[derive(serde::Deserialize)] +pub(super) struct ProjectQuery { + status: Option, + priority: Option, + effort: Option, + label: Option, + q: Option, + page: Option, + sort: Option, + order: Option, +} + +pub(super) async fn project_handler( + State(state): State, + AxumPath(name): AxumPath, + Query(mut query): Query, +) -> Response { + // Default to showing open tasks when no status filter is specified. + if query.status.is_none() { + query.status = Some("open".to_string()); + } + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result { + let all_projects = list_projects_safe(&root); + let store = Store::open(&root, &name)?; + let tasks = store.list_tasks()?; + + // Stats from the full unfiltered set. + let stats_open = tasks + .iter() + .filter(|t| t.status == db::Status::Open) + .count(); + let stats_in_progress = tasks + .iter() + .filter(|t| t.status == db::Status::InProgress) + .count(); + let stats_closed = tasks + .iter() + .filter(|t| t.status == db::Status::Closed) + .count(); + + // Collect distinct labels for the filter dropdown. + let mut label_set: HashSet = HashSet::new(); + for t in &tasks { + for l in &t.labels { + label_set.insert(l.clone()); + } + } + let mut all_labels: Vec = label_set.into_iter().collect(); + all_labels.sort(); + + // Next-up scoring (top 5 open tasks). + let open_tasks: Vec = tasks + .iter() + .filter(|t| t.status == db::Status::Open) + .map(|t| score::TaskInput { + id: t.id.as_str().to_string(), + title: t.title.clone(), + priority_score: t.priority.score(), + effort_score: t.effort.score(), + priority_label: db::priority_label(t.priority).to_string(), + effort_label: db::effort_label(t.effort).to_string(), + }) + .collect(); + + let edges: Vec<(String, String)> = tasks + .iter() + .filter(|t| t.status == db::Status::Open) + .flat_map(|t| { + t.blockers + .iter() + .map(|b| (t.id.as_str().to_string(), b.as_str().to_string())) + .collect::>() + }) + .collect(); + + let parents_with_open_children: HashSet = tasks + .iter() + .filter(|t| t.status == db::Status::Open) + .filter_map(|t| t.parent.as_ref().map(|p| p.as_str().to_string())) + .collect(); + + let scored = score::rank( + &open_tasks, + &edges, + &parents_with_open_children, + score::Mode::Impact, + 5, + ); + + let next_up: Vec = scored + .into_iter() + .map(|s| ScoredEntry { + short_id: TaskId::display_id(&s.id), + id: s.id, + title: s.title, + score: format!("{:.2}", s.score), + status: "open".to_string(), + status_display: friendly_status("open"), + }) + .collect(); + + // Apply filters. + let mut filtered: Vec<&db::Task> = tasks.iter().collect(); + + if let Some(ref s) = query.status { + if !s.is_empty() { + if let Ok(parsed) = db::parse_status(s) { + filtered.retain(|t| t.status == parsed); + } + } + } + if let Some(ref p) = query.priority { + if !p.is_empty() { + if let Ok(parsed) = db::parse_priority(p) { + filtered.retain(|t| t.priority == parsed); + } + } + } + if let Some(ref e) = query.effort { + if !e.is_empty() { + if let Ok(parsed) = db::parse_effort(e) { + filtered.retain(|t| t.effort == parsed); + } + } + } + if let Some(ref l) = query.label { + if !l.is_empty() { + filtered.retain(|t| t.labels.iter().any(|x| x == l)); + } + } + let search_term = query.q.clone().unwrap_or_default(); + if !search_term.is_empty() { + let q = search_term.to_ascii_lowercase(); + filtered.retain(|t| t.title.to_ascii_lowercase().contains(&q)); + } + + // Sort: user-selected column, or priority+created as default. + let sort_field = query + .sort + .as_deref() + .and_then(SortField::parse) + .unwrap_or(SortField::Priority); + let sort_order = query + .order + .as_deref() + .and_then(SortOrder::parse) + .unwrap_or_else(|| sort_field.default_order()); + sort_tasks(&mut filtered, sort_field, sort_order); + + // Pagination. + let total = filtered.len(); + let total_pages = if total == 0 { + 1 + } else { + total.div_ceil(PAGE_SIZE) + }; + let page = query.page.unwrap_or(1).clamp(1, total_pages); + let start = (page - 1) * PAGE_SIZE; + let end = (start + PAGE_SIZE).min(total); + + let page_tasks: Vec = filtered[start..end] + .iter() + .map(|t| { + let status = db::status_label(t.status).to_string(); + TaskRow { + full_id: t.id.as_str().to_string(), + short_id: t.id.short(), + status_display: friendly_status(&status), + status, + priority: db::priority_label(t.priority).to_string(), + effort: db::effort_label(t.effort).to_string(), + title: t.title.clone(), + created_at_display: friendly_date(&t.created_at), + created_at: t.created_at.clone(), + } + }) + .collect(); + + // Build filter query string for sort links (excludes sort/order/page). + let filter_qs = { + let mut parts = Vec::new(); + if let Some(ref s) = query.status { + parts.push(format!("status={s}")); + } + if let Some(ref p) = query.priority { + parts.push(format!("priority={p}")); + } + if let Some(ref e) = query.effort { + parts.push(format!("effort={e}")); + } + if let Some(ref l) = query.label { + parts.push(format!("label={l}")); + } + if !search_term.is_empty() { + parts.push(format!("q={search_term}")); + } + parts.join("&") + }; + + let proj_name = store.project_name().to_string(); + let sort_ctx = SortContext { + base_href: format!("/projects/{proj_name}"), + field: sort_field.as_str().to_string(), + order: sort_order.as_str().to_string(), + filter_qs, + }; + + Ok(ProjectTemplate { + all_projects, + active_project: Some(name), + project_name: proj_name, + stats_open, + stats_in_progress, + stats_closed, + next_up, + page_tasks, + all_labels, + filter_status: query.status, + filter_priority: query.priority, + filter_effort: query.effort, + filter_label: query.label, + filter_search: search_term, + page, + total_pages, + pagination_pages: (1..=total_pages).collect(), + sort_ctx, + }) + }) + .await; + + match result { + Ok(Ok(tmpl)) => render(tmpl), + Ok(Err(e)) => error_response(500, &format!("{e}"), &[]), + Err(e) => error_response(500, &format!("join error: {e}"), &[]), + } +} + +pub(super) async fn task_handler( + State(state): State, + AxumPath((name, id)): AxumPath<(String, String)>, +) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result { + let all_projects = list_projects_safe(&root); + let store = Store::open(&root, &name)?; + + let task_id = db::resolve_task_id(&store, &id, false)?; + let task = store + .get_task(&task_id, false)? + .ok_or_else(|| anyhow::anyhow!("task '{id}' not found"))?; + + // Partition blockers. + let partition = db::partition_blockers(&store, &task.blockers)?; + let blockers_open: Vec = partition + .open + .iter() + .map(|b| BlockerRef { + full_id: b.as_str().to_string(), + short_id: b.short(), + }) + .collect(); + let blockers_resolved: Vec = partition + .resolved + .iter() + .map(|b| BlockerRef { + full_id: b.as_str().to_string(), + short_id: b.short(), + }) + .collect(); + + // Find subtasks. + let all_tasks = store.list_tasks()?; + let subtasks: Vec = all_tasks + .iter() + .filter(|t| t.parent.as_ref() == Some(&task_id)) + .map(|t| { + let status = db::status_label(t.status).to_string(); + TaskRow { + full_id: t.id.as_str().to_string(), + short_id: t.id.short(), + status_display: friendly_status(&status), + status, + priority: db::priority_label(t.priority).to_string(), + effort: db::effort_label(t.effort).to_string(), + title: t.title.clone(), + created_at_display: friendly_date(&t.created_at), + created_at: t.created_at.clone(), + } + }) + .collect(); + + let task_view = TaskView { + full_id: task.id.as_str().to_string(), + short_id: task.id.short(), + title: task.title.clone(), + description: render_markdown(&task.description), + task_type: task.task_type.clone(), + status: db::status_label(task.status).to_string(), + priority: db::priority_label(task.priority).to_string(), + effort: db::effort_label(task.effort).to_string(), + created_at_display: friendly_date(&task.created_at), + created_at: task.created_at.clone(), + updated_at_display: friendly_date(&task.updated_at), + updated_at: task.updated_at.clone(), + labels: task.labels.clone(), + logs: task + .logs + .iter() + .map(|l| LogView { + timestamp_display: friendly_date(&l.timestamp), + timestamp: l.timestamp.clone(), + message: render_markdown(&l.message), + }) + .collect(), + }; + + Ok(TaskTemplate { + all_projects, + active_project: Some(name), + project_name: store.project_name().to_string(), + task: task_view, + blockers_open, + blockers_resolved, + subtasks, + }) + }) + .await; + + match result { + Ok(Ok(tmpl)) => render(tmpl), + Ok(Err(e)) => error_response(500, &format!("{e}"), &[]), + Err(e) => error_response(500, &format!("join error: {e}"), &[]), + } +} diff --git a/src/cmd/webui/helpers.rs b/src/cmd/webui/helpers.rs new file mode 100644 index 0000000000000000000000000000000000000000..4ebd7cd140133ec9f1ea9d92ad45190fd2fe2670 --- /dev/null +++ b/src/cmd/webui/helpers.rs @@ -0,0 +1,204 @@ +use askama::Template; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::Json; + +use crate::db; + +use super::views::ErrorTemplate; + +/// Columns the task table can be sorted by. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum SortField { + Id, + Status, + Priority, + Effort, + Title, + Created, +} + +impl SortField { + pub(super) fn parse(s: &str) -> Option { + match s { + "id" => Some(Self::Id), + "status" => Some(Self::Status), + "priority" => Some(Self::Priority), + "effort" => Some(Self::Effort), + "title" => Some(Self::Title), + "created" => Some(Self::Created), + _ => None, + } + } + + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Id => "id", + Self::Status => "status", + Self::Priority => "priority", + Self::Effort => "effort", + Self::Title => "title", + Self::Created => "created", + } + } + + /// Sensible default direction when the user first clicks a column. + pub(super) fn default_order(self) -> SortOrder { + match self { + // Newest first, alphabetical ascending for text fields. + Self::Created => SortOrder::Desc, + Self::Title | Self::Id => SortOrder::Asc, + // Highest priority/effort first; open before closed. + Self::Priority | Self::Effort | Self::Status => SortOrder::Asc, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum SortOrder { + Asc, + Desc, +} + +impl SortOrder { + pub(super) fn parse(s: &str) -> Option { + match s { + "asc" => Some(Self::Asc), + "desc" => Some(Self::Desc), + _ => None, + } + } + + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Asc => "asc", + Self::Desc => "desc", + } + } +} + +/// Map a `Status` to a numeric value for semantic sorting. +/// Lower values sort first in ascending order: open → in_progress → closed. +fn status_sort_key(s: db::Status) -> i32 { + match s { + db::Status::Open => 1, + db::Status::InProgress => 2, + db::Status::Closed => 3, + } +} + +/// Apply the chosen sort field and direction to a filtered task list. +pub(super) fn sort_tasks(tasks: &mut [&db::Task], field: SortField, order: SortOrder) { + tasks.sort_by(|a, b| { + let cmp = match field { + SortField::Id => a.id.as_str().cmp(b.id.as_str()), + SortField::Status => status_sort_key(a.status).cmp(&status_sort_key(b.status)), + SortField::Priority => a.priority.score().cmp(&b.priority.score()), + SortField::Effort => a.effort.score().cmp(&b.effort.score()), + SortField::Title => a + .title + .to_ascii_lowercase() + .cmp(&b.title.to_ascii_lowercase()), + SortField::Created => a.created_at.cmp(&b.created_at), + }; + match order { + SortOrder::Asc => cmp, + SortOrder::Desc => cmp.reverse(), + } + }); +} + +/// Return a human-friendly status label (e.g. "In progress" instead of +/// "in_progress"). +pub(super) fn friendly_status(raw: &str) -> &'static str { + match raw { + "open" => "Open", + "in_progress" => "In progress", + "closed" => "Closed", + _ => "Open", + } +} + +/// Format an ISO 8601 timestamp into a human-friendly form (e.g. "15 Mar 2026") +/// for the noscript fallback. Returns the original string unchanged on parse +/// failure so the page still renders something sensible. +pub(super) fn friendly_date(iso: &str) -> String { + chrono::NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%SZ") + .map(|dt| dt.format("%-d %b %Y %H:%M").to_string()) + .unwrap_or_else(|_| iso.to_string()) +} + +/// Render a markdown string to sanitised HTML. +/// +/// Uses pulldown-cmark for parsing and ammonia for sanitisation so that +/// untrusted user input can be displayed safely. Images are stripped +/// entirely — only structural/inline markup is allowed through. +pub(super) fn render_markdown(src: &str) -> String { + use pulldown_cmark::{html::push_html, Parser}; + + let parser = Parser::new(src); + let mut raw_html = String::new(); + push_html(&mut raw_html, parser); + + ammonia::Builder::default() + .rm_tags(&["img"]) + .clean(&raw_html) + .to_string() +} + +pub(super) fn render(tmpl: impl Template) -> Response { + match tmpl.render() { + Ok(html) => Html(html).into_response(), + Err(e) => error_response(500, &format!("template render failed: {e}"), &[]), + } +} + +pub(super) fn error_response(code: u16, msg: &str, all_projects: &[String]) -> Response { + let body = ErrorTemplate { + all_projects: all_projects.to_vec(), + active_project: None, + status_code: code, + message: msg.to_string(), + }; + let html = body + .render() + .unwrap_or_else(|_| format!("

{code}

{msg}

")); + let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + (status, Html(html)).into_response() +} + +pub(super) fn list_projects_safe(root: &std::path::Path) -> Vec { + db::list_projects_in(root).unwrap_or_default() +} + +/// Returns true when the client prefers a JSON response. +fn wants_json(headers: &HeaderMap) -> bool { + headers + .get("accept") + .and_then(|v| v.to_str().ok()) + .map(|v| v.contains("application/json")) + .unwrap_or(false) +} + +/// Build a redirect-or-JSON response after a successful mutation. +pub(super) fn mutation_response( + headers: &HeaderMap, + redirect_to: &str, + json_body: serde_json::Value, +) -> Response { + if wants_json(headers) { + Json(json_body).into_response() + } else { + Redirect::to(redirect_to).into_response() + } +} + +/// Build an error response appropriate for the caller (JSON or HTML). +pub(super) fn mutation_error(headers: &HeaderMap, code: u16, err: &anyhow::Error) -> Response { + let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + if wants_json(headers) { + (status, Json(serde_json::json!({"error": err.to_string()}))).into_response() + } else { + error_response(code, &err.to_string(), &[]) + } +} diff --git a/src/cmd/webui/mod.rs b/src/cmd/webui/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..a83a98d31d7b372082fb98e66ac40f8d1da38b55 --- /dev/null +++ b/src/cmd/webui/mod.rs @@ -0,0 +1,125 @@ +mod handlers; +mod helpers; +mod mutations; +mod views; + +use std::path::Path; +use std::sync::Arc; + +use anyhow::Result; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::Router; + +use crate::db; + +#[derive(Clone)] +struct AppState { + data_root: Arc, +} + +async fn static_oat_css() -> impl IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")], + include_bytes!("../../../static/oat.min.css").as_slice(), + ) +} + +async fn static_td_css() -> impl IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")], + include_bytes!("../../../static/td.css").as_slice(), + ) +} + +async fn static_js() -> impl IntoResponse { + ( + [( + axum::http::header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + )], + include_bytes!("../../../static/oat.min.js").as_slice(), + ) +} + +async fn static_td_js() -> impl IntoResponse { + ( + [( + axum::http::header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + )], + include_bytes!("../../../static/td.js").as_slice(), + ) +} + +pub fn run(cwd: &Path, host: &str, port: u16, explicit_project: Option<&str>) -> Result<()> { + let data_root = db::data_root()?; + let state = AppState { + data_root: Arc::new(data_root), + }; + + let app = Router::new() + .route("/", get(handlers::index_handler)) + .route("/projects", post(mutations::create_project_handler)) + .route("/projects/{name}", get(handlers::project_handler)) + .route("/projects/{name}/tasks", post(mutations::create_handler)) + .route( + "/projects/{name}/tasks/{id}", + get(handlers::task_handler).post(mutations::update_handler), + ) + .route( + "/projects/{name}/tasks/{id}/log", + post(mutations::log_handler), + ) + .route( + "/projects/{name}/tasks/{id}/done", + post(mutations::done_handler), + ) + .route( + "/projects/{name}/tasks/{id}/reopen", + post(mutations::reopen_handler), + ) + .route( + "/projects/{name}/tasks/{id}/labels", + post(mutations::label_handler), + ) + .route( + "/projects/{name}/tasks/{id}/deps", + post(mutations::dep_handler), + ) + .route( + "/projects/{name}/tasks/{id}/delete", + post(mutations::delete_handler), + ) + .route("/static/oat.min.css", get(static_oat_css)) + .route("/static/td.css", get(static_td_css)) + .route("/static/oat.min.js", get(static_js)) + .route("/static/td.js", get(static_td_js)) + .with_state(state); + + let addr = format!("{host}:{port}"); + let root_url = format!("http://{addr}"); + + // Resolve current project for a convenience URL. + let project_url = match explicit_project { + Some(p) => Some(format!("{root_url}/projects/{p}")), + None => db::try_open(cwd) + .ok() + .flatten() + .map(|s| format!("{root_url}/projects/{}", s.project_name())), + }; + + eprintln!("listening on {root_url}"); + if let Some(ref url) = project_url { + eprintln!("project: {url}"); + } + + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(async { + let listener = tokio::net::TcpListener::bind(&addr).await?; + axum::serve(listener, app).await?; + Ok(()) + }) +} diff --git a/src/cmd/webui/mutations.rs b/src/cmd/webui/mutations.rs new file mode 100644 index 0000000000000000000000000000000000000000..0071255025484b100ebb9887580305e51596c2ad --- /dev/null +++ b/src/cmd/webui/mutations.rs @@ -0,0 +1,379 @@ +use anyhow::Result; +use axum::extract::{Path as AxumPath, State}; +use axum::http::HeaderMap; +use axum::response::Response; +use axum::Form; + +use crate::db::{self, Store, TaskId}; +use crate::ops; + +use super::helpers::{mutation_error, mutation_response}; +use super::AppState; + +#[derive(serde::Deserialize)] +pub(super) struct ProjectForm { + name: String, + #[serde(default)] + bind_path: String, +} + +#[derive(serde::Deserialize)] +pub(super) struct CreateForm { + title: String, + #[serde(default)] + description: String, + #[serde(default = "default_task_type")] + task_type: String, + #[serde(default = "default_priority")] + priority: String, + #[serde(default = "default_effort")] + effort: String, + #[serde(default)] + labels: String, + #[serde(default)] + parent: String, +} + +fn default_task_type() -> String { + "task".to_string() +} + +fn default_priority() -> String { + "medium".to_string() +} + +fn default_effort() -> String { + "medium".to_string() +} + +#[derive(serde::Deserialize)] +pub(super) struct UpdateForm { + #[serde(default)] + title: Option, + #[serde(default)] + description: Option, + #[serde(default)] + status: Option, + #[serde(default)] + priority: Option, + #[serde(default)] + effort: Option, + #[serde(default)] + redirect: Option, +} + +#[derive(serde::Deserialize)] +pub(super) struct LogForm { + message: String, +} + +#[derive(serde::Deserialize)] +pub(super) struct LabelForm { + /// "add" or "rm" + action: String, + label: String, +} + +#[derive(serde::Deserialize)] +pub(super) struct DepForm { + /// "add" or "rm" + action: String, + blocker: String, +} + +pub(super) async fn create_project_handler( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result { + let bind = if form.bind_path.is_empty() { + None + } else { + Some(std::path::PathBuf::from(&form.bind_path)) + }; + ops::init_project(&root, &form.name, bind.as_deref())?; + Ok(form.name) + }) + .await; + + match result { + Ok(Ok(name)) => { + let redirect = format!("/projects/{name}"); + mutation_response(&headers, &redirect, serde_json::json!({"name": name})) + } + Ok(Err(e)) => mutation_error(&headers, 400, &e), + Err(e) => mutation_error(&headers, 500, &e.into()), + } +} + +pub(super) async fn create_handler( + State(state): State, + AxumPath(name): AxumPath, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result<(db::Task, String)> { + let store = Store::open(&root, &name)?; + + let parent = if form.parent.is_empty() { + None + } else { + Some(db::resolve_task_id(&store, &form.parent, false)?) + }; + + let labels: Vec = form + .labels + .split(',') + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(String::from) + .collect(); + + let task = ops::create_task( + &store, + ops::CreateOpts { + title: form.title, + description: form.description, + task_type: form.task_type, + priority: db::parse_priority(&form.priority)?, + effort: db::parse_effort(&form.effort)?, + parent, + labels, + }, + )?; + + let redirect = format!("/projects/{}/tasks/{}", name, task.id.as_str()); + Ok((task, redirect)) + }) + .await; + + match result { + Ok(Ok((task, redirect))) => mutation_response( + &headers, + &redirect, + serde_json::to_value(&task).unwrap_or_default(), + ), + Ok(Err(e)) => mutation_error(&headers, 400, &e), + Err(e) => mutation_error(&headers, 500, &e.into()), + } +} + +pub(super) async fn update_handler( + State(state): State, + AxumPath((name, id)): AxumPath<(String, String)>, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result<(db::Task, String)> { + let store = Store::open(&root, &name)?; + let task_id = db::resolve_task_id(&store, &id, false)?; + + let task = ops::update_task( + &store, + &task_id, + ops::UpdateOpts { + status: form + .status + .as_deref() + .filter(|s| !s.is_empty()) + .map(db::parse_status) + .transpose()?, + priority: form + .priority + .as_deref() + .filter(|s| !s.is_empty()) + .map(db::parse_priority) + .transpose()?, + effort: form + .effort + .as_deref() + .filter(|s| !s.is_empty()) + .map(db::parse_effort) + .transpose()?, + title: form.title.filter(|s| !s.is_empty()), + description: form.description, + }, + )?; + + let redirect = form + .redirect + .filter(|r| r.starts_with('/')) + .unwrap_or_else(|| format!("/projects/{}/tasks/{}", name, task.id.as_str())); + Ok((task, redirect)) + }) + .await; + + match result { + Ok(Ok((task, redirect))) => mutation_response( + &headers, + &redirect, + serde_json::to_value(&task).unwrap_or_default(), + ), + Ok(Err(e)) => mutation_error(&headers, 400, &e), + Err(e) => mutation_error(&headers, 500, &e.into()), + } +} + +pub(super) async fn log_handler( + State(state): State, + AxumPath((name, id)): AxumPath<(String, String)>, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result<(db::LogEntry, String)> { + let store = Store::open(&root, &name)?; + let task_id = db::resolve_task_id(&store, &id, false)?; + let entry = ops::add_log(&store, &task_id, &form.message)?; + let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str()); + Ok((entry, redirect)) + }) + .await; + + match result { + Ok(Ok((entry, redirect))) => mutation_response( + &headers, + &redirect, + serde_json::to_value(&entry).unwrap_or_default(), + ), + Ok(Err(e)) => mutation_error(&headers, 400, &e), + Err(e) => mutation_error(&headers, 500, &e.into()), + } +} + +pub(super) async fn done_handler( + State(state): State, + AxumPath((name, id)): AxumPath<(String, String)>, + headers: HeaderMap, +) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result<(TaskId, String)> { + let store = Store::open(&root, &name)?; + let task_id = db::resolve_task_id(&store, &id, false)?; + ops::mark_done(&store, &task_id)?; + let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str()); + Ok((task_id, redirect)) + }) + .await; + + match result { + Ok(Ok((task_id, redirect))) => mutation_response( + &headers, + &redirect, + serde_json::json!({"id": task_id, "status": "closed"}), + ), + Ok(Err(e)) => mutation_error(&headers, 400, &e), + Err(e) => mutation_error(&headers, 500, &e.into()), + } +} + +pub(super) async fn reopen_handler( + State(state): State, + AxumPath((name, id)): AxumPath<(String, String)>, + headers: HeaderMap, +) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result<(TaskId, String)> { + let store = Store::open(&root, &name)?; + let task_id = db::resolve_task_id(&store, &id, false)?; + ops::reopen_task(&store, &task_id)?; + let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str()); + Ok((task_id, redirect)) + }) + .await; + + match result { + Ok(Ok((task_id, redirect))) => mutation_response( + &headers, + &redirect, + serde_json::json!({"id": task_id, "status": "open"}), + ), + Ok(Err(e)) => mutation_error(&headers, 400, &e), + Err(e) => mutation_error(&headers, 500, &e.into()), + } +} + +pub(super) async fn label_handler( + State(state): State, + AxumPath((name, id)): AxumPath<(String, String)>, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result { + let store = Store::open(&root, &name)?; + let task_id = db::resolve_task_id(&store, &id, false)?; + match form.action.as_str() { + "add" => ops::add_label(&store, &task_id, &form.label)?, + "rm" => ops::remove_label(&store, &task_id, &form.label)?, + other => anyhow::bail!("unknown label action '{other}'; expected 'add' or 'rm'"), + } + Ok(format!("/projects/{}/tasks/{}", name, task_id.as_str())) + }) + .await; + + match result { + Ok(Ok(redirect)) => mutation_response(&headers, &redirect, serde_json::json!({"ok": true})), + Ok(Err(e)) => mutation_error(&headers, 400, &e), + Err(e) => mutation_error(&headers, 500, &e.into()), + } +} + +pub(super) async fn dep_handler( + State(state): State, + AxumPath((name, id)): AxumPath<(String, String)>, + headers: HeaderMap, + Form(form): Form, +) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result { + let store = Store::open(&root, &name)?; + let child_id = db::resolve_task_id(&store, &id, false)?; + let blocker_id = db::resolve_task_id(&store, &form.blocker, form.action == "rm")?; + match form.action.as_str() { + "add" => ops::add_dep(&store, &child_id, &blocker_id)?, + "rm" => ops::remove_dep(&store, &child_id, &blocker_id)?, + other => anyhow::bail!("unknown dep action '{other}'; expected 'add' or 'rm'"), + } + Ok(format!("/projects/{}/tasks/{}", name, child_id.as_str())) + }) + .await; + + match result { + Ok(Ok(redirect)) => mutation_response(&headers, &redirect, serde_json::json!({"ok": true})), + Ok(Err(e)) => mutation_error(&headers, 400, &e), + Err(e) => mutation_error(&headers, 500, &e.into()), + } +} + +pub(super) async fn delete_handler( + State(state): State, + AxumPath((name, id)): AxumPath<(String, String)>, + headers: HeaderMap, +) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result<(ops::DeleteResult, String)> { + let store = Store::open(&root, &name)?; + let task_id = db::resolve_task_id(&store, &id, false)?; + let dr = ops::soft_delete(&store, &[task_id], false)?; + let redirect = format!("/projects/{}", name); + Ok((dr, redirect)) + }) + .await; + + match result { + Ok(Ok((dr, redirect))) => { + let json_body = serde_json::json!({ + "deleted_ids": dr.deleted_ids.iter().map(ToString::to_string).collect::>(), + "unblocked_ids": dr.unblocked_ids.iter().map(ToString::to_string).collect::>(), + }); + mutation_response(&headers, &redirect, json_body) + } + Ok(Err(e)) => mutation_error(&headers, 400, &e), + Err(e) => mutation_error(&headers, 500, &e.into()), + } +} diff --git a/src/cmd/webui/views.rs b/src/cmd/webui/views.rs new file mode 100644 index 0000000000000000000000000000000000000000..c3bb9110c1d439ba9d1e43dc9f8fb4091549d9c5 --- /dev/null +++ b/src/cmd/webui/views.rs @@ -0,0 +1,214 @@ +use askama::Template; + +use super::helpers::SortField; + +/// A project card on the root page — either healthy or failed. +pub(super) enum ProjectCard { + Ok { + name: String, + open: usize, + in_progress: usize, + closed: usize, + total: usize, + }, + Err { + name: String, + error: String, + }, +} + +/// Minimal view-model for a scored task in the "Next Up" table. +pub(super) struct ScoredEntry { + pub(super) id: String, + pub(super) short_id: String, + pub(super) title: String, + pub(super) score: String, + pub(super) status: String, + pub(super) status_display: &'static str, +} + +/// Minimal view-model for a task row in the project task table. +pub(super) struct TaskRow { + pub(super) full_id: String, + pub(super) short_id: String, + pub(super) status: String, + pub(super) status_display: &'static str, + pub(super) priority: String, + pub(super) effort: String, + pub(super) title: String, + pub(super) created_at: String, + pub(super) created_at_display: String, +} + +/// View-model for the task detail page. +pub(super) struct TaskView { + pub(super) full_id: String, + pub(super) short_id: String, + pub(super) title: String, + pub(super) description: String, + pub(super) task_type: String, + pub(super) status: String, + pub(super) priority: String, + pub(super) effort: String, + pub(super) created_at: String, + pub(super) created_at_display: String, + pub(super) updated_at: String, + pub(super) updated_at_display: String, + pub(super) labels: Vec, + pub(super) logs: Vec, +} + +pub(super) struct LogView { + pub(super) timestamp: String, + pub(super) timestamp_display: String, + pub(super) message: String, +} + +/// A blocker reference for the task detail page. +pub(super) struct BlockerRef { + pub(super) full_id: String, + pub(super) short_id: String, +} + +/// Sort context passed to the task_table macro. When present, column headers +/// become clickable links that set sort/order query params. +pub(super) struct SortContext { + /// Base URL for sort links (e.g. `/projects/myproj`). + pub(super) base_href: String, + /// Current sort field. + pub(super) field: String, + /// Current sort order ("asc" or "desc"). + pub(super) order: String, + /// Query string fragment for the current filters (without sort/order/page), + /// suitable for appending to hrefs. + pub(super) filter_qs: String, +} + +impl SortContext { + /// Build the href for a column header link. Clicking the currently-active + /// column toggles direction; clicking a different column uses its default. + fn column_href(&self, col: &str) -> String { + let order = if col == self.field { + // Toggle current direction. + match self.order.as_str() { + "asc" => "desc", + _ => "asc", + } + } else { + // Use the column's sensible default. + SortField::parse(col) + .map(|f| f.default_order().as_str()) + .unwrap_or("asc") + }; + let mut qs = self.filter_qs.clone(); + if !qs.is_empty() { + qs.push('&'); + } + qs.push_str(&format!("sort={col}&order={order}")); + format!("{}?{qs}", self.base_href) + } + + /// Return the arrow indicator for the active column, or empty string. + fn arrow(&self, col: &str) -> &str { + if col == self.field { + match self.order.as_str() { + "asc" => " ↑", + "desc" => " ↓", + _ => "", + } + } else { + "" + } + } +} + +#[derive(Template)] +#[template(path = "index.html")] +pub(super) struct IndexTemplate { + pub(super) all_projects: Vec, + pub(super) active_project: Option, + pub(super) projects: Vec, +} + +#[derive(Template)] +#[template(path = "project.html")] +pub(super) struct ProjectTemplate { + pub(super) all_projects: Vec, + pub(super) active_project: Option, + pub(super) project_name: String, + pub(super) stats_open: usize, + pub(super) stats_in_progress: usize, + pub(super) stats_closed: usize, + pub(super) next_up: Vec, + pub(super) page_tasks: Vec, + pub(super) all_labels: Vec, + pub(super) filter_status: Option, + pub(super) filter_priority: Option, + pub(super) filter_effort: Option, + pub(super) filter_label: Option, + pub(super) filter_search: String, + pub(super) page: usize, + pub(super) total_pages: usize, + pub(super) pagination_pages: Vec, + pub(super) sort_ctx: SortContext, +} + +impl ProjectTemplate { + /// Build a query-string fragment containing the current filters (no sort, + /// no page). Reused by both pagination and sort helpers. + fn filter_qs(&self) -> String { + let mut parts = Vec::new(); + if let Some(ref s) = self.filter_status { + parts.push(format!("status={s}")); + } + if let Some(ref p) = self.filter_priority { + parts.push(format!("priority={p}")); + } + if let Some(ref e) = self.filter_effort { + parts.push(format!("effort={e}")); + } + if let Some(ref l) = self.filter_label { + parts.push(format!("label={l}")); + } + if !self.filter_search.is_empty() { + parts.push(format!("q={}", self.filter_search)); + } + parts.join("&") + } + + /// Build a pagination link preserving current filter and sort params. + fn pagination_href(&self, target_page: &usize) -> String { + let target_page = *target_page; + let mut qs = self.filter_qs(); + if !qs.is_empty() { + qs.push('&'); + } + qs.push_str(&format!( + "sort={}&order={}", + self.sort_ctx.field, self.sort_ctx.order + )); + qs.push_str(&format!("&page={target_page}")); + format!("/projects/{}?{qs}", self.project_name) + } +} + +#[derive(Template)] +#[template(path = "task.html")] +pub(super) struct TaskTemplate { + pub(super) all_projects: Vec, + pub(super) active_project: Option, + pub(super) project_name: String, + pub(super) task: TaskView, + pub(super) blockers_open: Vec, + pub(super) blockers_resolved: Vec, + pub(super) subtasks: Vec, +} + +#[derive(Template)] +#[template(path = "error.html")] +pub(super) struct ErrorTemplate { + pub(super) all_projects: Vec, + pub(super) active_project: Option, + pub(super) status_code: u16, + pub(super) message: String, +}