diff --git a/src/cmd/create.rs b/src/cmd/create.rs index caeecd72e1bdc30ab8428eab58a29b50af931d3e..64a662f687c3326d45f37b0771cb276b5a746a6f 100644 --- a/src/cmd/create.rs +++ b/src/cmd/create.rs @@ -1,9 +1,9 @@ use anyhow::{anyhow, Result}; -use loro::LoroMap; use std::path::Path; use crate::db; use crate::editor; +use crate::ops; pub struct Opts<'a> { pub title: Option<&'a str>, @@ -47,9 +47,7 @@ pub fn run(root: &Path, opts: Opts) -> Result<()> { ) }; - let ts = db::now_utc(); let store = db::open(root)?; - let id = db::gen_id(); let parent = if let Some(raw) = opts.parent { Some(db::resolve_task_id(&store, raw, false)?) @@ -57,41 +55,29 @@ pub fn run(root: &Path, opts: Opts) -> Result<()> { None }; - store.apply_and_persist(|doc| { - let tasks = doc.get_map("tasks"); - let task = db::insert_task_map(&tasks, &id)?; - - task.insert("title", title)?; - task.insert("description", desc)?; - task.insert("type", opts.task_type)?; - task.insert("priority", db::priority_label(opts.priority))?; - task.insert("status", db::status_label(db::Status::Open))?; - task.insert("effort", db::effort_label(opts.effort))?; - task.insert("parent", parent.as_ref().map(|p| p.as_str()).unwrap_or(""))?; - task.insert("created_at", ts.clone())?; - task.insert("updated_at", ts.clone())?; - task.insert("deleted_at", "")?; - task.insert_container("labels", LoroMap::new())?; - task.insert_container("blockers", LoroMap::new())?; - task.insert_container("logs", LoroMap::new())?; - - if let Some(label_str) = opts.labels { - let labels = db::get_or_create_child_map(&task, "labels")?; - for lbl in label_str - .split(',') + let labels = opts + .labels + .map(|s| { + s.split(',') .map(str::trim) .filter(|l| !l.is_empty()) - { - labels.insert(lbl, true)?; - } - } - - Ok(()) - })?; + .map(String::from) + .collect() + }) + .unwrap_or_default(); - let task = store - .get_task(&id, false)? - .ok_or_else(|| anyhow!("failed to reload created task"))?; + let task = ops::create_task( + &store, + ops::CreateOpts { + title: title.to_owned(), + description: desc.to_owned(), + task_type: opts.task_type.to_owned(), + priority: opts.priority, + effort: opts.effort, + parent, + labels, + }, + )?; if opts.json { println!("{}", serde_json::to_string(&task)?); diff --git a/src/cmd/dep.rs b/src/cmd/dep.rs index f075e01e0e9ddf73ee38504ca21c799c6847df6f..f90f933d3e55e5b5cb8c55934a5748dd20d6e996 100644 --- a/src/cmd/dep.rs +++ b/src/cmd/dep.rs @@ -1,9 +1,9 @@ -use anyhow::{anyhow, bail, Result}; -use std::collections::{HashMap, HashSet, VecDeque}; +use anyhow::Result; use std::path::Path; use crate::cli::DepAction; use crate::db; +use crate::ops; pub fn run(root: &Path, action: &DepAction, json: bool) -> Result<()> { let store = db::open(root)?; @@ -12,22 +12,7 @@ pub fn run(root: &Path, action: &DepAction, json: bool) -> Result<()> { DepAction::Add { child, parent } => { let child_id = db::resolve_task_id(&store, child, false)?; let parent_id = db::resolve_task_id(&store, parent, false)?; - if child_id == parent_id { - bail!("adding dependency would create a cycle"); - } - if would_cycle(&store, &child_id, &parent_id)? { - bail!("adding dependency would create a cycle"); - } - let ts = db::now_utc(); - store.apply_and_persist(|doc| { - let tasks = doc.get_map("tasks"); - let child_task = db::get_task_map(&tasks, &child_id)? - .ok_or_else(|| anyhow!("task not found"))?; - let blockers = db::get_or_create_child_map(&child_task, "blockers")?; - blockers.insert(parent_id.as_str(), true)?; - child_task.insert("updated_at", ts.clone())?; - Ok(()) - })?; + ops::add_dep(&store, &child_id, &parent_id)?; if json { println!( "{}", @@ -44,16 +29,7 @@ pub fn run(root: &Path, action: &DepAction, json: bool) -> Result<()> { DepAction::Rm { child, parent } => { let child_id = db::resolve_task_id(&store, child, false)?; let parent_id = db::resolve_task_id(&store, parent, true)?; - let ts = db::now_utc(); - store.apply_and_persist(|doc| { - let tasks = doc.get_map("tasks"); - let child_task = db::get_task_map(&tasks, &child_id)? - .ok_or_else(|| anyhow!("task not found"))?; - let blockers = db::get_or_create_child_map(&child_task, "blockers")?; - blockers.delete(parent_id.as_str())?; - child_task.insert("updated_at", ts.clone())?; - Ok(()) - })?; + ops::remove_dep(&store, &child_id, &parent_id)?; if !json { let c = crate::color::stdout_theme(); println!( @@ -80,36 +56,3 @@ pub fn run(root: &Path, action: &DepAction, json: bool) -> Result<()> { Ok(()) } - -fn would_cycle(store: &db::Store, child: &db::TaskId, parent: &db::TaskId) -> Result { - let tasks = store.list_tasks_unfiltered()?; - let mut graph: HashMap> = HashMap::new(); - for task in tasks { - for blocker in task.blockers { - graph - .entry(task.id.as_str().to_string()) - .or_default() - .insert(blocker.as_str().to_string()); - } - } - graph - .entry(child.as_str().to_string()) - .or_default() - .insert(parent.as_str().to_string()); - - let mut seen = HashSet::new(); - let mut queue = VecDeque::from([parent.as_str().to_string()]); - while let Some(node) = queue.pop_front() { - if node == child.as_str() { - return Ok(true); - } - if !seen.insert(node.clone()) { - continue; - } - if let Some(nexts) = graph.get(&node) { - queue.extend(nexts.iter().cloned()); - } - } - - Ok(false) -} diff --git a/src/cmd/done.rs b/src/cmd/done.rs index 5e42805bb59838509435d2bcfb95fe2243d6f720..bed0cfb32762a5c8510d1aa032105a570a9125d9 100644 --- a/src/cmd/done.rs +++ b/src/cmd/done.rs @@ -2,22 +2,15 @@ use anyhow::Result; use std::path::Path; use crate::db; +use crate::ops; pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> { let store = db::open(root)?; - let ts = db::now_utc(); let mut closed = Vec::new(); for raw in ids { let id = db::resolve_task_id(&store, raw, false)?; - store.apply_and_persist(|doc| { - let tasks = doc.get_map("tasks"); - if let Some(task) = db::get_task_map(&tasks, &id)? { - task.insert("status", db::status_label(db::Status::Closed))?; - task.insert("updated_at", ts.clone())?; - } - Ok(()) - })?; + ops::mark_done(&store, &id)?; closed.push(id); } diff --git a/src/cmd/label.rs b/src/cmd/label.rs index 83181ed65e5ac397d80bb8f8acb8dd3c65c468a4..51a7725a08c6da17199e6b2fdb0f32c7c3fb2d5d 100644 --- a/src/cmd/label.rs +++ b/src/cmd/label.rs @@ -4,6 +4,7 @@ use std::path::Path; use crate::cli::LabelAction; use crate::db; +use crate::ops; pub fn run(root: &Path, action: &LabelAction, json: bool) -> Result<()> { let store = db::open(root)?; @@ -11,16 +12,7 @@ pub fn run(root: &Path, action: &LabelAction, json: bool) -> Result<()> { match action { LabelAction::Add { id, label } => { let task_id = db::resolve_task_id(&store, id, false)?; - let ts = db::now_utc(); - store.apply_and_persist(|doc| { - let tasks = doc.get_map("tasks"); - let task = - db::get_task_map(&tasks, &task_id)?.ok_or_else(|| anyhow!("task not found"))?; - let labels = db::get_or_create_child_map(&task, "labels")?; - labels.insert(label, true)?; - task.insert("updated_at", ts.clone())?; - Ok(()) - })?; + ops::add_label(&store, &task_id, label)?; if json { println!("{}", serde_json::json!({"id": task_id, "label": label})); @@ -31,16 +23,7 @@ pub fn run(root: &Path, action: &LabelAction, json: bool) -> Result<()> { } LabelAction::Rm { id, label } => { let task_id = db::resolve_task_id(&store, id, false)?; - let ts = db::now_utc(); - store.apply_and_persist(|doc| { - let tasks = doc.get_map("tasks"); - let task = - db::get_task_map(&tasks, &task_id)?.ok_or_else(|| anyhow!("task not found"))?; - let labels = db::get_or_create_child_map(&task, "labels")?; - labels.delete(label)?; - task.insert("updated_at", ts.clone())?; - Ok(()) - })?; + ops::remove_label(&store, &task_id, label)?; if !json { let c = crate::color::stdout_theme(); diff --git a/src/cmd/log.rs b/src/cmd/log.rs index 35fd04ae5cadb6aa623ad54dc68562d9e9ad5d4b..fd964529b441f00754a1eb3332fb8ef8bacaed86 100644 --- a/src/cmd/log.rs +++ b/src/cmd/log.rs @@ -1,32 +1,13 @@ use anyhow::Result; -use loro::LoroMap; use std::path::Path; use crate::db; +use crate::ops; pub fn run(root: &Path, id: &str, message: &str, json: bool) -> Result<()> { let store = db::open(root)?; let task_id = db::resolve_task_id(&store, id, false)?; - let log_id = db::gen_id(); - let ts = db::now_utc(); - - store.apply_and_persist(|doc| { - let tasks = doc.get_map("tasks"); - let task = - db::get_task_map(&tasks, &task_id)?.ok_or_else(|| anyhow::anyhow!("task not found"))?; - let logs = db::get_or_create_child_map(&task, "logs")?; - let entry = logs.insert_container(log_id.as_str(), LoroMap::new())?; - entry.insert("timestamp", ts.clone())?; - entry.insert("message", message)?; - task.insert("updated_at", ts.clone())?; - Ok(()) - })?; - - let entry = db::LogEntry { - id: log_id, - timestamp: ts, - message: message.to_string(), - }; + let entry = ops::add_log(&store, &task_id, message)?; if json { println!("{}", serde_json::to_string(&entry)?); diff --git a/src/cmd/reopen.rs b/src/cmd/reopen.rs index cf399bbe6c2ceae0b78faeed9d169bd8c2042a82..5de0791c8c2f3a380646f6aeac0ec853ff2f3d0c 100644 --- a/src/cmd/reopen.rs +++ b/src/cmd/reopen.rs @@ -2,22 +2,15 @@ use anyhow::Result; use std::path::Path; use crate::db; +use crate::ops; pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> { let store = db::open(root)?; - let ts = db::now_utc(); let mut reopened = Vec::new(); for raw in ids { let id = db::resolve_task_id(&store, raw, false)?; - store.apply_and_persist(|doc| { - let tasks = doc.get_map("tasks"); - if let Some(task) = db::get_task_map(&tasks, &id)? { - task.insert("status", db::status_label(db::Status::Open))?; - task.insert("updated_at", ts.clone())?; - } - Ok(()) - })?; + ops::reopen_task(&store, &id)?; reopened.push(id); } diff --git a/src/cmd/rm.rs b/src/cmd/rm.rs index 5208138dbe12d0d3fc0ba9dac6a9e35bf9031eb5..42f14ba33c604e9e84268567fc7f76da2c696af1 100644 --- a/src/cmd/rm.rs +++ b/src/cmd/rm.rs @@ -1,9 +1,9 @@ -use anyhow::{anyhow, bail, Result}; +use anyhow::Result; use serde::Serialize; -use std::collections::{BTreeSet, HashSet}; use std::path::Path; use crate::db; +use crate::ops; #[derive(Serialize)] struct RmResult { @@ -14,93 +14,40 @@ struct RmResult { pub fn run(root: &Path, ids: &[String], recursive: bool, force: bool, json: bool) -> Result<()> { let store = db::open(root)?; - let all = store.list_tasks_unfiltered()?; - let mut to_delete = BTreeSet::new(); - for raw in ids { - let id = db::resolve_task_id(&store, raw, false)?; - if recursive { - collect_subtree(&all, &id, &mut to_delete); - } else { - if all - .iter() - .any(|t| t.parent.as_ref() == Some(&id) && t.deleted_at.is_none()) - { - bail!("task '{id}' has children; use --recursive to delete subtree"); - } - to_delete.insert(id); - } - } - - let deleted_ids: Vec = to_delete.into_iter().collect(); - let deleted_set: HashSet = deleted_ids + let resolved: Vec = ids .iter() - .map(|id| id.as_str().to_string()) - .collect(); + .map(|raw| db::resolve_task_id(&store, raw, false)) + .collect::>()?; - let unblocked_ids: Vec = all - .iter() - .filter(|t| !deleted_set.contains(t.id.as_str())) - .filter(|t| t.blockers.iter().any(|b| deleted_set.contains(b.as_str()))) - .map(|t| t.id.clone()) - .collect(); + let result = ops::soft_delete(&store, &resolved, recursive)?; - let ts = db::now_utc(); - store.apply_and_persist(|doc| { - let tasks = doc.get_map("tasks"); - - for task_id in &deleted_ids { - let task = - db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?; - task.insert("deleted_at", ts.clone())?; - task.insert("updated_at", ts.clone())?; - task.insert("status", db::status_label(db::Status::Closed))?; - } - - for task in store.list_tasks_unfiltered()? { - if deleted_set.contains(task.id.as_str()) { - continue; - } - if let Some(task_map) = db::get_task_map(&tasks, &task.id)? { - let blockers = db::get_or_create_child_map(&task_map, "blockers")?; - for deleted in &deleted_ids { - blockers.delete(deleted.as_str())?; - } - } - } - - Ok(()) - })?; - - if !force && !unblocked_ids.is_empty() { - let short: Vec = unblocked_ids.iter().map(ToString::to_string).collect(); + if !force && !result.unblocked_ids.is_empty() { + let short: Vec = result + .unblocked_ids + .iter() + .map(ToString::to_string) + .collect(); eprintln!("warning: removed blockers from {}", short.join(", ")); } if json { let out = RmResult { requested_ids: ids.to_vec(), - deleted_ids: deleted_ids.iter().map(ToString::to_string).collect(), - unblocked_ids: unblocked_ids.iter().map(ToString::to_string).collect(), + deleted_ids: result.deleted_ids.iter().map(ToString::to_string).collect(), + unblocked_ids: result + .unblocked_ids + .iter() + .map(ToString::to_string) + .collect(), }; println!("{}", serde_json::to_string(&out)?); } else { let c = crate::color::stdout_theme(); - for id in deleted_ids { + for id in result.deleted_ids { println!("{}deleted{} {id}", c.green, c.reset); } } Ok(()) } - -fn collect_subtree(all: &[db::Task], root: &db::TaskId, out: &mut BTreeSet) { - if !out.insert(root.clone()) { - return; - } - for task in all { - if task.parent.as_ref() == Some(root) && task.deleted_at.is_none() { - collect_subtree(all, &task.id, out); - } - } -} diff --git a/src/cmd/update.rs b/src/cmd/update.rs index a0964f0ab7f13080784c56f77324031e49c1ed18..e2a83db135a58b0a9e8df691812c0b2abb6987de 100644 --- a/src/cmd/update.rs +++ b/src/cmd/update.rs @@ -3,6 +3,7 @@ use std::path::Path; use crate::db; use crate::editor; +use crate::ops; pub struct Opts<'a> { pub status: Option<&'a str>, @@ -16,7 +17,6 @@ pub struct Opts<'a> { pub fn run(root: &Path, id: &str, opts: Opts) -> Result<()> { let store = db::open(root)?; let task_id = db::resolve_task_id(&store, id, false)?; - let ts = db::now_utc(); let parsed_status = opts.status.map(db::parse_status).transpose()?; @@ -63,33 +63,19 @@ pub fn run(root: &Path, id: &str, opts: Opts) -> Result<()> { (opts.title, opts.desc) }; - store.apply_and_persist(|doc| { - let tasks = doc.get_map("tasks"); - let task = db::get_task_map(&tasks, &task_id)?.ok_or_else(|| anyhow!("task not found"))?; - - if let Some(s) = parsed_status { - task.insert("status", db::status_label(s))?; - } - if let Some(p) = opts.priority { - task.insert("priority", db::priority_label(p))?; - } - if let Some(e) = opts.effort { - task.insert("effort", db::effort_label(e))?; - } - if let Some(t) = title_override { - task.insert("title", t)?; - } - if let Some(d) = desc_override { - task.insert("description", d)?; - } - task.insert("updated_at", ts.clone())?; - Ok(()) - })?; + let task = ops::update_task( + &store, + &task_id, + ops::UpdateOpts { + status: parsed_status, + priority: opts.priority, + effort: opts.effort, + title: title_override.map(String::from), + description: desc_override.map(String::from), + }, + )?; if opts.json { - let task = store - .get_task(&task_id, false)? - .ok_or_else(|| anyhow!("task not found"))?; println!("{}", serde_json::to_string(&task)?); } else { let c = crate::color::stdout_theme(); diff --git a/src/cmd/webui.rs b/src/cmd/webui.rs index 4685abcf3418489574ebdce181e2ac9c520e16cf..ec7550bd5a52aeaeb42fa740c1b8ba399b0efe5d 100644 --- a/src/cmd/webui.rs +++ b/src/cmd/webui.rs @@ -5,12 +5,13 @@ use std::sync::Arc; use anyhow::Result; use askama::Template; use axum::extract::{Path as AxumPath, Query, State}; -use axum::http::StatusCode; -use axum::response::{Html, IntoResponse, Response}; -use axum::routing::get; -use axum::Router; +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; @@ -785,6 +786,411 @@ async fn task_handler( } } +// --------------------------------------------------------------------------- +// 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, +} + +#[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 = 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")], @@ -831,8 +1237,19 @@ pub fn run(cwd: &Path, host: &str, port: u16, explicit_project: Option<&str>) -> let app = Router::new() .route("/", get(index_handler)) + .route("/projects", post(create_project_handler)) .route("/projects/{name}", get(project_handler)) - .route("/projects/{name}/tasks/{id}", get(task_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)) diff --git a/src/lib.rs b/src/lib.rs index 5538d071a456e6b4dcadbc84ede97fbd552c435d..56d0ae47d12414b356dc229cf9eda416cdf0c3b2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod color; pub mod db; pub mod editor; pub mod migrate; +pub mod ops; pub mod score; use clap::Parser; diff --git a/src/ops.rs b/src/ops.rs new file mode 100644 index 0000000000000000000000000000000000000000..8775185c62eaa0b8870d4852092e33e183876a0a --- /dev/null +++ b/src/ops.rs @@ -0,0 +1,357 @@ +//! Shared mutation helpers for task operations. +//! +//! These functions encapsulate the Loro document mutations so that both +//! CLI commands and web handlers can perform the same operations without +//! duplicating store logic. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::Path; + +use anyhow::{anyhow, bail, Result}; +use loro::LoroMap; + +use crate::db::{self, LogEntry, Store, Task, TaskId}; + +/// Create a new project and optionally bind a directory path to it. +pub fn init_project(root: &Path, name: &str, bind_path: Option<&Path>) -> Result<()> { + std::fs::create_dir_all(root.join(db::PROJECTS_DIR))?; + Store::init(root, name)?; + if let Some(path) = bind_path { + db::use_project(path, name)?; + } + Ok(()) +} + +/// Input for creating a new task. +pub struct CreateOpts { + pub title: String, + pub description: String, + pub task_type: String, + pub priority: db::Priority, + pub effort: db::Effort, + pub parent: Option, + pub labels: Vec, +} + +/// Create a task and return the hydrated result. +pub fn create_task(store: &Store, opts: CreateOpts) -> Result { + let ts = db::now_utc(); + let id = db::gen_id(); + + store.apply_and_persist(|doc| { + let tasks = doc.get_map("tasks"); + let task = db::insert_task_map(&tasks, &id)?; + + task.insert("title", opts.title.as_str())?; + task.insert("description", opts.description.as_str())?; + task.insert("type", opts.task_type.as_str())?; + task.insert("priority", db::priority_label(opts.priority))?; + task.insert("status", db::status_label(db::Status::Open))?; + task.insert("effort", db::effort_label(opts.effort))?; + task.insert( + "parent", + opts.parent.as_ref().map(|p| p.as_str()).unwrap_or(""), + )?; + task.insert("created_at", ts.clone())?; + task.insert("updated_at", ts.clone())?; + task.insert("deleted_at", "")?; + task.insert_container("labels", LoroMap::new())?; + task.insert_container("blockers", LoroMap::new())?; + task.insert_container("logs", LoroMap::new())?; + + if !opts.labels.is_empty() { + let labels = db::get_or_create_child_map(&task, "labels")?; + for lbl in &opts.labels { + labels.insert(lbl, true)?; + } + } + + Ok(()) + })?; + + store + .get_task(&id, false)? + .ok_or_else(|| anyhow!("failed to reload created task")) +} + +/// Input for updating an existing task. All fields are optional; only +/// populated fields are written. +pub struct UpdateOpts { + pub status: Option, + pub priority: Option, + pub effort: Option, + pub title: Option, + pub description: Option, +} + +/// Update task fields and return the refreshed task. +pub fn update_task(store: &Store, task_id: &TaskId, opts: UpdateOpts) -> Result { + let ts = db::now_utc(); + + store.apply_and_persist(|doc| { + let tasks = doc.get_map("tasks"); + let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?; + + if let Some(s) = opts.status { + task.insert("status", db::status_label(s))?; + } + if let Some(p) = opts.priority { + task.insert("priority", db::priority_label(p))?; + } + if let Some(e) = opts.effort { + task.insert("effort", db::effort_label(e))?; + } + if let Some(ref t) = opts.title { + task.insert("title", t.as_str())?; + } + if let Some(ref d) = opts.description { + task.insert("description", d.as_str())?; + } + task.insert("updated_at", ts.clone())?; + Ok(()) + })?; + + store + .get_task(task_id, false)? + .ok_or_else(|| anyhow!("task not found")) +} + +/// Mark a single task as closed. +pub fn mark_done(store: &Store, task_id: &TaskId) -> Result<()> { + let ts = db::now_utc(); + store.apply_and_persist(|doc| { + let tasks = doc.get_map("tasks"); + if let Some(task) = db::get_task_map(&tasks, task_id)? { + task.insert("status", db::status_label(db::Status::Closed))?; + task.insert("updated_at", ts.clone())?; + } + Ok(()) + })?; + Ok(()) +} + +/// Reopen a single closed task. +pub fn reopen_task(store: &Store, task_id: &TaskId) -> Result<()> { + let ts = db::now_utc(); + store.apply_and_persist(|doc| { + let tasks = doc.get_map("tasks"); + if let Some(task) = db::get_task_map(&tasks, task_id)? { + task.insert("status", db::status_label(db::Status::Open))?; + task.insert("updated_at", ts.clone())?; + } + Ok(()) + })?; + Ok(()) +} + +/// Append a log entry to a task and return the entry. +pub fn add_log(store: &Store, task_id: &TaskId, message: &str) -> Result { + let log_id = db::gen_id(); + let ts = db::now_utc(); + + store.apply_and_persist(|doc| { + let tasks = doc.get_map("tasks"); + let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?; + let logs = db::get_or_create_child_map(&task, "logs")?; + let entry = logs.insert_container(log_id.as_str(), LoroMap::new())?; + entry.insert("timestamp", ts.clone())?; + entry.insert("message", message)?; + task.insert("updated_at", ts.clone())?; + Ok(()) + })?; + + Ok(LogEntry { + id: log_id, + timestamp: ts, + message: message.to_string(), + }) +} + +/// Add a label to a task. +pub fn add_label(store: &Store, task_id: &TaskId, label: &str) -> Result<()> { + let ts = db::now_utc(); + store.apply_and_persist(|doc| { + let tasks = doc.get_map("tasks"); + let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?; + let labels = db::get_or_create_child_map(&task, "labels")?; + labels.insert(label, true)?; + task.insert("updated_at", ts.clone())?; + Ok(()) + })?; + Ok(()) +} + +/// Remove a label from a task. +pub fn remove_label(store: &Store, task_id: &TaskId, label: &str) -> Result<()> { + let ts = db::now_utc(); + store.apply_and_persist(|doc| { + let tasks = doc.get_map("tasks"); + let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?; + let labels = db::get_or_create_child_map(&task, "labels")?; + labels.delete(label)?; + task.insert("updated_at", ts.clone())?; + Ok(()) + })?; + Ok(()) +} + +/// Add a blocker dependency (child is blocked by blocker). +/// +/// Rejects self-references and cycles. +pub fn add_dep(store: &Store, child_id: &TaskId, blocker_id: &TaskId) -> Result<()> { + if child_id == blocker_id { + bail!("adding dependency would create a cycle"); + } + if would_cycle(store, child_id, blocker_id)? { + bail!("adding dependency would create a cycle"); + } + + let ts = db::now_utc(); + store.apply_and_persist(|doc| { + let tasks = doc.get_map("tasks"); + let child_task = + db::get_task_map(&tasks, child_id)?.ok_or_else(|| anyhow!("task not found"))?; + let blockers = db::get_or_create_child_map(&child_task, "blockers")?; + blockers.insert(blocker_id.as_str(), true)?; + child_task.insert("updated_at", ts.clone())?; + Ok(()) + })?; + Ok(()) +} + +/// Remove a blocker dependency. +pub fn remove_dep(store: &Store, child_id: &TaskId, blocker_id: &TaskId) -> Result<()> { + let ts = db::now_utc(); + store.apply_and_persist(|doc| { + let tasks = doc.get_map("tasks"); + let child_task = + db::get_task_map(&tasks, child_id)?.ok_or_else(|| anyhow!("task not found"))?; + let blockers = db::get_or_create_child_map(&child_task, "blockers")?; + blockers.delete(blocker_id.as_str())?; + child_task.insert("updated_at", ts.clone())?; + Ok(()) + })?; + Ok(()) +} + +/// Result of a soft-delete operation. +pub struct DeleteResult { + pub deleted_ids: Vec, + pub unblocked_ids: Vec, +} + +/// Soft-delete one or more tasks. +/// +/// When `recursive` is true, subtrees rooted at the given IDs are also +/// deleted. Blocker references from surviving tasks are cleaned up +/// automatically; pass `force` to suppress the warning about unblocked +/// tasks. +pub fn soft_delete(store: &Store, ids: &[TaskId], recursive: bool) -> Result { + use std::collections::BTreeSet; + + let all = store.list_tasks_unfiltered()?; + + let mut to_delete = BTreeSet::new(); + for id in ids { + if recursive { + collect_subtree(&all, id, &mut to_delete); + } else { + if all + .iter() + .any(|t| t.parent.as_ref() == Some(id) && t.deleted_at.is_none()) + { + bail!("task '{id}' has children; use --recursive to delete subtree"); + } + to_delete.insert(id.clone()); + } + } + + let deleted_ids: Vec = to_delete.into_iter().collect(); + let deleted_set: HashSet = deleted_ids + .iter() + .map(|id| id.as_str().to_string()) + .collect(); + + let unblocked_ids: Vec = all + .iter() + .filter(|t| !deleted_set.contains(t.id.as_str())) + .filter(|t| t.blockers.iter().any(|b| deleted_set.contains(b.as_str()))) + .map(|t| t.id.clone()) + .collect(); + + let ts = db::now_utc(); + store.apply_and_persist(|doc| { + let tasks = doc.get_map("tasks"); + + for task_id in &deleted_ids { + let task = + db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?; + task.insert("deleted_at", ts.clone())?; + task.insert("updated_at", ts.clone())?; + task.insert("status", db::status_label(db::Status::Closed))?; + } + + for task in store.list_tasks_unfiltered()? { + if deleted_set.contains(task.id.as_str()) { + continue; + } + if let Some(task_map) = db::get_task_map(&tasks, &task.id)? { + let blockers = db::get_or_create_child_map(&task_map, "blockers")?; + for deleted in &deleted_ids { + blockers.delete(deleted.as_str())?; + } + } + } + + Ok(()) + })?; + + Ok(DeleteResult { + deleted_ids, + unblocked_ids, + }) +} + +fn would_cycle(store: &Store, child: &TaskId, parent: &TaskId) -> Result { + let tasks = store.list_tasks_unfiltered()?; + let mut graph: HashMap> = HashMap::new(); + for task in tasks { + for blocker in task.blockers { + graph + .entry(task.id.as_str().to_string()) + .or_default() + .insert(blocker.as_str().to_string()); + } + } + graph + .entry(child.as_str().to_string()) + .or_default() + .insert(parent.as_str().to_string()); + + let mut seen = HashSet::new(); + let mut queue = VecDeque::from([parent.as_str().to_string()]); + while let Some(node) = queue.pop_front() { + if node == child.as_str() { + return Ok(true); + } + if !seen.insert(node.clone()) { + continue; + } + if let Some(nexts) = graph.get(&node) { + queue.extend(nexts.iter().cloned()); + } + } + + Ok(false) +} + +fn collect_subtree(all: &[Task], root: &TaskId, out: &mut std::collections::BTreeSet) { + if !out.insert(root.clone()) { + return; + } + for task in all { + if task.parent.as_ref() == Some(root) && task.deleted_at.is_none() { + collect_subtree(all, &task.id, out); + } + } +} diff --git a/static/td.css b/static/td.css index 5af1462f0836c656e1212c30b005be35c9ce11a8..d4c50a6a9547b7ba209a22be70ebf64c3393acba 100644 --- a/static/td.css +++ b/static/td.css @@ -49,3 +49,61 @@ code, pre, .mono { font-family: var(--font-mono); } transform: translateX(-50%); top: 0; } +[hidden] { + display: none !important; +} + +/* FAB */ +.fab-group { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + z-index: 100; +} +.fab-toggle { + width: 3.5rem; + height: 3.5rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + transition: transform 0.2s ease; +} +.fab-toggle:hover { + transform: scale(1.05); +} +.fab-toggle .fab-icon-close { + display: none; +} +.fab-toggle[aria-expanded="true"] .fab-icon-plus { + display: none; +} +.fab-toggle[aria-expanded="true"] .fab-icon-close { + display: block; +} +.fab-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; +} +.fab-actions[hidden] { + display: none !important; +} +.fab-action { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: 2rem; + cursor: pointer; + white-space: nowrap; + box-shadow: 0 2px 6px rgba(0,0,0,0.15); +} diff --git a/static/td.js b/static/td.js index 08f4e2fb7681c113fc654847aa15c7b6959d44bf..ab6c2a8057437b3f8e82805874b6cc7599c3e9af 100644 --- a/static/td.js +++ b/static/td.js @@ -1,35 +1,49 @@ -console.log("[td] td.js loaded"); - document.addEventListener("DOMContentLoaded", () => { - console.log("[td] DOMContentLoaded fired"); - - const timeEls = document.querySelectorAll("time[datetime]"); - console.log("[td] Found %d