{"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[3f35edab] progress\n\nSide A — unified diff (full patch):\ndiff --git a/server/src/api/mod.rs b/server/src/api/mod.rs\nindex a10ce662105cff8fad949c6b83f7035ce79bed18..a986f706ea4b261cbaf004c02b4cf84184b41371 100644\n--- a/server/src/api/mod.rs\n+++ b/server/src/api/mod.rs\n@@ -3,6 +3,7 @@ mod helpers;\n mod rpc;\n mod stream;\n mod validate;\n+mod ui_html;\n mod web_post;\n \n pub use auth::{\n@@ -33,6 +34,7 @@ pub use stream::{get_html_stream, get_stream};\n \n pub use validate::{normalize_room_and_thread, validate_ingest_document, ValidatedIngest};\n \n+pub use ui_html::post_ui_html;\n pub use web_post::{check_web_ingest, post_web_ingest, post_web_redact};\n \n #[cfg(test)]\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..2b40a72059981d558768f73d189b991f3448c257\n--- /dev/null\n+++ b/server/src/api/ui_html.rs\n@@ -0,0 +1,139 @@\n+//! Single `POST /ui` entry for browser [`crate::html::ui_action::HtmlUiAction`] (JSON in `__rpc__` + holes).\n+\n+use axum::{\n+ body::Body,\n+ extract::State,\n+ http::{header, HeaderMap, StatusCode},\n+ response::{IntoResponse, Response},\n+ Form,\n+};\n+use axum_extra::extract::cookie::CookieJar;\n+use std::collections::HashMap;\n+\n+use crate::{\n+ api::{\n+ auth::optional_principal,\n+ web_post::{run_check_web_ingest, run_post_web_ingest, run_post_web_redact, WebPostForm, WebRedactForm},\n+ },\n+ html::{\n+ fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup,\n+ parse_html_ui_from_form, user_can_post_room, user_can_view_room, HtmlUiAction, JsBuilder,\n+ ThreadNav,\n+ },\n+ state::AppState,\n+};\n+\n+pub async fn post_ui_html(\n+ State(state): State,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ Form(form): Form>,\n+) -> impl IntoResponse {\n+ let action = match parse_html_ui_from_form(&form) {\n+ Ok(a) => a,\n+ Err(e) => return ui_js_warn(&e.to_string()).into_response(),\n+ };\n+\n+ match action {\n+ HtmlUiAction::PostIngest {\n+ room,\n+ thread_tag,\n+ text,\n+ error_target,\n+ form_id,\n+ } => {\n+ run_post_web_ingest(\n+ &state,\n+ &headers,\n+ &jar,\n+ WebPostForm {\n+ room,\n+ thread_tag,\n+ text,\n+ error_target,\n+ form_id,\n+ },\n+ )\n+ .await\n+ }\n+ HtmlUiAction::CheckIngest {\n+ room,\n+ thread_tag,\n+ text,\n+ error_target,\n+ form_id,\n+ } => {\n+ run_check_web_ingest(\n+ &state,\n+ &headers,\n+ &jar,\n+ WebPostForm {\n+ room,\n+ thread_tag,\n+ text,\n+ error_target,\n+ form_id,\n+ },\n+ )\n+ .await\n+ }\n+ HtmlUiAction::RedactPost { post_id } => {\n+ run_post_web_redact(&state, &headers, &jar, WebRedactForm { post_id }).await\n+ }\n+ HtmlUiAction::ExpandPublicNewThreadForm => {\n+ let reduced = state.reduced.read().await;\n+ let user = optional_principal(&headers, &jar, &reduced);\n+ drop(reduced);\n+ let markup = if user.is_some() {\n+ fragment_public_new_thread_form(true)\n+ } else {\n+ login_to_post_hint_markup()\n+ };\n+ JsBuilder::new()\n+ .morph_selector(\"#public-new-thread-ui-slot\", markup)\n+ .into_response()\n+ }\n+ HtmlUiAction::ExpandRoomNewThreadForm { room_wire } => {\n+ let room_wire = room_wire.trim().to_string();\n+ if room_wire.is_empty() {\n+ return ui_js_warn(\"missing room\").into_response();\n+ }\n+ let reduced = state.reduced.read().await;\n+ let user = optional_principal(&headers, &jar, &reduced);\n+ if !reduced.rooms.contains(&room_wire) {\n+ drop(reduced);\n+ return ui_js_warn(\"room not found\").into_response();\n+ }\n+ if !user_can_view_room(&reduced, &room_wire, user.as_deref()) {\n+ drop(reduced);\n+ return ui_js_warn(\"forbidden\").into_response();\n+ }\n+ let can_post = user\n+ .as_ref()\n+ .map(|u| user_can_post_room(&reduced, &room_wire, u))\n+ .unwrap_or(false);\n+ drop(reduced);\n+ let Some(nav) = ThreadNav::from_room_id(&room_wire) else {\n+ return ui_js_warn(\"bad room\").into_response();\n+ };\n+ let markup = if can_post {\n+ fragment_room_new_thread_form(&nav, true)\n+ } else {\n+ login_to_post_hint_markup()\n+ };\n+ JsBuilder::new()\n+ .morph_selector(\"#room-new-thread-ui-slot\", markup)\n+ .into_response()\n+ }\n+ }\n+}\n+\n+fn ui_js_warn(msg: &str) -> Response {\n+ use crate::html::js_string_literal;\n+ let js = format!(\"console.warn({});\", js_string_literal(msg));\n+ Response::builder()\n+ .status(StatusCode::OK)\n+ .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n+ .body(Body::from(js))\n+ .unwrap()\n+}\ndiff --git a/server/src/api/web_post.rs b/server/src/api/web_post.rs\nindex a64010e382d3039821c836a5529adad0fe67cce5..265025f41ff1548d05b2d2d5d84202245388053f 100644\n--- a/server/src/api/web_post.rs\n+++ b/server/src/api/web_post.rs\n@@ -222,8 +222,18 @@ pub async fn post_web_redact(\n jar: CookieJar,\n Form(form): Form,\n ) -> impl IntoResponse {\n+ run_post_web_redact(&state, &headers, &jar, form).await\n+}\n+\n+/// Shared with [`crate::api::ui_html::post_ui_html`].\n+pub(crate) async fn run_post_web_redact(\n+ state: &AppState,\n+ headers: &HeaderMap,\n+ jar: &CookieJar,\n+ form: WebRedactForm,\n+) -> Response {\n let reduced = state.reduced.read().await;\n- let Some(_username) = optional_principal(&headers, &jar, &reduced) else {\n+ let Some(_username) = optional_principal(headers, jar, &reduced) else {\n drop(reduced);\n return js_redirect(\"/login\").into_response();\n };\n@@ -239,8 +249,8 @@ pub async fn post_web_redact(\n return js_redirect(\"/login\").into_response();\n };\n \n- match rpc_post_redact(&state, &headers, form.post_id).await {\n- Ok(RpcResult::RedactPostOk {}) => redact_success_response(&state).await.into_response(),\n+ match rpc_post_redact(state, headers, form.post_id).await {\n+ Ok(RpcResult::RedactPostOk {}) => redact_success_response(state).await.into_response(),\n Ok(_) => (StatusCode::BAD_REQUEST, \"unexpected response\").into_response(),\n Err((msg, hint)) => {\n let detail = hint.as_deref().unwrap_or(\"\");\n@@ -255,8 +265,18 @@ pub async fn post_web_ingest(\n jar: CookieJar,\n Form(form): Form,\n ) -> impl IntoResponse {\n+ run_post_web_ingest(&state, &headers, &jar, form).await\n+}\n+\n+/// Shared with [`crate::api::ui_html::post_ui_html`] (`POST /ui`).\n+pub(crate) async fn run_post_web_ingest(\n+ state: &AppState,\n+ headers: &HeaderMap,\n+ jar: &CookieJar,\n+ form: WebPostForm,\n+) -> Response {\n let reduced = state.reduced.read().await;\n- let Some(_username) = optional_principal(&headers, &jar, &reduced) else {\n+ let Some(_username) = optional_principal(headers, jar, &reduced) else {\n drop(reduced);\n return js_redirect(\"/login\").into_response();\n };\n@@ -282,8 +302,8 @@ pub async fn post_web_ingest(\n .into_response();\n }\n \n- match rpc_post_with_bearer(&state, &bearer, room.clone(), thread_tag.clone(), text).await {\n- Ok(RpcResult::PostOk { .. }) => post_success_response(&state, &form, &headers, &jar)\n+ match rpc_post_with_bearer(state, &bearer, room.clone(), thread_tag.clone(), text).await {\n+ Ok(RpcResult::PostOk { .. }) => post_success_response(state, &form, headers, jar)\n .await\n .into_response(),\n Ok(_) => form_js_error(&form, \"unexpected response\", \"Post did not return PostOk.\").into_response(),\n@@ -297,8 +317,18 @@ pub async fn check_web_ingest(\n jar: CookieJar,\n Form(form): Form,\n ) -> impl IntoResponse {\n+ run_check_web_ingest(&state, &headers, &jar, form).await\n+}\n+\n+/// Shared with [`crate::api::ui_html::post_ui_html`] (`POST /ui`).\n+pub(crate) async fn run_check_web_ingest(\n+ state: &AppState,\n+ headers: &HeaderMap,\n+ jar: &CookieJar,\n+ form: WebPostForm,\n+) -> Response {\n let reduced = state.reduced.read().await;\n- let Some(_username) = optional_principal(&headers, &jar, &reduced) else {\n+ let Some(_username) = optional_principal(headers, jar, &reduced) else {\n drop(reduced);\n return js_redirect(\"/login\").into_response();\n };\n@@ -324,7 +354,7 @@ pub async fn check_web_ingest(\n return js_clear_errors(&form_error_target(&form)).into_response();\n }\n \n- match rpc_check_with_bearer(&state, &bearer, room, form.text.clone()).await {\n+ match rpc_check_with_bearer(state, &bearer, room, form.text.clone()).await {\n Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(&form)).into_response(),\n Ok(_) => form_js_error(&form, \"unexpected response\", \"Check did not return CheckOk.\").into_response(),\n Err((msg, hint)) => form_js_error(&form, &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\ndiff --git a/server/src/form_template.rs b/server/src/form_template.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3709c2c09a859da006e4af173413d5d235bc19be\n--- /dev/null\n+++ b/server/src/form_template.rs\n@@ -0,0 +1,142 @@\n+//! Plan2-style JSON templates with `{\"$form\": \"field_name\"}` holes, filled from\n+//! `application/x-www-form-urlencoded` (or any `String` → `String` map) **before**\n+//! deserializing into a typed struct.\n+//!\n+//! # Wire format\n+//!\n+//! Templates are **compact JSON** (`serde_json::to_string`): one line, no pretty\n+//! printing, strings escaped per JSON rules (`\\\"`, `\\n`, etc.). Embed that string\n+//! in HTML attributes or text nodes with normal HTML escaping (e.g. maud), not\n+//! bespoke encodings.\n+//!\n+//! # Power vs flat hidden fields\n+//!\n+//! A form is always a string→string map. You can fake depth with dotted keys (`a.b.c`),\n+//! but one structured blob (`__rpc__` = compact JSON) gives you nested objects,\n+//! arrays, and optional fields without inventing a new naming scheme each time.\n+//!\n+//! # Security\n+//!\n+//! Substitution runs **before** `serde` into your command type. It does not fix\n+//! authorization: if the client can replace the hidden `__rpc__` value, they can\n+//! change the command shape unless you validate (signed blob, server-side session\n+//! context, or treat the blob as hints only). Same threat model as any hidden field.\n+\n+use serde::Serialize;\n+use serde_json::Value;\n+use std::collections::HashMap;\n+\n+/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field.\n+pub fn template_json_compact(v: &T) -> serde_json::Result {\n+ serde_json::to_string(v)\n+}\n+\n+/// Recursively walk the JSON AST and replace `{\"$form\": \"key\"}` with the submitted\n+/// string for `key` (empty if missing). Other keys are unchanged.\n+pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap) {\n+ match val {\n+ Value::Object(map) => {\n+ if map.len() == 1 {\n+ if let Some(Value::String(field_name)) = map.get(\"$form\") {\n+ let submitted = form_data\n+ .get(field_name.as_str())\n+ .map(|s| s.as_str())\n+ .unwrap_or(\"\");\n+ *val = Value::String(submitted.to_string());\n+ return;\n+ }\n+ }\n+ for v in map.values_mut() {\n+ substitute_form_vars(v, form_data);\n+ }\n+ }\n+ Value::Array(arr) => {\n+ for v in arr.iter_mut() {\n+ substitute_form_vars(v, form_data);\n+ }\n+ }\n+ _ => {}\n+ }\n+}\n+\n+/// Parse JSON, apply [`substitute_form_vars`], return the mutated value.\n+pub fn fill_template_from_form(\n+ template_json: &str,\n+ form_data: &HashMap,\n+) -> Result {\n+ let mut v: Value = serde_json::from_str(template_json)?;\n+ substitute_form_vars(&mut v, form_data);\n+ Ok(v)\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use serde::Deserialize;\n+\n+ #[derive(Debug, Deserialize, PartialEq, Eq)]\n+ struct Demo {\n+ room: String,\n+ thread_tag: String,\n+ nested: Nested,\n+ }\n+\n+ #[derive(Debug, Deserialize, PartialEq, Eq)]\n+ struct Nested {\n+ text: String,\n+ }\n+\n+ #[test]\n+ fn holes_become_strings() {\n+ let json = r#\"{\n+ \"room\": \"public\",\n+ \"thread_tag\": {\"$form\": \"tag\"},\n+ \"nested\": {\"text\": {\"$form\": \"body\"}}\n+ }\"#;\n+ let mut form = HashMap::new();\n+ form.insert(\"tag\".into(), \"foo\".into());\n+ form.insert(\"body\".into(), \"hello\\nworld\".into());\n+\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ let d: Demo = serde_json::from_value(v).unwrap();\n+ assert_eq!(\n+ d,\n+ Demo {\n+ room: \"public\".into(),\n+ thread_tag: \"foo\".into(),\n+ nested: Nested {\n+ text: \"hello\\nworld\".into(),\n+ },\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn missing_form_key_is_empty_string() {\n+ let json = r#\"{\"x\": {\"$form\": \"nope\"}}\"#;\n+ let mut form = HashMap::new();\n+ form.insert(\"other\".into(), \"y\".into());\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ assert_eq!(v[\"x\"], \"\");\n+ }\n+\n+ #[test]\n+ fn array_of_holes() {\n+ let json = r#\"{\"items\": [{\"$form\": \"a\"}, {\"$form\": \"b\"}]}\"#;\n+ let mut form = HashMap::new();\n+ form.insert(\"a\".into(), \"1\".into());\n+ form.insert(\"b\".into(), \"2\".into());\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ assert_eq!(v[\"items\"], serde_json::json!([\"1\", \"2\"]));\n+ }\n+\n+ #[test]\n+ fn template_json_compact_escapes_and_single_line() {\n+ let s = template_json_compact(&serde_json::json!({\n+ \"x\": \"quote\\\"and\\nnewline\"\n+ }))\n+ .unwrap();\n+ assert!(!s.contains('\\n'));\n+ assert!(s.contains(\"\\\\\\\"\") || s.contains(\"\\\\n\"));\n+ }\n+}\ndiff --git a/server/src/html/forum.rs b/server/src/html/forum.rs\nindex f789e347dc136f8ffd356f4492f0bd76cea04bf0..b1c037f3dd93c7bb96d6624cab6019228432c501 100644\n--- a/server/src/html/forum.rs\n+++ b/server/src/html/forum.rs\n@@ -11,12 +11,15 @@ use crate::{\n api::optional_principal,\n canonical_path::{canonicalize_item, canonicalize_tag},\n events::ThreadCapability,\n+ form_template::template_json_compact,\n identity::parse_username,\n reducer::{scope_from_room_wire, ReducerState, ScopeId},\n state::AppState,\n timeago,\n };\n \n+use super::ui_action::{HtmlUiAction, UI_RPC_FIELD};\n+\n use super::{\n bc_segment, bc_threads, cli_panel, layout, now_ms, profile_href, recency_class,\n render_linkified_with_embeds_in_scope, theme_from_jar, theme_next_from_uri, JsBuilder,\n@@ -289,7 +292,7 @@ fn rooms_for_user(reduced: &ReducerState, username: &str) -> Vec {\n v\n }\n \n-fn user_can_view_room(reduced: &ReducerState, room_id: &str, username: Option<&str>) -> bool {\n+pub(crate) fn user_can_view_room(reduced: &ReducerState, room_id: &str, username: Option<&str>) -> bool {\n if !reduced.rooms.contains(room_id) {\n return false;\n }\n@@ -299,7 +302,7 @@ fn user_can_view_room(reduced: &ReducerState, room_id: &str, username: Option<&s\n reduced.user_has_cap(room_id, u, ThreadCapability::View)\n }\n \n-fn user_can_post_room(reduced: &ReducerState, room_id: &str, username: &str) -> bool {\n+pub(crate) fn user_can_post_room(reduced: &ReducerState, room_id: &str, username: &str) -> bool {\n reduced.user_has_cap(room_id, username, ThreadCapability::Post)\n }\n \n@@ -591,7 +594,6 @@ pub async fn home(\n let nav = ThreadNav::public();\n let reduced_read = state.reduced.read().await;\n let strip = auth_strip(&headers, &jar, &reduced_read);\n- let show_forms = user.is_some();\n drop(reduced_read);\n \n let page = layout(\n@@ -617,8 +619,14 @@ pub async fn home(\n }\n }\n p class=\"muted\" { \"dark = time-ordered · light = vote-ranked\" }\n+ div class=\"thread-feed-toolbar\" {\n+ form method=\"POST\" action=\"/ui\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(expand_public_new_thread_rpc_value());\n+ button type=\"submit\" class=\"section-add-btn\" { \"+\" }\n+ }\n+ }\n+ div id=\"public-new-thread-ui-slot\" {}\n (render_thread_feed(Some(&nav), \"thread-feed\", &public_rows, now))\n- (new_thread_form_public(show_forms))\n (cli_panel(\"npx slugsocial public forum list\"))\n },\n None,\n@@ -901,7 +909,13 @@ pub async fn room_page(\n h3 { \"threads\" }\n (render_thread_feed(Some(&nav), \"room-thread-feed\", &rows, now))\n @if show_new {\n- (new_thread_form_for_room(&nav, show_new))\n+ div class=\"thread-feed-toolbar\" {\n+ form method=\"POST\" action=\"/ui\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(expand_room_new_thread_rpc_value(&nav));\n+ button type=\"submit\" class=\"section-add-btn\" { \"+\" }\n+ }\n+ }\n+ div id=\"room-new-thread-ui-slot\" {}\n }\n (cli_panel(&forum_cli))\n (cli_panel(&garden_cli))\n@@ -945,6 +959,31 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool) -> Markup {\n }\n }\n \n+pub(crate) fn expand_public_new_thread_rpc_value() -> String {\n+ template_json_compact(&HtmlUiAction::ExpandPublicNewThreadForm).expect(\"static json\")\n+}\n+\n+pub(crate) fn expand_room_new_thread_rpc_value(nav: &ThreadNav) -> String {\n+ template_json_compact(&HtmlUiAction::ExpandRoomNewThreadForm {\n+ room_wire: nav.room_wire.clone(),\n+ })\n+ .expect(\"static json\")\n+}\n+\n+pub(crate) fn login_to_post_hint_markup() -> Markup {\n+ html! {\n+ p class=\"muted\" { \"log in to post\" }\n+ }\n+}\n+\n+pub(crate) fn fragment_public_new_thread_form(show: bool) -> Markup {\n+ new_thread_form_public(show)\n+}\n+\n+pub(crate) fn fragment_room_new_thread_form(nav: &ThreadNav, show: bool) -> Markup {\n+ new_thread_form_for_room(nav, show)\n+}\n+\n async fn thread_post_view_inner(\n state: AppState,\n tag: String,\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 53041a0cf0b30e6f20749c92ecc15a4e9ac56e09..a4fd2dd60ac283f7eb9b2e64522501b21d1e27e9 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -16,6 +16,7 @@ mod editor;\n mod forum;\n mod garden;\n mod search;\n+pub mod ui_action;\n use breadcrumb_path::OntologyPath;\n \n pub use auth::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page};\n@@ -27,9 +28,15 @@ pub use forum::{\n thread_post_collapse_deleted, thread_post_expand, thread_post_expand_deleted, thread_post_view,\n thread_view, ThreadNav,\n };\n+\n+pub(crate) use forum::{\n+ fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup,\n+ user_can_post_room, user_can_view_room,\n+};\n pub use garden::{garden_index, ontology_path, room_garden_index, room_ontology_path};\n pub use search::{search_page, search_results_fragment};\n pub use forum::user_profile_page;\n+pub use ui_action::{parse_html_ui_from_form, HtmlUiAction, HtmlUiParseError, UI_RPC_FIELD};\n \n /// Public profile URL path for a stored username (no `@`).\n pub(crate) fn profile_href(username: &str) -> String {\ndiff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3bc69ecbb7e16a59d2d393b2d59f9cd3fedb12b1\n--- /dev/null\n+++ b/server/src/html/ui_action.rs\n@@ -0,0 +1,116 @@\n+//! Browser-only UI commands: JSON in hidden `__rpc__` plus hole fill ([`crate::form_template`]).\n+//! Not part of [`slug_types::RpcCommand`] (CLI / JSON API).\n+\n+use crate::form_template::fill_template_from_form;\n+use serde::{Deserialize, Serialize};\n+use serde_json::Value;\n+use std::collections::HashMap;\n+use thiserror::Error;\n+\n+/// Form field name for the compact JSON template (possibly with `{\"$form\":\"…\"}` holes).\n+pub const UI_RPC_FIELD: &str = \"__rpc__\";\n+\n+/// HTML form / fetch `POST /ui` payload after template fill and deserialization.\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n+#[serde(tag = \"action\", rename_all = \"snake_case\")]\n+pub enum HtmlUiAction {\n+ /// Same semantics as `POST /post` (forum ingest).\n+ PostIngest {\n+ room: String,\n+ thread_tag: String,\n+ text: String,\n+ #[serde(default)]\n+ error_target: Option,\n+ #[serde(default)]\n+ form_id: Option,\n+ },\n+ /// Same as `POST /post/check`.\n+ CheckIngest {\n+ room: String,\n+ thread_tag: String,\n+ text: String,\n+ #[serde(default)]\n+ error_target: Option,\n+ #[serde(default)]\n+ form_id: Option,\n+ },\n+ /// Same as `POST /post/redact`.\n+ RedactPost {\n+ post_id: String,\n+ },\n+ /// Morph `#public-new-thread-ui-slot` to the new-thread form (or login hint).\n+ ExpandPublicNewThreadForm,\n+ /// Morph `#room-new-thread-ui-slot` for the given room wire id.\n+ ExpandRoomNewThreadForm {\n+ room_wire: String,\n+ },\n+}\n+\n+#[derive(Debug, Error)]\n+pub enum HtmlUiParseError {\n+ #[error(\"missing __rpc__ field\")]\n+ MissingRpc,\n+ #[error(\"invalid template json: {0}\")]\n+ Template(serde_json::Error),\n+ #[error(\"invalid ui action: {0}\")]\n+ Action(serde_json::Error),\n+}\n+\n+/// Parse `__rpc__` JSON, apply `$form` holes from the rest of the form map, deserialize.\n+pub fn parse_html_ui_from_form(form: &HashMap) -> Result {\n+ let template = form\n+ .get(UI_RPC_FIELD)\n+ .ok_or(HtmlUiParseError::MissingRpc)?;\n+ let mut hole_map = form.clone();\n+ hole_map.remove(UI_RPC_FIELD);\n+ let v: Value = fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?;\n+ serde_json::from_value(v).map_err(HtmlUiParseError::Action)\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+\n+ #[test]\n+ fn round_trip_post_ingest_with_holes() {\n+ let template = serde_json::json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": \"public\",\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"e\",\n+ \"form_id\": \"f\",\n+ });\n+ let mut form = HashMap::new();\n+ form.insert(\n+ UI_RPC_FIELD.to_string(),\n+ serde_json::to_string(&template).unwrap(),\n+ );\n+ form.insert(\"thread_tag\".into(), \"x\".into());\n+ form.insert(\"text\".into(), \"body\".into());\n+\n+ let a = parse_html_ui_from_form(&form).unwrap();\n+ assert_eq!(\n+ a,\n+ HtmlUiAction::PostIngest {\n+ room: \"public\".into(),\n+ thread_tag: \"x\".into(),\n+ text: \"body\".into(),\n+ error_target: Some(\"e\".into()),\n+ form_id: Some(\"f\".into()),\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn expand_public_unit_variant() {\n+ let template = serde_json::json!({ \"action\": \"expand_public_new_thread_form\" });\n+ let mut form = HashMap::new();\n+ form.insert(\n+ UI_RPC_FIELD.to_string(),\n+ serde_json::to_string(&template).unwrap(),\n+ );\n+ let a = parse_html_ui_from_form(&form).unwrap();\n+ assert_eq!(a, HtmlUiAction::ExpandPublicNewThreadForm);\n+ }\n+}\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex b9d4b79791ba8dd3a3c26ff777943a2548092de5..5f5b6b0f01b29350c7415e5801ec5caa74f0b452 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -3,6 +3,7 @@ pub mod paths;\n pub mod api;\n pub mod canonical_path;\n pub mod dsl;\n+pub mod form_template;\n pub mod html;\n pub mod event_log;\n pub mod events;\n@@ -33,6 +34,7 @@ pub fn create_app(state: AppState) -> Router {\n .route(\"/post\", post(api::post_web_ingest))\n .route(\"/post/redact\", post(api::post_web_redact))\n .route(\"/post/check\", post(api::check_web_ingest))\n+ .route(\"/ui\", post(api::post_ui_html))\n .route(\"/theme\", post(crate::html::post_theme))\n .route(\"/sse\", get(api::get_html_stream))\n .route(\"/stream\", get(api::get_stream))\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"}