You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit message: [047b82bd] Remove redundant zero-ratio guard from reducer. Zeros are already rejected at the DSL parser and browser handler; the guard in apply_vote was dead code. The negative clamping stays since add_edge_weight already skips weight-0 edges correctly. Co-Authored-By: Claude Sonnet 4.6 Side A — unified diff (full patch): diff --git a/server/src/reducer.rs b/server/src/reducer.rs index 0e36979abe0f051493038ff7e652efc7f7a0ac80..efbf7f67ff24c7a2989102fe62879b2794a27c5f 100644 --- a/server/src/reducer.rs +++ b/server/src/reducer.rs @@ -112,11 +112,6 @@ impl GroupState { if vote.ratio_right < 0 { vote.ratio_right = 0; } - if vote.ratio_left == 0 || vote.ratio_right == 0 { - // Zero on either side produces no valid edge; drop before registering items or pair. - return; - } - let a_idx = self.ensure_item(&vote.a); let b_idx = self.ensure_item(&vote.b); diff --git a/server/tests/basic.rs b/server/tests/basic.rs index 08159f4a7f0850fd165817a4a1af4f31ced2ad76..1748769ccf7196b2d81cf556d5368c7e723f6a4e 100644 --- a/server/tests/basic.rs +++ b/server/tests/basic.rs @@ -533,7 +533,7 @@ fn dsl_parse_rejects_zero_zero_vote_ratio() { #[test] fn reducer_negative_ratio_clamped_to_zero() { let _state = ReducerState::default(); - // GroupState::apply_vote clamps negatives to 0; when either side is 0 the vote is dropped. + // apply_vote clamps negatives to 0; add_edge_weight skips zero-weight edges. let mut group = GroupState::new(); group.apply_vote(slugsocial_server::reducer::VoteData { ts: 1, @@ -546,10 +546,9 @@ fn reducer_negative_ratio_clamped_to_zero() { delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()), thread_tag: "t".to_string(), }); - // Nothing registered: zero-clamped vote is dropped before ensure_item. - assert!(group.idx_to_item.is_empty()); + // Items and pair are registered; edges are absent because weight 0 is skipped. + assert_eq!(group.idx_to_item.len(), 2); assert!(group.edges.is_empty()); - assert!(group.voted_pairs.is_empty()); } Side B — contributor: tommy-mor Side B — commit message: [1d9d8ade] init seed Side B — unified diff (full patch): diff --git a/TEST.sh b/TEST.sh new file mode 100755 index 0000000000000000000000000000000000000000..266b71f29259f5c2cad2617476ebe2ebc9596d39 --- /dev/null +++ b/TEST.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cargo test --all +./scripts/clj-test.sh diff --git a/bundle.js b/bundle.js new file mode 100644 index 0000000000000000000000000000000000000000..8d316f9a57cc7c379fe4bd8e42e092c7eac5d265 --- /dev/null +++ b/bundle.js @@ -0,0 +1,52 @@ +/** + * Slug web UI: only plumbing — fetch/eval/SSE. No product UI logic here. + */ +(function () { + function evalJs(js) { + if (js && String(js).trim()) { + eval(js); + } + } + + // Theme cookie sync (runs before paint; full reload if localStorage disagrees with cookie) + + function initSlugUi() { + // POST forms → eval response (except theme + full-navigation forms) + document.addEventListener('submit', async function (e) { + var f = e.target; + if (!f || f.tagName !== 'FORM') return; + if ((f.method || 'get').toLowerCase() !== 'post') return; + if (f.id === 'slug-theme-form') return; + if (f.getAttribute('data-navigate') === 'full') return; + e.preventDefault(); + var resp = await fetch(f.action, { + method: 'POST', + body: new URLSearchParams(new FormData(f)), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + credentials: 'same-origin', + }); + evalJs(await resp.text()); + }); + + // SSE: server-pushed JS + function connectSSE() { + var ssePath = window.location.pathname + window.location.search; + var es = new EventSource('/sse?path=' + encodeURIComponent(ssePath)); + es.onmessage = function (e) { + evalJs(e.data); + }; + es.onerror = function () { + es.close(); + setTimeout(connectSSE, 3000); + }; + } + connectSSE(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initSlugUi); + } else { + initSlugUi(); + } +})(); + diff --git a/clj-test.sh b/clj-test.sh new file mode 100755 index 0000000000000000000000000000000000000000..b62a49b98ed60a65a46807b7ad80fa3142f14952 --- /dev/null +++ b/clj-test.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p target +exec clojure -M:kaocha diff --git a/forms.rs b/forms.rs new file mode 100644 index 0000000000000000000000000000000000000000..d509fd889fde3fed2662ce0a39006b7a51ae762a --- /dev/null +++ b/forms.rs @@ -0,0 +1,145 @@ +tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ cat server/src/form_template.rs +//! Plan2-style JSON templates with `{"$form": "field_name"}` holes, filled from +//! `application/x-www-form-urlencoded` (or any `String` → `String` map) **before** +//! deserializing into a typed struct. +//! +//! # Wire format +//! +//! Templates are **compact JSON** (`serde_json::to_string`): one line, no pretty +//! printing, strings escaped per JSON rules (`\"`, `\n`, etc.). Embed that string +//! in HTML attributes or text nodes with normal HTML escaping (e.g. maud), not +//! bespoke encodings. +//! +//! # Power vs flat hidden fields +//! +//! A form is always a string→string map. You can fake depth with dotted keys (`a.b.c`), +//! but one structured blob (`__rpc__` = compact JSON) gives you nested objects, +//! arrays, and optional fields without inventing a new naming scheme each time. +//! +//! # Security +//! +//! Substitution runs **before** `serde` into your command type. It does not fix +//! authorization: if the client can replace the hidden `__rpc__` value, they can +//! change the command shape unless you validate (signed blob, server-side session +//! context, or treat the blob as hints only). Same threat model as any hidden field. + +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; + +/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field. +pub fn template_json_compact(v: &T) -> serde_json::Result { + serde_json::to_string(v) +} + +/// Recursively walk the JSON AST and replace `{"$form": "key"}` with the submitted +/// string for `key` (empty if missing). Other keys are unchanged. +pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap) { + match val { + Value::Object(map) => { + if map.len() == 1 { + if let Some(Value::String(field_name)) = map.get("$form") { + let submitted = form_data + .get(field_name.as_str()) + .map(|s| s.as_str()) + .unwrap_or(""); + *val = Value::String(submitted.to_string()); + return; + } + } + for v in map.values_mut() { + substitute_form_vars(v, form_data); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + substitute_form_vars(v, form_data); + } + } + _ => {} + } +} + +/// Parse JSON, apply [`substitute_form_vars`], return the mutated value. +pub fn fill_template_from_form( + template_json: &str, + form_data: &HashMap, +) -> Result { + let mut v: Value = serde_json::from_str(template_json)?; + substitute_form_vars(&mut v, form_data); + Ok(v) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct Demo { + room: String, + thread_tag: String, + nested: Nested, + } + + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct Nested { + text: String, + } + + #[test] + fn holes_become_strings() { + let json = r#"{ + "room": "public", + "thread_tag": {"$form": "tag"}, + "nested": {"text": {"$form": "body"}} + }"#; + let mut form = HashMap::new(); + form.insert("tag".into(), "foo".into()); + form.insert("body".into(), "hello\nworld".into()); + + let v = fill_template_from_form(json, &form).unwrap(); + let d: Demo = serde_json::from_value(v).unwrap(); + assert_eq!( + d, + Demo { + room: "public".into(), + thread_tag: "foo".into(), + nested: Nested { + text: "hello\nworld".into(), + }, + } + ); + } + + #[test] + fn missing_form_key_is_empty_string() { + let json = r#"{"x": {"$form": "nope"}}"#; + let mut form = HashMap::new(); + form.insert("other".into(), "y".into()); + let v = fill_template_from_form(json, &form).unwrap(); + assert_eq!(v["x"], ""); + } + + #[test] + fn array_of_holes() { + let json = r#"{"items": [{"$form": "a"}, {"$form": "b"}]}"#; + let mut form = HashMap::new(); + form.insert("a".into(), "1".into()); + form.insert("b".into(), "2".into()); + let v = fill_template_from_form(json, &form).unwrap(); + assert_eq!(v["items"], serde_json::json!(["1", "2"])); + } + + #[test] + fn template_json_compact_escapes_and_single_line() { + let s = template_json_compact(&serde_json::json!({ + "x": "quote\"and\nnewline" + })) + .unwrap(); + assert!(!s.contains('\n')); + assert!(s.contains("\\\"") || s.contains("\\n")); + } +} +tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ + diff --git a/gameifying.tdsl b/gameifying.tdsl new file mode 100644 index 0000000000000000000000000000000000000000..93d2a61d94fcba4370c82d9612e6bb0f0318cc15 --- /dev/null +++ b/gameifying.tdsl @@ -0,0 +1,2 @@ +consider gating certain views (top all time?) by a 10 day usage streak.. or something like that. +or like a path, on homepage(?), that shows day 1: r/amitheasshole, day2: r/aww, day3: gaming, or something like that. progressive revelation + usage incentive. diff --git a/pagerank_streaming.tdsl b/pagerank_streaming.tdsl new file mode 100644 index 0000000000000000000000000000000000000000..0f2231b94936c17610adf653ca26b0faa6f2ac3a --- /dev/null +++ b/pagerank_streaming.tdsl @@ -0,0 +1,11 @@ +eventually i want to have ranking histories. +like "visionary" and "fraud" waxing and waning in a uplot graph over time for #elon-musk. +there are too many query combinations to precomupte the ranking histories +(arbitrary user filters, and tag overlap combinations maybe), +so we're just going to have to calculate them all on demand. +computers are fast, its okay. for each vote, we need to calculate rank centrality again. +i was thinking, for n votes we calculate the rank centrality, +and get node weights. we then output that data to the client (over websocket, or testable barrier). +then we calculate n+1 votes, _but we keep the node weights in memory_ +so the rank centrality process converges faster. +this also has the side effect of making the ranking stream in satisfyingly as you load the page. diff --git a/parser.rs b/parser.rs new file mode 100644 index 0000000000000000000000000000000000000000..2b87b974f8d1dd93bee35681d87668a94e4ef349 --- /dev/null +++ b/parser.rs @@ -0,0 +1,1808 @@ +use std::collections::HashMap; +use std::rc::Rc; +use std::cell::RefCell; +use crate::ui::action::UIAction; +use crate::ui::types::{Suggestion, GuideOption, ScrollingSuggestion}; + +// --- Core Abstractions --- + +/// Unique identifier for nodes in the graph +type NodeId = &'static str; + +/// Pattern matching for edges +#[derive(Debug, Clone)] +pub enum EdgePattern { + /// Matches exact literal string + Literal(&'static str), + + /// Matches any prefix of a string and suggests the full string + /// e.g., PrefixOf("reddit.com") matches "r", "re", "red", "reddit", "reddit.com" + PrefixOf(&'static str), + + /// Captures a variable segment (e.g., subreddit name, username) + Variable(&'static str), + + /// Matches any string (wildcard) + Any, +} + +impl EdgePattern { + /// Try to match this pattern against input, return (consumed_chars, captured_value) + fn matches(&self, input: &str) -> Option<(usize, Option)> { + match self { + EdgePattern::Literal(lit) => { + if input.starts_with(lit) { + Some((lit.len(), None)) + } else { + None + } + } + EdgePattern::PrefixOf(target) => { + // Check if input is a prefix of target + if target.starts_with(input) && !input.is_empty() { + // It's a valid prefix + Some((input.len(), None)) + } else if input.starts_with(target) { + // Full match + Some((target.len(), None)) + } else { + None + } + } + EdgePattern::Variable(var_name) => { + // Consume until next '/' or end of string + let end = input.find('/').unwrap_or(input.len()); + if end > 0 { + let captured = input[..end].to_string(); + // Validate based on variable type + if is_valid_variable(var_name, &captured) { + Some((end, Some(captured))) + } else { + None + } + } else { + None + } + } + EdgePattern::Any => { + // Match everything until next '/' or end + let end = input.find('/').unwrap_or(input.len()); + if end > 0 { + Some((end, Some(input[..end].to_string()))) + } else { + None + } + } + } + } + + /// Get the completion suggestion for this pattern + fn completion(&self, partial: &str) -> Option { + match self { + EdgePattern::PrefixOf(target) => { + if target.starts_with(partial) && partial != *target { + Some(target.to_string()) + } else { + None + } + } + _ => None, + } + } +} + +/// Edge in the graph +pub struct Edge { + pattern: EdgePattern, + target: NodeId, + /// Optional description for autocomplete + description: Option<&'static str>, +} + +/// Handler function for generating UI actions +type Handler = Box) -> UIAction>; + +/// Node in the graph +pub struct Node { + #[allow(dead_code)] + id: NodeId, + edges: Vec, + handler: Option, +} + +/// The composable parser graph +pub struct Graph { + nodes: HashMap>>, + root: NodeId, +} + +// --- Graph Builder (Fluent API) --- + +pub struct GraphBuilder { + nodes: HashMap>>, + current_node: Option, + root: NodeId, +} + +impl GraphBuilder { + pub fn new() -> Self { + let mut nodes = HashMap::new(); + let root_node = Rc::new(RefCell::new(Node { + id: "root", + edges: Vec::new(), + handler: None, + })); + nodes.insert("root", root_node); + + GraphBuilder { + nodes, + current_node: Some("root"), + root: "root", + } + } + + /// Select a node to add edges to + pub fn at(mut self, node_id: NodeId) -> Self { + // Create node if it doesn't exist + if !self.nodes.contains_key(node_id) { + let node = Rc::new(RefCell::new(Node { + id: node_id, + edges: Vec::new(), + handler: None, + })); + self.nodes.insert(node_id, node); + } + self.current_node = Some(node_id); + self + } + + /// Add an edge from the current node + pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self { + self.edge_with_desc(pattern, target, None) + } + + /// Add an edge with description + pub fn edge_with_desc(mut self, pattern: EdgePattern, target: NodeId, desc: Option<&'static str>) -> Self { + let current = self.current_node.expect("No current node selected"); + + // Create target node if it doesn't exist + if !self.nodes.contains_key(target) { + let node = Rc::new(RefCell::new(Node { + id: target, + edges: Vec::new(), + handler: None, + })); + self.nodes.insert(target, node); + } + + // Add edge to current node + if let Some(node) = self.nodes.get(current) { + node.borrow_mut().edges.push(Edge { + pattern, + target, + description: desc, + }); + } + + self + } + + /// Set handler for current node + pub fn handler(self, handler: F) -> Self + where + F: Fn(&str, &str, &HashMap) -> UIAction + 'static + { + let current = self.current_node.expect("No current node selected"); + if let Some(node) = self.nodes.get(current) { + node.borrow_mut().handler = Some(Box::new(handler)); + } + self + } + + /// Build the final graph + pub fn build(self) -> Graph { + Graph { + nodes: self.nodes, + root: self.root, + } + } +} + +// --- Parser Implementation --- + +impl Graph { + pub fn parse(&self, input: &str) -> UIAction { + let normalized = input.trim().to_lowercase(); + let mut state = ParserState { + input: &normalized, + cursor: 0, + current_node_id: self.root, + context: HashMap::new(), + original_query: input.to_string(), + current_prefix: String::new(), + }; + + self.parse_recursive(&mut state) + } + + fn parse_recursive(&self, state: &mut ParserState) -> UIAction { + let node = self.nodes.get(state.current_node_id) + .expect("Node not found in graph"); + let node_ref = node.borrow(); + + // If we've consumed all input, check for handler or suggestions + if state.cursor >= state.input.len() { + if let Some(handler) = &node_ref.handler { + return handler(&state.original_query, &state.current_prefix, &state.context); + } + + // No handler, try to suggest based on available edges + return self.suggest_from_edges(&node_ref, state); + } + + let remaining = &state.input[state.cursor..]; + + // Try to match each edge + for edge in &node_ref.edges { + if let Some((consumed, captured)) = edge.pattern.matches(remaining) { + // Save state for potential backtracking + let saved_cursor = state.cursor; + let saved_node = state.current_node_id; + let saved_prefix = state.current_prefix.clone(); + + // Update state + state.cursor += consumed; + state.current_node_id = edge.target; + state.current_prefix.push_str(&remaining[..consumed]); + + // Store captured variable if any + if let Some(value) = captured { + if let EdgePattern::Variable(var_name) = &edge.pattern { + state.context.insert(var_name.to_string(), value); + } + } + + // Check if this is a partial match that needs completion + if state.cursor == state.input.len() { + if let Some(completion_suffix) = edge.pattern.completion(remaining) { + // Use the current_prefix plus the completion suffix + let full_completion = format!("{}{}", + state.current_prefix, + completion_suffix.strip_prefix(remaining).unwrap_or(&completion_suffix) + ); + return UIAction::suggest( + state.original_query.clone(), + Some(Suggestion { + text: full_completion.clone(), + completion: full_completion, + description: edge.description.map(|d| d.to_string()), + score: 1.0, + }) + ); + } + } + + // Continue parsing from the target node + let result = self.parse_recursive(state); + + // If we got a valid response, return it + if !matches!(result, UIAction::ShowError(_)) { + return result; + } + + // Otherwise, restore state and try next edge + state.cursor = saved_cursor; + state.current_node_id = saved_node; + state.current_prefix = saved_prefix; + } + } + + // No edges matched - try to provide suggestions + self.suggest_from_edges(&node_ref, state) + } + + fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> UIAction { + let remaining = &state.input[state.cursor..]; + + // Find edges that could match with more input + for edge in &node.edges { + match &edge.pattern { + EdgePattern::PrefixOf(target) => { + if target.starts_with(remaining) && !remaining.is_empty() { + // Use current_prefix instead of rebuilding from input + let full_completion = format!("{}{}", state.current_prefix, target); + return UIAction::suggest( + state.original_query.clone(), + Some(Suggestion { + text: full_completion.clone(), + completion: full_completion, + description: edge.description.map(|d| d.to_string()), + score: 1.0, + }) + ); + } + } + EdgePattern::Literal(lit) => { + if lit.starts_with(remaining) && !remaining.is_empty() { + let full_completion = format!("{}{}", state.current_prefix, lit); + return UIAction::suggest( + state.original_query.clone(), + Some(Suggestion { + text: full_completion.clone(), + completion: full_completion, + description: edge.description.map(|d| d.to_string()), + score: 1.0, + }) + ); + } + } + _ => {} + } + } + + UIAction::error( + "InvalidPath".to_string(), + format!("'{}' doesn't match any known pattern", state.original_query) + ) + } +} + +struct ParserState<'a> { + input: &'a str, + cursor: usize, + current_node_id: NodeId, + context: HashMap, + original_query: String, + current_prefix: String, +} + +// --- Helper Functions --- + +fn is_valid_variable(var_name: &str, value: &str) -> bool { + match var_name { + "subreddit" => { + !value.is_empty() && + value.len() <= 21 && + value.chars().all(|c| c.is_alphanumeric() || c == '_') + } + "username" => { + !value.is_empty() && + value.len() <= 20 && + value.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') + } + "post_id" => { + !value.is_empty() && + value.len() <= 10 && + value.chars().all(|c| c.is_alphanumeric()) + } + _ => true, // Allow any value for unknown variables + } +} + +// --- Define the Reddit Graph --- + +pub fn build_reddit_graph() -> Graph { + GraphBuilder::new() + // === ROOT LEVEL: Direct aliases and domain/protocol patterns === + .at("root") + // Direct aliases to subreddit and user selection + .edge_with_desc( + EdgePattern::PrefixOf("r/"), + "subreddit_selection", + Some("Browse subreddits (e.g., r/programming)") + ) + .edge_with_desc( + EdgePattern::PrefixOf("u/"), + "user_selection", + Some("Browse users (e.g., u/spez)") + ) + + // Reddit shortcuts - one pattern handles ALL prefixes! + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Go to Reddit") + ) + + // Protocol patterns - "h" can suggest "https://" + .edge_with_desc( + EdgePattern::PrefixOf("https://"), + "https_protocol", + Some("HTTPS protocol") + ) + .edge_with_desc( + EdgePattern::PrefixOf("http://"), + "http_protocol", + Some("HTTP protocol") + ) + .edge_with_desc( + EdgePattern::PrefixOf("www."), + "www_prefix", + Some("World Wide Web") + ) + + + + // === HTTPS PROTOCOL: Can go to any domain === + .at("https_protocol") + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Reddit (HTTPS)") + ) + .edge_with_desc( + EdgePattern::PrefixOf("www."), + "https_www", + Some("WWW prefix") + ) + + // === HTTP PROTOCOL: Similar to HTTPS === + .at("http_protocol") + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Reddit (HTTP)") + ) + .edge_with_desc( + EdgePattern::PrefixOf("www."), + "http_www", + Some("WWW prefix") + ) + + // === HTTPS + WWW === + .at("https_www") + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Reddit") + ) + + // === HTTP + WWW === + .at("http_www") + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Reddit") + ) + + // === WWW PREFIX (without protocol) === + .at("www_prefix") + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Reddit") + ) + + // === REDDIT DOMAIN: Expect "/" === + .at("reddit_domain") + .edge(EdgePattern::Literal("/"), "reddit_root") + .handler(|query, prefix, _ctx| { + // If someone just types "reddit.com" (or with protocol) without slash + // Suggest adding the slash using the current prefix + let completion = format!("{}/", prefix); + + UIAction::suggest( + query.to_string(), + Some(Suggestion { + text: completion.clone(), + completion, + description: Some("Continue to Reddit homepage".to_string()), + score: 1.0, + }) + ) + }) + + // === REDDIT ROOT: The main Reddit navigation === + .at("reddit_root") + .edge(EdgePattern::Literal("r/"), "subreddit_selection") + .edge(EdgePattern::Literal("u/"), "user_selection") + .handler(|query, prefix, _ctx| { + UIAction::multiple(vec![ + UIAction::scrolling_suggestions( + query.to_string(), + vec![ + ScrollingSuggestion { + completion: format!("{}r/", prefix), + }, + ScrollingSuggestion { + completion: format!("{}u/", prefix), + }, + ], + 1400, // 1.4 second interval (slower) + true // loop through + ), + UIAction::guide( + query.to_string(), + "Welcome to Sorter for Reddit".to_string(), + "Where would you like to start?".to_string(), + vec![ + GuideOption { + key: "r".to_string(), + label: "Sort a Subreddit".to_string(), + description: "Find the best posts in a community.".to_string(), + completion: format!("{}r/", prefix) + }, + GuideOption { + key: "u".to_string(), + label: "Sort User Content".to_string(), + description: "Explore and rank a user's posts and comments.".to_string(), + completion: format!("{}u/", prefix) + }, + ] + ), + ]) + }) + + + + // === SUBREDDIT SELECTION: THE UNIFIED NODE === + // This node is now reached from `r/` OR `reddit.com/r/` + .at("subreddit_selection") + .edge(EdgePattern::Variable("subreddit"), "subreddit_page") + .handler(|query, prefix, _ctx| { + UIAction::multiple(vec![ + UIAction::scrolling_suggestions( + query.to_string(), + vec![ + ScrollingSuggestion { + completion: format!("{}programming", prefix), + }, + ScrollingSuggestion { + completion: format!("{}askreddit", prefix), + }, + ScrollingSuggestion { + completion: format!("{}aww", prefix), + }, + ScrollingSuggestion { + completion: format!("{}rust", prefix), + }, + ScrollingSuggestion { + completion: format!("{}webdev", prefix), + }, + ], + 1600, // 1.6 second interval (slower) + true // loop through + ), + // Live DB-backed suggestions for subreddits as the user types + UIAction::SuggestSubredditsFromDb { partial: query.to_string(), prefix: prefix.to_string() } + ]) + }) + + // === Specific subreddit page === + .at("subreddit_page") + .edge(EdgePattern::Literal("/"), "subreddit_slash") + .handler(|_query, prefix, ctx| { + // Use the new unified subreddit resolution logic + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + UIAction::ResolveAndDisplaySubreddit { + subreddit, + prefix: prefix.to_string(), + } + }) + + .at("subreddit_slash") + .edge(EdgePattern::Literal("hot"), "subreddit_hot") + .edge(EdgePattern::Literal("top"), "subreddit_top") + .edge(EdgePattern::Literal("new"), "subreddit_new") + .edge(EdgePattern::Literal("comments"), "subreddit_comments") + .handler(|query, prefix, ctx| { + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + UIAction::guide( + query.to_string(), + format!("What to sort in r/{}?", subreddit), + "Choose a category to begin sorting.".to_string(), + vec![ + GuideOption { + key: "hot".to_string(), + label: "Hot Posts".to_string(), + description: "Import and sort posts currently on the front page.".to_string(), + completion: format!("{}hot", prefix) + }, + GuideOption { + key: "top".to_string(), + label: "Top Posts".to_string(), + description: "Import and sort the highest-rated posts.".to_string(), + completion: format!("{}top", prefix) + }, + GuideOption { + key: "new".to_string(), + label: "New Posts".to_string(), + description: "Import and sort the newest posts.".to_string(), + completion: format!("{}new", prefix) + }, + GuideOption { + key: "comments".to_string(), + label: "All Comments".to_string(), + description: "Find the best comment across all imported threads.".to_string(), + completion: format!("{}comments/", prefix) + }, + ] + ) + }) + + + + .at("user_selection") + .edge(EdgePattern::Variable("username"), "user_profile") + .handler(|query, prefix, _ctx| { + UIAction::guide( + query.to_string(), + "User Profile Sorting".to_string(), + "Enter a Reddit username to sort their content.".to_string(), + vec![ + GuideOption { + key: "popular".to_string(), + label: "Popular Users".to_string(), + description: "Browse well-known Reddit users.".to_string(), + completion: prefix.to_string(), + }, + ] + ) + }) + + // === Subreddit sort types === + .at("subreddit_hot") + .handler(|_query, _prefix, ctx| { + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + UIAction::RenderEntityView { + ns: "reddit.subreddit".to_string(), + pk: subreddit, + } + }) + + .at("subreddit_top") + .handler(|_query, _prefix, ctx| { + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + UIAction::RenderEntityView { + ns: "reddit.subreddit".to_string(), + pk: subreddit, + } + }) + + .at("subreddit_new") + .handler(|_query, _prefix, ctx| { + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + UIAction::RenderEntityView { + ns: "reddit.subreddit".to_string(), + pk: subreddit, + } + }) + + .at("subreddit_comments") + .handler(|_query, _prefix, ctx| { + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + UIAction::RenderEntityView { + ns: "reddit.subreddit".to_string(), + pk: subreddit, + } + }) + + // === User profile === + .at("user_profile") + .handler(|_query, _prefix, ctx| { + let username = ctx.get("username").cloned().unwrap_or_default(); + UIAction::RenderEntityView { + ns: "reddit.user".to_string(), + pk: username, + } + }) + + // === Build the graph === + .build() +} + +// --- Public API --- + +/// Parse a query string and return a UI action +pub fn parse_reddit_url(query: &str) -> UIAction { + let graph = build_reddit_graph(); + graph.parse(query) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Represents a keystroke action + #[derive(Debug, Clone, PartialEq)] + enum KeyAction { + Type(String), // Type characters + Tab, // Press tab (accept completion) + + } + + /// Expected state after a keystroke + #[derive(Debug, Clone)] + enum ExpectedAction { + Suggestion { completion: String }, + ScrollingSuggestions { completions: Vec }, + Guide { title_contains: String }, + + RenderSubreddit { subreddit: String, sort: Option }, + RenderSubredditComments { subreddit: String }, + RenderUser { username: String }, + ResolveSubreddit { subreddit: String, prefix: String }, // New unified subreddit resolution + Error { error_type: String }, + Multiple { expected_actions: Vec }, // Multiple responses with specific expectations + MultipleAny, // Multiple responses (any sub-actions - legacy) + DbSuggestions { partial: String, prefix: String }, // Database-backed suggestions + + } + + impl ExpectedAction { + fn matches(&self, action: &UIAction) -> bool { + match (self, action) { + (ExpectedAction::Suggestion { completion }, UIAction::ShowSuggestions(data)) => { + data.suggestion.as_ref() + .map(|s| s.completion == *completion) + .unwrap_or(false) + } + (ExpectedAction::ScrollingSuggestions { completions }, UIAction::ShowScrollingSuggestions { suggestions, .. }) => { + let actual_completions: Vec = suggestions.iter().map(|s| s.completion.clone()).collect(); + *completions == actual_completions + } + (ExpectedAction::Guide { title_contains }, UIAction::ShowStaticGuide { title, .. }) => { + title.contains(title_contains) + } + (ExpectedAction::RenderSubreddit { subreddit, sort: _ }, + UIAction::RenderEntityView { ns, pk }) => { + ns == "reddit.subreddit" && pk == subreddit + } + (ExpectedAction::RenderSubredditComments { subreddit }, + UIAction::RenderEntityView { ns, pk }) => { + ns == "reddit.subreddit" && pk == subreddit + } + (ExpectedAction::RenderUser { username }, UIAction::RenderEntityView { ns, pk }) => { + ns == "reddit.user" && pk == username + } + (ExpectedAction::ResolveSubreddit { subreddit, prefix }, + UIAction::ResolveAndDisplaySubreddit { subreddit: s, prefix: p }) => { + s == subreddit && p == prefix + } + (ExpectedAction::Error { error_type }, UIAction::ShowError(data)) => { + data.error_type == *error_type + } + (ExpectedAction::Multiple { expected_actions }, UIAction::ShowMultiple { actions }) => { + // Check that all expected actions are present + if expected_actions.len() != actions.len() { + return false; + } + expected_actions.iter().zip(actions.iter()).all(|(expected, actual)| { + expected.matches(actual) + }) + } + (ExpectedAction::MultipleAny, UIAction::ShowMultiple { .. }) => true, + (ExpectedAction::DbSuggestions { partial, prefix }, + UIAction::SuggestSubredditsFromDb { partial: p, prefix: pr }) => { + p == partial && pr == prefix + } + + _ => false, + } + } + } + + /// Test helper to simulate a sequence of keystrokes + fn simulate_keystrokes(actions: Vec) -> Vec<(String, UIAction)> { + let graph = build_reddit_graph(); + let mut current_text = String::new(); + let mut results = Vec::new(); + + for action in actions { + match action { + KeyAction::Type(text) => { + current_text.push_str(&text); + let result = graph.parse(¤t_text); + results.push((current_text.clone(), result)); + } + KeyAction::Tab => { + // Tab accepts the current suggestion if there is one + let result = graph.parse(¤t_text); + if let UIAction::ShowSuggestions(ref data) = result { + if let Some(ref suggestion) = data.suggestion { + current_text = suggestion.completion.clone(); + let new_result = graph.parse(¤t_text); + results.push((current_text.clone(), new_result)); + } + } + } + + } + } + + results + } + + /// Test a flow using declarative (KeyAction, ExpectedAction) tuples + fn test_flow(name: &str, flow: Vec<(KeyAction, ExpectedAction)>) { + let graph = build_reddit_graph(); + let mut current_text = String::new(); + + println!("\n=== Flow: {} ===", name); + + for (i, (key_action, expected)) in flow.iter().enumerate() { + // Perform the keystroke + match key_action { + KeyAction::Type(text) => { + current_text.push_str(text); + } + KeyAction::Tab => { + // Tab accepts the current suggestion + let result = graph.parse(¤t_text); + if let UIAction::ShowSuggestions(data) = result { + if let Some(suggestion) = &data.suggestion { + current_text = suggestion.completion.clone(); + } + } + } + } + + // Check the result + let actual_action = graph.parse(¤t_text); + + println!(" Step {}: {:?} -> '{}' -> {:?}", + i + 1, key_action, current_text, actual_action); + + assert!( + expected.matches(&actual_action), + "Flow '{}' failed at step {}\n Expected: {:?}\n Actual: {:?}\n Text: '{}'", + name, i + 1, expected, actual_action, current_text + ); + } + + println!("✓ Flow '{}' passed!", name); + } + + /// Legacy helper for backward compatibility (will be removed) + fn assert_flow( + name: &str, + actions: Vec, + expected_checks: Vec bool>>, + ) { + let results = simulate_keystrokes(actions); + + println!("\n=== Flow: {} ===", name); + for (i, (text, action)) in results.iter().enumerate() { + println!(" Step {}: '{}' -> {:?}", i + 1, text, action); + + if i < expected_checks.len() { + let check = &expected_checks[i]; + assert!( + check(text, action), + "Flow '{}' failed at step {} with text '{}' and action {:?}", + name, i + 1, text, action + ); + } + } + println!("✓ Flow '{}' passed!", name); + } + + #[test] + fn test_https_reddit_tab_flow() { + // Test: typing "https://reddit.com" and pressing tab should give "https://reddit.com/" + assert_flow( + "HTTPS Reddit with Tab", + vec![ + KeyAction::Type("https://reddit.com".to_string()), + KeyAction::Tab, + ], + vec![ + Box::new(|text, action| { + // After typing "https://reddit.com", should get a suggestion + text == "https://reddit.com" && matches!(action, UIAction::ShowSuggestions(data) if + data.suggestion.as_ref().map(|s| s.completion == "https://reddit.com/").unwrap_or(false) + ) + }), + Box::new(|text, action| { + // After tab, should have "https://reddit.com/" and show guide + text == "https://reddit.com/" && matches!(action, UIAction::ShowMultiple { .. }) + }), + ], + ); + } + + #[test] + fn test_quick_subreddit_flow() { + // Test: "r" -> TAB -> "rust" (direct alias flow) + assert_flow( + "Quick Subreddit Access", + vec![ + KeyAction::Type("r".to_string()), + KeyAction::Tab, + KeyAction::Type("rust".to_string()), + ], + vec![ + Box::new(|text, action| { + // "r" should suggest "r/" + text == "r" && matches!(action, UIAction::ShowSuggestions(data) if + data.suggestion.as_ref().map(|s| s.completion == "r/").unwrap_or(false) + ) + }), + Box::new(|text, action| { + // After tab, should have "r/" and show subreddit selection + text == "r/" && matches!(action, UIAction::ShowMultiple { .. }) + }), + Box::new(|text, action| { + // "r/rust" should resolve the subreddit + text == "r/rust" && matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == "rust" && prefix == "r/rust") + }), + ], + ); + } + + #[test] + fn test_progressive_completion_flow() { + // Test progressive typing: "r" -> "re" -> "red" -> "redd" -> "reddit" -> TAB + let progressive_actions = vec![ + KeyAction::Type("r".to_string()), + KeyAction::Type("e".to_string()), + KeyAction::Type("d".to_string()), + KeyAction::Type("d".to_string()), + KeyAction::Type("i".to_string()), + KeyAction::Type("t".to_string()), + KeyAction::Tab, + ]; + + let results = simulate_keystrokes(progressive_actions); + + println!("\n=== Progressive Completion Flow ==="); + for (i, (text, action)) in results.iter().enumerate() { + println!(" '{}' -> {:?}", text, action); + + // First step: "r" should suggest "r/" + if i == 0 && text == "r" { + match action { + UIAction::ShowSuggestions(data) => { + assert_eq!( + data.suggestion.as_ref().unwrap().completion, + "r/", + "Should suggest r/ at 'r'" + ); + } + _ => panic!("Expected suggestion at 'r'"), + } + } + // Other steps before tab should suggest "reddit.com" + else if i > 0 && i < results.len() - 1 { + match action { + UIAction::ShowSuggestions(data) => { + assert_eq!( + data.suggestion.as_ref().unwrap().completion, + "reddit.com", + "Should suggest reddit.com at '{}'", text + ); + } + _ => panic!("Expected suggestion at '{}'", text), + } + } + } + + // After tab, should have "reddit.com" + let (final_text, _) = results.last().unwrap(); + assert_eq!(final_text, "reddit.com"); + println!("✓ Progressive completion flow passed!"); + } + + #[test] + fn test_subreddit_sort_flow() { + // Test navigating to a subreddit and choosing a sort option + assert_flow( + "Subreddit Sort Navigation", + vec![ + KeyAction::Type("reddit.com/r/programming/hot".to_string()), + ], + vec![ + Box::new(|text, action| { + text == "reddit.com/r/programming/hot" && + matches!(action, UIAction::RenderEntityView { ns, pk } + if ns == "reddit.subreddit" && pk == "programming") + }), + ], + ); + } + + #[test] + fn test_user_profile_flow() { + // Test navigating to a user profile + assert_flow( + "User Profile Navigation", + vec![ + KeyAction::Type("reddit.com/u/spez".to_string()), + ], + vec![ + Box::new(|text, action| { + text == "reddit.com/u/spez" && + matches!(action, UIAction::RenderEntityView { ns, pk } if ns == "reddit.user" && pk == "spez") + }), + ], + ); + } + + #[test] + fn test_alias_shortcut_flow() { + // Test using the "r/" shortcut - now correctly goes directly to subreddit + assert_flow( + "Alias Shortcut", + vec![ + KeyAction::Type("r/".to_string()), + KeyAction::Type("technology".to_string()), + ], + vec![ + Box::new(|text, action| { + text == "r/" && matches!(action, UIAction::ShowMultiple { .. }) + }), + Box::new(|text, action| { + text == "r/technology" && + matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == "technology" && prefix == "r/technology") + }), + ], + ); + } + + #[test] + fn test_www_prefix_flow() { + // Test with www prefix + assert_flow( + "WWW Prefix", + vec![ + KeyAction::Type("www.reddit.com".to_string()), + KeyAction::Tab, + ], + vec![ + Box::new(|text, action| { + // Should suggest adding slash + text == "www.reddit.com" && matches!(action, UIAction::ShowSuggestions(data) if + data.suggestion.as_ref().map(|s| s.completion == "www.reddit.com/").unwrap_or(false) + ) + }), + Box::new(|text, action| { + // After tab, should show guide + text == "www.reddit.com/" && matches!(action, UIAction::ShowMultiple { .. }) + }), + ], + ); + } + + #[test] + fn test_invalid_path_handling() { + // Test that invalid paths show errors + assert_flow( + "Invalid Path", + vec![ + KeyAction::Type("reddit.com/invalid/path".to_string()), + ], + vec![ + Box::new(|text, action| { + text == "reddit.com/invalid/path" && + matches!(action, UIAction::ShowError(_)) + }), + ], + ); + } + + #[test] + fn test_complete_user_journey() { + // Test a complete user journey: type partial URL, tab complete, navigate to subreddit + assert_flow( + "Complete User Journey", + vec![ + KeyAction::Type("http".to_string()), + KeyAction::Type("s://r".to_string()), + KeyAction::Tab, + KeyAction::Type("/".to_string()), + KeyAction::Type("r/".to_string()), + KeyAction::Type("programming".to_string()), + KeyAction::Type("/".to_string()), + KeyAction::Type("top".to_string()), + ], + vec![ + Box::new(|text, action| { + // "http" should suggest "https://" + text == "http" && matches!(action, UIAction::ShowSuggestions(data) if + data.suggestion.as_ref().map(|s| s.completion == "https://").unwrap_or(false) + ) + }), + Box::new(|text, action| { + // "https://r" should suggest "https://reddit.com" + text == "https://r" && matches!(action, UIAction::ShowSuggestions(data) if + data.suggestion.as_ref().map(|s| s.completion == "https://reddit.com").unwrap_or(false) + ) + }), + Box::new(|text, action| { + // After tab, should have "https://reddit.com" + text == "https://reddit.com" && matches!(action, UIAction::ShowSuggestions(_)) + }), + Box::new(|text, action| { + // "https://reddit.com/" should show guide + text == "https://reddit.com/" && matches!(action, UIAction::ShowMultiple { .. }) + }), + Box::new(|text, action| { + // "https://reddit.com/r/" should show subreddit selection + text == "https://reddit.com/r/" && matches!(action, UIAction::ShowMultiple { .. }) + }), + Box::new(|text, action| { + // "https://reddit.com/r/programming" should resolve subreddit + text == "https://reddit.com/r/programming" && + matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } + if subreddit == "programming" && prefix == "https://reddit.com/r/programming") + }), + Box::new(|text, action| { + // "https://reddit.com/r/programming/" should show sort options + text == "https://reddit.com/r/programming/" && + matches!(action, UIAction::ShowStaticGuide { .. }) + }), + Box::new(|text, action| { + // "https://reddit.com/r/programming/top" should render entity view + text == "https://reddit.com/r/programming/top" && + matches!(action, UIAction::RenderEntityView { ns, pk } + if ns == "reddit.subreddit" && pk == "programming") + }), + ], + ); + } + + #[test] + fn test_multiple_tab_completions() { + // Test multiple tab completions in sequence + let actions = vec![ + KeyAction::Type("h".to_string()), + KeyAction::Tab, // Complete to "https://" + KeyAction::Type("r".to_string()), + KeyAction::Tab, // Complete to "https://reddit.com" + KeyAction::Type("/".to_string()), + ]; + + let results = simulate_keystrokes(actions); + + println!("\n=== Multiple Tab Completions ==="); + for (i, (text, _action)) in results.iter().enumerate() { + println!(" Step {}: '{}'", i + 1, text); + } + + // Verify the final state + assert_eq!(results[1].0, "https://"); // After first tab + assert_eq!(results[3].0, "https://reddit.com"); // After second tab + assert_eq!(results[4].0, "https://reddit.com/"); // After typing / + + println!("✓ Multiple tab completions work correctly!"); + } + + // === CONVENIENCE MACROS FOR CLEANER TESTS === + + macro_rules! flow { + ($(($key:expr, $expected:expr)),* $(,)?) => { + vec![$(($key, $expected)),*] + }; + } + + macro_rules! type_text { + ($text:expr) => { + KeyAction::Type($text.to_string()) + }; + } + + macro_rules! suggests { + ($completion:expr) => { + ExpectedAction::Suggestion { completion: $completion.to_string() } + }; + } + + macro_rules! renders_subreddit { + ($subreddit:expr) => { + ExpectedAction::RenderSubreddit { subreddit: $subreddit.to_string(), sort: None } + }; + ($subreddit:expr, $sort:expr) => { + ExpectedAction::RenderSubreddit { + subreddit: $subreddit.to_string(), + sort: Some($sort.to_string()) + } + }; + } + + macro_rules! renders_subreddit_comments { + ($subreddit:expr) => { + ExpectedAction::RenderSubredditComments { subreddit: $subreddit.to_string() } + }; + } + + macro_rules! resolves_subreddit { + ($subreddit:expr, $prefix:expr) => { + ExpectedAction::ResolveSubreddit { + subreddit: $subreddit.to_string(), + prefix: $prefix.to_string() + } + }; + } + + macro_rules! shows_guide { + ($title_contains:expr) => { + ExpectedAction::Guide { title_contains: $title_contains.to_string() } + }; + } + + macro_rules! multiple { + ($($action:expr),* $(,)?) => { + ExpectedAction::Multiple { expected_actions: vec![$($action),*] } + }; + } + + macro_rules! scrolling_suggestions { + ($($completion:expr),* $(,)?) => { + ExpectedAction::ScrollingSuggestions { completions: vec![$($completion.to_string()),*] } + }; + } + + macro_rules! db_suggestions { + ($partial:expr, $prefix:expr) => { + ExpectedAction::DbSuggestions { partial: $partial.to_string(), prefix: $prefix.to_string() } + }; + } + + // === NEW DECLARATIVE TESTS === + + #[test] + fn test_declarative_https_tab_flow() { + test_flow("HTTPS Tab Completion", vec![ + (KeyAction::Type("https://reddit.com".to_string()), + ExpectedAction::Suggestion { completion: "https://reddit.com/".to_string() }), + (KeyAction::Tab, + multiple![ + scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"), + shows_guide!("Welcome to Sorter") + ]), + ]); + } + + #[test] + fn test_declarative_quick_subreddit_flow() { + test_flow("Quick Subreddit Flow", vec![ + (KeyAction::Type("r".to_string()), + ExpectedAction::Suggestion { completion: "r/".to_string() }), + (KeyAction::Tab, + ExpectedAction::MultipleAny), // r/ shows subreddit selection + (KeyAction::Type("rust".to_string()), + ExpectedAction::ResolveSubreddit { subreddit: "rust".to_string(), prefix: "r/rust".to_string() }), + ]); + } + + #[test] + fn test_declarative_complete_journey() { + test_flow("Complete User Journey", vec![ + (KeyAction::Type("http".to_string()), + ExpectedAction::Suggestion { completion: "https://".to_string() }), + (KeyAction::Type("s://r".to_string()), + ExpectedAction::Suggestion { completion: "https://reddit.com".to_string() }), + (KeyAction::Tab, + ExpectedAction::Suggestion { completion: "https://reddit.com/".to_string() }), + (KeyAction::Type("/r/programming/top".to_string()), + ExpectedAction::RenderSubreddit { + subreddit: "programming".to_string(), + sort: Some("top".to_string()) + }), + ]); + } + + #[test] + fn test_declarative_user_profile() { + test_flow("User Profile Navigation", vec![ + (KeyAction::Type("reddit.com/u/spez".to_string()), + ExpectedAction::RenderUser { username: "spez".to_string() }), + ]); + } + + #[test] + fn test_declarative_error_handling() { + test_flow("Error Handling", vec![ + (KeyAction::Type("reddit.com/invalid/path".to_string()), + ExpectedAction::Error { error_type: "InvalidPath".to_string() }), + ]); + } + + #[test] + fn test_declarative_progressive_completion() { + test_flow("Progressive Completion", vec![ + (KeyAction::Type("r".to_string()), + ExpectedAction::Suggestion { completion: "r/".to_string() }), + (KeyAction::Type("e".to_string()), + ExpectedAction::Suggestion { completion: "reddit.com".to_string() }), + (KeyAction::Type("d".to_string()), + ExpectedAction::Suggestion { completion: "reddit.com".to_string() }), + (KeyAction::Type("dit".to_string()), + ExpectedAction::Suggestion { completion: "reddit.com".to_string() }), + (KeyAction::Tab, + ExpectedAction::Suggestion { completion: "reddit.com/".to_string() }), + ]); + } + + #[test] + fn test_declarative_subreddit_guide() { + test_flow("Subreddit Guide", vec![ + (KeyAction::Type("reddit.com/r/programming/".to_string()), + ExpectedAction::Guide { title_contains: "What to sort".to_string() }), + ]); + } + + #[test] + fn test_clean_macro_example() { + // This is what the tests can look like with macros! + test_flow("Clean Macro Example", flow![ + (type_text!("r"), suggests!("r/")), + (KeyAction::Tab, ExpectedAction::MultipleAny), + (type_text!("rust/hot"), renders_subreddit!("rust", "hot")), + ]); + } + + #[test] + fn test_unified_subreddit_resolution_flow() { + // This test demonstrates the new unified behavior: + // - r/programming (exact) → tries exact match first + // - r/pro (partial) → tries exact match, then suggestions + TAB completion + + test_flow("Unified Resolution: Exact Match", flow![ + (type_text!("r/programming"), resolves_subreddit!("programming", "r/programming")), + ]); + + test_flow("Unified Resolution: Partial Match", flow![ + (type_text!("r/pro"), resolves_subreddit!("pro", "r/pro")), + ]); + + // Both go through the same action type, but dispatcher handles them differently: + // - If "programming" exists in DB → shows EntityView immediately + // - If "pro" doesn't exist in DB → shows Multiple with: + // 1. Suggestions (for TAB completion to best match) + // 2. Selection (for clickable options including import) + } + + #[test] + fn test_tab_completion_workflow_unified() { + // This demonstrates the desired TAB completion behavior: + // r/pro + TAB → r/programming (if "programming" is the best DB match) + + // Note: This test shows the PARSER behavior. The actual TAB completion + // happens in the frontend when it receives the Multiple response containing + // both Suggestions (for TAB) and Selection (for click options). + + let graph = build_reddit_graph(); + + // 1. Parser generates unified action for partial input + match graph.parse("r/pro") { + UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => { + assert_eq!(subreddit, "pro"); + assert_eq!(prefix, "r/pro"); + println!("✓ Parser correctly identifies 'r/pro' as subreddit resolution"); + } + _ => panic!("Expected ResolveAndDisplaySubreddit for 'r/pro'"), + } + + // 2. When dispatcher runs (in real app), it will return Multiple response with: + // - Suggestions: { completion: "r/programming" } (for TAB) + // - Selection: [ "Import r/pro", "r/programming", ... ] (for clicks) + + println!("✓ TAB completion workflow: r/pro → ResolveAndDisplaySubreddit → Multiple(Suggestions + Selection)"); + } + + #[test] + fn test_ultra_clean_user_journey() { + test_flow("Ultra Clean User Journey", flow![ + (type_text!("h"), suggests!("https://")), + (type_text!("ttps://r"), suggests!("https://reddit.com")), + (KeyAction::Tab, suggests!("https://reddit.com/")), + (type_text!("/u/spez"), ExpectedAction::RenderUser { username: "spez".to_string() }), + ]); + } + + // === TESTS FROM parser.tdsl === + + #[test] + fn test_tdsl_basic_reddit_progression() { + // Tests from parser.tdsl: r -> r/, then re -> reddit.com with all intermediate steps + test_flow("TDSL Basic Reddit Progression", flow![ + (type_text!("r"), suggests!("r/")), + (type_text!("e"), suggests!("reddit.com")), + (type_text!("d"), suggests!("reddit.com")), + (type_text!("d"), suggests!("reddit.com")), + (type_text!("i"), suggests!("reddit.com")), + (type_text!("t"), suggests!("reddit.com")), + (type_text!("."), suggests!("reddit.com")), + (type_text!("c"), suggests!("reddit.com")), + (type_text!("o"), suggests!("reddit.com")), + (KeyAction::Tab, suggests!("reddit.com/")), + ]); + } + + #[test] + fn test_tdsl_reddit_com_slash_infographic() { + // reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)} + test_flow("TDSL Reddit.com/ Infographic", flow![ + (type_text!("reddit.com/"), ExpectedAction::MultipleAny), + ]); + } + + #[test] + fn test_tdsl_subreddit_selection() { + // reddit.com/r/ -> reddit.com/r/{randomly chose sub from list} + test_flow("TDSL Subreddit Selection", flow![ + (type_text!("reddit.com/r/"), ExpectedAction::MultipleAny), + ]); + } + + #[test] + fn test_tdsl_subreddit_view() { + // reddit.com/r/{sub} -> {resolve subreddit (exact match or suggestions)} + test_flow("TDSL Subreddit View", flow![ + (type_text!("reddit.com/r/programming"), resolves_subreddit!("programming", "reddit.com/r/programming")), + ]); + } + + #[test] + fn test_tdsl_subreddit_slash_infographic() { + // reddit.com/r/{sub}/ -> {show infographic or something} + test_flow("TDSL Subreddit Slash Infographic", flow![ + (type_text!("reddit.com/r/programming/"), shows_guide!("What to sort")), + ]); + } + + #[test] + fn test_tdsl_subreddit_comments() { + // reddit.com/r/{sub}/comments/{randomly chose comment from sql} + test_flow("TDSL Subreddit Comments", flow![ + (type_text!("reddit.com/r/programming/comments"), renders_subreddit_comments!("programming")), + ]); + } + + #[test] + fn test_tdsl_h_to_https() { + // h->https:// (show supported domains) + test_flow("TDSL H to HTTPS", flow![ + (type_text!("h"), suggests!("https://")), + ]); + } + + #[test] + fn test_tdsl_composable_https_reddit() { + // https://r->https://reddit.com/ + test_flow("TDSL Composable HTTPS Reddit", flow![ + (type_text!("https://r"), suggests!("https://reddit.com")), + ]); + } + + #[test] + fn test_tdsl_composable_https_www() { + // https://w->https://www. + test_flow("TDSL Composable HTTPS WWW", flow![ + (type_text!("https://w"), suggests!("https://www.")), + ]); + } + + #[test] + fn test_tdsl_composable_https_www_reddit() { + // https://www.r->https://www.reddit.com/ + test_flow("TDSL Composable HTTPS WWW Reddit", flow![ + (type_text!("https://www.r"), suggests!("https://www.reddit.com")), + ]); + } + + #[test] + fn test_tdsl_full_composable_chain() { + // Complete chain showing composability: h -> https:// -> https://www.reddit.com + test_flow("TDSL Full Composable Chain Step 1", flow![ + (type_text!("h"), suggests!("https://")), + ]); + + test_flow("TDSL Full Composable Chain Step 2", flow![ + (type_text!("https://w"), suggests!("https://www.")), + ]); + + test_flow("TDSL Full Composable Chain Step 3", flow![ + (type_text!("https://www.r"), suggests!("https://www.reddit.com")), + ]); + } + + #[test] + fn test_tdsl_progressive_reddit_paths() { + // Test various reddit paths work as expected + test_flow("TDSL Progressive Reddit Paths", flow![ + (type_text!("reddit.com"), suggests!("reddit.com/")), + (KeyAction::Tab, ExpectedAction::MultipleAny), + ]); + } + + #[test] + fn test_tdsl_subreddit_sorting_options() { + // Test that subreddit sorting options work as described + test_flow("TDSL Subreddit Sorting", flow![ + (type_text!("reddit.com/r/rust/hot"), renders_subreddit!("rust", "hot")), + ]); + + test_flow("TDSL Subreddit Top", flow![ + (type_text!("reddit.com/r/rust/top"), renders_subreddit!("rust", "top")), + ]); + + test_flow("TDSL Subreddit New", flow![ + (type_text!("reddit.com/r/rust/new"), renders_subreddit!("rust", "new")), + ]); + } + + #[test] + fn test_tdsl_all_reddit_prefixes() { + // Test all the prefixes mentioned in parser.tdsl work + // "r" now suggests "r/", all others suggest "reddit.com" + let prefixes_to_reddit = vec!["re", "red", "redd", "reddi", "reddit", "reddit.", "reddit.c", "reddit.co"]; + + // Test "r" separately since it now suggests "r/" + test_flow("TDSL Prefix: r", flow![ + (type_text!("r"), suggests!("r/")), + ]); + + for prefix in prefixes_to_reddit { + test_flow(&format!("TDSL Prefix: {}", prefix), flow![ + (type_text!(prefix), suggests!("reddit.com")), + ]); + } + } + + #[test] + fn test_tdsl_protocol_combinations() { + // Test various protocol combinations from parser.tdsl + let test_cases = vec![ + ("http://r", "http://reddit.com"), + ("https://r", "https://reddit.com"), + ("www.r", "www.reddit.com"), + ("https://www.r", "https://www.reddit.com"), + ("http://www.r", "http://www.reddit.com"), + ]; + + for (input, expected) in test_cases { + test_flow(&format!("TDSL Protocol: {}", input), flow![ + (type_text!(input), suggests!(expected)), + ]); + } + } + + #[test] + fn test_tdsl_edge_case_completions() { + // Test edge cases mentioned in parser.tdsl + test_flow("TDSL Reddit.com completion", flow![ + (type_text!("reddit.com"), suggests!("reddit.com/")), + ]); + + // Test that typing full reddit.com suggests the slash + test_flow("TDSL Full domain completion", flow![ + (type_text!("reddit.com"), suggests!("reddit.com/")), + (KeyAction::Tab, ExpectedAction::MultipleAny), + ]); + } + + #[test] + fn test_real_user_trace_2025_08_07_fixed() { + // Based on actual WebSocket trace from 2025-08-07T01:35:46Z + // This test shows the CORRECT behavior after fixing the protocol preservation bug + test_flow("Real User Trace: Progressive Typing with Tab Completions (Fixed)", flow![ + // User started typing "h" + (type_text!("h"), suggests!("https://")), + + // User continued to "ht" + (type_text!("t"), suggests!("https://")), + + // User finished typing "https://" (trace shows full protocol) + (type_text!("tps://"), suggests!("https://")), + + // User started typing "r" after protocol + (type_text!("r"), suggests!("https://reddit.com")), + + // User continued typing "re" + (type_text!("e"), suggests!("https://reddit.com")), + + // User typed out or completed "https://reddit.com" + (type_text!("ddit.com"), suggests!("https://reddit.com/")), + + // User accepted completion to "https://reddit.com/" + // NOW suggestions should preserve the https:// protocol + (KeyAction::Tab, multiple![ + scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"), // This is the key fix! + shows_guide!("Welcome to Sorter") + ]), + ]); + } + + #[test] + fn test_protocol_preservation_bug_fix() { + // This test specifically verifies the fix for the protocol preservation bug + test_flow("Protocol Preservation: HTTPS Reddit Homepage", flow![ + (type_text!("https://reddit.com/"), multiple![ + scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"), // Should preserve https:// + shows_guide!("Welcome to Sorter") + ]), + ]); + + // Test that subreddit selection preserves protocol + test_flow("Protocol Preservation: HTTPS Subreddit Selection", flow![ + (type_text!("https://reddit.com/r/"), multiple![ + scrolling_suggestions!("https://reddit.com/r/programming", "https://reddit.com/r/askreddit", "https://reddit.com/r/aww", "https://reddit.com/r/rust", "https://reddit.com/r/webdev"), // Should preserve https:// + db_suggestions!("https://reddit.com/r/", "https://reddit.com/r/") + ]), + ]); + + // Test with different protocols + test_flow("Protocol Preservation: HTTP", flow![ + (type_text!("http://reddit.com/"), multiple![ + scrolling_suggestions!("http://reddit.com/r/", "http://reddit.com/u/"), // Should preserve http:// + shows_guide!("Welcome to Sorter") + ]), + ]); + + test_flow("Protocol Preservation: WWW", flow![ + (type_text!("www.reddit.com/"), multiple![ + scrolling_suggestions!("www.reddit.com/r/", "www.reddit.com/u/"), // Should preserve www. + shows_guide!("Welcome to Sorter") + ]), + ]); + + // Test that plain reddit.com still works + test_flow("Protocol Preservation: Plain Domain", flow![ + (type_text!("reddit.com/"), multiple![ + scrolling_suggestions!("reddit.com/r/", "reddit.com/u/"), // No protocol prefix + shows_guide!("Welcome to Sorter") + ]), + ]); + } + + #[test] + fn test_user_navigation_pattern() { + // Models how users actually navigate: type → tab → click suggestion → end up at destination + // This captures the "jump" from https://reddit.com/ to reddit.com/r/ seen in the trace + test_flow("User Navigation: Protocol to Domain", flow![ + // User types and gets to homepage + (type_text!("https://reddit.com/"), ExpectedAction::MultipleAny), + ]); + + // Then they navigate (perhaps clicking a suggestion) to subreddit selection + test_flow("User Navigation: Click to Subreddit", flow![ + // This is where they ended up - the suggestion in the guide probably said "reddit.com/r/" + (type_text!("reddit.com/r/"), ExpectedAction::MultipleAny), + ]); + } + + #[test] + fn test_progressive_typing_pattern() { + // Based on the trace pattern - users often type character by character + // This tests the exact sequence of suggestions they would see + test_flow("Progressive Typing Pattern", flow![ + (type_text!("h"), suggests!("https://")), + (type_text!("t"), suggests!("https://")), // Still suggests https:// + (type_text!("t"), suggests!("https://")), // ht -> htt, still suggests https:// + (type_text!("p"), suggests!("https://")), // http should still suggest https:// + (type_text!("s"), suggests!("https://")), // https should suggest https:// + (type_text!("://"), suggests!("https://")), // Even complete protocol still suggests itself + ]); + } + + #[test] + fn test_tab_completion_workflow() { + // Test what happens when user uses tab completions strategically + test_flow("Strategic Tab Completion Workflow", flow![ + // Start typing, get suggestion + (type_text!("h"), suggests!("https://")), + + // Accept suggestion with tab - this should move us to "https://" + (KeyAction::Tab, suggests!("https://")), // After tab, we're at "https://" which still suggests itself + + // Start typing reddit + (type_text!("r"), suggests!("https://reddit.com")), + + // Accept reddit suggestion + (KeyAction::Tab, suggests!("https://reddit.com/")), + + // Accept final suggestion to get to homepage + (KeyAction::Tab, ExpectedAction::MultipleAny), + ]); + } + + // Keep the original tests as well + #[test] + fn test_reddit_prefix_autocomplete() { + let graph = build_reddit_graph(); + + // "r" now suggests "r/", others suggest "reddit.com" + match graph.parse("r") { + UIAction::ShowSuggestions(data) => { + assert_eq!(data.suggestion.as_ref().unwrap().completion, "r/"); + println!("✓ 'r' → r/"); + } + _ => panic!("Expected suggestion for 'r'"), + } + + // Test other prefixes that should suggest "reddit.com" + let prefixes = vec!["re", "red", "redd", "reddi", "reddit", "reddit.", "reddit.c", "reddit.co"]; + + for prefix in prefixes { + match graph.parse(prefix) { + UIAction::ShowSuggestions(data) => { + assert_eq!(data.suggestion.as_ref().unwrap().completion, "reddit.com"); + println!("✓ '{}' → reddit.com", prefix); + } + _ => panic!("Expected suggestion for '{}'", prefix), + } + } + } + + #[test] + fn test_protocol_composition() { + let graph = build_reddit_graph(); + + // Test protocol + reddit compositions + let tests = vec![ + ("https://r", "https://reddit.com"), + ("https://re", "https://reddit.com"), + ("https://reddit", "https://reddit.com"), + ("https://www.r", "https://www.reddit.com"), + ("https://www.reddit", "https://www.reddit.com"), + ("http://r", "http://reddit.com"), + ("www.r", "www.reddit.com"), + ]; + + for (input, expected) in tests { + match graph.parse(input) { + UIAction::ShowSuggestions(data) => { + assert_eq!(data.suggestion.as_ref().unwrap().completion, expected); + println!("✓ '{}' → {}", input, expected); + } + _ => panic!("Expected suggestion for '{}'", input), + } + } + } + + #[test] + fn test_alias_and_full_paths() { + let graph = build_reddit_graph(); + + // reddit.com/ should show guide, r/ should show subreddit selection + match graph.parse("reddit.com/") { + UIAction::ShowMultiple { actions } => { + let has_guide = actions.iter() + .any(|a| matches!(a, UIAction::ShowStaticGuide { .. })); + assert!(has_guide, "Path 'reddit.com/' should show guide"); + println!("✓ 'reddit.com/' shows Reddit root guide"); + } + _ => panic!("Expected Multiple action for 'reddit.com/'"), + } + + match graph.parse("r/") { + UIAction::ShowMultiple { actions } => { + let has_scrolling_suggestions = actions.iter() + .any(|a| matches!(a, UIAction::ShowScrollingSuggestions { .. })); + let has_db_suggestions = actions.iter() + .any(|a| matches!(a, UIAction::SuggestSubredditsFromDb { .. })); + assert!(has_scrolling_suggestions && has_db_suggestions, + "Path 'r/' should show scrolling suggestions and DB suggestions"); + println!("✓ 'r/' shows subreddit selection"); + } + _ => panic!("Expected Multiple action for 'r/'"), + } + } + + #[test] + fn test_deep_navigation() { + let graph = build_reddit_graph(); + + // Test navigation to subreddit - now uses ResolveAndDisplaySubreddit + match graph.parse("reddit.com/r/rust") { + UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => { + assert_eq!(subreddit, "rust"); + assert_eq!(prefix, "reddit.com/r/rust"); + println!("✓ reddit.com/r/rust recognized"); + } + _ => panic!("Expected ResolveAndDisplaySubreddit for subreddit"), + } + + // Test with alias (r/ goes directly to subreddit, no double r/) + match graph.parse("r/programming") { + UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => { + assert_eq!(subreddit, "programming"); + assert_eq!(prefix, "r/programming"); + println!("✓ r/programming (alias) recognized"); + } + _ => panic!("Expected ResolveAndDisplaySubreddit for subreddit via alias"), + } + } +} diff --git a/parser.tdsl b/parser.tdsl new file mode 100644 index 0000000000000000000000000000000000000000..03ba9563ea8c73b7411cd1aa42dda9c7c313cd39 --- /dev/null +++ b/parser.tdsl @@ -0,0 +1,32 @@ +The parser is a series of edges/transitions, for example: +r -> reddit.com/ +where -> means that all of + r->reddit.com/ + re->reddit.com/ + red->reddit.com/ + redd->reddit.com/ + reddi->reddit.com/ + reddit->reddit.com/ + reddit.->reddit.com/ + reddit.c->reddit.com/ + reddit.co->reddit.com/ + reddit.com->reddt.com/ + are defined as autocomplete suggestions + +re -> reddit.com/ +reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)} -> reddit.com/r/ +reddit.com/r/ -> reddit.com/r/{randomly chose sub from list} +reddit.com/r/{sub} -> {show subrdedit view} -> reddit.com/r/{sub}/ +reddit.com/r/{sub}/ -> {show infographic or something} -> reddit.com/r/{sub}/comments/{randomly chose comment from sql} + + +h->https:// (show supported domains) + +// the edges are composable such that this is possible, while reusing the above reddit code. +https://r->https://reddit.com/ +https://w->https://www. +https://www.r->https://www.reddit.com/ + + +// and i also want to have commands after you press space, like +reddit.com/r/programming !vote(reddit.comment{t3_123, t4_4444})% diff --git a/ranking.rs b/ranking.rs new file mode 100644 index 0000000000000000000000000000000000000000..34241cf6bf13117b3ad3262761eb7f7b48b54d80 --- /dev/null +++ b/ranking.rs @@ -0,0 +1,393 @@ +tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ cat server/src/ranking.rs +use std::collections::{HashMap, HashSet}; + +use crate::path_types::ItemId; +use crate::reducer::GroupState; + +#[derive(Debug, Clone)] +pub struct RankedItem { + pub item: ItemId, + pub score: f64, +} + +/// Compute connected components over the voted-pairs graph (treated as undirected). +/// +/// Returns: +/// - `components`: each component is a sorted list of node indices, excluding isolates. +/// - `isolates`: sorted list of node indices with degree 0 (no voted pairs). +pub fn connected_components_from_voted_pairs( + n: usize, + voted_pairs: impl Iterator, +) -> (Vec>, Vec) { + let mut adj: Vec> = vec![Vec::new(); n]; + for (a, b) in voted_pairs { + if a >= n || b >= n || a == b { + continue; + } + adj[a].push(b); + adj[b].push(a); + } + + let mut isolates: Vec = (0..n).filter(|&i| adj[i].is_empty()).collect(); + isolates.sort(); + + let mut seen = vec![false; n]; + for &i in &isolates { + seen[i] = true; + } + + let mut comps: Vec> = Vec::new(); + for i in 0..n { + if seen[i] { + continue; + } + let mut stack = vec![i]; + seen[i] = true; + let mut comp: Vec = Vec::new(); + while let Some(x) = stack.pop() { + comp.push(x); + for &y in &adj[x] { + if !seen[y] { + seen[y] = true; + stack.push(y); + } + } + } + comp.sort(); + comps.push(comp); + } + + (comps, isolates) +} + +/// Compute rank centrality scores for a GroupState. +/// This matches the approach in the earlier standalone prototype but avoids dependencies by doing +/// an O(E) multiply per iteration. +pub fn compute_group_ranking(group: &mut GroupState, max_iters: usize, tol: f64) { + if !group.dirty && !group.cached_scores.is_empty() { + return; + } + + let n = group.idx_to_item.len(); + if n == 0 { + group.cached_scores = vec![]; + group.dirty = false; + return; + } + if n == 1 { + group.cached_scores = vec![1.0]; + group.dirty = false; + return; + } + + let scores = compute_scores_from_edges( + n, + group.edges.iter().map(|(&k, &w)| (k, w)), + max_iters, + tol, + ); + group.cached_scores = scores; + group.dirty = false; +} + +pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec { + compute_group_ranking(group, max_iters, tol); + let mut items: Vec = group + .idx_to_item + .iter() + .enumerate() + .map(|(i, item)| RankedItem { + item: item.clone(), + score: *group.cached_scores.get(i).unwrap_or(&0.0), + }) + .collect(); + + items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + items +} + +fn compute_scores_from_edges(n: usize, edges: impl Iterator, max_iters: usize, tol: f64) -> Vec { + if n == 0 { + return vec![]; + } + if n == 1 { + return vec![1.0]; + } + + // Collect raw edges into a map for pairwise normalization. + let mut raw: HashMap<(usize, usize), f64> = HashMap::new(); + for ((src, dst), w) in edges { + if src >= n || dst >= n || w <= 0.0 { + continue; + } + *raw.entry((src, dst)).or_insert(0.0) += w; + } + + // Pairwise normalization: a_ij = A_ij / (A_ij + A_ji). + // This ensures repeated votes on the same pair don't inflate influence + // beyond what the ratio implies. + let keys: Vec<(usize, usize)> = raw.keys().copied().collect(); + let mut normalized: HashMap<(usize, usize), f64> = HashMap::new(); + for (i, j) in keys { + if normalized.contains_key(&(i, j)) { + continue; + } + let w_ij = *raw.get(&(i, j)).unwrap_or(&0.0); + let w_ji = *raw.get(&(j, i)).unwrap_or(&0.0); + let total = w_ij + w_ji; + if total <= 0.0 { + continue; + } + normalized.insert((i, j), w_ij / total); + if w_ji > 0.0 { + normalized.insert((j, i), w_ji / total); + } + } + + // Rank Centrality (Negahban, Oh, Shah 2012, §3.1): + // P_ij = (1/d_max) * A_ij for i ≠ j compared + // P_ii = 1 - (1/d_max) * Σ_k A_ik + // where d_i is the *degree* (number of distinct neighbors compared) and + // d_max = max_i d_i. Using the unweighted degree — not the sum of + // pairwise-normalized weights — is what guarantees aperiodicity: it + // forces P_ii > 0 for every non-maximum-degree node, and for max-degree + // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous + // loss). Without this, regular comparison graphs (e.g. a pure star at + // ratio 2:1) produce a bipartite chain that oscillates instead of + // converging — see issue #146. + let mut out_edges: Vec> = vec![Vec::new(); n]; + let mut neighbors: Vec> = vec![HashSet::new(); n]; + + for ((src, dst), w) in &normalized { + out_edges[*src].push((*dst, *w)); + neighbors[*src].insert(*dst); + neighbors[*dst].insert(*src); + } + + let weight_sum: Vec = out_edges + .iter() + .map(|es| es.iter().map(|(_, w)| *w).sum()) + .collect(); + let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0); + if d_max == 0 { + return vec![1.0 / n as f64; n]; + } + let d_max_f = d_max as f64; + + let mut scores = vec![1.0 / n as f64; n]; + let mut next = vec![0.0f64; n]; + + for _ in 0..max_iters { + next.fill(0.0); + for i in 0..n { + let stay_prob = (d_max_f - weight_sum[i]) / d_max_f; + next[i] += scores[i] * stay_prob; + + if out_edges[i].is_empty() { + continue; + } + for &(dst, w) in &out_edges[i] { + next[dst] += scores[i] * (w / d_max_f); + } + } + + let diff: f64 = scores + .iter() + .zip(next.iter()) + .map(|(a, b)| (a - b).abs()) + .sum(); + + scores.clone_from_slice(&next); + if diff < tol { + break; + } + } + + let sum: f64 = scores.iter().sum(); + if sum.is_finite() && sum > 0.0 { + for s in &mut scores { + *s /= sum; + } + } + scores +} + +/// Rank-centrality within a subset of items (an induced subgraph), using the group's aggregated edges. +/// +/// `idxs` are indices into `group.idx_to_item`. The returned items use the original item names. +pub fn ranked_items_subset(group: &GroupState, idxs: &[usize], max_iters: usize, tol: f64) -> Vec { + if idxs.is_empty() { + return vec![]; + } + + // Map original idx -> compact idx [0..m) + let mut map: HashMap = HashMap::with_capacity(idxs.len()); + for (j, &i) in idxs.iter().enumerate() { + map.insert(i, j); + } + + let edges_iter = group.edges.iter().filter_map(|(&(src, dst), &w)| { + let s = *map.get(&src)?; + let d = *map.get(&dst)?; + Some(((s, d), w)) + }); + + let scores = compute_scores_from_edges(idxs.len(), edges_iter, max_iters, tol); + + // Filter out entries where idx_to_item doesn't have the slot (shouldn't happen, but be safe). + let mut items: Vec = idxs + .iter() + .enumerate() + .filter_map(|(j, &orig)| { + let item = group.idx_to_item.get(orig)?.clone(); + Some(RankedItem { item, score: *scores.get(j).unwrap_or(&0.0) }) + }) + .collect(); + + items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + items +} + +pub fn group_summary_scores( + group: &mut GroupState, + max_iters: usize, + tol: f64, +) -> HashMap { + ranked_items(group, max_iters, tol) + .into_iter() + .map(|r| (r.item, r.score)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::reducer::VoteData; + + fn mk_group() -> GroupState { + GroupState::new() + } + + fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData { + use crate::path_types::ItemId; + VoteData { + ts, + a: ItemId::parse(a).unwrap(), + b: ItemId::parse(b).unwrap(), + ratio_left: l, + ratio_right: r, + body: "because".to_string(), + principal: "test".to_string(), + delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()), + thread_tag: "untagged".to_string(), + } + } + + /// Regression for issue #146: pure forward star at default `>` ratio (2:1). + /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the + /// chain was bipartite; power iteration oscillated and returned the + /// uniform initial distribution after an even number of steps. Using the + /// paper's degree-based d_max gives every node a positive self-loop and + /// the chain converges to the correct stationary distribution. + #[test] + fn star_topology_winner_at_top_via_subset() { + let mut g = mk_group(); + g.apply_vote(vote(1, "zebra", "alpha", 2, 1)); + g.apply_vote(vote(2, "zebra", "beta", 2, 1)); + + let mut items: Vec<(usize, String)> = g + .idx_to_item + .iter() + .enumerate() + .map(|(i, it)| (i, it.as_str().to_string())) + .collect(); + items.sort_by(|a, b| a.1.cmp(&b.1)); + let idxs: Vec = items.iter().map(|(i, _)| *i).collect(); + + let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8); + for r in &ranked { + eprintln!("{}: {}", r.item.as_str(), r.score); + } + assert_eq!( + ranked[0].item.as_str(), + "https://slug.social/zebra", + "zebra won both votes and should rank #1" + ); + } + + #[test] + fn group_ranking_cache_dirty_flow() { + let mut g = mk_group(); + assert!(g.dirty); + + g.apply_vote(vote(1, "a", "b", 3, 1)); + assert!(g.dirty); + assert!(g.cached_scores.is_empty()); + + compute_group_ranking(&mut g, 10000, 1e-8); + assert!(!g.dirty); + assert_eq!(g.cached_scores.len(), g.idx_to_item.len()); + + // Recomputing when not dirty should be a no-op. + let before = g.cached_scores.clone(); + compute_group_ranking(&mut g, 10000, 1e-8); + assert_eq!(before, g.cached_scores); + } + + #[test] + fn connected_components_split_disconnected_pairs() { + let mut g = mk_group(); + // Two disconnected edges: (a,b) and (c,d) + g.apply_vote(vote(1, "a", "b", 3, 1)); + g.apply_vote(vote(2, "c", "d", 3, 1)); + + let n = g.idx_to_item.len(); + let (mut comps, isolates) = + connected_components_from_voted_pairs(n, g.voted_pairs.iter().copied()); + assert!(isolates.is_empty()); + // Order-independent: sort components by their item names for stable assert. + comps.sort_by_key(|c| { + c.iter() + .map(|&i| g.idx_to_item[i].clone()) + .collect::>() + }); + assert_eq!(comps.len(), 2); + let comp0 = comps[0] + .iter() + .map(|&i| g.idx_to_item[i].as_str()) + .collect::>(); + let comp1 = comps[1] + .iter() + .map(|&i| g.idx_to_item[i].as_str()) + .collect::>(); + assert_eq!(comp0, vec!["https://slug.social/a", "https://slug.social/b"]); + assert_eq!(comp1, vec!["https://slug.social/c", "https://slug.social/d"]); + } + + #[test] + fn subset_ranking_ranks_within_component_only() { + let mut g = mk_group(); + g.apply_vote(vote(1, "a", "b", 3, 1)); // a > b + g.apply_vote(vote(2, "c", "d", 1, 4)); // d > c + + let (comps, _) = + connected_components_from_voted_pairs(g.idx_to_item.len(), g.voted_pairs.iter().copied()); + assert_eq!(comps.len(), 2); + + // Rank each component and ensure winner is first within that component. + for comp in comps { + let ranked = ranked_items_subset(&g, &comp, 10000, 1e-8); + assert_eq!(ranked.len(), 2); + let names = ranked.iter().map(|r| r.item.as_str()).collect::>(); + if names.contains(&"https://slug.social/a") { + assert_eq!(names[0], "https://slug.social/a"); + } else { + assert_eq!(names[0], "https://slug.social/d"); + } + } + } +} + + +tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ + diff --git a/reducer.rs b/reducer.rs new file mode 100644 index 0000000000000000000000000000000000000000..1ba8030feaf783b066b2e0bba4d2d05659c992f4 --- /dev/null +++ b/reducer.rs @@ -0,0 +1,845 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +use serde::{Deserialize, Serialize}; + +use crate::canonical_path::canonicalize_tag; +use crate::dsl; +use crate::events::{Event, Ingest, ThreadCapability}; +use crate::path_types::ItemId; + +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub enum ScopeId { + Public, + Room(String), +} + +/// Wire `room` field → content scope (`"public"` → [`ScopeId::Public`]). +pub fn scope_from_room_wire(room: &str) -> ScopeId { + let r = room.trim(); + if r.is_empty() || r == "public" { + ScopeId::Public + } else { + ScopeId::Room(r.to_string()) + } +} + +/// Parsed vote data (internal representation). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VoteData { + pub ts: i64, + pub a: ItemId, + pub b: ItemId, + pub ratio_left: i32, + pub ratio_right: i32, + pub body: String, + pub principal: String, + pub delegate: Option, + /// Forum channel where this vote was cast (tag only, not room id). + pub thread_tag: String, +} + +#[derive(Debug, Clone)] +pub struct GroupState { + pub item_to_idx: HashMap, + pub idx_to_item: Vec, + + /// Aggregated directed edge weights: (src_idx, dst_idx) -> weight. + pub edges: HashMap<(usize, usize), f64>, + + /// Unordered pairs that have at least one vote recorded between them (i, + pub dirty: bool, + pub cached_scores: Vec, + pub recent_votes: VecDeque, +} + +impl Default for GroupState { + fn default() -> Self { + Self::new() + } +} + +impl GroupState { + pub fn new() -> Self { + Self { + item_to_idx: HashMap::new(), + idx_to_item: Vec::new(), + edges: HashMap::new(), + voted_pairs: HashSet::new(), + dirty: true, + cached_scores: Vec::new(), + recent_votes: VecDeque::with_capacity(200), + } + } + + fn ensure_item(&mut self, item: &ItemId) -> usize { + if let Some(&idx) = self.item_to_idx.get(item) { + return idx; + } + let idx = self.idx_to_item.len(); + self.idx_to_item.push(item.clone()); + self.item_to_idx.insert(item.clone(), idx); + self.dirty = true; + idx + } + + /// Public test helper: insert an item into the group without a vote (for unit tests). + pub fn ensure_item_pub(&mut self, item: &str) -> usize { + if let Some(canon) = ItemId::parse(item) { + self.ensure_item(&canon) + } else { + // Fallback: treat as raw storage string + let canon = ItemId::opaque(item.to_string()); + self.ensure_item(&canon) + } + } + + fn add_edge_weight(&mut self, src: usize, dst: usize, w: f64) { + if w <= 0.0 { + return; + } + *self.edges.entry((src, dst)).or_insert(0.0) += w; + self.dirty = true; + } + + pub fn apply_vote(&mut self, mut vote: VoteData) { + vote.a = ItemId::parse(vote.a.as_str()).unwrap_or_else(|| vote.a.clone()); + vote.b = ItemId::parse(vote.b.as_str()).unwrap_or_else(|| vote.b.clone()); + vote.thread_tag = canonicalize_tag(&vote.thread_tag); + if vote.ratio_left < 0 { + vote.ratio_left = 0; + } + if vote.ratio_right < 0 { + vote.ratio_right = 0; + } + let a_idx = self.ensure_item(&vote.a); + let b_idx = self.ensure_item(&vote.b); + + let (i, j) = if a_idx < b_idx { (a_idx, b_idx) } else { (b_idx, a_idx) }; + self.voted_pairs.insert((i, j)); + + let w_a = vote.ratio_left as f64; + let w_b = vote.ratio_right as f64; + + self.add_edge_weight(b_idx, a_idx, w_a); + self.add_edge_weight(a_idx, b_idx, w_b); + + self.recent_votes.push_front(vote); + while self.recent_votes.len() > 200 { + self.recent_votes.pop_back(); + } + } +} + +/// Compact rank-history entry stored per item in the ledger. +/// `caused_by` is resolved lazily at query time from `ingests_by_id`. +#[derive(Debug, Clone)] +pub struct RankHistoryEntry { + pub ts: i64, + pub scope_rank: usize, + pub scope_rank_delta: i32, + pub scope_total: usize, + pub global_rank: usize, + pub global_rank_delta: i32, + pub global_total: usize, + pub score: f64, + pub thread: String, + pub post_id: String, +} + +/// Invite link materialized from log events [`crate::events::InviteMinted`] / +/// [`crate::events::InviteRedeemed`] when those are replayed. +/// +/// **`RoomMintInvite` today** stores tokens only in [`crate::state::AppState::invites`] (RAM); +/// they are not appended to the JSONL log, so they disappear on restart. This struct is for +/// replay and any future persisted-mint path, not for the current ephemeral RPC mint. +#[derive(Debug, Clone)] +pub struct ActiveInviteState { + pub room_id: String, + pub capabilities: HashSet, + pub inviter: String, + pub uses_remaining: u32, + pub expires_ts_ms: Option, +} + +#[derive(Clone, Debug)] +pub enum RoomTimelineKind { + RoomCreated { + owner: String, + slug: String, + }, + RoomDeleted { + deleted_by: String, + }, + GrantAdded { + username: String, + granted_by: String, + capabilities: Vec, + }, + GrantRevoked { + username: String, + revoked_by: String, + capabilities: Vec, + }, +} + +#[derive(Clone, Debug)] +pub struct RoomTimelineEntry { + pub ts: i64, + pub kind: RoomTimelineKind, +} + +#[derive(Debug, Clone)] +#[derive(Default)] +pub struct ForumThreadState { + pub last_activity_ts: i64, + /// Username of the most recent person who bumped this thread. + pub last_actor: String, +} + + +#[derive(Debug, Clone, Default)] +pub struct ContentState { + pub ranking_group: GroupState, + pub items: HashSet, + pub item_bodies: HashMap, + /// Parent [`ItemId`] -> direct children. + pub item_children: HashMap>, + /// Per-item vote history (most recent first). + pub item_votes: HashMap>, + /// Per-item ingest references (most recent first). + pub item_snippets: HashMap>, + /// Item path -> threads that mention or vote on this item. + pub item_threads: HashMap>, + /// Per-item rank history, oldest first. + pub rank_history: HashMap>, +} + +#[derive(Debug, Clone)] +pub struct ReducerState { + pub content: HashMap, + + /// (provider, provider_id) -> username + pub users_by_provider: HashMap<(String, String), String>, + /// token_id -> (username, salt, token_hash) + pub tokens_by_id: HashMap, + pub agent_bindings: HashMap, + + + pub ingests_by_id: HashMap, + /// (scope, thread_tag) → ingest ids, newest first. + pub ingests_by_scope_thread: HashMap<(ScopeId, String), VecDeque>, + /// Private room ids (`shortid/slug`) known from [`RoomCreated`]. + pub rooms: HashSet, + /// (scope, thread_tag) → last activity. + pub forum_threads: HashMap<(ScopeId, String), ForumThreadState>, + pub actor_last_post_ts: HashMap, + + /// All ingest IDs in chronological order (oldest first). Used by the feed endpoint. + pub ingests_ordered: Vec, + /// Username → ingest ids in post order (oldest first), for profile pages. + pub posts_by_actor: HashMap>, + /// Ingest ids removed by author redaction (content and thread body omitted; tombstone in UI). + pub redacted_posts: HashSet, + /// Redaction event timestamp (ms) per post id. + pub post_redact_ts: HashMap, + /// room_id → username → capabilities + pub grants: HashMap>>, + /// room_id → chronological room admin lines (for thread UI). + pub room_timeline: HashMap>, + /// Invite token → active invite (absent when fully consumed or never minted). + pub invites: HashMap, +} + +impl ReducerState { + pub fn content_for_scope(&self, scope: &ScopeId) -> Option<&ContentState> { + self.content.get(scope) + } + + /// 0-based chronological index of `post_id` in `(scope, thread_tag)` (forum routes `/t/tag/N`). + pub fn try_thread_post_index_chronological( + &self, + scope: &ScopeId, + thread_tag: &str, + post_id: &str, + ) -> Option { + let tag = canonicalize_tag(thread_tag); + self.ingests_by_scope_thread + .get(&(scope.clone(), tag)) + .and_then(|q| q.iter().rev().position(|pid| pid == post_id)) + } + + pub fn thread_post_index_chronological( + &self, + scope: &ScopeId, + thread_tag: &str, + post_id: &str, + ) -> usize { + self.try_thread_post_index_chronological(scope, thread_tag, post_id) + .expect( + "post_id must appear in ingests_by_scope_thread for this scope and thread", + ) + } + + /// Ingest ids authored by `actor`, oldest first. Unfiltered; use with access checks per ingest. + pub fn posts_by_actor_ids(&self, actor: &str) -> Vec { + self.posts_by_actor + .get(actor) + .map(|q| q.iter().cloned().collect()) + .unwrap_or_default() + } + + /// Same order as [`Self::posts_by_actor_ids`], omitting ingests in scopes the viewer cannot see. + pub fn visible_posts_for_actor(&self, actor: &str, viewer: Option<&str>) -> Vec { + self.posts_by_actor_ids(actor) + .into_iter() + .filter(|id| { + !self.redacted_posts.contains(id) + && self.ingests_by_id.get(id).is_some_and(|ing| { + let scope = scope_from_room_wire(&ing.room_id); + match &scope { + ScopeId::Public => true, + ScopeId::Room(rid) => { + viewer.is_some_and(|u| self.user_has_cap(rid, u, ThreadCapability::View)) + } + } + }) + }) + .collect() + } + + pub fn user_has_cap(&self, room_id: &str, username: &str, cap: ThreadCapability) -> bool { + self.grants + .get(room_id) + .and_then(|t| t.get(username)) + .map(|caps| caps.contains(&cap)) + .unwrap_or(false) + } + + /// Invite link is present, not expired, and has uses left. + pub fn invite_token_active(&self, token: &str, now_ms: i64) -> Option<&ActiveInviteState> { + let inv = self.invites.get(token)?; + if inv.uses_remaining == 0 { + return None; + } + if let Some(exp) = inv.expires_ts_ms { + if now_ms > exp { + return None; + } + } + Some(inv) + } + + pub fn content_for_scope_mut(&mut self, scope: ScopeId) -> &mut ContentState { + self.content.entry(scope).or_default() + } + + pub fn public(&self) -> &ContentState { + self.content.get(&ScopeId::Public).expect("public scope missing") + } + + /// Register parent→child edges for the full ancestor chain. + /// For `a/b/c/d` this creates: `a/b/c→d`, `a/b→a/b/c`, `a→a/b`, `""→a`. + /// Stops early when an intermediate is already registered (its ancestors must be too). + /// @e2bdefa9-a6fa-4725-b0a2-c0b09d95bb20:claudecode:anthropic/claude-opus-4 + fn add_child_edge(content: &mut ContentState, item: &ItemId) { + let mut child = item.clone(); + loop { + let Some(parent) = child.parent() else { break }; + let is_new = content + .item_children + .entry(parent.clone()) + .or_default() + .insert(child); + if !is_new { break; } + child = parent; + } + } + + /// Resolve an item path as a first-class [`ItemId`]. + fn normalize_item(item: &str) -> Option { + ItemId::parse(item) + } + + /// 1-indexed rank of `item` within its connected component in the parent scope. + /// 0 if the item has no votes connecting it to siblings (unranked). + fn scope_rank_of( + group: &GroupState, + item: &ItemId, + item_children: &HashMap>, + ) -> usize { + let scope = match item.parent() { + Some(p) => p, + None => return 0, + }; + let children = match item_children.get(&scope) { + None => return 0, + Some(c) => c, + }; + let &item_global_idx = match group.item_to_idx.get(item) { + None => return 0, + Some(i) => i, + }; + // Map scope children to compact local indices. + let sibling_idxs: Vec = children.iter() + .filter_map(|c| group.item_to_idx.get(c).copied()) + .collect(); + let global_to_local: HashMap = sibling_idxs.iter() + .enumerate().map(|(l, &g)| (g, l)).collect(); + let item_local = match global_to_local.get(&item_global_idx) { + None => return 0, + Some(&l) => l, + }; + // Connected components within scope. + let (comps, _) = crate::ranking::connected_components_from_voted_pairs( + sibling_idxs.len(), + group.voted_pairs.iter().filter_map(|(a, b)| { + Some((global_to_local.get(a).copied()?, global_to_local.get(b).copied()?)) + }), + ); + // Find the component containing this item. + let comp_local = match comps.iter().find(|c| c.contains(&item_local)) { + None => return 0, + Some(c) => c, + }; + let comp_global: Vec = comp_local.iter() + .filter_map(|&l| sibling_idxs.get(l).copied()) + .collect(); + let ranked = crate::ranking::ranked_items_subset(group, &comp_global, 10000, 1e-8); + ranked.iter().position(|r| &r.item == item).map(|i| i + 1).unwrap_or(0) + } + + /// 1-indexed position of `item` in the component-aware global flat list. + /// Components sorted largest-first; items ranked within each component. + /// 0 if the item is not in the ranking group. + fn global_rank_of(group: &GroupState, item: &ItemId) -> usize { + if !group.item_to_idx.contains_key(item) { + return 0; + } + let n = group.idx_to_item.len(); + let (mut comps, _) = crate::ranking::connected_components_from_voted_pairs( + n, group.voted_pairs.iter().copied(), + ); + comps.sort_by_key(|b| std::cmp::Reverse(b.len())); + let mut pos = 1usize; + for comp in &comps { + let ranked = crate::ranking::ranked_items_subset(group, comp, 10000, 1e-8); + for r in &ranked { + if &r.item == item { + return pos; + } + pos += 1; + } + } + 0 + } + + /// Apply one ingest's DSL effects to `content` (votes, items, snippets, rank history). + fn apply_ingest_to_content(content: &mut ContentState, ing: &Ingest) -> Result<(), ()> { + let doc = dsl::parse_full(&ing.raw).map_err(|_| ())?; + let canonical_thread = canonicalize_tag(&ing.thread_tag); + + let voted_items: Vec = doc + .statements + .iter() + .filter_map(|s| { + if let dsl::Stmt::Vote { item1, item2, .. } = s { + Some([item1, item2]) + } else { + None + } + }) + .flat_map(|pair| pair.into_iter()) + .filter_map(|raw| Self::normalize_item(raw)) + .collect::>() + .into_iter() + .collect(); + + let principal = ing.principal.clone(); + let delegate = ing.delegate.clone(); + + let before: HashMap = if !voted_items.is_empty() { + crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8); + voted_items + .iter() + .map(|it| { + ( + it.clone(), + ( + Self::scope_rank_of(&content.ranking_group, it, &content.item_children), + Self::global_rank_of(&content.ranking_group, it), + ), + ) + }) + .collect() + } else { + HashMap::new() + }; + + let mut ingest_items: HashSet = HashSet::new(); + + for stmt in doc.statements { + match stmt { + dsl::Stmt::Item { title, body } => { + let Some(item) = Self::normalize_item(&title) else { + continue; + }; + nav!(content.items, set_elem(item.clone())); + ingest_items.insert(item.clone()); + Self::add_child_edge(content, &item); + + if let Some(body_text) = body { + if !body_text.trim().is_empty() { + nav!(content.item_bodies, keypath(item.clone()), setval(body_text)); + } + } + } + dsl::Stmt::Vote { + item1, + item2, + ratio_left, + ratio_right, + explanation, + } => { + let Some(item_a) = Self::normalize_item(&item1) else { + continue; + }; + let Some(item_b) = Self::normalize_item(&item2) else { + continue; + }; + + let vote = VoteData { + ts: ing.ts, + a: item_a.clone(), + b: item_b.clone(), + ratio_left, + ratio_right, + body: explanation, + principal: principal.clone(), + delegate: delegate.clone(), + thread_tag: canonical_thread.clone(), + }; + + ingest_items.insert(item_a.clone()); + ingest_items.insert(item_b.clone()); + + nav!(content.items, set_elem(item_a.clone())); + nav!(content.items, set_elem(item_b.clone())); + Self::add_child_edge(content, &item_a); + Self::add_child_edge(content, &item_b); + + content.ranking_group.apply_vote(vote.clone()); + + for it in [&item_a, &item_b] { + nav!(content.item_votes, keypath(it.clone()), push_front(vote.clone())); + } + } + dsl::Stmt::Prose { .. } => {} + } + } + + for item in ingest_items.iter() { + nav!(content.item_snippets, keypath(item.clone()), push_front(ing.id.clone())); + } + + for item in ingest_items.iter() { + nav!( + content.item_threads, + keypath(item.clone()), + set_elem(canonical_thread.clone()) + ); + } + + if !voted_items.is_empty() { + crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8); + let thread = canonical_thread.clone(); + for item in &voted_items { + let after_scope = Self::scope_rank_of(&content.ranking_group, item, &content.item_children); + let after_global = Self::global_rank_of(&content.ranking_group, item); + let score = content + .ranking_group + .item_to_idx + .get(item) + .and_then(|&i| content.ranking_group.cached_scores.get(i)) + .copied() + .unwrap_or(0.0); + let (before_scope, before_global) = before.get(item).copied().unwrap_or((0, 0)); + let prev = content.rank_history.get(item).and_then(|v| v.last()); + let scope_delta = if prev.is_none() { + 0 + } else { + after_scope as i32 - before_scope as i32 + }; + let global_delta = if prev.is_none() { + 0 + } else { + after_global as i32 - before_global as i32 + }; + let scope_total = item + .parent() + .and_then(|p| content.item_children.get(&p)) + .map(|s| s.len()) + .unwrap_or(0); + let global_total = content.ranking_group.idx_to_item.len(); + content.rank_history.entry(item.clone()).or_default().push(RankHistoryEntry { + ts: ing.ts, + scope_rank: after_scope, + scope_rank_delta: scope_delta, + scope_total, + global_rank: after_global, + global_rank_delta: global_delta, + global_total, + score, + thread: thread.clone(), + post_id: ing.id.clone(), + }); + } + } + + Ok(()) + } + + fn rebuild_scope_content(&mut self, scope: ScopeId) { + let mut cs = ContentState::default(); + for id in &self.ingests_ordered { + if self.redacted_posts.contains(id) { + continue; + } + let Some(ing) = self.ingests_by_id.get(id) else { + continue; + }; + if scope_from_room_wire(&ing.room_id) != scope { + continue; + } + let _ = Self::apply_ingest_to_content(&mut cs, ing); + } + self.content.insert(scope, cs); + // `ingests_by_scope_thread` is intentionally not rebuilt: tombstoned ids stay in the deque so + // per-post URLs and chronological indices remain stable; only projected garden state resets. + } + + /// Drop all reducer state keyed by a private room id (forum, garden scope, invites, grants). + fn purge_private_room(&mut self, room_id: &str) { + let scope = ScopeId::Room(room_id.to_string()); + self.rooms.remove(room_id); + self.grants.remove(room_id); + self.room_timeline.remove(room_id); + self.invites.retain(|_, inv| inv.room_id != room_id); + self.content.remove(&scope); + self.forum_threads.retain(|(s, _), _| s != &scope); + self.ingests_by_scope_thread.retain(|(s, _), _| s != &scope); + + let mut to_drop: Vec = self + .ingests_by_id + .iter() + .filter(|(_, ing)| ing.room_id.trim() == room_id) + .map(|(id, _)| id.clone()) + .collect(); + to_drop.sort(); + to_drop.dedup(); + for id in to_drop { + if let Some(ing) = self.ingests_by_id.remove(&id) { + self.posts_by_actor + .entry(ing.principal) + .and_modify(|q| { + q.retain(|x| x != &id); + }); + } + self.ingests_ordered.retain(|x| x != &id); + self.redacted_posts.remove(&id); + self.post_redact_ts.remove(&id); + } + } + + pub fn apply_event(&mut self, event: Event) { + match event { + Event::UserRegistered(ur) => { + self.users_by_provider.insert( + (ur.provider.to_lowercase(), ur.provider_id.clone()), + ur.username, + ); + } + Event::TokenIssued(ti) => { + self.tokens_by_id.insert( + ti.token_id.clone(), + (ti.username, ti.salt.clone(), ti.token_hash.clone()), + ); + } + Event::AgentBound(ab) => { + if ab.agent.is_empty() { + return; + } + self.agent_bindings.insert(ab.agent, ab.username); + } + Event::RoomCreated(rc) => { + self.rooms.insert(rc.room_id.clone()); + self.room_timeline + .entry(rc.room_id.clone()) + .or_default() + .push(RoomTimelineEntry { + ts: rc.ts, + kind: RoomTimelineKind::RoomCreated { + owner: rc.owner.clone(), + slug: rc.slug.clone(), + }, + }); + } + Event::RoomDeleted(rd) => { + let room_id = rd.room_id.clone(); + if self.rooms.contains(&room_id) { + self.room_timeline + .entry(room_id.clone()) + .or_default() + .push(RoomTimelineEntry { + ts: rd.ts, + kind: RoomTimelineKind::RoomDeleted { + deleted_by: rd.deleted_by.clone(), + }, + }); + } + self.purge_private_room(&room_id); + } + Event::Ingest(mut ing) => { + ing.thread_tag = canonicalize_tag(&ing.thread_tag); + let room_key = ing.room_id.trim().to_string(); + let scope = scope_from_room_wire(&room_key); + let canonical_thread = ing.thread_tag.clone(); + let scope_thread_key = (scope.clone(), canonical_thread.clone()); + + { + let content = self.content_for_scope_mut(scope.clone()); + if Self::apply_ingest_to_content(content, &ing).is_err() { + eprintln!( + "WARNING: Skipping malformed ingest event {}: parse failed", + ing.id + ); + return; + } + } + + self.ingests_by_id.insert(ing.id.clone(), ing.clone()); + + let ft = self.forum_threads.entry(scope_thread_key.clone()).or_default(); + let prev_ts = ft.last_activity_ts; + if ing.ts > prev_ts { + ft.last_activity_ts = ing.ts; + ft.last_actor = ing.principal.clone(); + } + + nav!(self.ingests_by_scope_thread, keypath(scope_thread_key), push_front(ing.id.clone())); + + nav!(self.ingests_ordered, push_back(ing.id.clone())); + + nav!(self.posts_by_actor, keypath(ing.principal.clone()), push_back(ing.id.clone())); + + nav!(self.actor_last_post_ts, keypath(ing.principal.clone()), setval(ing.ts)); + } + Event::PostRedacted(pr) => { + self.redacted_posts.insert(pr.post_id.clone()); + self.post_redact_ts.insert(pr.post_id.clone(), pr.ts); + let Some(ing) = self.ingests_by_id.get(&pr.post_id).cloned() else { + return; + }; + let scope = scope_from_room_wire(ing.room_id.trim()); + // Rebuilds garden projection only; `ingests_by_scope_thread` is left as-is — see `rebuild_scope_content`. + self.rebuild_scope_content(scope); + } + Event::GrantAdded(ga) => { + let room_id = ga.room_id.clone(); + let caps = self.grants + .entry(ga.room_id) + .or_default() + .entry(ga.username.clone()) + .or_default(); + for cap in ga.capabilities.iter().copied() { + caps.insert(cap); + } + self.room_timeline + .entry(room_id) + .or_default() + .push(RoomTimelineEntry { + ts: ga.ts, + kind: RoomTimelineKind::GrantAdded { + username: ga.username.clone(), + granted_by: ga.granted_by.clone(), + capabilities: ga.capabilities.clone(), + }, + }); + } + Event::GrantRevoked(gr) => { + let room_id = gr.room_id.clone(); + if let Some(room_grants) = self.grants.get_mut(&gr.room_id) { + let username = gr.username.clone(); + if let Some(caps) = room_grants.get_mut(&username) { + for cap in &gr.capabilities { + caps.remove(cap); + } + if caps.is_empty() { + room_grants.remove(&username); + } + } + if room_grants.is_empty() { + self.grants.remove(&gr.room_id); + } + } + self.room_timeline + .entry(room_id) + .or_default() + .push(RoomTimelineEntry { + ts: gr.ts, + kind: RoomTimelineKind::GrantRevoked { + username: gr.username.clone(), + revoked_by: gr.revoked_by.clone(), + capabilities: gr.capabilities.clone(), + }, + }); + } + Event::InviteMinted(im) => { + self.invites.insert( + im.token.clone(), + ActiveInviteState { + room_id: im.room_id.clone(), + capabilities: im.capabilities.iter().copied().collect(), + inviter: im.inviter.clone(), + uses_remaining: im.max_uses, + expires_ts_ms: im.expires_ts_ms, + }, + ); + } + Event::InviteRedeemed(ir) => { + if let Some(inv) = self.invites.get_mut(&ir.token) { + inv.uses_remaining = inv.uses_remaining.saturating_sub(1); + if inv.uses_remaining == 0 { + self.invites.remove(&ir.token); + } + } + } + } + } +} + +impl Default for ReducerState { + fn default() -> Self { + let mut content = HashMap::new(); + content.insert(ScopeId::Public, ContentState::default()); + Self { + content, + users_by_provider: HashMap::new(), + tokens_by_id: HashMap::new(), + agent_bindings: HashMap::new(), + ingests_by_id: HashMap::new(), + ingests_by_scope_thread: HashMap::new(), + rooms: HashSet::new(), + forum_threads: HashMap::new(), + actor_last_post_ts: HashMap::new(), + ingests_ordered: Vec::new(), + posts_by_actor: HashMap::new(), + redacted_posts: HashSet::new(), + post_redact_ts: HashMap::new(), + grants: HashMap::new(), + room_timeline: HashMap::new(), + invites: HashMap::new(), + } + } +} + diff --git a/seed.tdsl b/seed.tdsl new file mode 100644 index 0000000000000000000000000000000000000000..499a615041e3be5987dcb306ede84f4ce936e0be --- /dev/null +++ b/seed.tdsl @@ -0,0 +1,21 @@ +reddit sorter (reddit.sorter.social? sorter.social? reddit.slug.social? +jsonl based. Same stack as slug +fullscreen paginated percentile score of who is the biggest asshole on r/amitheasshole. Click to expand body using js eval +Viewcounters for every site +Login with Reddit oauth? +Streaks? Streak gates? Progressive revelation? +One pair of the day +Use attributes again, for assholery. To distinguish from reddits single dimension +r/sorter_south_park_characters +Is a one shot category that only has the characters. +subs starting with sorter have special priority in sorter index. + +layer above reddit? + + +use websocket based bar i had built in afterlife... + + +claude {{ +What I'd push on hardest: what's the artifact that gets shared? Letterboxd works because every movie has a beautiful URL, every user has a beautiful profile, every list is screenshot-worthy. AITA-but-ranked needs the same. "This person ranks 97th percentile of asshole, beating 4,891 other Reddit posters this month" — presented well, that's a tweet, that's a screenshot, that's how the next thousand users find you. Build the share artifact early. Streaks and progressive revelation are for retention; the share-out is for acquisition; you need both but the acquisition piece is what makes streaks worth designing. +}} diff --git a/ui_action.rs b/ui_action.rs new file mode 100644 index 0000000000000000000000000000000000000000..5e4131dd346a6f80bfe091a9b0cf7902721bc47f --- /dev/null +++ b/ui_action.rs @@ -0,0 +1,270 @@ +//! Browser-only UI commands: JSON in hidden `__rpc__` plus hole fill ([`crate::form_template`]). +//! Not part of [`slug_types::RpcCommand`] (CLI / JSON API). + +use crate::form_template::fill_template_from_form; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use thiserror::Error; + +/// Form field name for the compact JSON template (possibly with `{"$form":"…"}` holes). +pub const UI_RPC_FIELD: &str = "__rpc__"; + +fn default_ui_form_action() -> String { + "/ui".to_string() +} + +/// HTML form / fetch `POST /ui` payload after template fill and deserialization. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum HtmlUiAction { + /// Forum ingest via `POST /ui`. + PostIngest { + room: String, + thread_tag: String, + text: String, + #[serde(default)] + error_target: Option, + #[serde(default)] + form_id: Option, + }, + /// DSL check / validation via `POST /ui`. + CheckIngest { + room: String, + thread_tag: String, + text: String, + #[serde(default)] + error_target: Option, + #[serde(default)] + form_id: Option, + }, + /// Post a pairwise vote from `/vote` (browser compose). + VoteComparePost { + room: String, + thread_tag: String, + left_item: String, + right_item: String, + /// From form fields (string); parsed server-side. + ratio_left: String, + ratio_right: String, + explanation: String, + /// Same-origin path after successful post (JS redirect). + next: String, + /// Pool parent item path, if the vote was initiated from a pool URL. + #[serde(default)] + pool: Option, + #[serde(default = "default_ui_form_action")] + form_action: String, + }, + /// Set or clear the garden HUD pin cookie (`slug_garden_pin`). Response is **`303` + `Set-Cookie`** when submitted as a full-navigation form (`data-navigate="full"`), matching `POST /theme`. + SetGardenPin { + #[serde(default)] + clear: bool, + #[serde(default)] + room_wire: String, + #[serde(default)] + item_storage: Option, + next: String, + /// Must equal the form `action` (usually `/ui`). Used to reject forged requests that POST to another path. + #[serde(default = "default_ui_form_action")] + form_action: String, + }, + /// Resolve children or siblings for an external garden item. + ResolveExternal { + room_wire: String, + item_storage: String, + mode: String, + next: String, + #[serde(default = "default_ui_form_action")] + form_action: String, + }, + /// Author redacts own post via `POST /ui`. + RedactPost { post_id: String }, + /// Morph `#room-members-section` — members list open or collapsed (server-rendered). + SetRoomMembersExpanded { + room_wire: String, + #[serde(default)] + expanded: bool, + }, + /// Delete the private room (Manage only); redirects to `/` on success. + DeleteRoom { room: String }, + /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: "public"` for home). + SetNewThreadComposeExpanded { + room_wire: String, + #[serde(default)] + expanded: bool, + }, + /// Replace a truncated post card with the full body (same thread index). + ExpandPostFull { + room: String, + thread_tag: String, + post_index: usize, + }, + /// Expand a redacted/tombstone post to show stripped body (author view). + ExpandRedactedPost { + room: String, + thread_tag: String, + post_index: usize, + }, + /// Collapse an expanded redacted post back to the tombstone card. + CollapseRedactedPost { + room: String, + thread_tag: String, + post_index: usize, + }, + /// Copy full thread text (CLI `forum show` format) to the clipboard. + CopyThread { + room: String, + thread_tag: String, + copy_btn_id: String, + }, +} + +#[derive(Debug, Error)] +pub enum HtmlUiParseError { + #[error("missing __rpc__ field")] + MissingRpc, + #[error("invalid template json: {0}")] + Template(serde_json::Error), + #[error("invalid ui action: {0}")] + Action(serde_json::Error), +} + +/// Parse `__rpc__` JSON, apply `$form` holes from the rest of the form map, deserialize. +pub fn parse_html_ui_from_form( + form: &HashMap, +) -> Result { + let template = form.get(UI_RPC_FIELD).ok_or(HtmlUiParseError::MissingRpc)?; + let mut hole_map = form.clone(); + hole_map.remove(UI_RPC_FIELD); + let v: Value = + fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?; + serde_json::from_value(v).map_err(HtmlUiParseError::Action) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_post_ingest_with_holes() { + let template = serde_json::json!({ + "action": "post_ingest", + "room": "public", + "thread_tag": {"$form": "thread_tag"}, + "text": {"$form": "text"}, + "error_target": "e", + "form_id": "f", + }); + let mut form = HashMap::new(); + form.insert( + UI_RPC_FIELD.to_string(), + serde_json::to_string(&template).unwrap(), + ); + form.insert("thread_tag".into(), "x".into()); + form.insert("text".into(), "body".into()); + + let a = parse_html_ui_from_form(&form).unwrap(); + assert_eq!( + a, + HtmlUiAction::PostIngest { + room: "public".into(), + thread_tag: "x".into(), + text: "body".into(), + error_target: Some("e".into()), + form_id: Some("f".into()), + } + ); + } + + #[test] + fn expand_post_full_round_trip() { + let template = serde_json::json!({ + "action": "expand_post_full", + "room": "public", + "thread_tag": "demo", + "post_index": 3, + }); + let mut form = HashMap::new(); + form.insert( + UI_RPC_FIELD.to_string(), + serde_json::to_string(&template).unwrap(), + ); + let a = parse_html_ui_from_form(&form).unwrap(); + assert_eq!( + a, + HtmlUiAction::ExpandPostFull { + room: "public".into(), + thread_tag: "demo".into(), + post_index: 3, + } + ); + } + + #[test] + fn set_room_members_expanded_defaults_false() { + let template = serde_json::json!({ + "action": "set_room_members_expanded", + "room_wire": "ab/cd", + }); + let mut form = HashMap::new(); + form.insert( + UI_RPC_FIELD.to_string(), + serde_json::to_string(&template).unwrap(), + ); + let a = parse_html_ui_from_form(&form).unwrap(); + assert_eq!( + a, + HtmlUiAction::SetRoomMembersExpanded { + room_wire: "ab/cd".into(), + expanded: false, + } + ); + } + + #[test] + fn copy_thread_round_trip() { + let template = serde_json::json!({ + "action": "copy_thread", + "room": "public", + "thread_tag": "demo", + "copy_btn_id": "thread-copy-top", + }); + let mut form = HashMap::new(); + form.insert( + UI_RPC_FIELD.to_string(), + serde_json::to_string(&template).unwrap(), + ); + let a = parse_html_ui_from_form(&form).unwrap(); + assert_eq!( + a, + HtmlUiAction::CopyThread { + room: "public".into(), + thread_tag: "demo".into(), + copy_btn_id: "thread-copy-top".into(), + } + ); + } + + #[test] + fn set_new_thread_compose_expanded_true() { + let template = serde_json::json!({ + "action": "set_new_thread_compose_expanded", + "room_wire": "ab/cd", + "expanded": true, + }); + let mut form = HashMap::new(); + form.insert( + UI_RPC_FIELD.to_string(), + serde_json::to_string(&template).unwrap(), + ); + let a = parse_html_ui_from_form(&form).unwrap(); + assert_eq!( + a, + HtmlUiAction::SetNewThreadComposeExpanded { + room_wire: "ab/cd".into(), + expanded: true, + } + ); + } +} diff --git a/vote.rs b/vote.rs new file mode 100644 index 0000000000000000000000000000000000000000..d0ec78cd675eae284d056fb3b8eaf5cc853d6263 --- /dev/null +++ b/vote.rs @@ -0,0 +1,580 @@ +use axum::{ + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode, Uri}, + response::{Html, IntoResponse}, +}; +use axum_extra::extract::cookie::CookieJar; +use maud::html; +use serde::Deserialize; +use serde_json::json; +use std::collections::{HashMap, HashSet}; + +use crate::{ + api::optional_principal, + canonical_path::canonicalize_tag, + form_template::template_json_compact, + html::{ + forum::ThreadNav, + layout_full_bleed_chromeless, + ratio_pct, render_item_body_in_scope, + theme_from_jar, theme_next_from_uri, + user_can_post_room, + ui_action::UI_RPC_FIELD, + JsBuilder, + }, + middleware::canonical_view_url, + path_types::ItemId, + reducer::{ContentState, ScopeId}, + scope_rank::suggest_next_pair_in_pool, + state::AppState, +}; + +use super::{ + access::{content_for_garden_view, room_not_found_page, room_scope_has_garden_content, user_can_view_room}, + item::{item_display_path, login_href_with_next}, +}; + +fn pick_autothread_for_vote_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> String { + let cands: HashSet = content + .item_threads + .get(a) + .into_iter() + .chain(content.item_threads.get(b)) + .flat_map(|s| s.iter().cloned()) + .collect(); + if cands.is_empty() { + return "vote".to_string(); + } + let mut v: Vec = cands.into_iter().collect(); + v.sort(); + canonicalize_tag(&v[0]) +} + +/// Canonical unordered pair: lexicographic by storage string (stable edge identity). +pub(super) fn canonical_edge_items(a: &ItemId, b: &ItemId) -> (ItemId, ItemId) { + let ac = a.clone().normalized_storage(); + let bc = b.clone().normalized_storage(); + if ac.as_str() <= bc.as_str() { + (ac, bc) + } else { + (bc, ac) + } +} + +/// All votes whose endpoints are exactly this unordered pair (unsorted). +pub(super) fn edge_vote_entries_for_pair( + content: &ContentState, + a: &ItemId, + b: &ItemId, +) -> Vec { + let (lo, hi) = canonical_edge_items(a, b); + let lo_s = lo.as_str(); + let hi_s = hi.as_str(); + content + .item_votes + .get(&lo) + .into_iter() + .flat_map(|q| q.iter()) + .filter(|v| { + (v.a.as_str() == lo_s && v.b.as_str() == hi_s) + || (v.a.as_str() == hi_s && v.b.as_str() == lo_s) + }) + .cloned() + .collect() +} + +pub(super) fn ratios_for_compare_page( + v: &crate::reducer::VoteData, + page_left: &ItemId, + page_right: &ItemId, +) -> (i32, i32) { + let pl = page_left.as_str(); + let pr = page_right.as_str(); + match (v.a.as_str(), v.b.as_str()) { + (a, b) if a == pl && b == pr => (v.ratio_left, v.ratio_right), + (a, b) if a == pr && b == pl => (v.ratio_right, v.ratio_left), + _ => (v.ratio_left, v.ratio_right), + } +} + +fn left_share_normalized(ratio_left: i32, ratio_right: i32) -> f64 { + let l = ratio_left.max(0) as f64; + let r = ratio_right.max(0) as f64; + let sum = l + r; + if sum <= 0.0 { + 0.5 + } else { + l / sum + } +} + +/// Stronger preference for **`page_left` first**; ties **newer first**. +pub(super) fn sort_votes_for_compare_display( + mut votes: Vec, + page_left: &ItemId, + page_right: &ItemId, +) -> Vec { + votes.sort_by(|va, vb| { + let (ratio_left_a, ratio_right_a) = ratios_for_compare_page(va, page_left, page_right); + let (ratio_left_b, ratio_right_b) = ratios_for_compare_page(vb, page_left, page_right); + let sa = left_share_normalized(ratio_left_a, ratio_right_a); + let sb = left_share_normalized(ratio_left_b, ratio_right_b); + match sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal) { + std::cmp::Ordering::Equal => vb.ts.cmp(&va.ts), + o => o, + } + }); + votes +} + +/// Number of vote ingests recorded for this unordered pair in `content` (same scope as ranking). +pub(super) fn edge_vote_count_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> usize { + let (lo, hi) = canonical_edge_items(a, b); + let lo_s = lo.as_str(); + let hi_s = hi.as_str(); + content + .item_votes + .get(&lo) + .into_iter() + .flat_map(|q| q.iter()) + .filter(|v| { + (v.a.as_str() == lo_s && v.b.as_str() == hi_s) + || (v.a.as_str() == hi_s && v.b.as_str() == lo_s) + }) + .count() +} + +fn vote_thread_tags_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec { + let set: HashSet = content + .item_threads + .get(a) + .into_iter() + .chain(content.item_threads.get(b)) + .flat_map(|s| s.iter().cloned()) + .collect(); + let mut v: Vec = set.into_iter().collect(); + v.sort(); + v.into_iter().map(|t| canonicalize_tag(&t)).collect() +} + +fn vote_edge_history_markup(content: &ContentState, left: &ItemId, right: &ItemId) -> maud::Markup { + let votes = edge_vote_entries_for_pair(content, left, right); + let votes = sort_votes_for_compare_display(votes, left, right); + let legend_left = item_display_path(left.as_str()); + let legend_right = item_display_path(right.as_str()); + html! { + @if votes.is_empty() { + p class="muted vote-edge-empty" { "no votes on this pair in this scope yet" } + } @else { + h3 class="vote-edge-history-title" { + "votes on this edge" + span class="vote-edge-history-axis muted" { " · " (legend_left) " : " (legend_right) } + } + ul class="vote-edge-history" { + @for v in &votes { + @let (r_left, r_right) = ratios_for_compare_page(v, left, right); + @let pct = ratio_pct(r_left, r_right); + @let row_tip = format!( + "{}:{} counts toward {} (left of bar) vs {} (right of bar); #{} · @{}", + r_left, + r_right, + legend_left, + legend_right, + v.thread_tag, + v.principal, + ); + li class="vote-edge-history-row" title=(row_tip) { + div class="vote-edge-meta" { + span class="vote-edge-ratio" { (format!("{}:{}", r_left, r_right)) } + span class="muted" { " · #" (v.thread_tag) " · @" (v.principal) } + } + div class="ratio-bar vote-edge-bar" aria-hidden="true" { + div class="ratio-left" style={(format!("width: {:.3}%;", pct))} {} + div class="ratio-right" style={(format!("width: {:.3}%;", 100.0 - pct))} {} + } + @if !v.body.trim().is_empty() { + div class="vote-edge-reason muted" { (v.body.trim()) } + } + } + } + } + } + } +} + +/// After a successful vote post: refresh edge history (no in-page preview card). +#[allow(clippy::too_many_arguments)] +pub(crate) async fn vote_compare_post_success_js( + state: &AppState, + nav: &ThreadNav, + _room_wire: &str, + _thread_tag: &str, + left: &ItemId, + right: &ItemId, + pool: Option<&ItemId>, + _post_id: &str, + _post_idx: Option, +) -> String { + let reduced = state.reduced.read().await; + let content = content_for_garden_view(&reduced, &nav.scope()); + let edge_history = vote_edge_history_markup(content, left, right); + let next_pair = suggest_next_vote_pair(content, left, right, pool); + let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref(), pool); + drop(reduced); + JsBuilder::new() + .morph_inner_selector("#vote-edge-history-region", edge_history) + .morph_selector(".vote-compare-nav", nav_markup) + .build() +} + +pub(super) fn vote_compare_href( + nav: &ThreadNav, + left: &ItemId, + right: &ItemId, + thread_override: Option<&str>, + pool: Option<&ItemId>, +) -> String { + let left_dp = left.display_path(); + let right_dp = right.display_path(); + let left_q = urlencoding::encode(&left_dp); + let right_q = urlencoding::encode(&right_dp); + let mut base = format!( + "{}/vote?left={}&right={}", + nav.room_path_prefix_for_vote_compare(), + left_q, + right_q + ); + if let Some(t) = thread_override.filter(|s| !s.is_empty()) { + base = format!("{}&thread={}", base, urlencoding::encode(t)); + } + if let Some(p) = pool { + let pool_dp = p.display_path(); + base = format!("{}&pool={}", base, urlencoding::encode(&pool_dp)); + } + base +} + +pub(super) fn vote_pool_href(nav: &ThreadNav, pool_item_str: &str) -> String { + let display = ItemId::parse(pool_item_str) + .map(|i| i.display_path()) + .unwrap_or_else(|| pool_item_str.to_string()); + format!( + "{}/vote?pool={}", + nav.room_path_prefix_for_vote_compare(), + urlencoding::encode(&display) + ) +} + +fn vote_compare_nav_markup( + nav: &ThreadNav, + next_pair: Option<&(ItemId, ItemId)>, + pool: Option<&ItemId>, +) -> maud::Markup { + let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None, pool)); + html! { + div class="vote-compare-nav" { + @if let Some(href) = &next_pair_href { + a class="vote-compare-next" data-testid="vote-next-pair" href=(href) { "next pair" } + } @else { + span class="vote-compare-next is-disabled" { "no next pair" } + } + } + } +} + +pub(super) fn suggest_next_vote_pair( + content: &ContentState, + current_left: &ItemId, + current_right: &ItemId, + pool_parent: Option<&ItemId>, +) -> Option<(ItemId, ItemId)> { + let pool: Vec = if let Some(parent) = pool_parent { + content + .item_children + .get(parent) + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default() + } else if current_left.parent().as_ref().map(|p| p.as_str()) + == current_right.parent().as_ref().map(|p| p.as_str()) + { + current_left + .parent() + .and_then(|parent| { + content + .item_children + .get(&parent.normalized_storage()) + .cloned() + }) + .map(|children| children.into_iter().collect()) + .unwrap_or_default() + } else { + Vec::new() + }; + if pool.len() < 2 { + return None; + } + suggest_next_pair_in_pool( + &content.ranking_group, + &pool, + Some((current_left, current_right)), + ) +} + +pub(super) fn vote_compare_item_card( + nav: &ThreadNav, + item: &ItemId, + body: Option<&String>, + side_class: &str, + item_bodies: Option<&HashMap>, +) -> maud::Markup { + html! { + div class=(format!("vote-compare-side {side_class}")) { + a class=(format!("vote-compare-item {side_class}")) href=(nav.garden_item_href(item)) { + code { (item_display_path(item.as_str())) } + } + @if let Some(body) = body.filter(|b| !b.trim().is_empty()) { + div class="vote-compare-item-body" { + (render_item_body_in_scope( + body, + nav.garden_root_url(), + item_bodies, + )) + } + } @else { + p class="muted vote-compare-item-body-empty" { "no body yet" } + } + } + } +} +#[derive(Debug, Deserialize)] +pub struct VoteCompareQuery { + #[serde(default)] + pub left: Option, + #[serde(default)] + pub right: Option, + #[serde(default)] + pub thread: Option, + #[serde(default)] + pub pool: Option, +} + +/// Public pairwise vote UI — `/vote?left=&right=&thread=`. +pub async fn vote_compare_page( + State(state): State, + Query(q): Query, + headers: HeaderMap, + jar: CookieJar, + uri: Uri, +) -> impl IntoResponse { + let nav = ThreadNav::public(); + vote_compare_inner(state, q, nav, headers, jar, uri).await +} + +pub async fn room_vote_compare_page( + State(state): State, + Path(room_key): Path, + Query(q): Query, + headers: HeaderMap, + jar: CookieJar, + uri: Uri, +) -> impl IntoResponse { + let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else { + return (StatusCode::NOT_FOUND, "bad room path").into_response(); + }; + let Some(nav) = ThreadNav::from_room_id(&room_id) else { + return (StatusCode::NOT_FOUND, "bad room path").into_response(); + }; + let reduced = state.reduced.read().await; + let user = optional_principal(&headers, &jar, &reduced); + if !user_can_view_room(&reduced, &room_id, user.as_deref()) { + drop(reduced); + return room_not_found_page(&jar, &uri).into_response(); + } + if !room_scope_has_garden_content(&reduced, &nav) { + drop(reduced); + return room_not_found_page(&jar, &uri).into_response(); + } + drop(reduced); + vote_compare_inner(state, q, nav, headers, jar, uri).await +} + +async fn vote_compare_inner( + state: AppState, + q: VoteCompareQuery, + nav: ThreadNav, + headers: HeaderMap, + jar: CookieJar, + uri: Uri, +) -> axum::response::Response { + let pool_id: Option = match q.pool.as_deref() { + Some(p) => match ItemId::parse(p.trim()) { + Some(i) => Some(i.normalized_storage()), + None => return (StatusCode::BAD_REQUEST, "bad pool item").into_response(), + }, + None => None, + }; + + let (left, right) = match (q.left.as_deref(), q.right.as_deref()) { + (Some(l), Some(r)) => { + let left = match ItemId::parse(l.trim()) { + Some(i) => i.normalized_storage(), + None => return (StatusCode::NOT_FOUND, "bad left item").into_response(), + }; + let right = match ItemId::parse(r.trim()) { + Some(i) => i.normalized_storage(), + None => return (StatusCode::NOT_FOUND, "bad right item").into_response(), + }; + if left == right { + return (StatusCode::BAD_REQUEST, "items must differ").into_response(); + } + (left, right) + } + (None, None) => { + let Some(pool) = pool_id.as_ref() else { + return (StatusCode::BAD_REQUEST, "provide left+right or pool").into_response(); + }; + let reduced = state.reduced.read().await; + let content = content_for_garden_view(&reduced, &nav.scope()); + let children: Vec = content + .item_children + .get(pool) + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default(); + if children.len() < 2 { + drop(reduced); + return (StatusCode::BAD_REQUEST, "pool has fewer than 2 children to compare").into_response(); + } + let pair = suggest_next_pair_in_pool(&content.ranking_group, &children, None); + drop(reduced); + match pair { + Some(p) => p, + None => return (StatusCode::BAD_REQUEST, "no pairs available in pool").into_response(), + } + } + _ => return (StatusCode::BAD_REQUEST, "provide both left and right, or just pool").into_response(), + }; + + let reduced = state.reduced.read().await; + let content = content_for_garden_view(&reduced, &nav.scope()); + let viewer = optional_principal(&headers, &jar, &reduced); + let can_post = match &nav.scope() { + ScopeId::Public => viewer.is_some(), + ScopeId::Room(rid) => viewer + .as_ref() + .map(|u| user_can_post_room(&reduced, rid, u)) + .unwrap_or(false), + }; + let auto_thread = q + .thread + .as_ref() + .map(|t| canonicalize_tag(t)) + .filter(|t| !t.is_empty()) + .unwrap_or_else(|| pick_autothread_for_vote_pair(content, &left, &right)); + let thread_tags = vote_thread_tags_for_pair(content, &left, &right); + let edge_history = vote_edge_history_markup(content, &left, &right); + let left_body = content.item_bodies.get(&left).cloned(); + let right_body = content.item_bodies.get(&right).cloned(); + let item_bodies_for_cards = content.item_bodies.clone(); + let next_pair = suggest_next_vote_pair(content, &left, &right, pool_id.as_ref()); + drop(reduced); + + let title = format!( + "vote — {} vs {}", + item_display_path(left.as_str()), + item_display_path(right.as_str()) + ); + let next_path = uri + .path_and_query() + .map(|pq| pq.as_str().to_string()) + .unwrap_or_else(|| "/vote".into()); + + let rpc_json = template_json_compact(&json!({ + "action": "vote_compare_post", + "room": nav.room_wire, + "thread_tag": {"$form": "thread_tag"}, + "left_item": left.as_str(), + "right_item": right.as_str(), + "ratio_left": {"$form": "ratio_left"}, + "ratio_right": {"$form": "ratio_right"}, + "explanation": {"$form": "explanation"}, + "next": next_path, + "pool": pool_id.as_ref().map(|p| p.as_str()), + "form_action": "/ui", + })) + .expect("vote compare rpc json"); + + let body = html! { + section class="vote-compare-shell" { + h2 { "compare" } + div class="vote-compare-pair" { + (vote_compare_item_card( + &nav, + &left, + left_body.as_ref(), + "vote-compare-left", + Some(&item_bodies_for_cards), + )) + span class="vote-compare-vs" { "vs" } + (vote_compare_item_card( + &nav, + &right, + right_body.as_ref(), + "vote-compare-right", + Some(&item_bodies_for_cards), + )) + } + (vote_compare_nav_markup(&nav, next_pair.as_ref(), pool_id.as_ref())) + div id="vote-edge-history-region" { + (edge_history) + } + @if can_post { + form id="vote-compare-form" method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(rpc_json); + div class="vote-thread-picker" { + label class="vote-thread-picker-label" { "thread" } + select id="vote-thread-select" name="thread_tag" aria-label="Thread to post vote into" { + @if thread_tags.is_empty() { + option value="vote" selected { "#vote" } + } + @for t in &thread_tags { + @if *t == auto_thread { + option value=(t) selected { "#" (t) } + } @else { + option value=(t) { "#" (t) } + } + } + } + } + input type="hidden" name="ratio_left" id="vote-ratio-left" value="50"; + input type="hidden" name="ratio_right" id="vote-ratio-right" value="50"; + label class="vote-compare-slider-label" { + span id="vote-slider-left-label" { (item_display_path(left.as_str())) } + input type="range" id="vote-preference-slider" min="0" max="100" value="50" + aria-valuemin="0" aria-valuemax="100"; + span id="vote-slider-right-label" { (item_display_path(right.as_str())) } + } + label class="vote-explain-label" { "reason (required)" } + textarea name="explanation" id="vote-explain" rows="5" placeholder="why this split?" required {} + div id="vote-compare-errors" {} + p { button type="submit" { "post vote" } } + } + } @else { + p class="muted" { a href=(login_href_with_next(&next_path)) { "log in" } " to post this vote." } + } + } + }; + + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + + let page = layout_full_bleed_chromeless( + &title, + "view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen", + body, + Some(view_count), + theme_from_jar(&jar), + &theme_next_from_uri(&uri), + ); + Html(page.into_string()).into_response() +} +