diff --git a/Cargo.lock b/Cargo.lock index bdd3acea2f2285b10a8ef56c42ec790ca6485d07..5508371b3a9b3edf2cd8ebcd4baddbfc7ede8ec2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "ammonia" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "tendril", + "url", +] + [[package]] name = "anstream" version = "0.6.21" @@ -802,6 +815,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "ctr" version = "0.9.2" @@ -963,6 +999,21 @@ dependencies = [ "litrs", ] +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "either" version = "1.15.0" @@ -1135,6 +1186,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.32" @@ -1448,6 +1509,17 @@ dependencies = [ "digest", ] +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + [[package]] name = "http" version = "1.4.0" @@ -1972,6 +2044,12 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + [[package]] name = "magic-wormhole" version = "0.7.6" @@ -2013,6 +2091,34 @@ dependencies = [ "ws_stream_wasm", ] +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2057,6 +2163,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "noise-protocol" version = "0.2.0" @@ -2287,6 +2399,58 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2390,6 +2554,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "predicates" version = "3.1.4" @@ -2449,6 +2619,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quick_cache" version = "0.6.18" @@ -2838,6 +3026,12 @@ dependencies = [ "libc", ] +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "sized-chunks" version = "0.6.5" @@ -2910,6 +3104,31 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3000,6 +3219,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "termtree" version = "0.5.1" @@ -3285,6 +3515,12 @@ dependencies = [ "web-time", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3514,6 +3750,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3801,6 +4049,7 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" name = "yatd" version = "0.1.3" dependencies = [ + "ammonia", "anyhow", "askama", "assert_cmd", @@ -3812,6 +4061,7 @@ dependencies = [ "loro", "magic-wormhole", "predicates", + "pulldown-cmark", "serde", "serde_json", "shell-words", diff --git a/Cargo.toml b/Cargo.toml index fe90d20c74eb7875ccb744532d8147ec4c296fdf..817f1817cb01294dced5049602151ccf6687aec8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,8 @@ tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros"] } fs2 = "0.4" axum = "0.8" askama = "0.15" +pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] } +ammonia = "4" [dev-dependencies] assert_cmd = "2" diff --git a/src/cmd/webui.rs b/src/cmd/webui.rs index 2c5e3598dc1c6be1a5c5ebd7f972ba045fd6c3bf..fde7217c2ad3cef125ab5dcb93b2a9cb17486917 100644 --- a/src/cmd/webui.rs +++ b/src/cmd/webui.rs @@ -15,6 +15,33 @@ use crate::score; const PAGE_SIZE: usize = 25; +/// 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 // --------------------------------------------------------------------------- @@ -59,6 +86,8 @@ struct TaskRow { priority: String, effort: String, title: String, + created_at: String, + created_at_display: String, } /// View-model for the task detail page. @@ -72,13 +101,16 @@ struct TaskView { 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, } @@ -268,8 +300,12 @@ struct ProjectQuery { async fn project_handler( State(state): State, AxumPath(name): AxumPath, - Query(query): Query, + 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); @@ -407,6 +443,8 @@ async fn project_handler( 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(); @@ -484,6 +522,8 @@ async fn task_handler( 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(); @@ -491,20 +531,23 @@ async fn task_handler( full_id: task.id.as_str().to_string(), short_id: task.id.short(), title: task.title.clone(), - description: task.description.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: l.message.clone(), + message: render_markdown(&l.message), }) .collect(), }; diff --git a/templates/base.html b/templates/base.html index 7985b0448559888ebef3452dce99d82c4547d708..506755094b68914feba202cfeb8ab623a2504c7d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -7,6 +7,35 @@ + @@ -30,7 +59,7 @@
-
+
{% block content %}{% endblock %}
diff --git a/templates/macros.html b/templates/macros.html new file mode 100644 index 0000000000000000000000000000000000000000..fe5b4f590ebf03fd0849c6ca6e683bba84bfd77a --- /dev/null +++ b/templates/macros.html @@ -0,0 +1,29 @@ +{% macro task_table(project_name, tasks, caption) %} +
+ + + + + + + + + + + + + + {% for t in tasks %} + + + + + + + + + {% endfor %} + +
{{ caption }}
IDStatusPriorityEffortTitleCreated
{{ t.short_id }}{{ t.status }}{{ t.priority }}{{ t.effort }}{{ t.title }}
+
+{% endmacro %} diff --git a/templates/project.html b/templates/project.html index a42716d2ad70b95713380b6af7696682ee15df29..cfbe4b8fe0a8cebfbe1333768a30f877ae8afbd0 100644 --- a/templates/project.html +++ b/templates/project.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% import "macros.html" as macros %} {% block title %}td — {{ project_name }}{% endblock %} @@ -13,26 +14,24 @@

{{ project_name }}

-
-
+
-

Open

+

Open

{{ stats_open }}

-

In progress

+

In progress

{{ stats_in_progress }}

-

Closed

+

Closed

{{ stats_closed }}

-
{% if !next_up.is_empty() %}
-

Next up

+ Next up
@@ -59,8 +58,8 @@ {% endif %} -
-

Tasks

+ + Tasks
@@ -109,31 +108,7 @@ {% if page_tasks.is_empty() %}

No tasks match the current filters.

{% else %} -
-
Top scored tasks recommended to work on next
- - - - - - - - - - - - {% for t in page_tasks %} - - - - - - - - {% endfor %} - -
Task list for project {{ project_name }}
IDStatusPriorityEffortTitle
{{ t.short_id }}{{ t.status }}{{ t.priority }}{{ t.effort }}{{ t.title }}
-
+ {% call macros::task_table(project_name, page_tasks, "Task list for project") %}{% endcall %} {% if total_pages > 1 %}
-
+

{{ task.title }}

{{ task.status }}
{% if !task.description.is_empty() %} -

{{ task.description }}

+
{{ task.description|safe }}
{% endif %} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Task metadata
ID{{ task.short_id }}
Type{{ task.task_type }}
Priority{{ task.priority }}
Effort{{ task.effort }}
Created
Updated
-
+
+
+

{{ task.task_type }} · {{ task.priority }} priority · {{ task.effort }} effort
Created · Updated

+
{% if !task.labels.is_empty() %}
@@ -66,57 +43,34 @@
{% endif %} - {% if !blockers_open.is_empty() || !blockers_resolved.is_empty() %} -
-

Blockers

- -
- {% endif %} - - {% if !subtasks.is_empty() %} -
-

Subtasks

-
- - - - - - - - - - - {% for s in subtasks %} - - - - - - {% endfor %} - -
Subtasks of {{ task.title }}
IDStatusTitle
{{ s.short_id }}{{ s.status }}{{ s.title }}
-
-
- {% endif %} - {% if !task.logs.is_empty() %} -
-

Work log

+
+ Work log {% for log in task.logs %} -
- -

{{ log.message }}

-
+
— {{ log.message|safe }}
{% endfor %} -
+
{% endif %} + +{% if !blockers_open.is_empty() || !blockers_resolved.is_empty() %} +
+

Blockers

+ +
+{% endif %} + +{% if !subtasks.is_empty() %} +
+

Subtasks

+ {% call macros::task_table(project_name, subtasks, "Subtasks") %}{% endcall %} +
+{% endif %} {% endblock %}