diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index c4ab9d65c7b3cd42a5b4d093ba429993c101e9a8..82b2aa51d21ada1d0d849d3ddfc3a81e4241d861 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -9,7 +9,9 @@ use crate::{
html::{js_string_literal, ranking_panel, JsBuilder},
parser::parse_reddit_url,
parser_render::navigate_panel,
- state::AppState,
+ path_types::ItemId,
+ reddit::ensure_partial_tree,
+ state::{parse_item_param, AppState},
ui_action::{parse_html_ui_from_form, HtmlUiAction},
};
@@ -25,6 +27,10 @@ fn ui_js_warn(msg: &str) -> Response {
.unwrap()
}
+fn parent_from_scope(scope: &str) -> ItemId {
+ parse_item_param(scope)
+}
+
pub async fn post_ui_html(
State(state): State,
Form(form): Form>,
@@ -42,24 +48,36 @@ pub async fn post_ui_html(
ratio_right,
scope,
} => {
+ let parent = parent_from_scope(&scope);
if let Err(e) = state
- .record_vote(&scope, &a, &b, ratio_left, ratio_right)
+ .record_vote(&parent, &a, &b, ratio_left, ratio_right)
.await
{
return ui_js_warn(&e).into_response();
}
- let scope = crate::state::normalize_scope(&scope);
- let groups = state.groups.read().await;
+ let tree = state.tree.read().await;
let empty = crate::reducer::GroupState::new();
- let group = groups.get(&scope).unwrap_or(&empty);
- let panel = ranking_panel(&scope, group);
+ let group = tree
+ .get(&parent)
+ .map(|n| &n.local_ranking)
+ .unwrap_or(&empty);
+ let panel = ranking_panel(&parent, group);
JsBuilder::new()
.morph_selector("#ranking-panel", panel)
.into_response()
}
HtmlUiAction::ParseQuery { query } => match parse_reddit_url(&query) {
- Ok(subreddit) => {
- let dest = format!("/?sub={subreddit}");
+ Ok(item) => {
+ {
+ let mut tree = state.tree.write().await;
+ ensure_partial_tree(&mut tree, &item);
+ }
+ let _ = state.ensure_node(&item).await;
+ let dest = if item.is_root() {
+ "/".to_string()
+ } else {
+ format!("/?item={}", item.as_str())
+ };
JsBuilder::new()
.raw(&format!(
"window.location.href={};",
diff --git a/server/src/events.rs b/server/src/events.rs
index a862370fc840ffe02184a11c578e18239cc9474d..ed5be6b13b9d46e838831d6ce0f96f569b401730 100644
--- a/server/src/events.rs
+++ b/server/src/events.rs
@@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize};
pub enum Event {
/// Page view recorded (path → counter in views.json).
ViewRecorded { path: String, ts: i64 },
- /// Pairwise comparison vote (replayed into the scope's [`crate::reducer::GroupState`] on boot).
- /// `scope` is the ranking subject (e.g. a subreddit); empty string is the default/global scope.
+ /// Pairwise comparison vote (replayed into the parent node's [`crate::reducer::GroupState`] on boot).
+ /// `scope` is the parent [`crate::path_types::ItemId`] string; empty string is the tree root.
VoteRecorded {
ts: i64,
a: String,
@@ -16,4 +16,6 @@ pub enum Event {
#[serde(default)]
scope: String,
},
+ /// Register a node path in the fractal tree (no external fetch).
+ NodeEnsured { id: String },
}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 9650d333d29c4ac94ceb407aee3ee00399c7f40b..c973cb718ac74b95570dabea76e24459417790b9 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -12,9 +12,10 @@ use serde::Deserialize;
use crate::{
form_template::template_json_compact,
parser_render::navigate_panel,
+ path_types::ItemId,
ranking::{top_bottom, RankedItem},
- reducer::GroupState,
- state::{normalize_scope, AppState},
+ reducer::{GroupState, NodeState},
+ state::{parse_item_param, AppState},
ui_action::UI_RPC_FIELD,
};
@@ -216,6 +217,48 @@ fn layout(title: &str, body: Markup, views: u64, theme: &str, theme_next: &str)
}
}
+fn item_href(id: &ItemId) -> String {
+ if id.is_root() {
+ "/".to_string()
+ } else {
+ format!("/?item={}", id.as_str())
+ }
+}
+
+fn segment_label(seg: &str) -> &str {
+ seg
+}
+
+/// Generic breadcrumb trail from an [`ItemId`] path.
+pub fn breadcrumb_path(item: &ItemId) -> Markup {
+ html! {
+ nav class="breadcrumbs" aria-label="Breadcrumb" {
+ a href="/" { "Internet" }
+ @for path in item.breadcrumb_paths() {
+ @let seg = path.segments().last().map_or("", |v| *v);
+ span class="separator" { " / " }
+ a href=(item_href(&path)) { (segment_label(seg)) }
+ }
+ }
+ }
+}
+
+fn entity_panel(node: &NodeState) -> Markup {
+ html! {
+ @if let Some(data) = &node.data {
+ section id="entity-panel" class="demo-panel entity-card" {
+ h2 { (data.title) }
+ @if let Some(author) = &data.author {
+ p class="muted small" { "by " (author) }
+ }
+ @if let Some(body) = &data.body_html {
+ div class="entity-body" { (maud::PreEscaped(body)) }
+ }
+ }
+ }
+ }
+}
+
fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {
html! {
@if !items.is_empty() {
@@ -224,7 +267,9 @@ fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {
@for (i, r) in items.iter().enumerate() {
li {
span class="rank-num" { (start_rank + i) ". " }
- strong { (r.item.as_str()) }
+ a href=(item_href(&r.item)) {
+ strong { (display_label(&r.item)) }
+ }
span class="muted" {
" — "
({ format!("{:.1}%", r.score * 100.0) })
@@ -236,23 +281,30 @@ fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {
}
}
-pub fn ranking_panel(scope: &str, group: &GroupState) -> Markup {
+fn display_label(id: &ItemId) -> String {
+ id.segments()
+ .last()
+ .map_or("Internet", |v| *v)
+ .to_string()
+}
+
+pub fn ranking_panel(item: &ItemId, group: &GroupState) -> Markup {
let total = group.idx_to_item.len();
let (top, bottom) = top_bottom(group, 8);
html! {
section id="ranking-panel" class="demo-panel" {
h2 {
"Ranking"
- @if !scope.is_empty() {
- " — " span class="scope-name" { "r/" (scope) }
+ @if !item.is_root() {
+ " — " span class="scope-name" { (item.as_str()) }
}
}
@if total == 0 {
p class="muted" {
- @if scope.is_empty() {
+ @if item.is_root() {
"No votes yet — compare two items below."
} @else {
- "No votes yet for r/" (scope) " — compare two items below to start the ranking."
+ "No votes yet for " (item.as_str()) " — compare two items below to start the ranking."
}
}
} @else {
@@ -266,7 +318,8 @@ pub fn ranking_panel(scope: &str, group: &GroupState) -> Markup {
}
}
-pub fn vote_panel(scope: &str) -> Markup {
+pub fn vote_panel(parent: &ItemId) -> Markup {
+ let parent_str = parent.as_str();
let rpc = template_json_compact(&serde_json::json!({
"action": "record_vote",
"a": {"$form": "item_a"},
@@ -280,16 +333,17 @@ pub fn vote_panel(scope: &str) -> Markup {
section id="vote-panel" class="demo-panel" {
h2 { "Compare" }
p class="muted small" {
- @if scope.is_empty() {
+ @if parent.is_root() {
"Left item wins at 2:1. Votes append to the JSONL log and update rank centrality."
} @else {
- "Ranking " span class="scope-name" { "r/" (scope) }
+ "Ranking children of "
+ span class="scope-name" { (parent_str) }
". Left item wins at 2:1; each vote updates this ranking."
}
}
form method="post" action="/ui" id="vote-form" {
input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
- input type="hidden" name="scope" value=(scope);
+ input type="hidden" name="scope" value=(parent_str);
div class="vote-fields" {
label {
"Left (wins) "
@@ -329,17 +383,30 @@ pub async fn home(
let views = state.views.get_views(&path);
let theme = theme_from_jar(&jar);
let theme_next = theme_next_from_uri(&uri);
- let scope = normalize_scope(&query_param(&uri, "sub").unwrap_or_default());
- let groups = state.groups.read().await;
- let empty = GroupState::new();
- let group = groups.get(&scope).unwrap_or(&empty);
+ let item_raw = query_param(&uri, "item")
+ .or_else(|| query_param(&uri, "sub").map(|sub| {
+ if sub.is_empty() {
+ String::new()
+ } else {
+ format!("reddit.com/r/{sub}")
+ }
+ }))
+ .unwrap_or_default();
+ let item = parse_item_param(&item_raw);
+
+ let tree = state.tree.read().await;
+ let empty_node = NodeState::default();
+ let node = tree.get(&item).unwrap_or(&empty_node);
+ let group = &node.local_ranking;
let body = html! {
h1 { "sorter2" }
+ (breadcrumb_path(&item))
(navigate_panel("", None))
- (vote_panel(&scope))
- (ranking_panel(&scope, group))
+ (entity_panel(node))
+ (vote_panel(&item))
+ (ranking_panel(&item, group))
};
layout("sorter2", body, views, theme, &theme_next)
}
diff --git a/server/src/journal.rs b/server/src/journal.rs
new file mode 100644
index 0000000000000000000000000000000000000000..b02ca025683621470ffdf8cd85cf9b85c56d024d
--- /dev/null
+++ b/server/src/journal.rs
@@ -0,0 +1,89 @@
+use std::sync::Arc;
+
+use tokio::sync::{mpsc, oneshot, RwLock};
+
+use crate::{
+ event_log::EventLog,
+ events::Event,
+ path_types::ItemId,
+ reducer::{GlobalTree, VoteData},
+};
+
+pub struct JournalCommand {
+ pub parent: ItemId,
+ pub vote: VoteData,
+ pub event: Event,
+ pub reply: oneshot::Sender>,
+}
+
+#[derive(Clone)]
+pub struct JournalClient {
+ tx: mpsc::Sender,
+}
+
+impl JournalClient {
+ pub fn spawn(tree: Arc>, event_log: Arc) -> Self {
+ let (tx, rx) = mpsc::channel(64);
+ tokio::spawn(journal_worker(rx, tree, event_log));
+ Self { tx }
+ }
+
+ pub async fn record_vote(
+ &self,
+ parent: ItemId,
+ vote: VoteData,
+ event: Event,
+ ) -> Result<(), String> {
+ let (reply, rx) = oneshot::channel();
+ self.tx
+ .send(JournalCommand {
+ parent,
+ vote,
+ event,
+ reply,
+ })
+ .await
+ .map_err(|_| "journal worker stopped".to_string())?;
+ rx.await
+ .map_err(|_| "journal worker stopped".to_string())?
+ }
+}
+
+async fn journal_worker(
+ mut rx: mpsc::Receiver,
+ tree: Arc>,
+ event_log: Arc,
+) {
+ while let Some(first) = rx.recv().await {
+ let mut batch = vec![first];
+ while let Ok(more) = rx.try_recv() {
+ batch.push(more);
+ }
+
+ let mut disk_err: Option = None;
+ for cmd in &batch {
+ if let Err(e) = event_log.append(&cmd.event).await {
+ disk_err = Some(e.to_string());
+ break;
+ }
+ }
+
+ if let Some(err) = disk_err {
+ for cmd in batch {
+ let _ = cmd.reply.send(Err(err.clone()));
+ }
+ continue;
+ }
+
+ {
+ let mut w = tree.write().await;
+ for cmd in &batch {
+ w.apply_vote(&cmd.parent, cmd.vote.clone());
+ }
+ }
+
+ for cmd in batch {
+ let _ = cmd.reply.send(Ok(()));
+ }
+ }
+}
diff --git a/server/src/lib.rs b/server/src/lib.rs
index de8bca48cbf689cad22337883e7791966e7c4919..14e7cfbc38feb5d07aff859b64bce6e2ec45cf91 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -7,8 +7,9 @@ pub mod parser;
pub mod parser_render;
pub mod path_types;
pub mod ranking;
+pub mod reddit;
pub mod reducer;
-pub mod settlement;
+pub mod journal;
pub mod state;
pub mod ui_action;
pub mod views;
diff --git a/server/src/parser.rs b/server/src/parser.rs
index 50571a59f0d3ece1e2538f88e00ef46ec40ea545..51aa0e0f982546aab68bd5e1ca0cb726e88b3f50 100644
--- a/server/src/parser.rs
+++ b/server/src/parser.rs
@@ -1,47 +1,18 @@
-//! Extract a subreddit name from a pasted Reddit URL or path.
+//! Extract a canonical [`crate::path_types::ItemId`] from a pasted Reddit URL or path.
-pub fn parse_reddit_url(query: &str) -> Result {
+use crate::path_types::ItemId;
+
+pub fn parse_reddit_url(query: &str) -> Result {
let q = query.trim();
if q.is_empty() {
return Err("Paste a Reddit URL or r/subreddit path".into());
}
- if let Some(sub) = subreddit_after_prefix(q, "r/") {
- return Ok(sub);
- }
-
- if let Some(sub) = subreddit_from_path_segment(q, "/r/") {
- return Ok(sub);
+ if let Some(id) = ItemId::from_url(q) {
+ return Ok(id);
}
- Err("Could not find a subreddit in that URL".into())
-}
-
-fn subreddit_after_prefix(text: &str, prefix: &str) -> Option {
- let rest = text.strip_prefix(prefix)?;
- let sub = rest.split(['/', '?', '#']).next()?.trim();
- valid_subreddit(sub)
-}
-
-fn subreddit_from_path_segment(text: &str, needle: &str) -> Option {
- let idx = text.find(needle)?;
- let rest = &text[idx + needle.len()..];
- let sub = rest.split(['/', '?', '#']).next()?.trim();
- valid_subreddit(sub)
-}
-
-fn valid_subreddit(name: &str) -> Option {
- if name.is_empty() {
- return None;
- }
- if name
- .chars()
- .all(|c| c.is_ascii_alphanumeric() || c == '_')
- {
- Some(name.to_ascii_lowercase())
- } else {
- None
- }
+ Err("Could not parse that Reddit URL".into())
}
#[cfg(test)]
@@ -50,34 +21,37 @@ mod tests {
#[test]
fn parses_short_path() {
- assert_eq!(parse_reddit_url("r/rust").unwrap(), "rust");
- }
-
- #[test]
- fn parses_path_with_trailing_slash() {
- assert_eq!(parse_reddit_url("r/rust/").unwrap(), "rust");
+ assert_eq!(
+ parse_reddit_url("r/rust").unwrap().as_str(),
+ "reddit.com/r/rust"
+ );
}
#[test]
fn parses_full_url() {
assert_eq!(
- parse_reddit_url("https://www.reddit.com/r/programming/hot").unwrap(),
- "programming"
+ parse_reddit_url("https://www.reddit.com/r/programming/hot")
+ .unwrap()
+ .as_str(),
+ "reddit.com/r/programming"
);
}
#[test]
- fn parses_url_without_scheme() {
+ fn parses_post_url() {
+ let id = parse_reddit_url(
+ "https://old.reddit.com/r/AmItheAsshole/comments/1trnvdl/aita_for_cancelling/",
+ )
+ .unwrap();
assert_eq!(
- parse_reddit_url("reddit.com/r/AskReddit").unwrap(),
- "askreddit"
+ id.as_str(),
+ "reddit.com/r/amitheasshole/comments/1trnvdl"
);
}
#[test]
fn rejects_empty() {
assert!(parse_reddit_url("").is_err());
- assert!(parse_reddit_url(" ").is_err());
}
#[test]
diff --git a/server/src/parser_render.rs b/server/src/parser_render.rs
index f2341afe21476b689a536137798d97277211a962..acf2e7403f4238291677ef0c79d5766302ea78cf 100644
--- a/server/src/parser_render.rs
+++ b/server/src/parser_render.rs
@@ -21,7 +21,7 @@ pub fn navigate_panel(query: &str, error: Option<&str>) -> Markup {
p class="muted small" {
"Paste a Reddit URL or "
code { "r/subreddit" }
- " path, then click Go to rank that subreddit."
+ " path. Breadcrumb links drill down the tree; rankings apply to each node's children."
}
form method="post" action="/ui" id="parser-form" {
textarea
diff --git a/server/src/path_types.rs b/server/src/path_types.rs
index 1cdc96a25b1954f11aaf955203e2b9907b578366..b5c41444f7bcd4d8d289ed3af464bc3f14d0df99 100644
--- a/server/src/path_types.rs
+++ b/server/src/path_types.rs
@@ -1,11 +1,13 @@
use serde::{Deserialize, Serialize};
use std::fmt;
-/// Stable item key for votes and rankings (opaque string for now).
-#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+/// Canonical hierarchical identity for any URL/path in the fractal tree.
+#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
pub struct ItemId(String);
impl ItemId {
+ /// Parse an already-canonical path (no URL normalization). Empty string is invalid here;
+ /// use [`Self::root`] for the tree root.
pub fn parse(s: &str) -> Option {
let t = s.trim();
if t.is_empty() {
@@ -14,13 +16,170 @@ impl ItemId {
Some(Self(t.to_string()))
}
+ /// Build an opaque item key (legacy demo votes, non-URL items).
pub fn opaque(s: impl Into) -> Self {
Self(s.into())
}
+ /// Root of the internet tree (empty path).
+ pub fn root() -> Self {
+ Self(String::new())
+ }
+
+ pub fn is_root(&self) -> bool {
+ self.0.is_empty()
+ }
+
pub fn as_str(&self) -> &str {
&self.0
}
+
+ /// Creates a canonical ID from a raw URL or path. Normalizes domains and
+ /// trims tracking query params.
+ pub fn from_url(raw_url: &str) -> Option {
+ Self::canonicalize(raw_url).map(Self)
+ }
+
+ /// Map legacy scope keys (`""`, `"rust"`) to fractal parent nodes.
+ pub fn from_legacy_scope(raw: &str) -> Self {
+ let s = raw.trim();
+ if s.is_empty() {
+ return Self::root();
+ }
+ Self(format!("reddit.com/r/{s}"))
+ }
+
+ /// Extract the parent, e.g. `reddit.com/r/aww/comments/1trnvdl` →
+ /// `reddit.com/r/aww`.
+ pub fn parent(&self) -> Option {
+ if self.0.is_empty() {
+ return None;
+ }
+
+ let parts: Vec<&str> = self.0.trim_end_matches('/').split('/').collect();
+ if parts.len() <= 1 {
+ return None;
+ }
+
+ if self.0.contains("/comments/") {
+ return Some(Self(parts[..parts.len().saturating_sub(2)].join("/")));
+ }
+
+ Some(Self(parts[..parts.len() - 1].join("/")))
+ }
+
+ pub fn segments(&self) -> Vec<&str> {
+ self.0.split('/').filter(|s| !s.is_empty()).collect()
+ }
+
+ /// Cumulative paths for breadcrumb rendering, e.g.
+ /// `reddit.com/r/movies` → `["reddit.com", "reddit.com/r", "reddit.com/r/movies"]`.
+ pub fn breadcrumb_paths(&self) -> Vec {
+ let segs = self.segments();
+ let mut paths = Vec::with_capacity(segs.len());
+ let mut current = String::new();
+ for seg in segs {
+ if current.is_empty() {
+ current = seg.to_string();
+ } else {
+ current.push('/');
+ current.push_str(seg);
+ }
+ paths.push(ItemId(current.clone()));
+ }
+ paths
+ }
+
+ fn canonicalize(raw: &str) -> Option {
+ let s = raw.trim();
+ if s.is_empty() {
+ return None;
+ }
+
+ let owned = if let Some(rest) = s.strip_prefix("r/") {
+ format!("reddit.com/r/{rest}")
+ } else if let Some(rest) = s.strip_prefix("/r/") {
+ format!("reddit.com/r/{rest}")
+ } else {
+ s.to_string()
+ };
+
+ let (host_path, _query) = split_query(&owned);
+ let host_path = host_path.trim_end_matches('/');
+
+ let path = if host_path.contains("://") {
+ parse_url_host_path(host_path)?
+ } else if host_path.starts_with("reddit.com") || host_path.starts_with("www.reddit.com") {
+ normalize_reddit_host_path(host_path)
+ } else if host_path.contains('/') {
+ host_path.to_string()
+ } else {
+ return None;
+ };
+
+ Some(normalize_reddit_path(&path))
+ }
+}
+
+fn split_query(s: &str) -> (&str, Option<&str>) {
+ if let Some((path, q)) = s.split_once('?') {
+ (path, Some(q))
+ } else {
+ (s, None)
+ }
+}
+
+fn parse_url_host_path(url: &str) -> Option {
+ let rest = url
+ .strip_prefix("https://")
+ .or_else(|| url.strip_prefix("http://"))
+ .unwrap_or(url);
+ let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
+ let host = normalize_host(host);
+ if path.is_empty() {
+ Some(host)
+ } else {
+ Some(format!("{host}/{path}"))
+ }
+}
+
+fn normalize_host(host: &str) -> String {
+ let h = host
+ .strip_prefix("www.")
+ .unwrap_or(host)
+ .to_ascii_lowercase();
+ if h == "old.reddit.com" || h == "new.reddit.com" || h == "reddit.com" {
+ "reddit.com".to_string()
+ } else {
+ h
+ }
+}
+
+fn normalize_reddit_host_path(s: &str) -> String {
+ let (host, path) = s.split_once('/').unwrap_or((s, ""));
+ let host = normalize_host(host);
+ if path.is_empty() {
+ host
+ } else {
+ format!("{host}/{path}")
+ }
+}
+
+/// Lowercase subreddit segment, drop listing suffixes, drop title slug after post id.
+fn normalize_reddit_path(path: &str) -> String {
+ let mut parts: Vec = path.split('/').map(str::to_string).collect();
+ if parts.len() >= 3 && parts[1] == "r" {
+ parts[2] = parts[2].to_ascii_lowercase();
+ }
+ if let Some(i) = parts.iter().position(|p| p == "comments") {
+ if parts.len() > i + 2 {
+ parts.truncate(i + 2);
+ }
+ } else if parts.len() > 3 && parts.get(1).map(|s| s.as_str()) == Some("r") {
+ // reddit.com/r/{sub}/hot → reddit.com/r/{sub}
+ parts.truncate(3);
+ }
+ parts.join("/")
}
impl fmt::Display for ItemId {
@@ -28,3 +187,69 @@ impl fmt::Display for ItemId {
f.write_str(&self.0)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn from_url_normalizes_reddit_domains() {
+ let id = ItemId::from_url(
+ "https://old.reddit.com/r/AmItheAsshole/comments/1trnvdl/aita_for_cancelling/",
+ )
+ .unwrap();
+ assert_eq!(
+ id.as_str(),
+ "reddit.com/r/amitheasshole/comments/1trnvdl"
+ );
+ }
+
+ #[test]
+ fn from_url_strips_query() {
+ let id = ItemId::from_url("https://www.reddit.com/r/rust/?sort=top").unwrap();
+ assert_eq!(id.as_str(), "reddit.com/r/rust");
+ }
+
+ #[test]
+ fn from_url_short_path() {
+ assert_eq!(
+ ItemId::from_url("r/rust").unwrap().as_str(),
+ "reddit.com/r/rust"
+ );
+ }
+
+ #[test]
+ fn parent_of_post_is_subreddit() {
+ let id = ItemId::parse("reddit.com/r/aww/comments/1trnvdl").unwrap();
+ assert_eq!(
+ id.parent().unwrap().as_str(),
+ "reddit.com/r/aww"
+ );
+ }
+
+ #[test]
+ fn parent_of_subreddit_is_r_segment() {
+ let id = ItemId::parse("reddit.com/r/movies").unwrap();
+ assert_eq!(id.parent().unwrap().as_str(), "reddit.com/r");
+ }
+
+ #[test]
+ fn breadcrumb_paths() {
+ let id = ItemId::parse("reddit.com/r/movies").unwrap();
+ let crumbs = id.breadcrumb_paths();
+ let paths: Vec<_> = crumbs.iter().map(|p| p.as_str()).collect();
+ assert_eq!(
+ paths,
+ vec!["reddit.com", "reddit.com/r", "reddit.com/r/movies"]
+ );
+ }
+
+ #[test]
+ fn legacy_scope_maps_to_reddit_sub() {
+ assert_eq!(
+ ItemId::from_legacy_scope("rust").as_str(),
+ "reddit.com/r/rust"
+ );
+ assert!(ItemId::from_legacy_scope("").is_root());
+ }
+}
diff --git a/server/src/reddit.rs b/server/src/reddit.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d203dca09245daf869b3aa942898447700ae69fb
--- /dev/null
+++ b/server/src/reddit.rs
@@ -0,0 +1,21 @@
+//! Reddit API import (async, decoupled from UI request path).
+
+use crate::{
+ path_types::ItemId,
+ reducer::{EntityData, GlobalTree},
+};
+
+/// Bootstrap blank nodes along a URL path so breadcrumbs and voting work before fetch.
+pub fn ensure_partial_tree(tree: &mut GlobalTree, id: &ItemId) {
+ tree.ensure_path(id);
+}
+
+/// Placeholder for Reddit JSON import. Returns entity data when implemented.
+pub async fn fetch_reddit_entity(_id: &ItemId) -> Option {
+ None
+}
+
+/// Apply fetched entity data to a node (called from async worker).
+pub fn apply_entity(tree: &mut GlobalTree, id: &ItemId, data: EntityData) {
+ tree.set_entity_data(id, data);
+}
diff --git a/server/src/reducer.rs b/server/src/reducer.rs
index 8d28353e1f2a67e71b7d17a2041a0988150f3245..077f700bf00ddefe18ffd004bb5288bdc7c4adaf 100644
--- a/server/src/reducer.rs
+++ b/server/src/reducer.rs
@@ -115,6 +115,96 @@ impl GroupState {
}
}
+/// Structured data imported from Reddit or elsewhere.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct EntityData {
+ pub title: String,
+ pub author: Option,
+ pub body_html: Option,
+ pub thumb_url: Option,
+}
+
+/// One node in the fractal tree: entity + ranked children.
+#[derive(Debug, Clone, Default)]
+pub struct NodeState {
+ pub id: ItemId,
+ pub data: Option,
+ pub children: HashSet,
+ pub local_ranking: GroupState,
+}
+
+impl NodeState {
+ fn new(id: ItemId) -> Self {
+ Self {
+ id,
+ ..Default::default()
+ }
+ }
+}
+
+/// Global fractal graph: every URL is both an item and a ranking scope for its children.
+#[derive(Default)]
+pub struct GlobalTree {
+ pub nodes: HashMap,
+}
+
+impl GlobalTree {
+ pub fn new() -> Self {
+ let mut tree = Self::default();
+ tree.ensure_node(&ItemId::root());
+ tree
+ }
+
+ pub fn ensure_node(&mut self, id: &ItemId) -> &mut NodeState {
+ if !self.nodes.contains_key(id) {
+ self.nodes.insert(id.clone(), NodeState::new(id.clone()));
+ }
+ self.nodes.get_mut(id).expect("node just inserted")
+ }
+
+ /// Register a node and wire parent→child links along the canonical path.
+ pub fn ensure_path(&mut self, id: &ItemId) {
+ if id.is_root() {
+ self.ensure_node(id);
+ return;
+ }
+ self.ensure_node(&ItemId::root());
+ for path in id.breadcrumb_paths() {
+ self.ensure_node(&path);
+ if let Some(parent) = path.parent() {
+ self.ensure_node(&parent);
+ if let Some(p) = self.nodes.get_mut(&parent) {
+ p.children.insert(path.clone());
+ }
+ } else if let Some(r) = self.nodes.get_mut(&ItemId::root()) {
+ r.children.insert(path.clone());
+ }
+ }
+ }
+
+ pub fn get(&self, id: &ItemId) -> Option<&NodeState> {
+ self.nodes.get(id)
+ }
+
+ pub fn apply_vote(&mut self, parent: &ItemId, vote: VoteData) {
+ self.ensure_path(parent);
+ self.ensure_path(&vote.a);
+ self.ensure_path(&vote.b);
+ if let Some(node) = self.nodes.get_mut(parent) {
+ node.children.insert(vote.a.clone());
+ node.children.insert(vote.b.clone());
+ node.local_ranking.apply_vote(vote);
+ }
+ }
+
+ pub fn set_entity_data(&mut self, id: &ItemId, data: EntityData) {
+ self.ensure_path(id);
+ if let Some(node) = self.nodes.get_mut(id) {
+ node.data = Some(data);
+ }
+ }
+}
+
#[cfg(test)]
mod from_recorded_tests {
use super::*;
@@ -125,7 +215,20 @@ mod from_recorded_tests {
}
#[test]
- fn rejects_empty() {
+ fn rejects_empty_pair() {
assert!(VoteData::from_recorded(1, "", "b", 2, 1).is_none());
}
+
+ #[test]
+ fn ensure_path_wires_children() {
+ let mut tree = GlobalTree::new();
+ let id = ItemId::parse("reddit.com/r/rust").unwrap();
+ tree.ensure_path(&id);
+ let root = tree.get(&ItemId::root()).unwrap();
+ assert!(root.children.contains(&ItemId::parse("reddit.com").unwrap()));
+ let reddit = tree.get(&ItemId::parse("reddit.com").unwrap()).unwrap();
+ assert!(reddit.children.contains(&ItemId::parse("reddit.com/r").unwrap()));
+ let sub = tree.get(&id).unwrap();
+ assert_eq!(sub.id, id);
+ }
}
diff --git a/server/src/settlement.rs b/server/src/settlement.rs
deleted file mode 100644
index 7a44495512b7f788239b8aeefbbd0a83656eeac6..0000000000000000000000000000000000000000
--- a/server/src/settlement.rs
+++ /dev/null
@@ -1,94 +0,0 @@
-use std::collections::HashMap;
-use std::sync::Arc;
-
-use tokio::sync::{mpsc, oneshot, RwLock};
-
-use crate::{
- event_log::EventLog,
- events::Event,
- reducer::{GroupState, VoteData},
-};
-
-/// Per-scope ranking state, keyed by scope (e.g. subreddit; "" is the default scope).
-pub type GroupMap = HashMap;
-
-pub struct SettlementCommand {
- pub scope: String,
- pub vote: VoteData,
- pub event: Event,
- pub reply: oneshot::Sender>,
-}
-
-#[derive(Clone)]
-pub struct SettlementClient {
- tx: mpsc::Sender,
-}
-
-impl SettlementClient {
- pub fn spawn(groups: Arc>, event_log: Arc) -> Self {
- let (tx, rx) = mpsc::channel(64);
- tokio::spawn(settlement_worker(rx, groups, event_log));
- Self { tx }
- }
-
- pub async fn record_vote(
- &self,
- scope: String,
- vote: VoteData,
- event: Event,
- ) -> Result<(), String> {
- let (reply, rx) = oneshot::channel();
- self.tx
- .send(SettlementCommand {
- scope,
- vote,
- event,
- reply,
- })
- .await
- .map_err(|_| "settlement worker stopped".to_string())?;
- rx.await
- .map_err(|_| "settlement worker stopped".to_string())?
- }
-}
-
-async fn settlement_worker(
- mut rx: mpsc::Receiver,
- groups: Arc>,
- event_log: Arc,
-) {
- while let Some(first) = rx.recv().await {
- let mut batch = vec![first];
- while let Ok(more) = rx.try_recv() {
- batch.push(more);
- }
-
- let mut disk_err: Option = None;
- for cmd in &batch {
- if let Err(e) = event_log.append(&cmd.event).await {
- disk_err = Some(e.to_string());
- break;
- }
- }
-
- if let Some(err) = disk_err {
- for cmd in batch {
- let _ = cmd.reply.send(Err(err.clone()));
- }
- continue;
- }
-
- {
- let mut w = groups.write().await;
- for cmd in &batch {
- w.entry(cmd.scope.clone())
- .or_default()
- .apply_vote(cmd.vote.clone());
- }
- }
-
- for cmd in batch {
- let _ = cmd.reply.send(Ok(()));
- }
- }
-}
diff --git a/server/src/state.rs b/server/src/state.rs
index 2d9e5226057f8615897aac48bce947643a230fb4..cc1722f5a5bf4d415f2327ea585c488a15a75592 100644
--- a/server/src/state.rs
+++ b/server/src/state.rs
@@ -1,4 +1,3 @@
-use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
@@ -6,14 +5,22 @@ use tokio::sync::RwLock;
use crate::{
event_log::EventLog,
events::Event,
- reducer::VoteData,
- settlement::{GroupMap, SettlementClient},
+ path_types::ItemId,
+ reducer::{GlobalTree, VoteData},
+ journal::JournalClient,
views::ViewStore,
};
-/// Normalize a raw ranking subject into a scope key: strip an optional `r/`
-/// prefix, keep only `[a-z0-9_]`, lowercase, and cap the length. Empty string
-/// is the default/global scope.
+/// Parse `?item=` query value into a canonical node id.
+pub fn parse_item_param(raw: &str) -> ItemId {
+ let s = raw.trim();
+ if s.is_empty() {
+ return ItemId::root();
+ }
+ ItemId::from_url(s).or_else(|| ItemId::parse(s)).unwrap_or_else(|| ItemId::opaque(s))
+}
+
+/// Legacy: normalize raw ranking subject into a scope key for old event replay.
pub fn normalize_scope(raw: &str) -> String {
let s = raw.trim();
let s = s
@@ -27,6 +34,14 @@ pub fn normalize_scope(raw: &str) -> String {
.collect()
}
+fn parent_from_event_scope(scope: &str) -> ItemId {
+ if scope.contains('/') {
+ ItemId::parse(scope).unwrap_or_else(|| ItemId::from_legacy_scope(scope))
+ } else {
+ ItemId::from_legacy_scope(scope)
+ }
+}
+
#[derive(Clone)]
pub struct AppConfig {
pub data_dir: String,
@@ -56,8 +71,8 @@ pub struct AppState {
pub cfg: Arc,
pub event_log: Arc,
pub views: ViewStore,
- pub groups: Arc>,
- settlement: SettlementClient,
+ pub tree: Arc>,
+ journal: JournalClient,
}
impl AppState {
@@ -66,7 +81,7 @@ impl AppState {
let views_path = format!("{}/views.json", cfg.data_dir);
let views = ViewStore::new(&views_path);
- let mut groups: GroupMap = HashMap::new();
+ let mut tree = GlobalTree::new();
if let Ok((events, _)) = event_log.load_all().await {
for ev in events {
match ev {
@@ -81,29 +96,45 @@ impl AppState {
if let Some(vote) =
VoteData::from_recorded(ts, &a, &b, ratio_left, ratio_right)
{
- groups.entry(scope).or_default().apply_vote(vote);
+ let parent = parent_from_event_scope(&scope);
+ tree.apply_vote(&parent, vote);
}
}
Event::ViewRecorded { .. } => {}
+ Event::NodeEnsured { id } => {
+ if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {
+ tree.ensure_path(&parsed);
+ }
+ }
}
}
}
- let groups = Arc::new(RwLock::new(groups));
- let settlement = SettlementClient::spawn(groups.clone(), event_log.clone());
+ let tree = Arc::new(RwLock::new(tree));
+ let journal = JournalClient::spawn(tree.clone(), event_log.clone());
Self {
cfg: Arc::new(cfg),
event_log,
views,
- groups,
- settlement,
+ tree,
+ journal,
}
}
+ pub async fn ensure_node(&self, id: &ItemId) -> Result<(), String> {
+ let event = Event::NodeEnsured {
+ id: id.as_str().to_string(),
+ };
+ self.event_log.append(&event).await.map_err(|e| e.to_string())?;
+ let mut w = self.tree.write().await;
+ w.ensure_path(id);
+ Ok(())
+ }
+
pub async fn record_vote(
&self,
- scope: &str,
+ parent: &ItemId,
a: &str,
b: &str,
ratio_left: i32,
@@ -113,23 +144,24 @@ impl AppState {
let vote = VoteData::from_recorded(ts, a, b, ratio_left, ratio_right)
.ok_or_else(|| "invalid vote: need two distinct non-empty items".to_string())?;
- let scope = normalize_scope(scope);
let event = Event::VoteRecorded {
ts,
a: vote.a.as_str().to_string(),
b: vote.b.as_str().to_string(),
ratio_left: vote.ratio_left,
ratio_right: vote.ratio_right,
- scope: scope.clone(),
+ scope: parent.as_str().to_string(),
};
- self.settlement.record_vote(scope, vote, event).await
+ self.journal
+ .record_vote(parent.clone(), vote, event)
+ .await
}
}
#[cfg(test)]
mod tests {
- use super::normalize_scope;
+ use super::{normalize_scope, parse_item_param};
#[test]
fn normalize_scope_strips_prefix_and_lowercases() {
@@ -138,4 +170,15 @@ mod tests {
assert_eq!(normalize_scope("r/web_dev!!"), "web_dev");
assert_eq!(normalize_scope(""), "");
}
+
+ #[test]
+ fn parse_item_param_from_url() {
+ let id = parse_item_param("https://reddit.com/r/rust");
+ assert_eq!(id.as_str(), "reddit.com/r/rust");
+ }
+
+ #[test]
+ fn parse_item_param_empty_is_root() {
+ assert!(parse_item_param("").is_root());
+ }
}
diff --git a/server/src/ui_action.rs b/server/src/ui_action.rs
index 0e030b3448b8e45acbe49d2de47ea26372445c54..d798874d1d94c0dfee59ec1ff703f9ee6ef432c0 100644
--- a/server/src/ui_action.rs
+++ b/server/src/ui_action.rs
@@ -18,7 +18,7 @@ pub enum HtmlUiAction {
b: String,
ratio_left: i32,
ratio_right: i32,
- /// Ranking subject (e.g. a subreddit). Empty string = default/global scope.
+ /// Parent node [`ItemId`] string; empty = tree root.
#[serde(default)]
scope: String,
},
diff --git a/server/static/theme_default.css b/server/static/theme_default.css
index 6ad0ac712bbc840bedee60f613794de9385fbbd1..1bf8ac7a25207336c019f93cd4119bd0e06229f0 100644
--- a/server/static/theme_default.css
+++ b/server/static/theme_default.css
@@ -137,6 +137,38 @@ code {
margin-top: 0.5rem;
}
+.breadcrumbs {
+ font-size: 0.875rem;
+ margin-bottom: 1rem;
+ color: var(--muted);
+}
+
+.breadcrumbs a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.breadcrumbs a:hover {
+ text-decoration: underline;
+}
+
+.breadcrumbs .separator {
+ color: var(--muted);
+}
+
+.rank-list a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.rank-list a:hover {
+ text-decoration: underline;
+}
+
+.entity-card h2 {
+ margin-top: 0;
+}
+
.scope-name {
color: var(--accent);
font-weight: 600;
diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs
index afeeee24e32f2d7f1d852ca96ac799fac8bce665..f7ac9c26bf9f9277ad5f5708603224c095165fb8 100644
--- a/server/tests/integration_ui.rs
+++ b/server/tests/integration_ui.rs
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::net::SocketAddr;
use axum::Router;
-use sorter2_server::{create_app, create_app_state, state::AppConfig, ui_action::UI_RPC_FIELD};
+use sorter2_server::{create_app, create_app_state, path_types::ItemId, state::AppConfig, ui_action::UI_RPC_FIELD};
use tempfile::TempDir;
use tokio::net::TcpListener;
@@ -63,9 +63,9 @@ async fn post_ui_record_vote_morphs_ranking_and_persists() {
port: 0,
};
let state = create_app_state(cfg).await;
- let groups = state.groups.read().await;
- let group = groups.get("").expect("default scope group after replay");
- let ranked = sorter2_server::ranking::ranked_items(group);
+ let tree = state.tree.read().await;
+ let root = tree.get(&ItemId::root()).expect("root node after replay");
+ let ranked = sorter2_server::ranking::ranked_items(&root.local_ranking);
assert_eq!(ranked.len(), 2);
assert_eq!(ranked[0].item.as_str(), "alpha");
}
@@ -93,5 +93,5 @@ async fn post_ui_parse_query_redirects_to_subreddit() {
.unwrap();
assert!(body.contains("window.location.href"));
- assert!(body.contains("/?sub=rust"));
+ assert!(body.contains("/?item=reddit.com/r/rust"));
}