diff --git a/cli/src/main.rs b/cli/src/main.rs index 339005036eea2b7c0f98ab9b3007854de8ffaf2c..eff3e415a7995378b3286efd5af06a1259d0ef45 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -585,29 +585,35 @@ fn print_thread(resp: &ThreadDetailResponse) { ); } for (i, item) in resp.items.iter().enumerate() { - match item { + let xml = match item { ThreadItem::Post { index, ts, + actor, + delegate, body, .. - } => { - let timeago = slug_types::timeago::timeago_compact(now_ms, *ts); - let body = body.trim(); - println!(""); - println!("{body}"); - println!(""); - } + } => slug_types::thread_xml::format_post_at( + now_ms, + *index, + *ts, + actor, + delegate.as_deref(), + body, + ), ThreadItem::System { ts, text } => { let timeago = slug_types::timeago::timeago_compact(now_ms, *ts); - println!("{}", timeago, text.trim()); + slug_types::thread_xml::format_system(&timeago, text) } - } + }; + print!("{xml}"); if i + 1 < resp.items.len() { - println!(); - println!(); + print!("{}", slug_types::thread_xml::ITEM_SEPARATOR); } } + if !resp.items.is_empty() { + println!(); + } } /// Parse a Unix ms timestamp or YYYY-MM-DD date string to ms since epoch. diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index a0f8ee1d658bdba4d2168e5198c1104918b3575d..0b7070a2b7289e5c40778c8d062bd4150285a8df 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -478,6 +478,7 @@ fn rpc_forum_thread_detail( index: idx, ts: ing.ts, actor: ing.principal.clone(), + delegate: ing.delegate.clone(), body: if redacted { String::new() } else { ing.raw.clone() }, truncated: false, redacted, @@ -538,6 +539,7 @@ fn rpc_forum_thread_detail( index: idx, ts: ing.ts, actor: ing.principal.clone(), + delegate: ing.delegate.clone(), body, truncated, redacted, diff --git a/server/src/html/forum/copy.rs b/server/src/html/forum/copy.rs index 29057c68856cea85106e9f911a8d9466eedfc870..4c878921a51e23ff4fdf1bc28037847f69098876 100644 --- a/server/src/html/forum/copy.rs +++ b/server/src/html/forum/copy.rs @@ -3,7 +3,7 @@ use crate::form_template::template_json_compact; use crate::reducer::{scope_from_room_wire, ReducerState, ScopeId}; use crate::state::AppState; use maud::{html, Markup}; -use slug_types::timeago::timeago_compact; +use slug_types::thread_xml; use super::access::user_can_view_room; use super::ingest::thread_ui_fetch_onclick; @@ -11,7 +11,7 @@ use super::nav::ThreadNav; use crate::html::ui_action::HtmlUiAction; use crate::html::{JsBuilder, now_ms}; -/// CLI `forum show` text (`print_thread` in `cli/src/main.rs`). +/// CLI `forum show` and browser copy-thread text (`slug_types::thread_xml`). pub(crate) fn format_thread_cli_text( reduced: &ReducerState, scope: &ScopeId, @@ -32,19 +32,22 @@ pub(crate) fn format_thread_cli_text( continue; }; if i > 0 { - out.push_str("\n\n\n"); + out.push_str(thread_xml::ITEM_SEPARATOR); } - let index = i; - let timeago = timeago_compact(now_ms, ing.ts); let redacted = reduced.redacted_posts.contains(&ing.id); let body = if redacted { String::new() } else { ing.raw.trim().to_string() }; - out.push_str(&format!("\n")); - out.push_str(&body); - out.push_str("\n"); + out.push_str(&thread_xml::format_post_at( + now_ms, + i, + ing.ts, + &ing.principal, + ing.delegate.as_deref(), + &body, + )); } out } diff --git a/types/src/lib.rs b/types/src/lib.rs index d747e509094d75124573f252ee4b6c73186b4c24..0a6f7c2cd9acbb83acb9b3d9db629e6b9603dd8b 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -5,6 +5,7 @@ pub mod item_wire; pub mod item_id; pub mod paths; pub mod timeago; +pub mod thread_xml; pub use item_id::ItemId; pub use item_wire::{ @@ -163,6 +164,9 @@ pub enum ThreadItem { index: usize, ts: i64, actor: String, + /// Agent delegate (`uuid:rig:provider/model`) when present; omitted in JSON when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + delegate: Option, body: String, truncated: bool, /// Author redacted this post; body is empty and garden contributions were removed. diff --git a/types/src/thread_xml.rs b/types/src/thread_xml.rs new file mode 100644 index 0000000000000000000000000000000000000000..e7b42646de727c7660793b83fff3fef0403797be --- /dev/null +++ b/types/src/thread_xml.rs @@ -0,0 +1,110 @@ +//! Thread timeline as XML for CLI `forum show` and browser copy-thread. + +use crate::timeago::timeago_compact; + +/// Blank line block between consecutive thread rows in CLI / copy output. +pub const ITEM_SEPARATOR: &str = "\n\n\n"; + +fn escape_xml_attr(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '"' => out.push_str("""), + '<' => out.push_str("<"), + _ => out.push(c), + } + } + out +} + +/// Opening tag for one forum post (body omitted). +pub fn post_open_tag( + index: usize, + timeago: &str, + principal: &str, + delegate: Option<&str>, +) -> String { + let delegate_attr = delegate.unwrap_or(""); + format!( + "", + index, + escape_xml_attr(timeago), + escape_xml_attr(principal), + escape_xml_attr(delegate_attr), + ) +} + +/// One forum post element: opening tag, trimmed body, closing tag. +pub fn format_post( + index: usize, + timeago: &str, + principal: &str, + delegate: Option<&str>, + body: &str, +) -> String { + format!( + "{}\n{}\n", + post_open_tag(index, timeago, principal, delegate), + body.trim(), + ) +} + +/// One room system line in thread XML. +pub fn format_system(timeago: &str, text: &str) -> String { + format!( + "{}", + escape_xml_attr(timeago), + text.trim(), + ) +} + +/// Format a post row using wall-clock `now_ms` for the `timeago` attribute. +pub fn format_post_at( + now_ms: i64, + index: usize, + ts: i64, + principal: &str, + delegate: Option<&str>, + body: &str, +) -> String { + let timeago = timeago_compact(now_ms, ts); + format_post(index, &timeago, principal, delegate, body) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn post_includes_principal_and_delegate() { + let xml = format_post( + 2, + "1h2m3s", + "alice", + Some("00000000-0000-0000-0000-000000000000:test:local/dev"), + "hello\n", + ); + assert!(xml.starts_with( + "" + )); + assert!(xml.ends_with("\nhello\n")); + } + + #[test] + fn post_empty_delegate_when_human() { + let xml = format_post(0, "5s", "bob", None, "hi"); + assert!(xml.starts_with( + "" + )); + } + + #[test] + fn escapes_attribute_values() { + let xml = post_open_tag(0, "1s", "a&b", Some("d\"q")); + assert_eq!( + xml, + "" + ); + } +}