{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[bf118bdf] Sanitize Reddit entity body HTML before rendering.\n\nUse ammonia at render time so untrusted selftext_html cannot execute scripts in our origin.\n\nCo-authored-by: Cursor \n\nSide A — unified diff (full patch):\ndiff --git a/Cargo.lock b/Cargo.lock\nindex 3dec7cb72a182dc654a37dca8ba0b49d77504daa..0dd4fce5fb6400ae153cca4e3dbf5a5158e6d8b4 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -11,6 +11,19 @@ dependencies = [\n \"memchr\",\n ]\n \n+[[package]]\n+name = \"ammonia\"\n+version = \"4.1.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6\"\n+dependencies = [\n+ \"cssparser\",\n+ \"html5ever\",\n+ \"maplit\",\n+ \"tendril\",\n+ \"url\",\n+]\n+\n [[package]]\n name = \"anyhow\"\n version = \"1.0.102\"\n@@ -355,6 +368,29 @@ version = \"0.2.4\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5\"\n \n+[[package]]\n+name = \"cssparser\"\n+version = \"0.35.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa\"\n+dependencies = [\n+ \"cssparser-macros\",\n+ \"dtoa-short\",\n+ \"itoa\",\n+ \"phf\",\n+ \"smallvec\",\n+]\n+\n+[[package]]\n+name = \"cssparser-macros\"\n+version = \"0.6.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331\"\n+dependencies = [\n+ \"quote\",\n+ \"syn\",\n+]\n+\n [[package]]\n name = \"deranged\"\n version = \"0.5.8\"\n@@ -381,6 +417,21 @@ version = \"0.15.7\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b\"\n \n+[[package]]\n+name = \"dtoa\"\n+version = \"1.0.11\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590\"\n+\n+[[package]]\n+name = \"dtoa-short\"\n+version = \"0.3.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87\"\n+dependencies = [\n+ \"dtoa\",\n+]\n+\n [[package]]\n name = \"durable\"\n version = \"0.2.0\"\n@@ -482,6 +533,16 @@ dependencies = [\n \"percent-encoding\",\n ]\n \n+[[package]]\n+name = \"futf\"\n+version = \"0.1.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843\"\n+dependencies = [\n+ \"mac\",\n+ \"new_debug_unreachable\",\n+]\n+\n [[package]]\n name = \"futures-channel\"\n version = \"0.3.32\"\n@@ -614,6 +675,17 @@ version = \"0.5.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea\"\n \n+[[package]]\n+name = \"html5ever\"\n+version = \"0.35.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4\"\n+dependencies = [\n+ \"log\",\n+ \"markup5ever\",\n+ \"match_token\",\n+]\n+\n [[package]]\n name = \"http\"\n version = \"1.4.1\"\n@@ -974,6 +1046,15 @@ version = \"0.8.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0\"\n \n+[[package]]\n+name = \"lock_api\"\n+version = \"0.4.14\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965\"\n+dependencies = [\n+ \"scopeguard\",\n+]\n+\n [[package]]\n name = \"log\"\n version = \"0.4.30\"\n@@ -990,6 +1071,40 @@ dependencies = [\n \"libc\",\n ]\n \n+[[package]]\n+name = \"mac\"\n+version = \"0.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4\"\n+\n+[[package]]\n+name = \"maplit\"\n+version = \"1.0.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d\"\n+\n+[[package]]\n+name = \"markup5ever\"\n+version = \"0.35.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3\"\n+dependencies = [\n+ \"log\",\n+ \"tendril\",\n+ \"web_atoms\",\n+]\n+\n+[[package]]\n+name = \"match_token\"\n+version = \"0.35.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n [[package]]\n name = \"matchers\"\n version = \"0.2.0\"\n@@ -1092,6 +1207,12 @@ dependencies = [\n \"tempfile\",\n ]\n \n+[[package]]\n+name = \"new_debug_unreachable\"\n+version = \"1.0.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086\"\n+\n [[package]]\n name = \"nom\"\n version = \"7.1.3\"\n@@ -1175,6 +1296,29 @@ dependencies = [\n \"vcpkg\",\n ]\n \n+[[package]]\n+name = \"parking_lot\"\n+version = \"0.12.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a\"\n+dependencies = [\n+ \"lock_api\",\n+ \"parking_lot_core\",\n+]\n+\n+[[package]]\n+name = \"parking_lot_core\"\n+version = \"0.9.12\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"libc\",\n+ \"redox_syscall\",\n+ \"smallvec\",\n+ \"windows-link\",\n+]\n+\n [[package]]\n name = \"peeking_take_while\"\n version = \"0.1.2\"\n@@ -1187,6 +1331,58 @@ version = \"2.3.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220\"\n \n+[[package]]\n+name = \"phf\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078\"\n+dependencies = [\n+ \"phf_macros\",\n+ \"phf_shared\",\n+]\n+\n+[[package]]\n+name = \"phf_codegen\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a\"\n+dependencies = [\n+ \"phf_generator\",\n+ \"phf_shared\",\n+]\n+\n+[[package]]\n+name = \"phf_generator\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d\"\n+dependencies = [\n+ \"phf_shared\",\n+ \"rand 0.8.6\",\n+]\n+\n+[[package]]\n+name = \"phf_macros\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216\"\n+dependencies = [\n+ \"phf_generator\",\n+ \"phf_shared\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"phf_shared\"\n+version = \"0.11.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5\"\n+dependencies = [\n+ \"siphasher\",\n+]\n+\n [[package]]\n name = \"pin-project-lite\"\n version = \"0.2.17\"\n@@ -1223,6 +1419,12 @@ dependencies = [\n \"zerocopy\",\n ]\n \n+[[package]]\n+name = \"precomputed-hash\"\n+version = \"0.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c\"\n+\n [[package]]\n name = \"prettyplease\"\n version = \"0.2.37\"\n@@ -1379,6 +1581,15 @@ dependencies = [\n \"rand_core 0.9.5\",\n ]\n \n+[[package]]\n+name = \"redox_syscall\"\n+version = \"0.5.18\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d\"\n+dependencies = [\n+ \"bitflags 2.11.1\",\n+]\n+\n [[package]]\n name = \"regex\"\n version = \"1.12.3\"\n@@ -1563,6 +1774,12 @@ dependencies = [\n \"windows-sys 0.61.2\",\n ]\n \n+[[package]]\n+name = \"scopeguard\"\n+version = \"1.2.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49\"\n+\n [[package]]\n name = \"security-framework\"\n version = \"3.7.0\"\n@@ -1683,6 +1900,12 @@ dependencies = [\n \"libc\",\n ]\n \n+[[package]]\n+name = \"siphasher\"\n+version = \"1.0.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649\"\n+\n [[package]]\n name = \"slab\"\n version = \"0.4.12\"\n@@ -1709,6 +1932,7 @@ dependencies = [\n name = \"sorter2-server\"\n version = \"0.0.1\"\n dependencies = [\n+ \"ammonia\",\n \"async-stream\",\n \"axum\",\n \"axum-extra\",\n@@ -1742,6 +1966,31 @@ version = \"1.2.1\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596\"\n \n+[[package]]\n+name = \"string_cache\"\n+version = \"0.8.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f\"\n+dependencies = [\n+ \"new_debug_unreachable\",\n+ \"parking_lot\",\n+ \"phf_shared\",\n+ \"precomputed-hash\",\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"string_cache_codegen\"\n+version = \"0.5.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0\"\n+dependencies = [\n+ \"phf_generator\",\n+ \"phf_shared\",\n+ \"proc-macro2\",\n+ \"quote\",\n+]\n+\n [[package]]\n name = \"subtle\"\n version = \"2.6.1\"\n@@ -1813,6 +2062,17 @@ dependencies = [\n \"windows-sys 0.61.2\",\n ]\n \n+[[package]]\n+name = \"tendril\"\n+version = \"0.4.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0\"\n+dependencies = [\n+ \"futf\",\n+ \"mac\",\n+ \"utf-8\",\n+]\n+\n [[package]]\n name = \"thiserror\"\n version = \"1.0.69\"\n@@ -2116,6 +2376,12 @@ version = \"2.1.3\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da\"\n \n+[[package]]\n+name = \"utf-8\"\n+version = \"0.7.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9\"\n+\n [[package]]\n name = \"utf8_iter\"\n version = \"1.0.4\"\n@@ -2281,6 +2547,18 @@ dependencies = [\n \"wasm-bindgen\",\n ]\n \n+[[package]]\n+name = \"web_atoms\"\n+version = \"0.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414\"\n+dependencies = [\n+ \"phf\",\n+ \"phf_codegen\",\n+ \"string_cache\",\n+ \"string_cache_codegen\",\n+]\n+\n [[package]]\n name = \"windows-link\"\n version = \"0.2.1\"\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nindex 47659ff82fbfb50972eb2b87575e80f66e572ba4..27f552c20b97ef28cdde4cb6b1a4980375135111 100644\n--- a/server/Cargo.toml\n+++ b/server/Cargo.toml\n@@ -13,6 +13,7 @@ serde = { version = \"1\", features = [\"derive\"] }\n serde_json = \"1\"\n thiserror = \"1\"\n maud = { version = \"0.26\", features = [\"axum\"] }\n+ammonia = \"4.1\"\n tower = \"0.5\"\n tower-http = { version = \"0.5\", features = [\"trace\"] }\n tracing = \"0.1\"\ndiff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs\nindex dadf050515f0473943dad97df5d318032c8cb385..5b160c6b8bd216dfaf80149854aec0566cd00460 100644\n--- a/server/src/fetch/html.rs\n+++ b/server/src/fetch/html.rs\n@@ -4,6 +4,7 @@ use maud::{html, Markup};\n \n use crate::{\n form_template::template_json_compact,\n+ html::sanitize::entity_body_html,\n path_types::ItemId,\n reddit::{is_children_fetchable, is_fetchable},\n reducer::NodeState,\n@@ -27,7 +28,7 @@ pub fn entity_panel(node: &NodeState) -> Markup {\n p class=\"muted small\" { \"by \" (author) }\n }\n @if let Some(body) = &data.body_html {\n- div class=\"entity-body\" { (maud::PreEscaped(body)) }\n+ div class=\"entity-body\" { (maud::PreEscaped(entity_body_html(body))) }\n }\n }\n }\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex e180a0ca542a33e2300c0a4809e6b9cfee07ecfe..a58cbbee3490a08a625cb06df06848c59a615d65 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -20,6 +20,7 @@ use crate::{\n ui_action::UI_RPC_FIELD,\n };\n \n+pub mod sanitize;\n pub mod vote;\n \n const SORTER_CSS: &str = include_str!(\"../../static/sorter.css\");\ndiff --git a/server/src/html/sanitize.rs b/server/src/html/sanitize.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..6685ed2535281b9fd5d65b042cbc5a11c5d4df37\n--- /dev/null\n+++ b/server/src/html/sanitize.rs\n@@ -0,0 +1,39 @@\n+//! Whitelist sanitization for untrusted HTML fragments (e.g. Reddit `selftext_html`).\n+\n+use std::sync::LazyLock;\n+\n+use ammonia::Builder;\n+\n+static ENTITY_BODY: LazyLock> = LazyLock::new(|| {\n+ let mut b = Builder::default();\n+ b.strip_comments(true);\n+ b.link_rel(Some(\"noopener noreferrer\"));\n+ b\n+});\n+\n+/// Sanitize HTML safe for embedding in our pages via [`maud::PreEscaped`].\n+pub fn entity_body_html(raw: &str) -> String {\n+ ENTITY_BODY.clean(raw).to_string()\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::entity_body_html;\n+\n+ #[test]\n+ fn keeps_benign_markup() {\n+ assert_eq!(\n+ entity_body_html(\"

release notes

\"),\n+ \"

release notes

\"\n+ );\n+ }\n+\n+ #[test]\n+ fn strips_scripts_and_event_handlers() {\n+ let raw = \"

ok

\";\n+ let clean = entity_body_html(raw);\n+ assert!(!clean.contains(\"ok

\"));\n+ }\n+}\ndiff --git a/server/src/render/reddit.rs b/server/src/render/reddit.rs\nindex c4f98fc760c32ce1b41a91bd41e97908bd198937..7f840aa33b734a31d8cf3341a0581c8bcb9bbcf3 100644\n--- a/server/src/render/reddit.rs\n+++ b/server/src/render/reddit.rs\n@@ -3,6 +3,7 @@\n use maud::{html, Markup};\n \n use crate::{\n+ html::sanitize::entity_body_html,\n path_types::ItemId,\n reducer::{EntityData, GlobalTree, NodeState},\n };\n@@ -57,7 +58,7 @@ fn post_entity_card(data: &EntityData) -> Markup {\n }\n }\n @if let Some(body) = &data.body_html {\n- div class=\"entity-body\" { (maud::PreEscaped(body)) }\n+ div class=\"entity-body\" { (maud::PreEscaped(entity_body_html(body))) }\n }\n }\n }\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[40b975bf] nice\n\nSide B — unified diff (full patch):\ndiff --git a/Cargo.lock b/Cargo.lock\nindex 266e876bb7ccbe788beb1d5bd53ad5b45ee5825b..2cea973082716e761ef6f5dd5886acc08ff9aac0 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -222,6 +222,12 @@ dependencies = [\n \"syn\",\n ]\n \n+[[package]]\n+name = \"dotenvy\"\n+version = \"0.15.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b\"\n+\n [[package]]\n name = \"encoding_rs\"\n version = \"0.8.35\"\n@@ -1238,6 +1244,7 @@ version = \"0.0.1\"\n dependencies = [\n \"axum\",\n \"axum-extra\",\n+ \"dotenvy\",\n \"maud\",\n \"reqwest\",\n \"serde\",\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nindex 4677fedcb45292eebebe7e9cf6ce2f5738f18ddf..bd600138b613bd0f546bdec217a5334cdcb20aa5 100644\n--- a/server/Cargo.toml\n+++ b/server/Cargo.toml\n@@ -17,6 +17,7 @@ tower-http = { version = \"0.5\", features = [\"trace\"] }\n tracing = \"0.1\"\n tracing-subscriber = { version = \"0.3\", features = [\"env-filter\"] }\n reqwest = { version = \"0.12\", features = [\"json\"] }\n+dotenvy = \"0.15\"\n \n [dev-dependencies]\n reqwest = { version = \"0.12\", features = [\"json\"] }\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex d2024bd4582bcc8482b461b2ba4fedbd8bff7c66..b33a84e8bb5e817b26592868d88090e6d664d950 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -6,7 +6,7 @@ use axum::{\n use std::collections::HashMap;\n \n use crate::{\n- html::{input_panel, js_string_literal, ranking_panel, JsBuilder},\n+ html::{entity_section, input_panel, js_string_literal, ranking_panel, JsBuilder},\n parser::parse_reddit_url,\n path_types::ItemId,\n reddit::ensure_partial_tree,\n@@ -87,6 +87,20 @@ pub async fn post_ui_html(\n .into_response()\n }\n },\n+ HtmlUiAction::FetchEntity { item } => {\n+ let id = parse_item_param(&item);\n+ if id.is_root() {\n+ return ui_js_warn(\"nothing to fetch for the root\").into_response();\n+ }\n+ state.queue_entity_fetch(id.clone());\n+ let tree = state.tree.read().await;\n+ let empty = crate::reducer::NodeState::default();\n+ let node = tree.get(&id).unwrap_or(&empty);\n+ let panel = entity_section(&id, node, true);\n+ JsBuilder::new()\n+ .morph_selector(\"#entity-section\", panel)\n+ .into_response()\n+ },\n }\n }\n \ndiff --git a/server/src/events.rs b/server/src/events.rs\nindex ed5be6b13b9d46e838831d6ce0f96f569b401730..07ce24b5e56cf72b0b442c3c3241efbf6c3b006a 100644\n--- a/server/src/events.rs\n+++ b/server/src/events.rs\n@@ -1,4 +1,5 @@\n use serde::{Deserialize, Serialize};\n+use serde_json::Value;\n \n #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(tag = \"type\", rename_all = \"snake_case\")]\n@@ -18,4 +19,10 @@ pub enum Event {\n },\n /// Register a node path in the fractal tree (no external fetch).\n NodeEnsured { id: String },\n+ /// Full upstream API payload for a node (domain-specific view derived at replay/render time).\n+ EntityImported {\n+ id: String,\n+ ts: i64,\n+ payload: Value,\n+ },\n }\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex df6505021d9f446c2b453e20e3eb3cf696a111f9..caf1309c8d93b47104499c57f9cc35ee7631fbb9 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -10,6 +10,7 @@ use crate::{\n form_template::template_json_compact,\n path_types::ItemId,\n ranking::{top_bottom, RankedItem},\n+ reddit::is_fetchable,\n reducer::{GroupState, NodeState},\n state::AppState,\n ui_action::UI_RPC_FIELD,\n@@ -151,7 +152,7 @@ pub fn breadcrumb_path(item: &ItemId) -> Markup {\n fn entity_panel(node: &NodeState) -> Markup {\n html! {\n @if let Some(data) = &node.data {\n- section id=\"entity-panel\" class=\"demo-panel entity-card\" {\n+ div id=\"entity-panel\" class=\"entity-card\" {\n h2 { (data.title) }\n @if let Some(author) = &data.author {\n p class=\"muted small\" { \"by \" (author) }\n@@ -164,6 +165,42 @@ fn entity_panel(node: &NodeState) -> Markup {\n }\n }\n \n+/// Reddit/API import control — only shown on fetchable pages; never auto-fires.\n+pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {\n+ if !is_fetchable(item) {\n+ return html! {};\n+ }\n+ let label = if fetching {\n+ \"Fetching…\"\n+ } else if has_data {\n+ \"Fetch more\"\n+ } else {\n+ \"Fetch from Reddit\"\n+ };\n+ let rpc = template_json_compact(&serde_json::json!({\n+ \"action\": \"fetch_entity\",\n+ \"item\": item.as_str(),\n+ }))\n+ .expect(\"fetch_entity rpc template\");\n+ html! {\n+ form method=\"post\" action=\"/ui\" id=\"fetch-entity-form\" class=\"fetch-entity-form\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc);\n+ button type=\"submit\" class=\"btn-secondary\" disabled=(fetching) { (label) }\n+ }\n+ }\n+}\n+\n+/// Entity card + explicit fetch control (morphed as `#entity-section`).\n+pub fn entity_section(item: &ItemId, node: &NodeState, fetching: bool) -> Markup {\n+ let has_data = node.data.is_some();\n+ html! {\n+ section id=\"entity-section\" class=\"demo-panel\" {\n+ (entity_panel(node))\n+ (fetch_entity_panel(item, has_data, fetching))\n+ }\n+ }\n+}\n+\n fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {\n html! {\n @if !items.is_empty() {\n@@ -260,7 +297,7 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {\n h1 { \"sorter\" }\n (input_panel(\"\", None))\n (breadcrumb_path(&item))\n- (entity_panel(node))\n+ (entity_section(&item, node, false))\n (ranking_panel(&item, group))\n };\n layout(\"sorter2\", body, views)\n@@ -272,16 +309,5 @@ pub async fn home(State(state): State, uri: Uri) -> impl IntoResponse\n \n pub async fn browse(State(state): State, uri: Uri) -> impl IntoResponse {\n let item = ItemId::from_browse_uri(uri.path()).unwrap_or(ItemId::root());\n- if item.as_str().starts_with(\"reddit.com\") {\n- let needs_fetch = {\n- let tree = state.tree.read().await;\n- tree.get(&item)\n- .map(|n| n.data.is_none())\n- .unwrap_or(true)\n- };\n- if needs_fetch {\n- state.reddit.request_fetch(item.clone());\n- }\n- }\n item_page(state, uri, item).await\n }\ndiff --git a/server/src/main.rs b/server/src/main.rs\nindex c22ec6c9f5358e5ec99fb83210dc351938505a93..1f0cddc39302b35b0cd6a6219f44c9d59202facf 100644\n--- a/server/src/main.rs\n+++ b/server/src/main.rs\n@@ -2,6 +2,10 @@ use sorter2_server::state::AppConfig;\n \n #[tokio::main]\n async fn main() -> Result<(), Box> {\n+ if std::env::var(\"SORTER2_SKIP_DOTENV\").is_err() {\n+ let _ = dotenvy::dotenv();\n+ }\n+\n tracing_subscriber::fmt()\n .with_env_filter(\n tracing_subscriber::EnvFilter::try_from_default_env()\ndiff --git a/server/src/reddit.rs b/server/src/reddit.rs\nindex 90053ad03b1d7c8e94f325dd4ee64c2b4f7da900..ff0f01e57b18af878eb5be3efc47204a7673589d 100644\n--- a/server/src/reddit.rs\n+++ b/server/src/reddit.rs\n@@ -6,11 +6,15 @@ use std::time::{Duration, Instant};\n \n use reqwest::{header, Client, StatusCode};\n use serde::Deserialize;\n+use serde_json::Value;\n use tokio::sync::{mpsc, RwLock};\n \n use crate::{\n+ event_log::EventLog,\n+ events::Event,\n+ html::now_ms,\n path_types::ItemId,\n- reducer::{EntityData, GlobalTree},\n+ reducer::GlobalTree,\n };\n \n /// Bootstrap blank nodes along a URL path so breadcrumbs and voting work before fetch.\n@@ -20,6 +24,8 @@ pub fn ensure_partial_tree(tree: &mut GlobalTree, id: &ItemId) {\n \n pub struct RedditCommand {\n pub id: ItemId,\n+ /// User-initiated fetch bypasses the in-memory \"recently fetched\" cache.\n+ pub force: bool,\n }\n \n #[derive(Clone)]\n@@ -33,19 +39,31 @@ struct RedditCredentials {\n client_secret: String,\n }\n \n+#[derive(Clone)]\n+pub struct RedditApiConfig {\n+ pub api_base: String,\n+ pub oauth_base: String,\n+ pub user_agent: String,\n+ creds: Option,\n+}\n+\n struct OAuthToken {\n access_token: String,\n expires_at: Instant,\n }\n \n impl RedditBroker {\n- pub fn spawn(tree: Arc>, user_agent: &str) -> Self {\n+ pub fn spawn(\n+ tree: Arc>,\n+ event_log: Arc,\n+ config: RedditApiConfig,\n+ ) -> Self {\n let (tx, rx) = mpsc::channel(100);\n \n let mut headers = header::HeaderMap::new();\n headers.insert(\n header::USER_AGENT,\n- header::HeaderValue::from_str(user_agent).expect(\"valid user agent\"),\n+ header::HeaderValue::from_str(&config.user_agent).expect(\"valid user agent\"),\n );\n \n let client = Client::builder()\n@@ -54,22 +72,38 @@ impl RedditBroker {\n .build()\n .expect(\"reqwest client\");\n \n- let creds = RedditCredentials::from_env();\n- tokio::spawn(reddit_worker(rx, tree, client, creds));\n+ tokio::spawn(reddit_worker(rx, tree, event_log, client, config));\n \n Self { tx }\n }\n \n- /// Fire-and-forget: queue a fetch; worker updates the tree when done.\n- pub fn request_fetch(&self, id: ItemId) {\n- let _ = self.tx.try_send(RedditCommand { id });\n+ /// Queue a fetch; drops when the channel is full (backpressure).\n+ pub fn request_fetch(&self, id: ItemId, force: bool) {\n+ let _ = self.tx.try_send(RedditCommand { id, force });\n+ }\n+}\n+\n+impl RedditApiConfig {\n+ pub fn from_env() -> Self {\n+ Self {\n+ api_base: reddit_api_base(),\n+ oauth_base: reddit_oauth_base(),\n+ user_agent: default_user_agent(),\n+ creds: RedditCredentials::from_env(),\n+ }\n }\n }\n \n impl RedditCredentials {\n+ /// Reddit's OAuth docs call these \"client id\" and \"client secret\"; the app\n+ /// registration UI often labels them \"app id\" / \"app secret\" — same values.\n fn from_env() -> Option {\n- let client_id = std::env::var(\"REDDIT_CLIENT_ID\").ok()?;\n- let client_secret = std::env::var(\"REDDIT_CLIENT_SECRET\").ok()?;\n+ let client_id = std::env::var(\"REDDIT_CLIENT_ID\")\n+ .or_else(|_| std::env::var(\"REDDIT_APP_ID\"))\n+ .ok()?;\n+ let client_secret = std::env::var(\"REDDIT_CLIENT_SECRET\")\n+ .or_else(|_| std::env::var(\"REDDIT_APP_SECRET\"))\n+ .ok()?;\n if client_id.is_empty() || client_secret.is_empty() {\n return None;\n }\n@@ -80,29 +114,63 @@ impl RedditCredentials {\n }\n }\n \n+pub fn reddit_api_base() -> String {\n+ std::env::var(\"REDDIT_API_BASE\").unwrap_or_else(|_| \"https://www.reddit.com\".into())\n+}\n+\n+pub fn reddit_oauth_base() -> String {\n+ std::env::var(\"REDDIT_OAUTH_BASE\").unwrap_or_else(|_| \"https://www.reddit.com\".into())\n+}\n+\n pub fn default_user_agent() -> String {\n std::env::var(\"REDDIT_USER_AGENT\").unwrap_or_else(|_| {\n \"web:sorter2.social:v0.0.1 (by /u/sorter2)\".to_string()\n })\n }\n \n+/// True when this node can be loaded from the Reddit JSON API.\n+pub fn is_fetchable(id: &ItemId) -> bool {\n+ !map_item_to_reddit_api(id, \"https://example.com\").is_empty()\n+}\n+\n+/// Derive UI-facing fields from a stored payload (Reddit-specific when under reddit.com).\n+pub fn entity_view_from_payload(id: &ItemId, payload: &Value) -> Option {\n+ if id.as_str().starts_with(\"reddit.com\") {\n+ return parse_reddit_view(id, payload);\n+ }\n+ None\n+}\n+\n+/// Apply a full API payload to the in-memory tree (view derived for known domains).\n+pub fn apply_entity_import(tree: &mut GlobalTree, id: &ItemId, payload: Value) {\n+ let view = entity_view_from_payload(id, &payload);\n+ tree.apply_entity_raw(id, payload, view);\n+}\n+\n async fn reddit_worker(\n mut rx: mpsc::Receiver,\n tree: Arc>,\n+ event_log: Arc,\n client: Client,\n- creds: Option,\n+ config: RedditApiConfig,\n ) {\n let mut in_flight = HashSet::new();\n let mut recently_fetched: HashMap = HashMap::new();\n let mut current_delay = Duration::from_secs(1);\n let mut oauth: Option = None;\n let cache_ttl = Duration::from_secs(300);\n+ let creds = config.creds.clone();\n+ let api_base = config.api_base.clone();\n+ let oauth_base = config.oauth_base.clone();\n \n while let Some(cmd) = rx.recv().await {\n let now = Instant::now();\n recently_fetched.retain(|_, t| now.duration_since(*t) < cache_ttl);\n \n- if in_flight.contains(&cmd.id) || recently_fetched.contains_key(&cmd.id) {\n+ if in_flight.contains(&cmd.id) {\n+ continue;\n+ }\n+ if !cmd.force && recently_fetched.contains_key(&cmd.id) {\n continue;\n }\n \n@@ -112,18 +180,26 @@ async fn reddit_worker(\n tokio::time::sleep(current_delay).await;\n \n if let Some(c) = &creds {\n- oauth = ensure_oauth_token(&client, c, oauth.take()).await;\n+ oauth = ensure_oauth_token(&client, &oauth_base, c, oauth.take()).await;\n }\n \n let token = oauth.as_ref().map(|t| t.access_token.as_str());\n- let use_oauth = token.is_some();\n \n- match do_fetch(&client, &fetch_id, use_oauth, token).await {\n- Ok(FetchOutcome::Entity(data)) => {\n- let mut w = tree.write().await;\n- w.set_entity_data(&fetch_id, data);\n- recently_fetched.insert(fetch_id.clone(), Instant::now());\n- current_delay = Duration::from_millis(600);\n+ match do_fetch(&client, &api_base, &fetch_id, token).await {\n+ Ok(FetchOutcome::Payload(payload)) => {\n+ let ts = now_ms();\n+ let event = Event::EntityImported {\n+ id: fetch_id.as_str().to_string(),\n+ ts,\n+ payload: payload.clone(),\n+ };\n+ if let Err(e) = event_log.append(&event).await {\n+ tracing::warn!(\"event log append failed for {}: {}\", fetch_id, e);\n+ } else {\n+ apply_entity_import(&mut *tree.write().await, &fetch_id, payload);\n+ recently_fetched.insert(fetch_id.clone(), Instant::now());\n+ current_delay = Duration::from_millis(600);\n+ }\n }\n Ok(FetchOutcome::NotFound) => {\n recently_fetched.insert(fetch_id.clone(), Instant::now());\n@@ -149,13 +225,14 @@ async fn reddit_worker(\n }\n \n enum FetchOutcome {\n- Entity(EntityData),\n+ Payload(Value),\n NotFound,\n RateLimited { reset_secs: u64 },\n }\n \n async fn ensure_oauth_token(\n client: &Client,\n+ oauth_base: &str,\n creds: &RedditCredentials,\n existing: Option,\n ) -> Option {\n@@ -165,8 +242,13 @@ async fn ensure_oauth_token(\n }\n }\n \n+ let url = format!(\n+ \"{}/api/v1/access_token\",\n+ oauth_base.trim_end_matches('/')\n+ );\n+\n let resp = client\n- .post(\"https://www.reddit.com/api/v1/access_token\")\n+ .post(&url)\n .basic_auth(&creds.client_id, Some(&creds.client_secret))\n .form(&[(\"grant_type\", \"client_credentials\")])\n .send()\n@@ -207,11 +289,11 @@ async fn ensure_oauth_token(\n \n async fn do_fetch(\n client: &Client,\n+ api_base: &str,\n id: &ItemId,\n- use_oauth: bool,\n bearer: Option<&str>,\n ) -> Result {\n- let url = map_item_to_reddit_api(id, use_oauth);\n+ let url = map_item_to_reddit_api(id, api_base);\n if url.is_empty() {\n return Ok(FetchOutcome::NotFound);\n }\n@@ -241,10 +323,8 @@ async fn do_fetch(\n return Ok(FetchOutcome::RateLimited { reset_secs: reset });\n }\n \n- let bytes = resp.bytes().await.map_err(|e| e.to_string())?;\n- Ok(parse_reddit_json(id, &bytes)\n- .map(FetchOutcome::Entity)\n- .unwrap_or(FetchOutcome::NotFound))\n+ let payload: Value = resp.json().await.map_err(|e| e.to_string())?;\n+ Ok(FetchOutcome::Payload(payload))\n }\n \n fn rate_limit_remaining(resp: &reqwest::Response) -> Option {\n@@ -264,18 +344,14 @@ fn rate_limit_reset_secs(resp: &reqwest::Response) -> u64 {\n .unwrap_or(5)\n }\n \n-/// Map canonical item id to Reddit JSON API URL.\n-pub fn map_item_to_reddit_api(id: &ItemId, oauth: bool) -> String {\n+/// Map canonical item id to a Reddit JSON API URL under `api_base`.\n+pub fn map_item_to_reddit_api(id: &ItemId, api_base: &str) -> String {\n let path = id.as_str();\n if !path.starts_with(\"reddit.com/\") && path != \"reddit.com\" {\n return String::new();\n }\n \n- let base = if oauth {\n- \"https://oauth.reddit.com\"\n- } else {\n- \"https://www.reddit.com\"\n- };\n+ let base = api_base.trim_end_matches('/');\n \n let segments: Vec<&str> = path.split('/').collect();\n \n@@ -293,18 +369,17 @@ pub fn map_item_to_reddit_api(id: &ItemId, oauth: bool) -> String {\n String::new()\n }\n \n-fn parse_reddit_json(id: &ItemId, bytes: &[u8]) -> Option {\n- let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;\n+fn parse_reddit_view(id: &ItemId, v: &Value) -> Option {\n let segments: Vec<&str> = id.as_str().split('/').collect();\n \n if segments.iter().any(|&p| p == \"comments\") {\n- parse_post_listing(&v)\n+ parse_post_listing(v)\n } else {\n- parse_subreddit_about(&v)\n+ parse_subreddit_about(v)\n }\n }\n \n-fn parse_subreddit_about(v: &serde_json::Value) -> Option {\n+fn parse_subreddit_about(v: &Value) -> Option {\n let data = v.get(\"data\")?;\n let title = data\n .get(\"title\")\n@@ -323,7 +398,7 @@ fn parse_subreddit_about(v: &serde_json::Value) -> Option {\n .filter(|s| !s.is_empty())\n .map(|s| s.to_string());\n \n- Some(EntityData {\n+ Some(crate::reducer::EntityData {\n title,\n author: None,\n body_html,\n@@ -331,10 +406,9 @@ fn parse_subreddit_about(v: &serde_json::Value) -> Option {\n })\n }\n \n-fn parse_post_listing(v: &serde_json::Value) -> Option {\n+fn parse_post_listing(v: &Value) -> Option {\n let listing = v.as_array()?.first()?;\n- let child = listing\n- .pointer(\"/data/children/0/data\")?;\n+ let child = listing.pointer(\"/data/children/0/data\")?;\n let title = child.get(\"title\")?.as_str()?.to_string();\n let author = child\n .get(\"author\")\n@@ -352,7 +426,7 @@ fn parse_post_listing(v: &serde_json::Value) -> Option {\n .filter(|s| s.starts_with(\"http\"))\n .map(|s| s.to_string());\n \n- Some(EntityData {\n+ Some(crate::reducer::EntityData {\n title,\n author,\n body_html,\n@@ -368,48 +442,51 @@ mod tests {\n fn map_subreddit_about_url() {\n let id = ItemId::parse(\"reddit.com/r/rust\").unwrap();\n assert_eq!(\n- map_item_to_reddit_api(&id, false),\n+ map_item_to_reddit_api(&id, \"https://www.reddit.com\"),\n \"https://www.reddit.com/r/rust/about.json?raw_json=1\"\n );\n assert_eq!(\n- map_item_to_reddit_api(&id, true),\n- \"https://oauth.reddit.com/r/rust/about.json?raw_json=1\"\n+ map_item_to_reddit_api(&id, \"http://127.0.0.1:9999\"),\n+ \"http://127.0.0.1:9999/r/rust/about.json?raw_json=1\"\n );\n }\n \n #[test]\n fn map_post_url() {\n- let id =\n- ItemId::parse(\"reddit.com/r/amitheasshole/comments/1trnvdl\").unwrap();\n+ let id = ItemId::parse(\"reddit.com/r/amitheasshole/comments/1trnvdl\").unwrap();\n assert_eq!(\n- map_item_to_reddit_api(&id, false),\n+ map_item_to_reddit_api(&id, \"https://www.reddit.com\"),\n \"https://www.reddit.com/r/amitheasshole/comments/1trnvdl.json?raw_json=1\"\n );\n }\n \n #[test]\n- fn map_non_reddit_empty() {\n- let id = ItemId::opaque(\"example.com/foo\");\n- assert!(map_item_to_reddit_api(&id, false).is_empty());\n+ fn is_fetchable_reddit_sub() {\n+ let id = ItemId::parse(\"reddit.com/r/rust\").unwrap();\n+ assert!(is_fetchable(&id));\n+ assert!(!is_fetchable(&ItemId::opaque(\"example.com/x\")));\n }\n \n #[test]\n fn parse_subreddit_fixture() {\n- let json = r#\"{\"kind\":\"t5\",\"data\":{\"title\":\"Rust\",\"display_name\":\"rust\",\"public_description\":\"systems\"}}\"#;\n- let entity = parse_reddit_json(\n+ let json = include_str!(\"../../test/fixtures/reddit/r_rust_about.json\");\n+ let v: Value = serde_json::from_str(json).unwrap();\n+ let entity = entity_view_from_payload(\n &ItemId::parse(\"reddit.com/r/rust\").unwrap(),\n- json.as_bytes(),\n+ &v,\n )\n .unwrap();\n- assert_eq!(entity.title, \"Rust\");\n+ assert_eq!(entity.title, \"The Rust Programming Language\");\n+ assert!(entity.body_html.as_ref().is_some_and(|b| b.contains(\"Rust\")));\n }\n \n #[test]\n fn parse_post_fixture() {\n let json = r#\"[{\"kind\":\"Listing\",\"data\":{\"children\":[{\"kind\":\"t3\",\"data\":{\"title\":\"AITA\",\"author\":\"op\",\"selftext_html\":\"<p>hi</p>\",\"thumbnail\":\"https://b.thumbs.redditmedia.com/x.jpg\"}}]}}]\"#;\n- let entity = parse_reddit_json(\n+ let v: Value = serde_json::from_str(json).unwrap();\n+ let entity = entity_view_from_payload(\n &ItemId::parse(\"reddit.com/r/x/comments/abc\").unwrap(),\n- json.as_bytes(),\n+ &v,\n )\n .unwrap();\n assert_eq!(entity.title, \"AITA\");\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 077f700bf00ddefe18ffd004bb5288bdc7c4adaf..60db81a562e3590775012573225a76ba82a14563 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -1,6 +1,7 @@\n use std::collections::{HashMap, HashSet, VecDeque};\n \n use serde::{Deserialize, Serialize};\n+use serde_json::Value;\n \n use crate::path_types::ItemId;\n \n@@ -128,6 +129,9 @@ pub struct EntityData {\n #[derive(Debug, Clone, Default)]\n pub struct NodeState {\n pub id: ItemId,\n+ /// Full imported API JSON (persisted in the event log).\n+ pub entity_raw: Option,\n+ /// Domain-specific view derived from `entity_raw` (e.g. Reddit title/author).\n pub data: Option,\n pub children: HashSet,\n pub local_ranking: GroupState,\n@@ -197,10 +201,11 @@ impl GlobalTree {\n }\n }\n \n- pub fn set_entity_data(&mut self, id: &ItemId, data: EntityData) {\n+ pub fn apply_entity_raw(&mut self, id: &ItemId, payload: Value, view: Option) {\n self.ensure_path(id);\n if let Some(node) = self.nodes.get_mut(id) {\n- node.data = Some(data);\n+ node.entity_raw = Some(payload);\n+ node.data = view;\n }\n }\n }\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 8c03aa60c15aee803a534439400b69935b1a3d84..d71d1079a8f486cdab15795384aef0b81b32544d 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -7,7 +7,7 @@ use crate::{\n events::Event,\n journal::JournalClient,\n path_types::ItemId,\n- reddit::{default_user_agent, RedditBroker},\n+ reddit::{apply_entity_import, RedditApiConfig, RedditBroker},\n reducer::{GlobalTree, VoteData},\n views::ViewStore,\n };\n@@ -108,13 +108,18 @@ impl AppState {\n tree.ensure_path(&parsed);\n }\n }\n+ Event::EntityImported { id, payload, .. } => {\n+ if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n+ apply_entity_import(&mut tree, &parsed, payload);\n+ }\n+ }\n }\n }\n }\n \n let tree = Arc::new(RwLock::new(tree));\n let journal = JournalClient::spawn(tree.clone(), event_log.clone());\n- let reddit = RedditBroker::spawn(tree.clone(), &default_user_agent());\n+ let reddit = RedditBroker::spawn(tree.clone(), event_log.clone(), RedditApiConfig::from_env());\n \n Self {\n cfg: Arc::new(cfg),\n@@ -135,10 +140,14 @@ impl AppState {\n let mut w = self.tree.write().await;\n w.ensure_path(id);\n }\n- self.reddit.request_fetch(id.clone());\n Ok(())\n }\n \n+ /// User-initiated Reddit/API import (via \"Fetch more\" — never on paste or navigate).\n+ pub fn queue_entity_fetch(&self, id: ItemId) {\n+ self.reddit.request_fetch(id, true);\n+ }\n+\n pub async fn record_vote(\n &self,\n parent: &ItemId,\n@@ -169,6 +178,44 @@ impl AppState {\n #[cfg(test)]\n mod tests {\n use super::{normalize_scope, parse_item_param};\n+ use crate::{\n+ event_log::EventLog,\n+ events::Event,\n+ path_types::ItemId,\n+ reddit::apply_entity_import,\n+ reducer::GlobalTree,\n+ };\n+ use serde_json::json;\n+\n+ #[tokio::test]\n+ async fn replay_entity_imported_restores_view() {\n+ let tmp = tempfile::tempdir().unwrap();\n+ let log_path = tmp.path().join(\"events.jsonl\");\n+ let log = EventLog::new(log_path.to_string_lossy().into_owned());\n+ let payload = json!({\"kind\":\"t5\",\"data\":{\"title\":\"Rust\",\"display_name\":\"rust\"}});\n+ log.append(&Event::EntityImported {\n+ id: \"reddit.com/r/rust\".into(),\n+ ts: 1,\n+ payload: payload.clone(),\n+ })\n+ .await\n+ .unwrap();\n+\n+ let mut tree = GlobalTree::new();\n+ let (events, _) = log.load_all().await.unwrap();\n+ for ev in events {\n+ if let Event::EntityImported { id, payload, .. } = ev {\n+ let parsed = ItemId::parse(&id).unwrap();\n+ apply_entity_import(&mut tree, &parsed, payload);\n+ }\n+ }\n+ let node = tree.get(&ItemId::parse(\"reddit.com/r/rust\").unwrap()).unwrap();\n+ assert_eq!(node.data.as_ref().unwrap().title, \"Rust\");\n+ assert_eq!(\n+ node.entity_raw.as_ref().unwrap()[\"data\"][\"display_name\"],\n+ \"rust\"\n+ );\n+ }\n \n #[test]\n fn normalize_scope_strips_prefix_and_lowercases() {\ndiff --git a/server/src/ui_action.rs b/server/src/ui_action.rs\nindex d798874d1d94c0dfee59ec1ff703f9ee6ef432c0..3d6a49a2a3fb950752827efe1f5a049308f09baf 100644\n--- a/server/src/ui_action.rs\n+++ b/server/src/ui_action.rs\n@@ -26,6 +26,10 @@ pub enum HtmlUiAction {\n ParseQuery {\n query: String,\n },\n+ /// Fetch upstream entity data for the current page (explicit user action only).\n+ FetchEntity {\n+ item: String,\n+ },\n }\n \n #[derive(Debug, Error)]\ndiff --git a/server/static/sorter.css b/server/static/sorter.css\nindex 36f68d415cea33c8562d5d02c0c9d4c5b225a782..1f9f5158414902d43a380dd899232c70d8cf5d63 100644\n--- a/server/static/sorter.css\n+++ b/server/static/sorter.css\n@@ -52,6 +52,25 @@ body {\n filter: brightness(1.1);\n }\n \n+.btn-secondary {\n+ background: transparent;\n+ color: var(--accent);\n+ border: 1px solid var(--border);\n+ padding: 0.4rem 0.85rem;\n+ font-size: 0.9rem;\n+ cursor: pointer;\n+ margin-top: 0.75rem;\n+}\n+\n+.btn-secondary:disabled {\n+ opacity: 0.6;\n+ cursor: wait;\n+}\n+\n+.fetch-entity-form {\n+ margin-top: 0.5rem;\n+}\n+\n code {\n font-size: 0.85em;\n background: var(--bg);\ndiff --git a/test/fixtures/reddit/r_rust_about.json b/test/fixtures/reddit/r_rust_about.json\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..bb7fd74ab00d63356d9b672a775996ed155b984d\n--- /dev/null\n+++ b/test/fixtures/reddit/r_rust_about.json\n@@ -0,0 +1,21 @@\n+{\n+ \"kind\": \"t5\",\n+ \"data\": {\n+ \"user_flair_background_color\": null,\n+ \"submit_text_html\": null,\n+ \"banner_img\": \"\",\n+ \"user_is_banned\": null,\n+ \"wiki_enabled\": true,\n+ \"show_media\": true,\n+ \"id\": \"2qh0i\",\n+ \"display_name\": \"rust\",\n+ \"title\": \"The Rust Programming Language\",\n+ \"public_description\": \"A place for all things related to the Rust programming language.\",\n+ \"public_description_html\": \"<!-- SC_OFF --><div class=\\\"md\\\"><p>A place for all things related to the Rust programming language.</p>\\n</div><!-- SC_ON -->\",\n+ \"icon_img\": \"https://styles.redditmedia.com/t5_2qh0i/styles/communityIcon_kfqpknb2j6j51.png\",\n+ \"community_icon\": \"https://styles.redditmedia.com/t5_2qh0i/styles/communityIcon_kfqpknb2j6j51.png\",\n+ \"subscribers\": 350000,\n+ \"active_user_count\": 1200,\n+ \"over18\": false\n+ }\n+}\ndiff --git a/test/reddit_import.clj b/test/reddit_import.clj\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..54edaedeef08718c8f184d6aa468d8e6415068c7\n--- /dev/null\n+++ b/test/reddit_import.clj\n@@ -0,0 +1,116 @@\n+(ns test.reddit-import\n+ (:require [babashka.process :as process]\n+ [clojure.java.io :as io]\n+ [clojure.string :as str]\n+ [clojure.test :refer [deftest is testing]])\n+ (:import [com.sun.net.httpserver HttpServer HttpHandler HttpExchange]\n+ [java.net InetSocketAddress]))\n+\n+(defn- repo-root []\n+ (.getCanonicalPath (io/file (System/getProperty \"user.dir\"))))\n+\n+(defn- pick-port []\n+ (with-open [s (java.net.ServerSocket. 0)]\n+ (.getLocalPort s)))\n+\n+(defn- start-mock-reddit [port fixtures-dir]\n+ (let [fixture (io/file fixtures-dir \"r_rust_about.json\")\n+ body (.getBytes (slurp fixture) \"UTF-8\")\n+ server (HttpServer/create (InetSocketAddress. \"127.0.0.1\" port) 0)\n+ handler\n+ (proxy [HttpHandler] []\n+ (handle [^HttpExchange exchange]\n+ (.sendResponseHeaders exchange 200 (alength body))\n+ (let [out (.getResponseBody exchange)]\n+ (.write out body)\n+ (.close out))))]\n+ (.createContext server \"/\" handler)\n+ (.setExecutor server nil)\n+ (.start server)\n+ (fn stop []\n+ (.stop server 0))))\n+\n+(defn- wait-health [base-url ms]\n+ (let [deadline (+ (System/currentTimeMillis) ms)\n+ url (str base-url \"/healthz\")]\n+ (loop []\n+ (let [resp (try\n+ (process/shell {:out :string :err :string}\n+ \"curl\" \"-sf\" url)\n+ (catch Exception _ nil))]\n+ (if (and resp (zero? (:exit resp)) (= \"ok\" (str/trim (:out resp \"\"))))\n+ true\n+ (if (< (System/currentTimeMillis) deadline)\n+ (do (Thread/sleep 200) (recur))\n+ false))))))\n+\n+(defn- curl-post-ui [base rpc-json]\n+ (process/shell {:out :string :err :string}\n+ \"curl\" \"-sf\" \"-X\" \"POST\" (str base \"/ui\")\n+ \"--data-urlencode\" (str \"__rpc__=\" rpc-json)))\n+\n+(defn- wait-event-log [path ms]\n+ (let [deadline (+ (System/currentTimeMillis) ms)]\n+ (loop []\n+ (if (.exists (io/file path))\n+ true\n+ (if (< (System/currentTimeMillis) deadline)\n+ (do (Thread/sleep 200) (recur))\n+ false)))))\n+\n+(deftest reddit-fetch-via-mock-api\n+ (testing \"Fetch more queues import; event log stores full payload; page shows title\"\n+ (let [root (repo-root)\n+ fixtures (str root \"/test/fixtures/reddit\")\n+ data-dir (.getAbsolutePath\n+ (doto (io/file (System/getProperty \"java.io.tmpdir\")\n+ (str \"sorter2-reddit-\" (System/currentTimeMillis)))\n+ (.mkdirs)))\n+ reddit-port (pick-port)\n+ app-port (pick-port)\n+ reddit-base (str \"http://127.0.0.1:\" reddit-port)\n+ app-base (str \"http://127.0.0.1:\" app-port)\n+ bin (str root \"/target/release/sorter2-server\")\n+ stop-mock (start-mock-reddit reddit-port fixtures)]\n+ (try\n+ (is (zero? (:exit (process/shell {:dir root}\n+ \"cargo\" \"build\" \"--release\" \"--package\" \"sorter2-server\")))\n+ \"release build succeeds\")\n+ (let [proc (process/process {:dir root\n+ :env (into (into {} (System/getenv))\n+ {\"SORTER2_SKIP_DOTENV\" \"1\"\n+ \"SORTER2_DATA_DIR\" data-dir\n+ \"SORTER2_EVENT_LOG\" (str data-dir \"/events.jsonl\")\n+ \"PORT\" (str app-port)\n+ \"REDDIT_API_BASE\" reddit-base\n+ \"REDDIT_OAUTH_BASE\" reddit-base\n+ \"REDDIT_CLIENT_ID\" \"\"\n+ \"REDDIT_CLIENT_SECRET\" \"\"\n+ \"REDDIT_APP_ID\" \"\"\n+ \"REDDIT_APP_SECRET\" \"\"})\n+ :out :string\n+ :err :string}\n+ bin)]\n+ (try\n+ (is (wait-health app-base 20000) \"app healthz\")\n+ (let [browse-url (str app-base \"/~/https://reddit.com/r/rust\")\n+ before (:out (process/shell {:out :string :err :string}\n+ \"curl\" \"-sf\" browse-url))]\n+ (is (str/includes? before \"Fetch from Reddit\"))\n+ (is (not (str/includes? before \"The Rust Programming Language\")))\n+ (let [rpc \"{\\\"action\\\":\\\"fetch_entity\\\",\\\"item\\\":\\\"reddit.com/r/rust\\\"}\"\n+ post (curl-post-ui app-base rpc)\n+ log-path (str data-dir \"/events.jsonl\")]\n+ (is (zero? (:exit post)) \"fetch_entity POST succeeds\")\n+ (is (wait-event-log log-path 10000) \"event log written\")\n+ (let [after (:out (process/shell {:out :string :err :string}\n+ \"curl\" \"-sf\" browse-url))\n+ log (slurp (io/file log-path))]\n+ (is (str/includes? after \"The Rust Programming Language\"))\n+ (is (str/includes? log \"\\\"type\\\":\\\"entity_imported\\\"\"))\n+ (is (str/includes? log \"\\\"subscribers\\\":350000\"))\n+ (is (str/includes? log \"\\\"display_name\\\":\\\"rust\\\"\")))))\n+ (finally\n+ (process/destroy proc))))\n+ (finally\n+ (stop-mock))))))\n","role":"user"}],"model":"~anthropic/claude-sonnet-latest"}