1use anyhow::Result;
2use axum::extract::{Path as AxumPath, State};
3use axum::http::HeaderMap;
4use axum::response::Response;
5use axum::Form;
6
7use crate::db::{self, Store};
8use crate::model::{Effort, LogEntry, Priority, Status, Task, TaskId};
9use crate::ops;
10
11use super::super::helpers::{mutation_error, mutation_response};
12use super::super::AppState;
13
14#[derive(serde::Deserialize)]
15pub(in crate::cmd::webui) struct ProjectForm {
16 name: String,
17 #[serde(default)]
18 bind_path: String,
19}
20
21#[derive(serde::Deserialize)]
22pub(in crate::cmd::webui) struct CreateForm {
23 title: String,
24 #[serde(default)]
25 description: String,
26 #[serde(default = "default_task_type")]
27 task_type: String,
28 #[serde(default = "default_priority")]
29 priority: String,
30 #[serde(default = "default_effort")]
31 effort: String,
32 #[serde(default)]
33 labels: String,
34 #[serde(default)]
35 parent: String,
36}
37
38fn default_task_type() -> String {
39 "task".to_string()
40}
41
42fn default_priority() -> String {
43 "medium".to_string()
44}
45
46fn default_effort() -> String {
47 "medium".to_string()
48}
49
50#[derive(serde::Deserialize)]
51pub(in crate::cmd::webui) struct UpdateForm {
52 #[serde(default)]
53 title: Option<String>,
54 #[serde(default)]
55 description: Option<String>,
56 #[serde(default)]
57 status: Option<String>,
58 #[serde(default)]
59 priority: Option<String>,
60 #[serde(default)]
61 effort: Option<String>,
62 #[serde(default)]
63 task_type: Option<String>,
64 /// Present and non-empty = set parent; present and empty = clear parent;
65 /// absent = don't change. The form uses `_parent_present` as a sentinel
66 /// so we can distinguish "field not submitted" from "submitted empty".
67 #[serde(default)]
68 parent: Option<String>,
69 #[serde(default)]
70 _parent_present: Option<String>,
71 #[serde(default)]
72 redirect: Option<String>,
73}
74
75#[derive(serde::Deserialize)]
76pub(in crate::cmd::webui) struct LogForm {
77 message: String,
78}
79
80#[derive(serde::Deserialize)]
81pub(in crate::cmd::webui) struct LabelForm {
82 /// "add" or "rm"
83 action: String,
84 label: String,
85}
86
87#[derive(serde::Deserialize)]
88pub(in crate::cmd::webui) struct DepForm {
89 /// "add" or "rm"
90 action: String,
91 blocker: String,
92}
93
94pub(in crate::cmd::webui) async fn create_project_handler(
95 State(state): State<AppState>,
96 headers: HeaderMap,
97 Form(form): Form<ProjectForm>,
98) -> Response {
99 let root = state.data_root.clone();
100 let result = tokio::task::spawn_blocking(move || -> Result<String> {
101 let bind = if form.bind_path.is_empty() {
102 None
103 } else {
104 Some(std::path::PathBuf::from(&form.bind_path))
105 };
106 ops::init_project(&root, &form.name, bind.as_deref())?;
107 Ok(form.name)
108 })
109 .await;
110
111 match result {
112 Ok(Ok(name)) => {
113 let redirect = format!("/projects/{name}");
114 mutation_response(&headers, &redirect, serde_json::json!({"name": name}))
115 }
116 Ok(Err(e)) => mutation_error(&headers, 400, &e),
117 Err(e) => mutation_error(&headers, 500, &e.into()),
118 }
119}
120
121pub(in crate::cmd::webui) async fn create_handler(
122 State(state): State<AppState>,
123 AxumPath(name): AxumPath<String>,
124 headers: HeaderMap,
125 Form(form): Form<CreateForm>,
126) -> Response {
127 let root = state.data_root.clone();
128 let result = tokio::task::spawn_blocking(move || -> Result<(Task, String)> {
129 let store = Store::open(&root, &name)?;
130
131 let parent = if form.parent.is_empty() {
132 None
133 } else {
134 Some(db::resolve_task_id(&store, &form.parent, false)?)
135 };
136
137 let labels: Vec<String> = form
138 .labels
139 .split(',')
140 .map(str::trim)
141 .filter(|l| !l.is_empty())
142 .map(String::from)
143 .collect();
144
145 let task = ops::create_task(
146 &store,
147 ops::CreateOpts {
148 title: form.title,
149 description: form.description,
150 task_type: form.task_type,
151 priority: Priority::parse(&form.priority)?,
152 effort: Effort::parse(&form.effort)?,
153 parent,
154 labels,
155 },
156 )?;
157
158 let redirect = format!("/projects/{}/tasks/{}", name, task.id.as_str());
159 Ok((task, redirect))
160 })
161 .await;
162
163 match result {
164 Ok(Ok((task, redirect))) => mutation_response(
165 &headers,
166 &redirect,
167 serde_json::to_value(&task).unwrap_or_default(),
168 ),
169 Ok(Err(e)) => mutation_error(&headers, 400, &e),
170 Err(e) => mutation_error(&headers, 500, &e.into()),
171 }
172}
173
174pub(in crate::cmd::webui) async fn update_handler(
175 State(state): State<AppState>,
176 AxumPath((name, id)): AxumPath<(String, String)>,
177 headers: HeaderMap,
178 Form(form): Form<UpdateForm>,
179) -> Response {
180 let root = state.data_root.clone();
181 let result = tokio::task::spawn_blocking(move || -> Result<(Task, String)> {
182 let store = Store::open(&root, &name)?;
183 let task_id = db::resolve_task_id(&store, &id, false)?;
184
185 let task = ops::update_task(
186 &store,
187 &task_id,
188 ops::UpdateOpts {
189 status: form
190 .status
191 .as_deref()
192 .filter(|s| !s.is_empty())
193 .map(Status::parse)
194 .transpose()?,
195 priority: form
196 .priority
197 .as_deref()
198 .filter(|s| !s.is_empty())
199 .map(Priority::parse)
200 .transpose()?,
201 effort: form
202 .effort
203 .as_deref()
204 .filter(|s| !s.is_empty())
205 .map(Effort::parse)
206 .transpose()?,
207 title: form.title.filter(|s| !s.is_empty()),
208 description: form.description,
209 task_type: form.task_type.filter(|s| !s.is_empty()),
210 // Only interpret `parent` when the sentinel field proves the
211 // edit form was the one submitted (not a status-change form).
212 parent: if form._parent_present.is_some() {
213 match form.parent.as_deref() {
214 Some(p) if !p.is_empty() => {
215 Some(Some(db::resolve_task_id(&store, p, false)?))
216 }
217 _ => Some(None), // clear parent
218 }
219 } else {
220 None // field not submitted — don't change
221 },
222 },
223 )?;
224
225 let redirect = form
226 .redirect
227 .filter(|r| r.starts_with('/'))
228 .unwrap_or_else(|| format!("/projects/{}/tasks/{}", name, task.id.as_str()));
229 Ok((task, redirect))
230 })
231 .await;
232
233 match result {
234 Ok(Ok((task, redirect))) => mutation_response(
235 &headers,
236 &redirect,
237 serde_json::to_value(&task).unwrap_or_default(),
238 ),
239 Ok(Err(e)) => mutation_error(&headers, 400, &e),
240 Err(e) => mutation_error(&headers, 500, &e.into()),
241 }
242}
243
244pub(in crate::cmd::webui) async fn log_handler(
245 State(state): State<AppState>,
246 AxumPath((name, id)): AxumPath<(String, String)>,
247 headers: HeaderMap,
248 Form(form): Form<LogForm>,
249) -> Response {
250 let root = state.data_root.clone();
251 let result = tokio::task::spawn_blocking(move || -> Result<(LogEntry, String)> {
252 let store = Store::open(&root, &name)?;
253 let task_id = db::resolve_task_id(&store, &id, false)?;
254 let entry = ops::add_log(&store, &task_id, &form.message)?;
255 let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str());
256 Ok((entry, redirect))
257 })
258 .await;
259
260 match result {
261 Ok(Ok((entry, redirect))) => mutation_response(
262 &headers,
263 &redirect,
264 serde_json::to_value(&entry).unwrap_or_default(),
265 ),
266 Ok(Err(e)) => mutation_error(&headers, 400, &e),
267 Err(e) => mutation_error(&headers, 500, &e.into()),
268 }
269}
270
271pub(in crate::cmd::webui) async fn done_handler(
272 State(state): State<AppState>,
273 AxumPath((name, id)): AxumPath<(String, String)>,
274 headers: HeaderMap,
275) -> Response {
276 let root = state.data_root.clone();
277 let result = tokio::task::spawn_blocking(move || -> Result<(TaskId, String)> {
278 let store = Store::open(&root, &name)?;
279 let task_id = db::resolve_task_id(&store, &id, false)?;
280 ops::mark_done(&store, &task_id)?;
281 let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str());
282 Ok((task_id, redirect))
283 })
284 .await;
285
286 match result {
287 Ok(Ok((task_id, redirect))) => mutation_response(
288 &headers,
289 &redirect,
290 serde_json::json!({"id": task_id, "status": "closed"}),
291 ),
292 Ok(Err(e)) => mutation_error(&headers, 400, &e),
293 Err(e) => mutation_error(&headers, 500, &e.into()),
294 }
295}
296
297pub(in crate::cmd::webui) async fn reopen_handler(
298 State(state): State<AppState>,
299 AxumPath((name, id)): AxumPath<(String, String)>,
300 headers: HeaderMap,
301) -> Response {
302 let root = state.data_root.clone();
303 let result = tokio::task::spawn_blocking(move || -> Result<(TaskId, String)> {
304 let store = Store::open(&root, &name)?;
305 let task_id = db::resolve_task_id(&store, &id, false)?;
306 ops::reopen_task(&store, &task_id)?;
307 let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str());
308 Ok((task_id, redirect))
309 })
310 .await;
311
312 match result {
313 Ok(Ok((task_id, redirect))) => mutation_response(
314 &headers,
315 &redirect,
316 serde_json::json!({"id": task_id, "status": "open"}),
317 ),
318 Ok(Err(e)) => mutation_error(&headers, 400, &e),
319 Err(e) => mutation_error(&headers, 500, &e.into()),
320 }
321}
322
323pub(in crate::cmd::webui) async fn label_handler(
324 State(state): State<AppState>,
325 AxumPath((name, id)): AxumPath<(String, String)>,
326 headers: HeaderMap,
327 Form(form): Form<LabelForm>,
328) -> Response {
329 let root = state.data_root.clone();
330 let result = tokio::task::spawn_blocking(move || -> Result<String> {
331 let store = Store::open(&root, &name)?;
332 let task_id = db::resolve_task_id(&store, &id, false)?;
333 match form.action.as_str() {
334 "add" => ops::add_label(&store, &task_id, &form.label)?,
335 "rm" => ops::remove_label(&store, &task_id, &form.label)?,
336 other => anyhow::bail!("unknown label action '{other}'; expected 'add' or 'rm'"),
337 }
338 Ok(format!("/projects/{}/tasks/{}", name, task_id.as_str()))
339 })
340 .await;
341
342 match result {
343 Ok(Ok(redirect)) => mutation_response(&headers, &redirect, serde_json::json!({"ok": true})),
344 Ok(Err(e)) => mutation_error(&headers, 400, &e),
345 Err(e) => mutation_error(&headers, 500, &e.into()),
346 }
347}
348
349pub(in crate::cmd::webui) async fn dep_handler(
350 State(state): State<AppState>,
351 AxumPath((name, id)): AxumPath<(String, String)>,
352 headers: HeaderMap,
353 Form(form): Form<DepForm>,
354) -> Response {
355 let root = state.data_root.clone();
356 let result = tokio::task::spawn_blocking(move || -> Result<String> {
357 let store = Store::open(&root, &name)?;
358 let child_id = db::resolve_task_id(&store, &id, false)?;
359 let blocker_id = db::resolve_task_id(&store, &form.blocker, form.action == "rm")?;
360 match form.action.as_str() {
361 "add" => ops::add_dep(&store, &child_id, &blocker_id)?,
362 "rm" => ops::remove_dep(&store, &child_id, &blocker_id)?,
363 other => anyhow::bail!("unknown dep action '{other}'; expected 'add' or 'rm'"),
364 }
365 Ok(format!("/projects/{}/tasks/{}", name, child_id.as_str()))
366 })
367 .await;
368
369 match result {
370 Ok(Ok(redirect)) => mutation_response(&headers, &redirect, serde_json::json!({"ok": true})),
371 Ok(Err(e)) => mutation_error(&headers, 400, &e),
372 Err(e) => mutation_error(&headers, 500, &e.into()),
373 }
374}
375
376pub(in crate::cmd::webui) async fn delete_handler(
377 State(state): State<AppState>,
378 AxumPath((name, id)): AxumPath<(String, String)>,
379 headers: HeaderMap,
380) -> Response {
381 let root = state.data_root.clone();
382 let result = tokio::task::spawn_blocking(move || -> Result<(ops::DeleteResult, String)> {
383 let store = Store::open(&root, &name)?;
384 let task_id = db::resolve_task_id(&store, &id, false)?;
385 let dr = ops::soft_delete(&store, &[task_id], false)?;
386 let redirect = format!("/projects/{}", name);
387 Ok((dr, redirect))
388 })
389 .await;
390
391 match result {
392 Ok(Ok((dr, redirect))) => {
393 let json_body = serde_json::json!({
394 "deleted_ids": dr.deleted_ids.iter().map(ToString::to_string).collect::<Vec<_>>(),
395 "unblocked_ids": dr.unblocked_ids.iter().map(ToString::to_string).collect::<Vec<_>>(),
396 });
397 mutation_response(&headers, &redirect, json_body)
398 }
399 Ok(Err(e)) => mutation_error(&headers, 400, &e),
400 Err(e) => mutation_error(&headers, 500, &e.into()),
401 }
402}