diff --git a/agents.md b/agents.md index 380200d7e196b991e38b0b98cf1f573bf97ad07e..28d8ddb25c65ff778acd0c01624ed68afa4c31f9 100644 --- a/agents.md +++ b/agents.md @@ -49,15 +49,18 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma - **`CopyGardenRank`:** Browser copy control on garden ranking headings. Returns **`text/javascript`** via **`JsBuilder::clipboard_write_text_and_label_btn`** (same **`fetch` → `eval`** loop as **`CopyThread`**). Payload includes **`room`**, **`parent_path`**, **`depth`**, **`copy_btn_id`**, and optional **`external_hosts`** (for **`/-/`** host-root indexes). Clipboard text is a concise markdown numbered list of **leaf** display paths (plus unranked bullets). -- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-edge-history-region`** (recomputed **`
`** linkified view.
- - **Import thread:** one forum thread per GitHub repo (`import:https:::github.com:{owner}:{repo}`), shared by repo root / issues / pulls / commits / releases imports.
+- **`ResolveExternal`:** Resolver buttons (GitHub, are.na) are browser actions through **`POST /ui`**. Success responses morph **`#external-resolver-status`** then redirect to the sanitized shareable **`GET`** page so imported children render through the normal page path; errors morph the same status region. Resolver results are durable system ingests, while cooldown state is RAM-only. Implementation lives under **`server/src/resolvers/`** (per-domain resolver + import card JSON); **`mod.rs::resolve_external_children`** dispatches on URL host and **`try_render_resolver_item_body`** tries each card renderer before falling back to the usual **``** linkified view (called from **`render_item_body_in_scope`** in **`server/src/html/mod.rs`**, used by ontology item pages and the **`GET /vote/compare`** left/right columns). The resolver panel (**`external_resolver_controls`** in **`server/src/html/garden/external.rs`**) renders per-host ("GitHub resolver" / "Are.na resolver"); only GitHub items get a siblings button (are.na block URLs are not path-children of their channel).
+ - **Import thread:** one forum thread per GitHub repo (`import:https:::github.com:{owner}:{repo}`), shared by repo root / issues / pulls / commits / releases imports. Are.na: one thread per channel (`import:https:::www.are.na:channel:{slug}`).
- **Issues:** each open issue is its own **`system:github-resolver`** ingest in that repo import thread (so a later redact removes that issue from the garden). Refresh pages **all** open issues from the GitHub API (up to the page safety cap), keeps still-open single-issue posts, and **`SystemRedact`s** posts for closed/missing issues (and multi-issue bulk posts, which are re-imported as singles). The `slug-github-card` fence payload is **base64(JSON)** so markdown fences in issue bodies cannot terminate the DSL toggle-fence; decode yields the real excerpt string for rich/markdown rendering.
+ - **Are.na channels:** each channel entry (block or nested channel) is its own **`system:arena-resolver`** ingest in the channel import thread, with the same refresh semantics as issues: page **`GET /v3/channels/{slug}/contents`** (`per=100`, page cap 25), keep still-connected posts, redact removed ones. Block URLs (`/block/:id`) are **not** path-children of the channel, so membership is an **explicit containment claim** (`block <: channel` with a leading `{ … }` explanation) in the same post as the block's `slug-arena-card` body; blocks keep cross-channel identity (one item, multiple scopes). Validation requires containment sides to have bodies, so a ghost channel (never pasted with a body) gets a one-time channel-card definition ingest before block posts (**`ensure_channel_body`**). Cards carry `image_url` / `source_url` and render an `
` for image blocks. Env: **`SLUG_ARENA_API_BASE_URL`** (default `https://api.are.na`, v3), optional **`SLUG_ARENA_TOKEN`**, **`SLUG_ARENA_RESOLVER_COOLDOWN_MS`**. Card CSS classes are resolver-agnostic (`import-card__*`, shared with GitHub cards).
- **External URL wire form:** Canonical garden paths are **`/-/https://host/…`**. Legacy **`/-/host/…`** and collapsed **`/-/https:/host/…`** permanently redirect to the canonical form. Action `next` fields always use the canonical path.
- - **Tilde garden URLs / leaf identity:** Leaf pages are canonical (`GET /~/luke` renders the item/scope page). Nested `GET /~/x/luke` permanently redirects (308, query preserved) to the leaf — same precedent as `/-/` legacy redirects (`OntologyPath::nested_redirect_target`). Room-scoped `/r/:room/~/*path` follows the same rule (`/r/…/~/x/luke` → `/r/…/~/luke`). **`GET /~`** is the root-electorate index (via the root scope view). `~/x/luke` and `~/y/luke` are the same item `~luke`. Breadcrumbs walk **containment** (strongest parent weight), not the typed URL path; other active scopes are listed as alternates on the item page.
+ - **Tilde garden URLs / leaf identity:** Leaf pages are canonical (`GET /~/luke` renders the item/scope page). Nested `GET /~/x/luke` permanently redirects (308, query preserved) to the leaf — same precedent as `/-/` legacy redirects (`OntologyPath::nested_redirect_target`). Trailing slashes on the garden roots and leaf pages also 308 to the slash-free form (`/~/` → `/~`, `/~/luke/` → `/~/luke`; same for `/-/` and `/r/:room_key/~/` / `/r/:room_key/-/`). Room-scoped `/r/:room/~/*path` follows the same rule (`/r/…/~/x/luke` → `/r/…/~/luke`). **`GET /~`** is the root-electorate index (via the root scope view). `~/x/luke` and `~/y/luke` are the same item `~luke`. Breadcrumbs walk **containment** (strongest parent weight), not the typed URL path; other active scopes are listed as alternates on the item page.
- **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`** (fullscreen **`GET`** page: no HUD; other garden pages). HUD (**`#slug-pin-hud`**): only when **`layout`** passes garden metadata on **`body`**; the label is **`POST /ui`** **`set_garden_pin`** **`clear:true`** (**`slug_ui.js`**), not a permalink to the item.
- **Aspect sub-scopes:** A `:slug` / `:slug {prompt}` / bare `:` statement is parse-derived from ingest text (no event-schema change, no new write path). Votes under an aspect go to a ranking group keyed `(scope_item, aspect)` where `scope_item` is a shared active-membership scope of both vote items; the canonical `ranking_group` and `rank_position_cache` stay aspect-free. Garden parent/item pages (`render_scope_view`, public `~/`) and the `/try` editor preview list aspect sections **below** the canonical ranking when that scope has aspect votes (heading `:{slug}`, optional prompt). No `?aspect=` routes or default-aspect redirects. CLI `garden children --aspect` / `garden rank --aspect` and MCP `get_rank.aspect` are opt-in reads via `GetGardenRank.aspect`. Prompts live on `ContentState.aspect_prompts` per `(room scope, slug)`. `parent_path` in those reads resolves to its leaf item (`~/x/luke` → `~luke`).
@@ -77,7 +80,7 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma
| Ingests, grants, rooms, identity tokens, agent binds, redactions, thread graduations, etc. | **JSONL** | Appended in `server/src/api/rpc.rs`, `server/src/api/auth.rs` (and related paths) before updating `ReducerState` |
| **`RoomMintInvite` links** | **RAM only** | `AppState.invites` — not appended as `InviteMinted` today; **lost on restart** (`server/src/state.rs`, `server/src/api/rpc.rs`). Event types `InviteMinted` / `InviteRedeemed` exist for replay and a possible future persisted mint (`server/src/reducer.rs`). |
| **OAuth / pending sessions** | **RAM only** | `AppState.pending_sessions` (`server/src/state.rs`, `server/src/api/auth.rs`) |
-| **External resolver cooldowns** | **RAM only** | `AppState.resolver_runs` — debounce/rate-limit guard for on-demand resolver buttons (`SLUG_GITHUB_RESOLVER_COOLDOWN_MS`, default 15s). Resolver results themselves are durable synthetic `Ingest` / `PostRedacted` events in `events.jsonl`. |
+| **External resolver cooldowns** | **RAM only** | `AppState.resolver_runs` — debounce/rate-limit guard for on-demand resolver buttons (`SLUG_GITHUB_RESOLVER_COOLDOWN_MS` / `SLUG_ARENA_RESOLVER_COOLDOWN_MS`, default 15s). Resolver results themselves are durable synthetic `Ingest` / `PostRedacted` events in `events.jsonl`. |
| **Rank-position memo** | **RAM only / derived** | `ContentState.rank_position_cache` — generation-keyed global ranks, component-local Rank Centrality scores, and per-parent scope ranks. Reuses one ingest’s “after” ordering as the next ingest’s “before”. Scores come from each connected component’s solve (never a whole-graph mix of disconnected clusters). Rebuilt lazily during event replay; never persisted. Canonical-only: aspect groups are not in this memo. |
| **Aspect groups / prompts** | **Derived** | `ContentState.aspect_groups` keyed `(scope_item, slug)` and `aspect_prompts` keyed by slug (per room `ContentState`). Rebuilt from DSL on ingest replay; never persisted. Membership in an aspect group is the scope item's active-member electorate. |
| **Containment / borders / fallen-border journal** | **Derived / RAM** | `ContentState.containment` `(child, parent) → {explicit, sugar}`, `borders` `(child, parent) → weight`, `members_by_scope` / `scopes_by_member`, `fallen_border_journal`. Rebuilt from DSL on ingest replay; never persisted. Accessors: `members_of`, `scopes_of`, `border_state`, `fallen_borders`. Item pages render memberships, suspended borders, and this item's journal from these maps. |
diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index bcd99736d6caec3c01fcdfc0bad913b0eea9aa3f..66b1ffa4e4fd4d3612b1db90481621f422f717bf 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -236,6 +236,7 @@ Add --json to scoped commands for machine-readable output (RPC-shaped JSON where
~/contact {
Web: https://slug.social
+Questions: https://slug.social/q/psalms/beauty (shareable pairwise prompt over a garden scope)
GitHub: https://github.com/sortersocial/slug
Issues/feedback: https://github.com/sortersocial/slug/issues
}
diff --git a/cli/src/main.rs b/cli/src/main.rs
index c70aa482f8611ab06f31384b1a918d06dcf9be4e..1dc21bc0f7258f2894a297ae227e8bda7b580bab 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -14,7 +14,12 @@ use std::path::PathBuf;
)]
struct Cli {
/// Dev only: server URL (env: SLUG_SERVER). Not exposed as a flag.
- #[arg(env = "SLUG_SERVER", default_value = "https://slug.social", hide_env_values = true, hide = true)]
+ #[arg(
+ env = "SLUG_SERVER",
+ default_value = "https://slug.social",
+ hide_env_values = true,
+ hide = true
+ )]
server: String,
#[command(subcommand)]
@@ -372,7 +377,6 @@ enum GardenCmd {
},
}
-
fn print_item_response(resp: &ItemResponse) {
if let Some(body) = &resp.body {
println!("{body}");
@@ -400,7 +404,14 @@ fn print_item_response(resp: &ItemResponse) {
}
if !resp.threads.is_empty() {
println!();
- println!("threads: {}", resp.threads.iter().map(|t| format!("#{t}")).collect::>().join(" "));
+ println!(
+ "threads: {}",
+ resp.threads
+ .iter()
+ .map(|t| format!("#{t}"))
+ .collect::>()
+ .join(" ")
+ );
}
}
@@ -410,7 +421,11 @@ fn format_connectivity_stats(stats: &ConnectivityStats) -> String {
} else {
stats.pairs_voted as f64 / stats.pairs_possible as f64 * 100.0
};
- let component_label = if stats.components == 1 { "component" } else { "components" };
+ let component_label = if stats.components == 1 {
+ "component"
+ } else {
+ "components"
+ };
let connection_status = if stats.comparisons_until_connected == 0 {
"connected".to_string()
} else {
@@ -419,7 +434,10 @@ fn format_connectivity_stats(stats: &ConnectivityStats) -> String {
} else {
"comparisons"
};
- format!("{} {comparison_label} to connect", stats.comparisons_until_connected)
+ format!(
+ "{} {comparison_label} to connect",
+ stats.comparisons_until_connected
+ )
};
format!(
"graph: {} items, {}/{} pairs ({density:.1}% density), {} {component_label}, {connection_status}",
@@ -441,7 +459,14 @@ fn print_pair_response(resp: &PairResponse) {
}
if !resp.threads.is_empty() {
println!();
- println!("threads: {}", resp.threads.iter().map(|t| format!("#{t}")).collect::>().join(" "));
+ println!(
+ "threads: {}",
+ resp.threads
+ .iter()
+ .map(|t| format!("#{t}"))
+ .collect::>()
+ .join(" ")
+ );
}
if let Some(stats) = &resp.connectivity {
println!();
@@ -478,27 +503,47 @@ fn print_rank_history_response(resp: &slug_types::RankHistoryResponse) {
.as_millis() as i64;
let n = resp.history.len();
for (i, e) in resp.history.iter().enumerate() {
- let label = if i == 0 { " ← first appearance" } else if i == n - 1 { " ← current" } else { "" };
+ let label = if i == 0 {
+ " ← first appearance"
+ } else if i == n - 1 {
+ " ← current"
+ } else {
+ ""
+ };
let scope_delta = match e.scope_rank_delta.cmp(&0) {
- std::cmp::Ordering::Less => format!(" ↑{}", e.scope_rank_delta.unsigned_abs()),
+ std::cmp::Ordering::Less => format!(" ↑{}", e.scope_rank_delta.unsigned_abs()),
std::cmp::Ordering::Greater => format!(" ↓{}", e.scope_rank_delta),
- std::cmp::Ordering::Equal => String::new(),
+ std::cmp::Ordering::Equal => String::new(),
};
let global_delta = match e.global_rank_delta.cmp(&0) {
- std::cmp::Ordering::Less => format!(" ↑{}", e.global_rank_delta.unsigned_abs()),
+ std::cmp::Ordering::Less => format!(" ↑{}", e.global_rank_delta.unsigned_abs()),
std::cmp::Ordering::Greater => format!(" ↓{}", e.global_rank_delta),
- std::cmp::Ordering::Equal => String::new(),
+ std::cmp::Ordering::Equal => String::new(),
};
println!(
"\n #{} of {} among siblings{} #{} of {} globally{} {} {} post #{}{}",
- e.scope_rank, e.scope_total, scope_delta,
- e.global_rank, e.global_total, global_delta,
+ e.scope_rank,
+ e.scope_total,
+ scope_delta,
+ e.global_rank,
+ e.global_total,
+ global_delta,
slug_types::timeago::timeago(now_ms, e.ts),
- e.thread, e.thread_post_index,
+ e.thread,
+ e.thread_post_index,
label,
);
for v in &e.caused_by {
- println!(" {} {} {} {}", v.a, v.ratio, v.b, v.actor.as_deref().map(|a| format!(" ({a})")).unwrap_or_default());
+ println!(
+ " {} {} {} {}",
+ v.a,
+ v.ratio,
+ v.b,
+ v.actor
+ .as_deref()
+ .map(|a| format!(" ({a})"))
+ .unwrap_or_default()
+ );
if !v.body.is_empty() {
println!(" {}", v.body.lines().next().unwrap_or(&v.body).trim());
}
@@ -511,8 +556,16 @@ fn print_rank_history_response(resp: &slug_types::RankHistoryResponse) {
}
fn print_global_rank_response(resp: &GlobalRankResponse) {
- let show_percent = resp.components.iter().flat_map(|component| &component.ranking).any(|row| row.percent.is_some());
- let shown_ranked: usize = resp.components.iter().map(|component| component.ranking.len()).sum();
+ let show_percent = resp
+ .components
+ .iter()
+ .flat_map(|component| &component.ranking)
+ .any(|row| row.percent.is_some());
+ let shown_ranked: usize = resp
+ .components
+ .iter()
+ .map(|component| component.ranking.len())
+ .sum();
let shown_total = shown_ranked + resp.unranked_items.len();
println!(
"global rank (showing {}-{} of {} ranked + {} unranked)",
@@ -523,10 +576,21 @@ fn print_global_rank_response(resp: &GlobalRankResponse) {
);
let mut rank = resp.offset + 1;
for (component_index, component) in resp.components.iter().enumerate() {
- println!("\ncomponent {} ({} items, {} pairs; scores are component-local)", component_index + 1, component.ranking.len(), component.pairs);
+ println!(
+ "\ncomponent {} ({} items, {} pairs; scores are component-local)",
+ component_index + 1,
+ component.ranking.len(),
+ component.pairs
+ );
for row in &component.ranking {
if show_percent {
- println!("{:>4}. {:<40} {:>6.1}% ({:.6})", rank, row.item, row.percent.unwrap_or(0.0), row.score);
+ println!(
+ "{:>4}. {:<40} {:>6.1}% ({:.6})",
+ rank,
+ row.item,
+ row.percent.unwrap_or(0.0),
+ row.score
+ );
} else {
println!("{:>4}. {:<40} {:.6}", rank, row.item, row.score);
}
@@ -612,10 +676,7 @@ fn print_threads(resp: &ThreadsResponse) {
.as_millis() as i64;
for t in &resp.threads {
let ago = slug_types::timeago::timeago(now_ms, t.last_activity_ts);
- println!(
- "{:<32} {}",
- t.thread, ago
- );
+ println!("{:<32} {}", t.thread, ago);
}
}
@@ -679,7 +740,9 @@ fn parse_ts(s: &str) -> Result {
let days = days_from_epoch(y, m, d)?;
return Ok(days * 86_400_000);
}
- Err(anyhow!("expected Unix ms timestamp or YYYY-MM-DD, got '{s}'"))
+ Err(anyhow!(
+ "expected Unix ms timestamp or YYYY-MM-DD, got '{s}'"
+ ))
}
/// Days from Unix epoch (1970-01-01) to the given Gregorian date.
@@ -731,7 +794,9 @@ fn rpc_line_ok(line: &RpcLine) -> Result<&RpcResult> {
}
return Err(anyhow!(m));
}
- line.result.as_ref().ok_or_else(|| anyhow!("rpc missing result"))
+ line.result
+ .as_ref()
+ .ok_or_else(|| anyhow!("rpc missing result"))
}
const PRIVATE_ROOM_NEEDS_BEARER: &str = "needs bearer token, use slugsocial identity command";
@@ -821,10 +886,7 @@ fn ontology_path_for_api_query(normalized: &str) -> String {
/// Parse `--depth` for garden children (`all` / `∞` / `inf` → unbounded).
fn parse_garden_depth_arg(raw: &str) -> usize {
let s = raw.trim();
- if matches!(
- s,
- "all" | "∞" | "inf" | "infinity" | "*" | "unlimited"
- ) {
+ if matches!(s, "all" | "∞" | "inf" | "infinity" | "*" | "unlimited") {
return usize::MAX;
}
s.parse::().unwrap_or(1).max(1)
@@ -874,7 +936,8 @@ fn write_secret_file(name: &str, contents: &str) -> Result<()> {
let dir = slug_config_dir();
std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
let path = dir.join(name);
- std::fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))?;
+ std::fs::write(&path, contents)
+ .with_context(|| format!("failed to write {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -972,7 +1035,15 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {
match sub {
ScopedCmd::Garden { sub } => match sub {
GardenCmd::Tree { json } => {
- let batch = send_rpc(&client, base, scoped_read_bearer, vec![RpcCommand::GetLeaves { room: room.to_string() }]).await?;
+ let batch = send_rpc(
+ &client,
+ base,
+ scoped_read_bearer,
+ vec![RpcCommand::GetLeaves {
+ room: room.to_string(),
+ }],
+ )
+ .await?;
match rpc_line_ok_scoped_read(&batch.results[0], room)? {
RpcResult::Leaves(resp) => {
if json {
@@ -1014,7 +1085,12 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {
_ => return Err(anyhow!("unexpected RPC result")),
}
}
- GardenCmd::Children { paths, depth, json, aspect } => {
+ GardenCmd::Children {
+ paths,
+ depth,
+ json,
+ aspect,
+ } => {
let paths: Vec = paths
.iter()
.map(|p| normalize_ontology_path_input(p).map_err(anyhow::Error::msg))
@@ -1124,7 +1200,13 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {
_ => return Err(anyhow!("unexpected RPC result")),
}
}
- GardenCmd::Rank { limit, offset, percent, json, aspect } => {
+ GardenCmd::Rank {
+ limit,
+ offset,
+ percent,
+ json,
+ aspect,
+ } => {
if let Some(aspect) = aspect {
let batch = send_rpc(
&client,
@@ -1177,202 +1259,204 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {
}
}
},
- ScopedCmd::Forum { sub } => match sub {
- ForumCmd::List { json } => {
- let batch = send_rpc(
- &client,
- base,
- scoped_read_bearer,
- vec![RpcCommand::ListForumThreads {
- room: room.to_string(),
- }],
- )
- .await?;
- match rpc_line_ok_scoped_read(&batch.results[0], room)? {
- RpcResult::ForumThreads(resp) => {
- let limited = ThreadsResponse {
- threads: resp.threads.iter().take(10).cloned().collect(),
- };
- if json {
- println!("{}", serde_json::to_string_pretty(&limited)?);
- } else {
- print_threads(&limited);
+ ScopedCmd::Forum { sub } => {
+ match sub {
+ ForumCmd::List { json } => {
+ let batch = send_rpc(
+ &client,
+ base,
+ scoped_read_bearer,
+ vec![RpcCommand::ListForumThreads {
+ room: room.to_string(),
+ }],
+ )
+ .await?;
+ match rpc_line_ok_scoped_read(&batch.results[0], room)? {
+ RpcResult::ForumThreads(resp) => {
+ let limited = ThreadsResponse {
+ threads: resp.threads.iter().take(10).cloned().collect(),
+ };
+ if json {
+ println!("{}", serde_json::to_string_pretty(&limited)?);
+ } else {
+ print_threads(&limited);
+ }
}
+ _ => return Err(anyhow!("unexpected RPC result")),
}
- _ => return Err(anyhow!("unexpected RPC result")),
}
- }
- ForumCmd::Show {
- tag,
- json,
- offset,
- limit,
- since,
- before,
- actor,
- post,
- } => {
- let thread_tag = normalize_thread_input(&tag);
- let batch = send_rpc(
- &client,
- base,
- scoped_read_bearer,
- vec![RpcCommand::GetForumThread {
- room: room.to_string(),
- thread_tag,
- offset,
- limit,
- since: match &since {
- Some(s) => Some(parse_ts(s)?),
- None => None,
- },
- before: match &before {
- Some(s) => Some(parse_ts(s)?),
- None => None,
- },
- actor,
- post_id: post,
- }],
- )
- .await?;
- match rpc_line_ok_scoped_read(&batch.results[0], room)? {
- RpcResult::ForumThread(resp) => {
- if json {
- println!("{}", serde_json::to_string_pretty(&resp)?);
- } else {
- print_thread(resp);
+ ForumCmd::Show {
+ tag,
+ json,
+ offset,
+ limit,
+ since,
+ before,
+ actor,
+ post,
+ } => {
+ let thread_tag = normalize_thread_input(&tag);
+ let batch = send_rpc(
+ &client,
+ base,
+ scoped_read_bearer,
+ vec![RpcCommand::GetForumThread {
+ room: room.to_string(),
+ thread_tag,
+ offset,
+ limit,
+ since: match &since {
+ Some(s) => Some(parse_ts(s)?),
+ None => None,
+ },
+ before: match &before {
+ Some(s) => Some(parse_ts(s)?),
+ None => None,
+ },
+ actor,
+ post_id: post,
+ }],
+ )
+ .await?;
+ match rpc_line_ok_scoped_read(&batch.results[0], room)? {
+ RpcResult::ForumThread(resp) => {
+ if json {
+ println!("{}", serde_json::to_string_pretty(&resp)?);
+ } else {
+ print_thread(resp);
+ }
}
+ _ => return Err(anyhow!("unexpected RPC result")),
}
- _ => return Err(anyhow!("unexpected RPC result")),
}
- }
- ForumCmd::Post {
- tag,
- delegate,
- file,
- json,
- } => {
- let delegate = delegate.trim();
- if delegate.is_empty() {
- return Err(anyhow!(
+ ForumCmd::Post {
+ tag,
+ delegate,
+ file,
+ json,
+ } => {
+ let delegate = delegate.trim();
+ if delegate.is_empty() {
+ return Err(anyhow!(
"--delegate is required for CLI posts (humans use the website); set SLUG_DELEGATE or pass --delegate uuid:rig:provider/model"
));
- }
- let mut text = String::new();
- match file {
- Some(path) => {
- text = std::fs::read_to_string(&path)
- .with_context(|| format!("failed to read {}", path.display()))?;
}
- None => {
- std::io::stdin()
- .read_to_string(&mut text)
- .context("failed to read stdin")?;
+ let mut text = String::new();
+ match file {
+ Some(path) => {
+ text = std::fs::read_to_string(&path)
+ .with_context(|| format!("failed to read {}", path.display()))?;
+ }
+ None => {
+ std::io::stdin()
+ .read_to_string(&mut text)
+ .context("failed to read stdin")?;
+ }
}
- }
- if text.trim().is_empty() {
- return Err(anyhow!("no input provided (empty)"));
- }
- let bearer = effective_bearer().ok_or_else(private_room_needs_bearer_error)?;
- let thread_tag = normalize_thread_input(&tag);
- let batch = send_rpc(
- &client,
- base,
- Some(&bearer),
- vec![RpcCommand::Post {
- room: room.to_string(),
- thread_tag,
- delegate: Some(delegate.to_string()),
- text,
- return_rank_diff: true,
- }],
- )
- .await?;
- match rpc_line_ok(&batch.results[0])? {
- RpcResult::PostOk {
- events_appended,
- post_id: _,
- post_index: _,
- ranking_changes,
- threads,
- next,
- } => {
- if json {
- println!(
- "{}",
- serde_json::to_string_pretty(&serde_json::json!({
- "ok": true,
- "events_appended": events_appended,
- "ranking_changes": ranking_changes,
- "threads": threads,
- "next": next,
- }))?
- );
- } else {
- println!("✓ posted");
- println!("events: {events_appended}");
- if !threads.is_empty() {
- println!("threads:");
- for t in threads {
- println!(" {t}");
+ if text.trim().is_empty() {
+ return Err(anyhow!("no input provided (empty)"));
+ }
+ let bearer = effective_bearer().ok_or_else(private_room_needs_bearer_error)?;
+ let thread_tag = normalize_thread_input(&tag);
+ let batch = send_rpc(
+ &client,
+ base,
+ Some(&bearer),
+ vec![RpcCommand::Post {
+ room: room.to_string(),
+ thread_tag,
+ delegate: Some(delegate.to_string()),
+ text,
+ return_rank_diff: true,
+ }],
+ )
+ .await?;
+ match rpc_line_ok(&batch.results[0])? {
+ RpcResult::PostOk {
+ events_appended,
+ post_id: _,
+ post_index: _,
+ ranking_changes,
+ threads,
+ next,
+ } => {
+ if json {
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&serde_json::json!({
+ "ok": true,
+ "events_appended": events_appended,
+ "ranking_changes": ranking_changes,
+ "threads": threads,
+ "next": next,
+ }))?
+ );
+ } else {
+ println!("✓ posted");
+ println!("events: {events_appended}");
+ if !threads.is_empty() {
+ println!("threads:");
+ for t in threads {
+ println!(" {t}");
+ }
}
+ if let Some(ref rc) = ranking_changes {
+ print_ranking_changes(rc);
+ }
+ print_next(next);
+ println!();
+ println!("---");
+ println!("For your next comparison: remember to ask your human first. Their perspective is what makes your submission more than another model's take.");
+ println!("---");
}
- if let Some(ref rc) = ranking_changes {
- print_ranking_changes(rc);
- }
- print_next(next);
- println!();
- println!("---");
- println!("For your next comparison: remember to ask your human first. Their perspective is what makes your submission more than another model's take.");
- println!("---");
}
+ _ => return Err(anyhow!("unexpected RPC result")),
}
- _ => return Err(anyhow!("unexpected RPC result")),
}
- }
- ForumCmd::Graduate { tag, json } => {
- if room == "public" {
- return Err(anyhow!(
+ ForumCmd::Graduate { tag, json } => {
+ if room == "public" {
+ return Err(anyhow!(
"forum graduate is only for private rooms; use `slugsocial private forum graduate `"
));
- }
- let bearer = effective_bearer().ok_or_else(private_room_needs_bearer_error)?;
- let thread_tag = normalize_thread_input(&tag);
- let batch = send_rpc(
- &client,
- base,
- Some(&bearer),
- vec![RpcCommand::ThreadGraduate {
- room: room.to_string(),
- thread_tag,
- }],
- )
- .await?;
- match rpc_line_ok(&batch.results[0])? {
- RpcResult::ThreadGraduatedOk {
- thread_tag,
- posts_copied,
- web,
- } => {
- if json {
- println!(
- "{}",
- serde_json::to_string_pretty(&serde_json::json!({
- "ok": true,
- "thread_tag": thread_tag,
- "posts_copied": posts_copied,
- "web": web,
- }))?
- );
- } else {
- println!("✓ graduated #{thread_tag} to public ({posts_copied} posts copied)");
- println!(" {web}");
+ }
+ let bearer = effective_bearer().ok_or_else(private_room_needs_bearer_error)?;
+ let thread_tag = normalize_thread_input(&tag);
+ let batch = send_rpc(
+ &client,
+ base,
+ Some(&bearer),
+ vec![RpcCommand::ThreadGraduate {
+ room: room.to_string(),
+ thread_tag,
+ }],
+ )
+ .await?;
+ match rpc_line_ok(&batch.results[0])? {
+ RpcResult::ThreadGraduatedOk {
+ thread_tag,
+ posts_copied,
+ web,
+ } => {
+ if json {
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&serde_json::json!({
+ "ok": true,
+ "thread_tag": thread_tag,
+ "posts_copied": posts_copied,
+ "web": web,
+ }))?
+ );
+ } else {
+ println!("✓ graduated #{thread_tag} to public ({posts_copied} posts copied)");
+ println!(" {web}");
+ }
}
+ _ => return Err(anyhow!("unexpected RPC result")),
}
- _ => return Err(anyhow!("unexpected RPC result")),
}
}
- },
+ }
ScopedCmd::InviteLink { caps, uses, json } => {
let caps: Vec = caps
.into_iter()
@@ -1446,7 +1530,12 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {
if resp.grants.is_empty() {
println!("(no grants recorded)");
} else {
- let w_user = resp.grants.iter().map(|g| g.username.len()).max().unwrap_or(0);
+ let w_user = resp
+ .grants
+ .iter()
+ .map(|g| g.username.len())
+ .max()
+ .unwrap_or(0);
for g in &resp.grants {
let caps = g.capabilities.join(", ");
println!("{: Result<()> {
// If no command provided, print the guide
let Some(cmd) = cmd else {
- print!("{}", include_str!("../GUIDE.sorter").replace("npx slugsocial", "slugsocial"));
+ print!(
+ "{}",
+ include_str!("../GUIDE.sorter").replace("npx slugsocial", "slugsocial")
+ );
return Ok(());
};
@@ -1593,13 +1685,8 @@ async fn run() -> Result<()> {
RoomCmd::List { json } => {
let client = http_client()?;
let bearer = effective_bearer().ok_or_else(private_room_needs_bearer_error)?;
- let batch = send_rpc(
- &client,
- base,
- Some(&bearer),
- vec![RpcCommand::RoomList],
- )
- .await?;
+ let batch =
+ send_rpc(&client, base, Some(&bearer), vec![RpcCommand::RoomList]).await?;
match rpc_line_ok(&batch.results[0])? {
RpcResult::RoomList(resp) => {
if json {
@@ -1659,7 +1746,9 @@ async fn run() -> Result<()> {
}
}
if !resp.threads.is_empty() {
- if !resp.items.is_empty() { println!(); }
+ if !resp.items.is_empty() {
+ println!();
+ }
println!("threads ({})", resp.threads.len());
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -1675,7 +1764,9 @@ async fn run() -> Result<()> {
}
}
if !resp.posts.is_empty() {
- if !resp.items.is_empty() || !resp.threads.is_empty() { println!(); }
+ if !resp.items.is_empty() || !resp.threads.is_empty() {
+ println!();
+ }
println!("posts ({})", resp.posts.len());
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -1683,7 +1774,12 @@ async fn run() -> Result<()> {
.as_millis() as i64;
for p in &resp.posts {
let first_line = p.snippet.lines().next().unwrap_or("").trim();
- println!(" {} · {} {}", p.thread, slug_types::timeago::timeago(now_ms, p.ts), first_line);
+ println!(
+ " {} · {} {}",
+ p.thread,
+ slug_types::timeago::timeago(now_ms, p.ts),
+ first_line
+ );
}
}
if resp.items.is_empty() && resp.threads.is_empty() && resp.posts.is_empty() {
@@ -1692,7 +1788,12 @@ async fn run() -> Result<()> {
}
}
- Command::Feed { delegate, since, limit, json } => {
+ Command::Feed {
+ delegate,
+ since,
+ limit,
+ json,
+ } => {
let client = http_client()?;
let bearer = effective_bearer().ok_or_else(|| {
anyhow!(
@@ -1730,10 +1831,14 @@ async fn run() -> Result<()> {
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
- let since_attr = resp.since
+ let since_attr = resp
+ .since
.map(|ts| format!(" since=\"{}\"", slug_types::timeago::timeago(now_ms, ts)))
.unwrap_or_default();
- println!("", resp.total, limit, since_attr);
+ println!(
+ "",
+ resp.total, limit, since_attr
+ );
if resp.posts.is_empty() {
println!("");
} else {
@@ -1749,7 +1854,9 @@ async fn run() -> Result<()> {
p.id, ago, p.room, thread_attr
);
print!("{}", p.body);
- if !p.body.ends_with('\n') { println!(); }
+ if !p.body.ends_with('\n') {
+ println!();
+ }
println!("");
}
}
@@ -1774,7 +1881,8 @@ async fn run() -> Result<()> {
)
.await?;
- let poll_url = if start.poll_url.starts_with("http://") || start.poll_url.starts_with("https://")
+ let poll_url = if start.poll_url.starts_with("http://")
+ || start.poll_url.starts_with("https://")
{
start.poll_url.clone()
} else {
@@ -1799,7 +1907,9 @@ async fn run() -> Result<()> {
println!();
println!(" {}", start.login_url);
println!();
- println!("Show the above link to the user. Then immediately run (do not wait):");
+ println!(
+ "Show the above link to the user. Then immediately run (do not wait):"
+ );
println!();
println!(" slugsocial identity poll {}", start.session);
println!();
@@ -1821,7 +1931,8 @@ async fn run() -> Result<()> {
eprintln!("Polling for login completion (every {poll_interval_ms}ms, max {max_wait_secs}s)…");
}
- let deadline = std::time::Instant::now() + std::time::Duration::from_secs(max_wait_secs);
+ let deadline =
+ std::time::Instant::now() + std::time::Duration::from_secs(max_wait_secs);
let mut token_out: Option = None;
let mut user_out: Option = None;
let mut agent_out: Option = None;
@@ -1830,7 +1941,12 @@ async fn run() -> Result<()> {
tokio::time::sleep(std::time::Duration::from_millis(poll_interval_ms)).await;
let poll: PendingSessionPollResponse =
expect_json(client.get(&poll_url).send().await?).await?;
- if let Some(a) = poll.agent.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
+ if let Some(a) = poll
+ .agent
+ .as_deref()
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ {
agent_out = Some(a.to_string());
}
if poll.complete {
diff --git a/server/benches/garden_render.rs b/server/benches/garden_render.rs
index be9b40837e5e6dadd1923f702f00cd3078b6b7de..55be3e5e010bfc51e8cef82959357dad03fbc011 100644
--- a/server/benches/garden_render.rs
+++ b/server/benches/garden_render.rs
@@ -37,7 +37,13 @@ fn single_component_scope(c: &mut Criterion) {
let parent = bench_parent();
g.throughput(Throughput::Elements(n as u64));
g.bench_with_input(BenchmarkId::from_parameter(n), &content, |b, content| {
- b.iter(|| black_box(build_children_rankings(content, &parent).component_rankings.len()))
+ b.iter(|| {
+ black_box(
+ build_children_rankings(content, &parent)
+ .component_rankings
+ .len(),
+ )
+ })
});
}
g.finish();
@@ -63,7 +69,11 @@ fn component_fanout(c: &mut Criterion) {
&content,
|b, content| {
b.iter(|| {
- black_box(build_children_rankings(content, &parent).component_rankings.len())
+ black_box(
+ build_children_rankings(content, &parent)
+ .component_rankings
+ .len(),
+ )
})
},
);
diff --git a/server/benches/ranking_kernel.rs b/server/benches/ranking_kernel.rs
index 4d5c6824e7abc828957bbf39677200ab3eea2999..4389f473aa133fee0db8e89dea22ca0d54db9fe4 100644
--- a/server/benches/ranking_kernel.rs
+++ b/server/benches/ranking_kernel.rs
@@ -37,21 +37,17 @@ fn topology_convergence(c: &mut Criterion) {
] {
let base = build_group(topo, n);
g.throughput(Throughput::Elements(n as u64));
- g.bench_with_input(
- BenchmarkId::new(topo.label(), n),
- &base,
- |b, base| {
- b.iter_batched_ref(
- || base.clone(),
- |group| {
- group.dirty = true;
- compute_group_ranking(group, MAX_ITERS, TOL);
- black_box(group.cached_scores.len())
- },
- criterion::BatchSize::SmallInput,
- )
- },
- );
+ g.bench_with_input(BenchmarkId::new(topo.label(), n), &base, |b, base| {
+ b.iter_batched_ref(
+ || base.clone(),
+ |group| {
+ group.dirty = true;
+ compute_group_ranking(group, MAX_ITERS, TOL);
+ black_box(group.cached_scores.len())
+ },
+ criterion::BatchSize::SmallInput,
+ )
+ });
}
}
g.finish();
diff --git a/server/examples/bench_probe.rs b/server/examples/bench_probe.rs
index c63e37619cf9cf3ffa81b78a8696478470d89f81..c44e0e27e1d8a8b194f284b55328eb51b7d73bcd 100644
--- a/server/examples/bench_probe.rs
+++ b/server/examples/bench_probe.rs
@@ -5,13 +5,13 @@
use std::time::Instant;
+use slugsocial_server::events::{Event, Ingest};
+use slugsocial_server::path_types::ItemId;
use slugsocial_server::ranking::{
compute_group_ranking, connected_components_from_voted_pairs, ranked_items_subset,
};
use slugsocial_server::reducer::{GroupState, ReducerState, VoteData};
use slugsocial_server::scope_rank::build_rankings_for_item_set;
-use slugsocial_server::path_types::ItemId;
-use slugsocial_server::events::{Event, Ingest};
fn item_name(i: usize) -> String {
format!("~/bench/i{i:06}")
@@ -187,7 +187,10 @@ fn main() {
// If time keeps growing all the way to 10_000, the cap is binding and the
// returned scores are NOT converged.
println!("\n== where does power iteration actually stop? (time vs max_iters cap) ==");
- println!("{:<28} {:>9} {:>9} {:>9} {:>9} {:>9}", "graph", "k=10", "k=100", "k=1000", "k=10000", "verdict");
+ println!(
+ "{:<28} {:>9} {:>9} {:>9} {:>9} {:>9}",
+ "graph", "k=10", "k=100", "k=1000", "k=10000", "verdict"
+ );
for (label, g) in [
("chain n=256", chain_group(256)),
("chain n=1024", chain_group(1024)),
@@ -227,7 +230,11 @@ fn main() {
times[1],
times[2],
times[3],
- if saturated { "CAP BINDS (not converged)" } else { "converged early" }
+ if saturated {
+ "CAP BINDS (not converged)"
+ } else {
+ "converged early"
+ }
);
}
@@ -287,7 +294,12 @@ fn main() {
g.voted_pairs.iter().copied(),
);
let ms = t.elapsed().as_secs_f64() * 1000.0;
- println!("n={n:7} P={:8} cc: {ms:9.3} ms ({} comps, {} isolates)", g.voted_pairs.len(), c.len(), i.len());
+ println!(
+ "n={n:7} P={:8} cc: {ms:9.3} ms ({} comps, {} isolates)",
+ g.voted_pairs.len(),
+ c.len(),
+ i.len()
+ );
}
// Attribute the per-post cost: replay the same 135 posts with the vote lines
@@ -347,8 +359,14 @@ fn main() {
let graph_s = t.elapsed().as_secs_f64();
println!("full replay : {full_s:8.3} s");
- println!(" DSL parse + item bookkeeping : {parse_s:8.3} s ({:5.1}%)", parse_s / full_s * 100.0);
- println!(" raw vote-graph construction : {graph_s:8.3} s ({:5.1}%)", graph_s / full_s * 100.0);
+ println!(
+ " DSL parse + item bookkeeping : {parse_s:8.3} s ({:5.1}%)",
+ parse_s / full_s * 100.0
+ );
+ println!(
+ " raw vote-graph construction : {graph_s:8.3} s ({:5.1}%)",
+ graph_s / full_s * 100.0
+ );
println!(
" ranking + rank-history : {:8.3} s ({:5.1}%)",
full_s - parse_s - graph_s,
diff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs
index c58cb0fec20a1bc9c0e27b7e5a392b3289431b41..2ab25c778ab1ccb02ba5ac2ebef3b7a059203249 100644
--- a/server/src/api/helpers.rs
+++ b/server/src/api/helpers.rs
@@ -6,8 +6,7 @@ use slug_types::*;
use std::collections::HashMap;
use crate::{
- canonical_path::canonicalize_item,
- ranking::connected_components_from_voted_pairs,
+ canonical_path::canonicalize_item, ranking::connected_components_from_voted_pairs,
reducer::ContentState,
};
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index 19be1c3b5c448b69e00ebfdc54b91376bbcabd54..29749c3c757f79f99b6e9acf181b451e844d6e74 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -23,8 +23,7 @@ use crate::{
use super::auth::{parse_bearer, verify_bearer_principal};
use super::helpers::{
compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings, parse_parent_specs,
- pick_random_distinct_item_pair, validate_garden_parent_scope_paths,
- vote_touches_path,
+ pick_random_distinct_item_pair, validate_garden_parent_scope_paths, vote_touches_path,
};
use super::validate::validate_ingest_document;
@@ -235,10 +234,7 @@ fn build_rank_response_for_content(
));
}
if specs.len() > 1 {
- return Err((
- "aspect ranking requires a single parent".into(),
- None,
- ));
+ return Err(("aspect ranking requires a single parent".into(), None));
}
}
@@ -253,9 +249,7 @@ fn build_rank_response_for_content(
.unwrap_or_else(|| ItemId::opaque(specs[0].clone()))
};
let empty = crate::reducer::GroupState::new();
- let group = content
- .aspect_group(&parent_id, slug)
- .unwrap_or(&empty);
+ let group = content.aspect_group(&parent_id, slug).unwrap_or(&empty);
let items = if depth > 1 && !is_global && !specs.is_empty() {
crate::scope_rank::resolve_scope_recursive(content, &specs, depth)
} else if is_global || specs.is_empty() {
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index c941ade0e1cc9cb169ce29ea3aba6bc3970ae045..30b1b2613ff35e4b45673f76f36709f5936853ef 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -18,17 +18,17 @@ use crate::{
rpc::{rpc_post_redact, rpc_post_with_bearer, rpc_room_delete, rpc_thread_graduate},
},
canonical_path::{canonicalize_tag, validate_thread_tag},
- resolvers::resolve_github_children,
html::vote_compare_post_success_js,
html::{
- external_resolver_status_markup, fragment_new_thread_slot, login_to_post_hint_markup,
- parse_html_ui_from_form, room_members_section_markup, thread_feed_html,
- thread_feed_html_for_room, thread_latest_page_region, thread_ui_collapse_redacted_post,
- garden_ui_copy_rank, thread_ui_copy_thread, thread_ui_expand_post_full,
+ external_resolver_status_markup, fragment_new_thread_slot, garden_ui_copy_rank,
+ login_to_post_hint_markup, parse_html_ui_from_form, room_members_section_markup,
+ thread_feed_html, thread_feed_html_for_room, thread_latest_page_region,
+ thread_ui_collapse_redacted_post, thread_ui_copy_thread, thread_ui_expand_post_full,
thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, user_can_view_room,
HtmlUiAction, JsBuilder, ThreadNav,
},
reducer::{scope_from_room_wire, ScopeId},
+ resolvers::resolve_external_children,
state::AppState,
};
@@ -211,6 +211,7 @@ async fn dispatch_ui_action(
explanation,
next,
pool,
+ aspect,
form_action,
} => {
if form_action != "/ui" {
@@ -290,7 +291,19 @@ async fn dispatch_ui_action(
}
let (rl, rr) = crate::dsl::reduce_ratio(rl, rr);
- let text = format!(
+ let aspect = aspect.as_deref().map(str::trim).filter(|s| !s.is_empty());
+ if let Some(slug) = aspect {
+ if !crate::dsl::is_valid_aspect_slug(slug) {
+ return form_js_error(
+ err_tgt.as_ref(),
+ "invalid aspect",
+ "Aspect slugs are [a-z0-9_-]{1,64}.",
+ )
+ .into_response();
+ }
+ }
+
+ let vote_line = format!(
"{{\n{}\n}}\n{} {}:{} {}\n",
exp,
left_id.as_str(),
@@ -298,6 +311,10 @@ async fn dispatch_ui_action(
rr,
right_id.as_str()
);
+ let text = match aspect {
+ Some(slug) => format!(":{slug}\n{vote_line}"),
+ None => vote_line,
+ };
match rpc_post_with_bearer(
state,
@@ -341,6 +358,7 @@ async fn dispatch_ui_action(
pool_id.as_ref(),
pid.as_str(),
post_index,
+ &next,
)
.await;
Response::builder()
@@ -425,10 +443,11 @@ async fn dispatch_ui_action(
}
let reduced = state.reduced.read().await;
if matches!(scope_from_room_wire(room), ScopeId::Room(_))
- && !user_can_post_room(&reduced, room, &session.username) {
- drop(reduced);
- return ui_js_warn("forbidden").into_response();
- }
+ && !user_can_post_room(&reduced, room, &session.username)
+ {
+ drop(reduced);
+ return ui_js_warn("forbidden").into_response();
+ }
drop(reduced);
let Some(item) = crate::path_types::ItemId::parse(item_storage.trim()) else {
@@ -442,11 +461,18 @@ async fn dispatch_ui_action(
} else {
item.normalized_storage()
};
- match resolve_github_children(state, room, &target).await {
+ match resolve_external_children(state, room, &target).await {
Ok(stats) => JsBuilder::new()
.morph_inner_selector(
"#external-resolver-status",
- external_resolver_status_markup(Ok(stats), &sanitize_garden_pin_next(&next)),
+ external_resolver_status_markup(
+ Ok((
+ stats,
+ crate::resolvers::resolver_source_label(&target)
+ .unwrap_or("GitHub"),
+ )),
+ &sanitize_garden_pin_next(&next),
+ ),
)
.redirect(&sanitize_garden_pin_next(&next))
.into_response(),
diff --git a/server/src/api/validate.rs b/server/src/api/validate.rs
index 301112c78465c88f925981a63ba191a6b3a870ee..6f803dd929b4565ec2931637213da3b3bc7e1122 100644
--- a/server/src/api/validate.rs
+++ b/server/src/api/validate.rs
@@ -33,11 +33,15 @@ pub fn validate_ingest_document(
_ => reduced.content_for_scope(scope),
};
let item_exists = |key: &ItemId| {
- scoped_content.map(|c| c.items.contains(key)).unwrap_or(false)
+ scoped_content
+ .map(|c| c.items.contains(key))
+ .unwrap_or(false)
|| public_content.items.contains(key)
};
let body_exists = |key: &ItemId| {
- scoped_content.map(|c| c.item_bodies.contains_key(key)).unwrap_or(false)
+ scoped_content
+ .map(|c| c.item_bodies.contains_key(key))
+ .unwrap_or(false)
|| public_content.item_bodies.contains_key(key)
};
let doc = match dsl::parse_full(text) {
@@ -60,20 +64,33 @@ pub fn validate_ingest_document(
let item = match resolve_item(title) {
Ok(v) => v.ontology_leaf(),
Err(msg) => {
- return Err((StatusCode::BAD_REQUEST, "invalid item path".to_string(), Some(msg)));
+ return Err((
+ StatusCode::BAD_REQUEST,
+ "invalid item path".to_string(),
+ Some(msg),
+ ));
}
};
let Some(body_text) = body else {
return Err((
StatusCode::BAD_REQUEST,
- format!("item missing body: {}", GardenItemUrl::from_stored(&item, room_wire)),
- Some("items must be declared with bodies, e.g. `~/path/item { ... }`".to_string()),
+ format!(
+ "item missing body: {}",
+ GardenItemUrl::from_stored(&item, room_wire)
+ ),
+ Some(
+ "items must be declared with bodies, e.g. `~/path/item { ... }`"
+ .to_string(),
+ ),
));
};
if body_text.trim().is_empty() {
return Err((
StatusCode::BAD_REQUEST,
- format!("item body is empty: {}", GardenItemUrl::from_stored(&item, room_wire)),
+ format!(
+ "item body is empty: {}",
+ GardenItemUrl::from_stored(&item, room_wire)
+ ),
Some("write at least one sentence inside `{ ... }`".to_string()),
));
}
@@ -182,8 +199,5 @@ pub fn validate_ingest_document(
}
pub fn normalize_room_and_thread(room: &str, thread_tag: &str) -> Result<(String, String), String> {
- Ok((
- room.trim().to_string(),
- validate_thread_tag(thread_tag)?,
- ))
+ Ok((room.trim().to_string(), validate_thread_tag(thread_tag)?))
}
diff --git a/server/src/api/write_actor.rs b/server/src/api/write_actor.rs
index b2468416bfac1027390a5e6d75662992ce4cddfb..e4177f1984c2ccbce569509fa78810e1d51eadce 100644
--- a/server/src/api/write_actor.rs
+++ b/server/src/api/write_actor.rs
@@ -7,7 +7,10 @@ use tokio::sync::mpsc;
use crate::{
dsl,
- events::{AgentBound, Event, GrantAdded, Ingest, PostRedacted, RoomDeleted, ThreadGraduated, UserRegistered},
+ events::{
+ AgentBound, Event, GrantAdded, Ingest, PostRedacted, RoomDeleted, ThreadGraduated,
+ UserRegistered,
+ },
html::JsBuilder,
identity::parse_agent,
path_types::ItemId,
@@ -83,9 +86,14 @@ async fn broadcast_web_refresh(
crate::html::thread_feed_html_for_room(state, room_key).await
};
- let morphs: crate::html::ThreadRegionPageMorphs =
- crate::html::thread_region_page_morphs(state, Some(room_key), thread_id, None, changed_post_index)
- .await;
+ let morphs: crate::html::ThreadRegionPageMorphs = crate::html::thread_region_page_morphs(
+ state,
+ Some(room_key),
+ thread_id,
+ None,
+ changed_post_index,
+ )
+ .await;
// Two SSE payloads: the bump-list morph must not ship private HTML to subscribers who only
// matched `/` (public) or lack room access — see [`crate::api::stream::get_html_stream`].
@@ -528,7 +536,9 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) {
} => {
let out = async {
let (room_key, thread_id) = normalize_room_and_thread(&room, &thread_tag)
- .map_err(|msg| (msg, Some("system thread tags must not contain '/'".into())))?;
+ .map_err(|msg| {
+ (msg, Some("system thread tags must not contain '/'".into()))
+ })?;
let scope = scope_from_room_wire(&room_key);
let is_private = !matches!(scope, ScopeId::Public);
let mut reduced = state.reduced.write().await;
@@ -852,12 +862,8 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) {
let Some(source) = reduced.ingests_by_id.get(source_id).cloned() else {
continue;
};
- let v = validate_ingest_document(
- &reduced,
- &source.raw,
- &ScopeId::Public,
- )
- .map_err(|(_, m, h)| (m, h))?;
+ let v = validate_ingest_document(&reduced, &source.raw, &ScopeId::Public)
+ .map_err(|(_, m, h)| (m, h))?;
let new_post_id = uuid::Uuid::new_v4().to_string();
let ingest_event = Event::Ingest(Ingest {
diff --git a/server/src/dsl.rs b/server/src/dsl.rs
index f50821fc653bd28ad90bf1025aaecf47ad772a5b..036984ae78cfc037ea99bafcd4705de3629cf34b 100644
--- a/server/src/dsl.rs
+++ b/server/src/dsl.rs
@@ -801,17 +801,17 @@ fn parse_block_prefixed_statement(
Ok(stmts)
}
-fn parse_item_definition_statement(stripped: &str, masker: &BlockMasker) -> Result, DslError> {
+fn parse_item_definition_statement(
+ stripped: &str,
+ masker: &BlockMasker,
+) -> Result, DslError> {
let (item1_raw, j) = parse_item_name_at(stripped, 0)
.ok_or_else(|| DslError::Parse("invalid item name".to_string()))?;
let i = skip_ws(stripped, j);
if i >= stripped.len() {
let (title, mut stmts) = sugar_containments(&item1_raw);
- stmts.push(Stmt::Item {
- title,
- body: None,
- });
+ stmts.push(Stmt::Item { title, body: None });
return Ok(stmts);
}
@@ -1299,7 +1299,11 @@ mod tests {
let doc = parse_full("{prefer a}\n~/a 100:1 ~/b").unwrap();
assert!(matches!(
doc.statements.last(),
- Some(Stmt::Vote { ratio_left: 100, ratio_right: 1, .. })
+ Some(Stmt::Vote {
+ ratio_left: 100,
+ ratio_right: 1,
+ ..
+ })
));
}
@@ -1699,7 +1703,10 @@ mod tests {
aspect: None,
}]
);
- assert!(containments(&doc).is_empty(), "URL items are never desugared");
+ assert!(
+ containments(&doc).is_empty(),
+ "URL items are never desugared"
+ );
}
#[test]
diff --git a/server/src/event_log.rs b/server/src/event_log.rs
index eaae0d495e43a45d6590603892265a62cc92906e..c578d25b4ac13a0d5d031a77bf0c642000edca60 100644
--- a/server/src/event_log.rs
+++ b/server/src/event_log.rs
@@ -79,5 +79,3 @@ impl EventLog {
Ok((events, bad_lines))
}
}
-
-
diff --git a/server/src/html/auth.rs b/server/src/html/auth.rs
index b066d651e19a9f3929c93118cfd7862792c38f0d..699b02538452ad0ee6f458dca4fc6f9c715dc728 100644
--- a/server/src/html/auth.rs
+++ b/server/src/html/auth.rs
@@ -23,7 +23,12 @@ fn form_inner(session: &str, error: Option<&str>) -> Markup {
}
}
-pub fn choose_username_page(session: &str, error: Option<&str>, theme: &str, theme_next: &str) -> Markup {
+pub fn choose_username_page(
+ session: &str,
+ error: Option<&str>,
+ theme: &str,
+ theme_next: &str,
+) -> Markup {
let body = html! {
nav.breadcrumb {
a href="/" { "slug.social" }
@@ -36,7 +41,16 @@ pub fn choose_username_page(session: &str, error: Option<&str>, theme: &str, the
(form_inner(session, error))
}
};
- super::layout("join — slug.social", "view-auth", body, None, theme, theme_next, None, None)
+ super::layout(
+ "join — slug.social",
+ "view-auth",
+ body,
+ None,
+ theme,
+ theme_next,
+ None,
+ None,
+ )
}
/// Fragment returned to the poem JS on error — replaces the form's innerHTML.
@@ -63,5 +77,14 @@ pub fn auth_complete_page(theme: &str, theme_next: &str) -> Markup {
p { "Return to your terminal — your agent is polling and will collect your token automatically." }
p.auth-hint { "You can close this tab." }
};
- super::layout("signed in — slug.social", "view-auth", body, None, theme, theme_next, None, None)
+ super::layout(
+ "signed in — slug.social",
+ "view-auth",
+ body,
+ None,
+ theme,
+ theme_next,
+ None,
+ None,
+ )
}
diff --git a/server/src/html/breadcrumb_path.rs b/server/src/html/breadcrumb_path.rs
index 6a970d4fef62113baca356f2a9beea46ddf0a3d3..a0abb8b706f606f2bd4053f386fc6ce072adb07b 100644
--- a/server/src/html/breadcrumb_path.rs
+++ b/server/src/html/breadcrumb_path.rs
@@ -29,15 +29,21 @@ impl OntologyPath {
}
/// Nested `x/luke` → canonical leaf path `/~/luke`. `luke` (already a leaf) → `None`.
+ /// A trailing slash on a leaf (`luke/`) or nested source (`x/luke/`) also redirects.
///
/// Mirrors [`ExternalOntologyPath::legacy_redirect_target`]: callers issue a permanent
/// redirect and preserve the query string.
pub(super) fn nested_redirect_target(request_path: &str) -> Option {
- let item = tilde_http_path_to_item_id(request_path);
+ let trimmed = request_path.trim_matches('/');
+ let had_trailing_slash = request_path.ends_with('/');
+ let item = tilde_http_path_to_item_id(trimmed);
let leaf = item.ontology_leaf();
- if leaf.as_str() == item.as_str() {
+ if leaf.as_str() == item.as_str() && !had_trailing_slash {
return None;
}
+ if leaf.tilde_tail() == Some("") {
+ return Some("/~".to_string());
+ }
let disp = leaf.display_path();
Some(format!("/{}", disp.trim_start_matches('/')))
}
@@ -177,24 +183,15 @@ mod tests {
let host = ExternalOntologyPath::from_input("example.com");
assert!(!host.is_root());
assert_eq!(host.breadcrumb_chain().len(), 1);
- assert_eq!(
- host.breadcrumb_chain()[0].as_str(),
- "https://example.com"
- );
+ assert_eq!(host.breadcrumb_chain()[0].as_str(), "https://example.com");
}
#[test]
fn external_path_keeps_each_url_segment_for_breadcrumbs() {
let path = ExternalOntologyPath::from_input("https://example.com/a/b");
assert_eq!(path.breadcrumb_chain().len(), 3);
- assert_eq!(
- path.breadcrumb_chain()[0].as_str(),
- "https://example.com"
- );
- assert_eq!(
- path.breadcrumb_chain()[1].as_str(),
- "https://example.com/a"
- );
+ assert_eq!(path.breadcrumb_chain()[0].as_str(), "https://example.com");
+ assert_eq!(path.breadcrumb_chain()[1].as_str(), "https://example.com/a");
assert_eq!(
path.breadcrumb_chain()[2].as_str(),
"https://example.com/a/b"
@@ -235,5 +232,13 @@ mod tests {
);
assert_eq!(OntologyPath::nested_redirect_target("luke"), None);
assert_eq!(OntologyPath::nested_redirect_target(""), None);
+ assert_eq!(
+ OntologyPath::nested_redirect_target("luke/").as_deref(),
+ Some("/~/luke")
+ );
+ assert_eq!(
+ OntologyPath::nested_redirect_target("x/luke/").as_deref(),
+ Some("/~/luke")
+ );
}
}
diff --git a/server/src/html/editor.rs b/server/src/html/editor.rs
index 542e8067021b1c4e602896af4a48cfe3b66ab020..40bd231bc17fde96129489637f51828b613006eb 100644
--- a/server/src/html/editor.rs
+++ b/server/src/html/editor.rs
@@ -9,11 +9,8 @@ use maud::{html, Markup};
use serde::Deserialize;
use crate::{
- api::validate_ingest_document,
- html::JsBuilder,
- middleware::canonical_view_url,
- reducer::ScopeId,
- state::AppState,
+ api::validate_ingest_document, html::JsBuilder, middleware::canonical_view_url,
+ reducer::ScopeId, state::AppState,
};
use super::{bc_segment, layout, theme_from_jar, theme_next_from_uri};
@@ -26,7 +23,11 @@ fn bc_try() -> Markup {
}
/// The interactive editor page — `/try`.
-pub async fn editor_page(State(state): State, jar: CookieJar, uri: Uri) -> impl IntoResponse {
+pub async fn editor_page(
+ State(state): State,
+ jar: CookieJar,
+ uri: Uri,
+) -> impl IntoResponse {
let url_key = canonical_view_url(&uri);
let view_count = state.views.get_views(&url_key);
@@ -80,10 +81,7 @@ pub async fn editor_check(
let results = html! {
div id="editor-results" {}
};
- (
- html! { div id="editor-status" { (status) } },
- results,
- )
+ (html! { div id="editor-status" { (status) } }, results)
}
Ok(v) => {
drop(reduced);
@@ -144,10 +142,7 @@ pub async fn editor_check(
}
};
- (
- html! { div id="editor-status" { (status) } },
- results,
- )
+ (html! { div id="editor-status" { (status) } }, results)
}
};
diff --git a/server/src/html/forum/copy.rs b/server/src/html/forum/copy.rs
index 4c878921a51e23ff4fdf1bc28037847f69098876..17fc8694ae081e58b212f53447d75c74c8f0d83f 100644
--- a/server/src/html/forum/copy.rs
+++ b/server/src/html/forum/copy.rs
@@ -9,7 +9,7 @@ use super::access::user_can_view_room;
use super::ingest::thread_ui_fetch_onclick;
use super::nav::ThreadNav;
use crate::html::ui_action::HtmlUiAction;
-use crate::html::{JsBuilder, now_ms};
+use crate::html::{now_ms, JsBuilder};
/// CLI `forum show` and browser copy-thread text (`slug_types::thread_xml`).
pub(crate) fn format_thread_cli_text(
diff --git a/server/src/html/forum/feed.rs b/server/src/html/forum/feed.rs
index f7180716f02a30ae76d51e15cd9581ba69e64e1a..a96dc1f009cebc5160a4e085785f84cc446dd340 100644
--- a/server/src/html/forum/feed.rs
+++ b/server/src/html/forum/feed.rs
@@ -17,9 +17,7 @@ use super::ingest::ingest_entry_markup;
use super::nav::ThreadNav;
use super::new_thread::{fragment_new_thread_slot, login_to_post_hint_markup};
use super::page::auth_strip;
-use super::paginator::{
- latest_page_offset, render_thread_paginator, snap_page_offset, PAGE_SIZE,
-};
+use super::paginator::{latest_page_offset, render_thread_paginator, snap_page_offset, PAGE_SIZE};
use crate::html::{
bc_threads, cli_panel, layout_with_post_stats, now_ms, recency_class, recency_color_style,
theme_from_jar, theme_next_from_uri,
@@ -41,7 +39,11 @@ pub(super) struct RoomRow {
pub(super) last_ts: i64,
}
-pub(super) fn collect_thread_rows_for_scope(reduced: &ReducerState, scope: &ScopeId, now: i64) -> Vec {
+pub(super) fn collect_thread_rows_for_scope(
+ reduced: &ReducerState,
+ scope: &ScopeId,
+ now: i64,
+) -> Vec {
let _ = now;
reduced
.forum_threads
@@ -80,7 +82,12 @@ pub(super) fn rooms_for_user(reduced: &ReducerState, username: &str) -> Vec, feed_id: &str, rows: &[ThreadRow], now: i64) -> Markup {
+pub(super) fn render_thread_feed(
+ nav: Option<&ThreadNav>,
+ feed_id: &str,
+ rows: &[ThreadRow],
+ now: i64,
+) -> Markup {
html! {
div id=(feed_id) {
@if rows.is_empty() {
diff --git a/server/src/html/forum/ingest.rs b/server/src/html/forum/ingest.rs
index 3bc01de3e93686ce5c5c0599b7942c0c2f4eb308..ab14af3fc94be6b87953c072303de8f5f522280a 100644
--- a/server/src/html/forum/ingest.rs
+++ b/server/src/html/forum/ingest.rs
@@ -30,7 +30,10 @@ pub(super) fn thread_nav_for_ingest(ing: &crate::events::Ingest) -> Option Option {
+pub(super) fn thread_post_index_in_scope(
+ reduced: &ReducerState,
+ ing: &crate::events::Ingest,
+) -> Option {
let scope = scope_from_room_wire(&ing.room_id);
reduced.try_thread_post_index_chronological(&scope, &ing.thread_tag, &ing.id)
}
@@ -109,7 +112,16 @@ pub(super) fn redacted_header_row(
now: i64,
expanded: bool,
) -> Markup {
- let meta = post_header_meta(nav, tag, post_idx, &ing.principal, &ing.delegate, ing.ts, now, None);
+ let meta = post_header_meta(
+ nav,
+ tag,
+ post_idx,
+ &ing.principal,
+ &ing.delegate,
+ ing.ts,
+ now,
+ None,
+ );
let rpc_expand = template_json_compact(&json!({
"action": "expand_redacted_post",
"room": nav.room_wire,
@@ -184,7 +196,9 @@ pub(crate) fn ingest_entry_markup(
} else {
"ingest-entry"
};
- let item_bodies = reduced.content_for_scope(&nav.scope()).map(|c| &c.item_bodies);
+ let item_bodies = reduced
+ .content_for_scope(&nav.scope())
+ .map(|c| &c.item_bodies);
html! {
div class=(entry_class) id=(frag.as_str()) data-ingest-id=(ing.id) {
(post_header_row(nav, tag, post_idx, ing, viewer, now, show_delete))
diff --git a/server/src/html/forum/mod.rs b/server/src/html/forum/mod.rs
index 8bb880ca96678b4aa886ce29840d15a602293ac7..3c39a4c7d4ee9172998aa9d8697642e75899c806 100644
--- a/server/src/html/forum/mod.rs
+++ b/server/src/html/forum/mod.rs
@@ -19,8 +19,8 @@ pub use feed::{home, thread_feed_html, thread_feed_html_for_room};
pub(crate) use feed::{
thread_latest_page_region, thread_region_page_morphs, ThreadRegionPageMorphs,
};
-pub(crate) use paginator::PAGE_SIZE as THREAD_PAGE_SIZE;
pub use nav::ThreadNav;
+pub(crate) use paginator::PAGE_SIZE as THREAD_PAGE_SIZE;
pub use post_single::{room_thread_post_view, thread_post_view};
pub use profile::user_profile_page;
pub use views::{room_page, room_thread_view, thread_view};
diff --git a/server/src/html/forum/nav.rs b/server/src/html/forum/nav.rs
index b9afec5f621a52ffc1a6bd3e072778bbb36ccce9..2d08cf5e13ac55651043d17407273c3922a0575f 100644
--- a/server/src/html/forum/nav.rs
+++ b/server/src/html/forum/nav.rs
@@ -78,7 +78,11 @@ impl ThreadNav {
let ext_prefix = format!("{}-", self.garden_path_prefix.trim_end_matches('~'));
format!("{ext_prefix}/{rest}")
} else {
- format!("{}/{}", self.garden_path_prefix, canonicalize_item(c.as_str()))
+ format!(
+ "{}/{}",
+ self.garden_path_prefix,
+ canonicalize_item(c.as_str())
+ )
}
}
@@ -115,6 +119,15 @@ impl ThreadNav {
}
}
}
+
+ /// Shareable question page: `/q/:leaf` or `/q/:leaf/:aspect` (room-prefixed when private).
+ pub(crate) fn question_href(&self, collection_leaf: &str, aspect: Option<&str>) -> String {
+ let prefix = self.room_path_prefix_for_vote_compare();
+ match aspect.filter(|s| !s.is_empty()) {
+ Some(a) => format!("{prefix}/q/{collection_leaf}/{a}"),
+ None => format!("{prefix}/q/{collection_leaf}"),
+ }
+ }
}
#[cfg(test)]
@@ -124,8 +137,14 @@ mod tests {
#[test]
fn thread_url_for_post_uses_page_offset_and_fragment() {
let nav = ThreadNav::public();
- assert_eq!(nav.thread_url_for_post("hist-test", 0), "/t/hist-test#post-0");
- assert_eq!(nav.thread_url_for_post("hist-test", 9), "/t/hist-test#post-9");
+ assert_eq!(
+ nav.thread_url_for_post("hist-test", 0),
+ "/t/hist-test#post-0"
+ );
+ assert_eq!(
+ nav.thread_url_for_post("hist-test", 9),
+ "/t/hist-test#post-9"
+ );
assert_eq!(
nav.thread_url_for_post("hist-test", 10),
"/t/hist-test?offset=10#post-10"
@@ -145,4 +164,19 @@ mod tests {
);
assert_eq!(nav.post_url("topic", 12), "/r/abcd123demo/t/topic/12");
}
+
+ #[test]
+ fn question_href_public_and_room() {
+ let nav = ThreadNav::public();
+ assert_eq!(nav.question_href("psalms", None), "/q/psalms");
+ assert_eq!(
+ nav.question_href("psalms", Some("beauty")),
+ "/q/psalms/beauty"
+ );
+ let room = ThreadNav::from_room_id("abcd123/demo").unwrap();
+ assert_eq!(
+ room.question_href("psalms", Some("beauty")),
+ "/r/abcd123demo/q/psalms/beauty"
+ );
+ }
}
diff --git a/server/src/html/forum/new_thread.rs b/server/src/html/forum/new_thread.rs
index 4d0f1386608707bf58f4b8bddeeacdd11334c1a7..a93341119ed05885e06f365e5f4def740184df31 100644
--- a/server/src/html/forum/new_thread.rs
+++ b/server/src/html/forum/new_thread.rs
@@ -70,14 +70,22 @@ fn new_thread_slot_inner(nav: &ThreadNav, compose_expanded: bool) -> Markup {
}
}
-pub(crate) fn new_thread_slot_markup(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup {
+pub(crate) fn new_thread_slot_markup(
+ nav: &ThreadNav,
+ show: bool,
+ compose_expanded: bool,
+) -> Markup {
if !show {
return html! {};
}
new_thread_slot_inner(nav, compose_expanded)
}
-pub(crate) fn fragment_new_thread_slot(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup {
+pub(crate) fn fragment_new_thread_slot(
+ nav: &ThreadNav,
+ show: bool,
+ compose_expanded: bool,
+) -> Markup {
new_thread_slot_markup(nav, show, compose_expanded)
}
diff --git a/server/src/html/forum/page.rs b/server/src/html/forum/page.rs
index 57166acedbbdcfa5d45a33b61f21b2257ed7dca9..2697f0a3826b543acec842ed08f2d0f88a2470eb 100644
--- a/server/src/html/forum/page.rs
+++ b/server/src/html/forum/page.rs
@@ -8,11 +8,7 @@ use crate::reducer::ReducerState;
use super::nav::ThreadNav;
use crate::html::bc_segment;
-pub(super) fn auth_strip(
- headers: &HeaderMap,
- jar: &CookieJar,
- reduced: &ReducerState,
-) -> Markup {
+pub(super) fn auth_strip(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Markup {
match optional_principal(headers, jar, reduced) {
Some(u) => html! {
p class="muted auth-strip" {
diff --git a/server/src/html/forum/paginator.rs b/server/src/html/forum/paginator.rs
index 68459d5b599424f2a7668212476ac7ae1818d78c..5d930227fd31c27df5c99a258f569402f538ccda 100644
--- a/server/src/html/forum/paginator.rs
+++ b/server/src/html/forum/paginator.rs
@@ -25,9 +25,18 @@ pub(crate) fn snap_page_offset(requested: usize, total: usize) -> usize {
}
/// Prev/next links between chronological posts on a single-post (`/t/tag/N`) page.
-pub(super) fn render_post_permalink_nav(nav: &ThreadNav, tag: &str, index: usize, total: usize) -> Markup {
+pub(super) fn render_post_permalink_nav(
+ nav: &ThreadNav,
+ tag: &str,
+ index: usize,
+ total: usize,
+) -> Markup {
let prev_idx = index.checked_sub(1);
- let next_idx = if index + 1 < total { Some(index + 1) } else { None };
+ let next_idx = if index + 1 < total {
+ Some(index + 1)
+ } else {
+ None
+ };
html! {
div class="post-nav" {
@if let Some(i) = prev_idx {
@@ -47,7 +56,13 @@ pub(super) fn render_post_permalink_nav(nav: &ThreadNav, tag: &str, index: usize
}
}
-pub(super) fn render_thread_paginator(nav: &ThreadNav, tag: &str, offset: usize, total: usize, top: bool) -> Markup {
+pub(super) fn render_thread_paginator(
+ nav: &ThreadNav,
+ tag: &str,
+ offset: usize,
+ total: usize,
+ top: bool,
+) -> Markup {
let newer_offset = offset.checked_add(PAGE_SIZE).filter(|&o| o < total);
let older_offset = if offset > 0 {
Some(offset.saturating_sub(PAGE_SIZE))
diff --git a/server/src/html/forum/post_single.rs b/server/src/html/forum/post_single.rs
index 91866b2cc3dea98d0c3847f54379245eca67894c..057e233c6f9bdf9c2b5ebac15d96e0d9ff7f894a 100644
--- a/server/src/html/forum/post_single.rs
+++ b/server/src/html/forum/post_single.rs
@@ -15,8 +15,8 @@ use crate::state::AppState;
use super::access::user_can_view_room;
use super::ingest::ingest_entry_markup;
use super::nav::ThreadNav;
-use super::paginator::render_post_permalink_nav;
use super::page::bc_room;
+use super::paginator::render_post_permalink_nav;
use super::views::room_not_found_page;
use crate::html::{bc_threads, layout, now_ms, theme_from_jar, theme_next_from_uri};
use maud::Markup;
@@ -51,7 +51,12 @@ async fn thread_post_view_inner(
ingest_entry_markup(&nav, &tag, nav_index, i, viewer.as_deref(), now, &reduced)
});
let permalink_nav = if total_posts > 1 {
- Some(render_post_permalink_nav(&nav, &tag, nav_index, total_posts))
+ Some(render_post_permalink_nav(
+ &nav,
+ &tag,
+ nav_index,
+ total_posts,
+ ))
} else {
None
};
@@ -110,8 +115,7 @@ pub async fn thread_post_view(
let reduced = state.reduced.read().await;
let viewer = optional_principal(&headers, &jar, &reduced);
drop(reduced);
- thread_post_view_inner(state, tag, index_str, ThreadNav::public(), viewer, jar, uri)
- .await
+ thread_post_view_inner(state, tag, index_str, ThreadNav::public(), viewer, jar, uri).await
}
pub async fn room_thread_post_view(
diff --git a/server/src/html/forum/profile.rs b/server/src/html/forum/profile.rs
index d3ad20cfe1aa11bd20afc05da34d14d57f0ac053..9c0a06dd2f1bf61896116ee54ea15546fa81be08 100644
--- a/server/src/html/forum/profile.rs
+++ b/server/src/html/forum/profile.rs
@@ -14,11 +14,11 @@ use crate::state::AppState;
use super::ingest::{thread_nav_for_ingest, thread_post_index_in_scope};
use super::page::auth_strip;
-use crate::timeago;
use crate::html::{
bc_segment, cli_panel, layout, now_ms, profile_href, recency_color_style, theme_from_jar,
theme_next_from_uri,
};
+use crate::timeago;
struct ProfilePostRow {
thread_tag: String,
diff --git a/server/src/html/forum/thread_morph.rs b/server/src/html/forum/thread_morph.rs
index 53f026e6b49d8c6a59c2012ff0277ce935b21930..e75375f5986bbc43230334cfce605bf34f980f03 100644
--- a/server/src/html/forum/thread_morph.rs
+++ b/server/src/html/forum/thread_morph.rs
@@ -8,7 +8,7 @@ use crate::state::AppState;
use super::access::user_can_view_room;
use super::ingest::{ingest_entry_markup, post_header_row, redacted_header_row};
use super::nav::{post_fragment_id, ThreadNav};
-use crate::html::{render_linkified_with_embeds_in_scope, JsBuilder, now_ms};
+use crate::html::{now_ms, render_linkified_with_embeds_in_scope, JsBuilder};
pub(crate) async fn thread_ui_expand_post_full(
state: &AppState,
diff --git a/server/src/html/forum/views.rs b/server/src/html/forum/views.rs
index d457297f55750540d8b85015c7bd45d85d572c2a..30d2aeb2fc2eae6f93966547d2c318d2efa00674 100644
--- a/server/src/html/forum/views.rs
+++ b/server/src/html/forum/views.rs
@@ -15,9 +15,9 @@ use crate::middleware::canonical_view_url;
use crate::reducer::ScopeId;
use crate::state::AppState;
+use super::access::user_can_manage_room;
use super::access::user_can_post_room;
use super::access::user_can_view_room;
-use super::access::user_can_manage_room;
use super::feed::{collect_thread_rows_for_scope, render_thread_feed};
use super::graduate::{thread_graduate_button_markup, thread_graduated_banner_markup};
use super::ingest::ingest_entry_markup;
@@ -27,9 +27,7 @@ use super::page::{auth_strip, bc_room};
use super::paginator::{render_thread_paginator, snap_page_offset, PAGE_SIZE};
use super::room_members::room_members_section_markup;
use crate::html::ui_action::UI_RPC_FIELD;
-use crate::html::{
- bc_threads, cli_panel, layout, now_ms, theme_from_jar, theme_next_from_uri,
-};
+use crate::html::{bc_threads, cli_panel, layout, now_ms, theme_from_jar, theme_next_from_uri};
fn compose_form(nav: &ThreadNav, thread_tag: &str, show: bool) -> Markup {
if !show {
@@ -118,7 +116,10 @@ async fn thread_view_inner(
.is_some_and(|rid| reduced.is_thread_graduated(rid, &tag));
let can_manage = room_id
.as_ref()
- .and_then(|rid| user.as_ref().map(|u| user_can_manage_room(&reduced, rid, u)))
+ .and_then(|rid| {
+ user.as_ref()
+ .map(|u| user_can_manage_room(&reduced, rid, u))
+ })
.unwrap_or(false);
let show_compose = !graduated
&& match &sc {
@@ -299,8 +300,7 @@ pub async fn room_page(
drop(reduced);
return (StatusCode::NOT_FOUND, "room not found").into_response();
};
- let members_markup =
- room_members_section_markup(&reduced, &room_id, false, user.as_deref());
+ let members_markup = room_members_section_markup(&reduced, &room_id, false, user.as_deref());
let forum_cli = format!("npx slugsocial private {room_id} forum list");
let garden_cli = format!("npx slugsocial private {room_id} garden tree");
let audit_cli = format!("npx slugsocial private {room_id} audit");
diff --git a/server/src/html/garden/access.rs b/server/src/html/garden/access.rs
index 284b8b10438d48c98627de7d1b06d7cc7fbc8d27..35199c8486775040eb276353df10dd06066d8509 100644
--- a/server/src/html/garden/access.rs
+++ b/server/src/html/garden/access.rs
@@ -7,12 +7,7 @@ use maud::html;
use crate::{
events::ThreadCapability,
- html::{
- forum::ThreadNav,
- layout,
- theme_from_jar,
- theme_next_from_uri,
- },
+ html::{forum::ThreadNav, layout, theme_from_jar, theme_next_from_uri},
reducer::{ContentState, ReducerState, ScopeId},
};
@@ -36,7 +31,11 @@ pub(super) fn room_not_found_page(jar: &CookieJar, uri: &Uri) -> impl IntoRespon
(StatusCode::NOT_FOUND, Html(page.into_string()))
}
-pub(super) fn user_can_view_room(reduced: &ReducerState, room_id: &str, username: Option<&str>) -> bool {
+pub(super) fn user_can_view_room(
+ reduced: &ReducerState,
+ room_id: &str,
+ username: Option<&str>,
+) -> bool {
if !reduced.rooms.contains(room_id) {
return false;
}
diff --git a/server/src/html/garden/browse.rs b/server/src/html/garden/browse.rs
index bd9aea9183e7f0b8edbff6a804fff16759f08a05..2d32bbfd428a90df44574221481ada7657df1a80 100644
--- a/server/src/html/garden/browse.rs
+++ b/server/src/html/garden/browse.rs
@@ -2,9 +2,9 @@ use maud::html;
use crate::{
html::{
+ bc_path_external, bc_segment,
breadcrumb_path::{ExternalOntologyPath, OntologyPath},
forum::ThreadNav,
- bc_path_external, bc_segment,
},
path_types::ItemId,
reducer::ScopeId,
@@ -14,7 +14,10 @@ pub(super) fn garden_layout_meta(nav: &ThreadNav) -> (String, String) {
(nav.room_wire.clone(), nav.garden_root_url().to_string())
}
-pub(super) fn scoped_bc_path_external(path: &ExternalOntologyPath, nav: &ThreadNav) -> maud::Markup {
+pub(super) fn scoped_bc_path_external(
+ path: &ExternalOntologyPath,
+ nav: &ThreadNav,
+) -> maud::Markup {
match nav.scope() {
ScopeId::Public => bc_path_external(path),
ScopeId::Room(rid) => {
diff --git a/server/src/html/garden/copy.rs b/server/src/html/garden/copy.rs
index 40816681f3ec3501e110b16d428ecae5d22e7ad4..19b612e8b3f2cf00c980c2ea6f39bd318a484a6b 100644
--- a/server/src/html/garden/copy.rs
+++ b/server/src/html/garden/copy.rs
@@ -4,7 +4,7 @@ use crate::form_template::template_json_compact;
use crate::html::forum::ThreadNav;
use crate::html::js_string_literal;
use crate::html::ui_action::HtmlUiAction;
-use crate::html::{JsBuilder, ui_js_warn};
+use crate::html::{ui_js_warn, JsBuilder};
use crate::path_types::ItemId;
use crate::reducer::scope_from_room_wire;
use crate::scope_rank::{
@@ -68,7 +68,8 @@ fn rankings_for_copy(
let hosts = external_root_host_items(state_content);
return build_rankings_for_item_set(state_content, &hosts);
}
- let parent = ItemId::parse(parent_path.trim()).map(|id| id.ontology_leaf())
+ let parent = ItemId::parse(parent_path.trim())
+ .map(|id| id.ontology_leaf())
.unwrap_or_else(|| ItemId::ontology_root())
.normalized_storage();
let depth = depth.max(1);
diff --git a/server/src/html/garden/external.rs b/server/src/html/garden/external.rs
index 7b6c5c7fb15add39aafbb5d708c4aac6d91771c4..39177205bee0ded1f253b41c7205147266918d53 100644
--- a/server/src/html/garden/external.rs
+++ b/server/src/html/garden/external.rs
@@ -2,7 +2,10 @@ use maud::html;
use crate::{
form_template::template_json_compact,
- html::{forum::ThreadNav, ui_action::{HtmlUiAction, UI_RPC_FIELD}},
+ html::{
+ forum::ThreadNav,
+ ui_action::{HtmlUiAction, UI_RPC_FIELD},
+ },
path_types::ItemId,
};
@@ -38,16 +41,22 @@ pub(super) fn external_frame_allowed(item: &str) -> bool {
!matches!(host, "github.com" | "www.github.com")
}
-pub(super) fn github_resolver_controls(item: &str, nav: &ThreadNav, next: &str) -> Option {
+pub(super) fn external_resolver_controls(
+ item: &str,
+ nav: &ThreadNav,
+ next: &str,
+) -> Option {
let item_id = ItemId::parse(item)?.normalized_storage();
let url = url::Url::parse(item_id.as_str()).ok()?;
- if !url
- .host_str()
- .map(|h| h.eq_ignore_ascii_case("github.com"))
- .unwrap_or(false)
- {
- return None;
- }
+ let host = url.host_str()?.to_ascii_lowercase();
+ let (source, testid_prefix) = match host.as_str() {
+ "github.com" => ("GitHub", "github"),
+ "www.are.na" | "are.na" => ("Are.na", "arena"),
+ _ => return None,
+ };
+ // are.na block URLs are not path-children of their channel, so sibling
+ // resolution is meaningless there; only GitHub items get a siblings button.
+ let siblings_allowed = host == "github.com";
let children_rpc = template_json_compact(&HtmlUiAction::ResolveExternal {
room_wire: nav.room_wire.clone(),
item_storage: item_id.as_str().to_string(),
@@ -56,32 +65,36 @@ pub(super) fn github_resolver_controls(item: &str, nav: &ThreadNav, next: &str)
form_action: "/ui".to_string(),
})
.ok()?;
- let siblings_rpc = item_id.parent().and_then(|_| {
- template_json_compact(&HtmlUiAction::ResolveExternal {
- room_wire: nav.room_wire.clone(),
- item_storage: item_id.as_str().to_string(),
- mode: "siblings".to_string(),
- next: next.to_string(),
- form_action: "/ui".to_string(),
+ let siblings_rpc = if siblings_allowed {
+ item_id.parent().and_then(|_| {
+ template_json_compact(&HtmlUiAction::ResolveExternal {
+ room_wire: nav.room_wire.clone(),
+ item_storage: item_id.as_str().to_string(),
+ mode: "siblings".to_string(),
+ next: next.to_string(),
+ form_action: "/ui".to_string(),
+ })
+ .ok()
})
- .ok()
- });
+ } else {
+ None
+ };
Some(html! {
section id="external-resolver-panel" class="ont-tab-panel ont-external-resolver" {
- h3 { "GitHub resolver" }
+ h3 { (source) " resolver" }
p class="muted" {
- "Import GitHub neighbors on demand. Results are saved as system ingests."
+ "Import " (source) " neighbors on demand. Results are saved as system ingests."
}
div class="resolver-actions" {
form method="POST" action="/ui" {
input type="hidden" name=(UI_RPC_FIELD) value=(children_rpc);
- button type="submit" data-testid="github-resolve-children" { "Load / refresh children from GitHub" }
+ button type="submit" data-testid=(format!("{testid_prefix}-resolve-children")) { "Load / refresh children from " (source) }
}
@if let Some(rpc) = siblings_rpc {
form method="POST" action="/ui" {
input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
- button type="submit" data-testid="github-resolve-siblings" { "Load siblings from GitHub" }
+ button type="submit" data-testid=(format!("{testid_prefix}-resolve-siblings")) { "Load siblings from " (source) }
}
}
a class="resolver-refresh-link" href=(next) { "Refresh page" }
@@ -92,7 +105,7 @@ pub(super) fn github_resolver_controls(item: &str, nav: &ThreadNav, next: &str)
}
pub(crate) fn external_resolver_status_markup(
- imported: Result,
+ imported: Result<(crate::resolvers::ResolveStats, &str), &str>,
next: &str,
) -> maud::Markup {
let next = if next.trim().starts_with('/') && !next.trim().starts_with("//") {
@@ -102,9 +115,9 @@ pub(crate) fn external_resolver_status_markup(
};
html! {
@match imported {
- Ok(stats) => {
+ Ok((stats, source)) => {
p class="resolver-status-ok" {
- (github_resolve_status_text(stats))
+ (resolve_status_text(stats, source))
" "
a href=(next) { "Refresh page" }
" to render the updated ontology."
@@ -117,14 +130,14 @@ pub(crate) fn external_resolver_status_markup(
}
}
-fn github_resolve_status_text(stats: crate::resolvers::GithubResolveStats) -> String {
+fn resolve_status_text(stats: crate::resolvers::ResolveStats, source: &str) -> String {
if stats.total_touched() == 0 {
- return "No GitHub children found.".to_string();
+ return format!("No {source} children found.");
}
let mut parts = Vec::new();
if stats.imported > 0 {
parts.push(format!(
- "Imported {} GitHub item{}.",
+ "Imported {} {source} item{}.",
stats.imported,
if stats.imported == 1 { "" } else { "s" }
));
@@ -139,18 +152,17 @@ fn github_resolve_status_text(stats: crate::resolvers::GithubResolveStats) -> St
if stats.kept > 0 {
if stats.imported == 0 && stats.deleted == 0 {
parts.push(format!(
- "Already up to date ({} open issue{}).",
+ "Already up to date ({} item{}).",
stats.kept,
if stats.kept == 1 { "" } else { "s" }
));
} else {
- parts.push(format!("Kept {} still-open.", stats.kept));
+ parts.push(format!("Kept {} current.", stats.kept));
}
}
if parts.is_empty() {
- "Updated GitHub items.".to_string()
+ format!("Updated {source} items.")
} else {
parts.join(" ")
}
}
-
diff --git a/server/src/html/garden/item.rs b/server/src/html/garden/item.rs
index b5a842a59956b1490b7109b9f058001389b747e9..ad46407e545a9a5ce7cd3c1f3dfe5f120c4457f2 100644
--- a/server/src/html/garden/item.rs
+++ b/server/src/html/garden/item.rs
@@ -1,11 +1,7 @@
use axum::http::Uri;
use maud::{html, Markup};
-use crate::{
- canonical_path::canonicalize_item,
- html::forum::ThreadNav,
- path_types::ItemId,
-};
+use crate::{canonical_path::canonicalize_item, html::forum::ThreadNav, path_types::ItemId};
/// Sentinel for "every descendant" ranking depth (`?depth=all`).
pub(super) const GARDEN_DEPTH_ALL: usize = usize::MAX;
@@ -44,10 +40,7 @@ pub(super) fn parse_garden_depth(raw: &str) -> usize {
// Percent-decoding for ∞ (%E2%88%9E) if a client encodes the glyph.
let decoded = percent_decode_basic(s);
let key = decoded.as_str();
- if matches!(
- key,
- "all" | "∞" | "inf" | "infinity" | "*" | "unlimited"
- ) {
+ if matches!(key, "all" | "∞" | "inf" | "infinity" | "*" | "unlimited") {
return GARDEN_DEPTH_ALL;
}
key.parse::().unwrap_or(1).max(1)
@@ -190,4 +183,4 @@ mod tests {
let all = garden_depth_select_markup(GARDEN_DEPTH_ALL).into_string();
assert!(all.contains("value=\"all\" selected"));
}
-}
\ No newline at end of file
+}
diff --git a/server/src/html/garden/item_page.rs b/server/src/html/garden/item_page.rs
index bfb75e5aad477461064e90bdc0dcb6de73672ced..74fd96d967a50a6608a1b1bf43a1db34f5d778d6 100644
--- a/server/src/html/garden/item_page.rs
+++ b/server/src/html/garden/item_page.rs
@@ -5,9 +5,7 @@ use maud::html;
use crate::{
html::forum::ThreadNav,
path_types::ItemId,
- reducer::{
- BorderPairState, ContentState, FallenBorderEntry, MembershipStatus, ScopeId,
- },
+ reducer::{BorderPairState, ContentState, FallenBorderEntry, MembershipStatus, ScopeId},
scope_rank::{
build_children_rankings, build_children_rankings_in_group, build_rankings_for_item_set,
resolve_scope_recursive, ChildrenRankings,
@@ -250,27 +248,28 @@ fn build_sibling_nav(
// Components are already sorted largest-first; only the first is the "largest ranking group".
let current_path = current.to_storage_string();
- let (largest_group_rank, winner_percentile) =
- match rankings.component_rankings.first() {
- Some(largest) if largest.ranked.len() >= 2 => {
- let of = largest.ranked.len();
- match largest.ranked.iter().position(|r| {
- r.item.clone().normalized_storage().as_str() == current_path
- }) {
- Some(idx) => {
- let rank = idx + 1;
- let pct = if rank == 1 {
- winner_percentile_for_group_size(of)
- } else {
- None
- };
- (Some((rank, of)), pct)
- }
- None => (None, None),
+ let (largest_group_rank, winner_percentile) = match rankings.component_rankings.first() {
+ Some(largest) if largest.ranked.len() >= 2 => {
+ let of = largest.ranked.len();
+ match largest
+ .ranked
+ .iter()
+ .position(|r| r.item.clone().normalized_storage().as_str() == current_path)
+ {
+ Some(idx) => {
+ let rank = idx + 1;
+ let pct = if rank == 1 {
+ winner_percentile_for_group_size(of)
+ } else {
+ None
+ };
+ (Some((rank, of)), pct)
}
+ None => (None, None),
}
- _ => (None, None),
- };
+ }
+ _ => (None, None),
+ };
Some(SiblingNavBar {
groups,
@@ -279,7 +278,11 @@ fn build_sibling_nav(
})
}
-pub(super) fn sibling_nav_markup(nav: &ThreadNav, bar: &SiblingNavBar, current_item: &str) -> maud::Markup {
+pub(super) fn sibling_nav_markup(
+ nav: &ThreadNav,
+ bar: &SiblingNavBar,
+ current_item: &str,
+) -> maud::Markup {
html! {
div class="ont-sibling-nav-wrap" {
nav class="breadcrumb ont-sibling-nav" aria-label="siblings under same parent" {
@@ -600,4 +603,3 @@ pub(super) fn build_item_page_view_model(
threads,
}
}
-
diff --git a/server/src/html/garden/mod.rs b/server/src/html/garden/mod.rs
index 69012a2c1d069e47fa611d63d3bd2a329fd6255d..bda048dae032bfd9d59cfd68a6b098ef684527a6 100644
--- a/server/src/html/garden/mod.rs
+++ b/server/src/html/garden/mod.rs
@@ -7,6 +7,7 @@ mod external;
mod item;
mod item_page;
mod pin;
+mod question;
mod render;
mod routes;
mod vote;
@@ -19,8 +20,12 @@ pub(crate) use external::external_resolver_status_markup;
pub(crate) use pin::{encode_pin_cookie_value, GARDEN_PIN_COOKIE};
pub(crate) use vote::vote_compare_post_success_js;
+pub use question::{
+ question_aspect_page, question_page, room_question_aspect_page, room_question_page,
+};
pub use routes::{
external_garden_index, external_ontology_path, garden_index, ontology_path,
- room_external_garden_index, room_external_ontology_path, room_garden_index, room_ontology_path,
+ redirect_strip_trailing_slash, room_external_garden_index, room_external_ontology_path,
+ room_garden_index, room_ontology_path,
};
pub use vote::{room_vote_compare_page, vote_compare_page};
diff --git a/server/src/html/garden/pin.rs b/server/src/html/garden/pin.rs
index 3069c8a8431fcf8900c3e008340fcc52c250d1f2..a02e100255b095ce469e581fcd5509cf347e8722 100644
--- a/server/src/html/garden/pin.rs
+++ b/server/src/html/garden/pin.rs
@@ -25,7 +25,9 @@ pub(super) fn pinned_item_from_jar(jar: &CookieJar) -> Option<(String, ItemId)>
v.to_string()
};
let (room, rest) = raw.split_once(PIN_COOKIE_SEP)?;
- let item = ItemId::parse(rest.trim())?.ontology_leaf().normalized_storage();
+ let item = ItemId::parse(rest.trim())?
+ .ontology_leaf()
+ .normalized_storage();
Some((room.trim().to_string(), item))
}
diff --git a/server/src/html/garden/question.rs b/server/src/html/garden/question.rs
new file mode 100644
index 0000000000000000000000000000000000000000..9c1607eec45354d05ff0d251a0a7a3f5e523522c
--- /dev/null
+++ b/server/src/html/garden/question.rs
@@ -0,0 +1,310 @@
+//! Shareable question pages: `GET /q/:collection` and `GET /q/:collection/:aspect`.
+//!
+//! A rendering of pairwise compare seeded by a garden scope (and optional aspect).
+//! No new POST; votes use the existing `VoteComparePost` UI action.
+
+use axum::{
+ extract::{Path, State},
+ http::{HeaderMap, StatusCode, Uri},
+ response::{Html, IntoResponse},
+};
+use axum_extra::extract::cookie::CookieJar;
+use maud::html;
+
+use crate::{
+ api::optional_principal,
+ dsl::is_valid_aspect_slug,
+ html::{
+ forum::ThreadNav, layout_full_bleed_chromeless, render_item_body_in_scope, theme_from_jar,
+ theme_next_from_uri,
+ },
+ middleware::canonical_view_url,
+ path_types::ItemId,
+ reducer::ContentState,
+ scope_rank::comparable_items,
+ state::AppState,
+};
+
+use super::{
+ access::{
+ content_for_garden_view, room_not_found_page, room_scope_has_garden_content,
+ user_can_view_room,
+ },
+ vote::{vote_compare_inner, QuestionCtx, QuestionHeadline, VoteCompareQuery},
+};
+
+/// `psalms` → leaf item `~psalms`. Rejects root and multi-segment tails.
+pub(super) fn parse_collection_leaf(collection: &str) -> Option {
+ let raw = collection.trim().trim_start_matches('/');
+ if raw.is_empty() || raw.contains('/') {
+ return None;
+ }
+ let spec = if raw.starts_with('~') {
+ raw.to_string()
+ } else {
+ format!("~{raw}")
+ };
+ let id = ItemId::parse(&spec)?.normalized_storage().ontology_leaf();
+ if id.tilde_tail() == Some("") {
+ return None;
+ }
+ Some(id)
+}
+
+pub(super) fn collection_is_known(content: &ContentState, id: &ItemId) -> bool {
+ content.items.contains(id)
+ || content.item_bodies.contains_key(id)
+ || !content.members_of(id).is_empty()
+}
+
+pub(super) fn question_headline(
+ content: &ContentState,
+ collection: &ItemId,
+ aspect: Option<&str>,
+) -> QuestionHeadline {
+ if let Some(slug) = aspect {
+ if let Some(p) = content
+ .aspect_prompt(slug)
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ {
+ return QuestionHeadline::Body(p.to_string());
+ }
+ return QuestionHeadline::Fallback(format!(":{slug} — which wins?"));
+ }
+ if let Some(body) = content
+ .item_bodies
+ .get(collection)
+ .map(|b| b.trim())
+ .filter(|s| !s.is_empty())
+ {
+ return QuestionHeadline::Body(body.to_string());
+ }
+ QuestionHeadline::Fallback(format!("Which is greater: {}?", collection.last_segment()))
+}
+
+fn question_title(headline: &QuestionHeadline) -> String {
+ match headline {
+ QuestionHeadline::Fallback(s) => s.clone(),
+ QuestionHeadline::Body(b) => {
+ let line = b.lines().next().unwrap_or("").trim();
+ if line.is_empty() {
+ "question".into()
+ } else if line.chars().count() > 80 {
+ format!("{}…", line.chars().take(79).collect::())
+ } else {
+ line.to_string()
+ }
+ }
+ }
+}
+
+fn ranking_href(nav: &ThreadNav, collection: &ItemId, aspect: Option<&str>) -> String {
+ let base = nav.garden_item_href(collection);
+ match aspect.filter(|s| !s.is_empty()) {
+ Some(a) => format!("{base}#aspect-{a}"),
+ None => base,
+ }
+}
+
+fn build_question_ctx(
+ nav: &ThreadNav,
+ collection: &ItemId,
+ aspect: Option<&str>,
+ headline: QuestionHeadline,
+) -> QuestionCtx {
+ let leaf = collection.last_segment().to_string();
+ QuestionCtx {
+ aspect: aspect.map(str::to_string),
+ question_path: nav.question_href(&leaf, aspect),
+ garden_href: ranking_href(nav, collection, aspect),
+ thread_href: nav.thread_url(&leaf),
+ title: question_title(&headline),
+ headline,
+ }
+}
+
+fn question_empty_page(
+ state: &AppState,
+ nav: &ThreadNav,
+ uri: &Uri,
+ jar: &CookieJar,
+ ctx: QuestionCtx,
+) -> axum::response::Response {
+ let url_key = canonical_view_url(uri);
+ let view_count = state.views.get_views(&url_key);
+ let body = html! {
+ section class="vote-compare-shell" {
+ header class="vote-question" {
+ @match &ctx.headline {
+ QuestionHeadline::Body(text) => {
+ div class="vote-question-headline" {
+ (render_item_body_in_scope(text, nav.garden_root_url(), None))
+ }
+ }
+ QuestionHeadline::Fallback(text) => {
+ h1 class="vote-question-headline" { (text) }
+ }
+ }
+ nav class="vote-question-links muted" {
+ a data-testid="question-ranking-link" href=(ctx.garden_href) { "ranking" }
+ " · "
+ a data-testid="question-thread-link" href=(ctx.thread_href) { "thread" }
+ }
+ }
+ p class="muted vote-question-empty" {
+ "nothing to compare yet — this scope needs at least two items with bodies."
+ }
+ }
+ };
+ let page = layout_full_bleed_chromeless(
+ &ctx.title,
+ "view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen view-vote-question",
+ body,
+ Some(view_count),
+ theme_from_jar(jar),
+ &theme_next_from_uri(uri),
+ );
+ Html(page.into_string()).into_response()
+}
+
+async fn question_inner(
+ state: AppState,
+ collection: String,
+ aspect: Option,
+ nav: ThreadNav,
+ headers: HeaderMap,
+ jar: CookieJar,
+ uri: Uri,
+) -> axum::response::Response {
+ if let Some(slug) = aspect.as_deref() {
+ if !is_valid_aspect_slug(slug) {
+ return room_not_found_page(&jar, &uri).into_response();
+ }
+ }
+ let Some(collection_id) = parse_collection_leaf(&collection) else {
+ return room_not_found_page(&jar, &uri).into_response();
+ };
+
+ let reduced = state.reduced.read().await;
+ let content = content_for_garden_view(&reduced, &nav.scope());
+ if !collection_is_known(content, &collection_id) {
+ drop(reduced);
+ return room_not_found_page(&jar, &uri).into_response();
+ }
+ let headline = question_headline(content, &collection_id, aspect.as_deref());
+ let members = comparable_items(content, content.members_of(&collection_id));
+ let ctx = build_question_ctx(&nav, &collection_id, aspect.as_deref(), headline);
+ let leaf = collection_id.last_segment().to_string();
+ let pool_display = collection_id.display_path();
+ drop(reduced);
+
+ if members.len() < 2 {
+ return question_empty_page(&state, &nav, &uri, &jar, ctx);
+ }
+
+ let q = VoteCompareQuery {
+ left: None,
+ right: None,
+ thread: Some(leaf),
+ pool: Some(pool_display),
+ };
+ vote_compare_inner(state, q, nav, headers, jar, uri, Some(ctx)).await
+}
+
+/// `GET /q/:collection`
+pub async fn question_page(
+ State(state): State,
+ Path(collection): Path,
+ headers: HeaderMap,
+ jar: CookieJar,
+ uri: Uri,
+) -> impl IntoResponse {
+ question_inner(
+ state,
+ collection,
+ None,
+ ThreadNav::public(),
+ headers,
+ jar,
+ uri,
+ )
+ .await
+}
+
+/// `GET /q/:collection/:aspect`
+pub async fn question_aspect_page(
+ State(state): State,
+ Path((collection, aspect)): Path<(String, String)>,
+ headers: HeaderMap,
+ jar: CookieJar,
+ uri: Uri,
+) -> impl IntoResponse {
+ question_inner(
+ state,
+ collection,
+ Some(aspect),
+ ThreadNav::public(),
+ headers,
+ jar,
+ uri,
+ )
+ .await
+}
+
+async fn room_question_gate(
+ state: AppState,
+ room_key: &str,
+ headers: &HeaderMap,
+ jar: &CookieJar,
+ uri: &Uri,
+) -> Result<(AppState, ThreadNav), axum::response::Response> {
+ let Some(room_id) = slug_types::room_id_from_route_segment(room_key) else {
+ return Err((StatusCode::NOT_FOUND, "bad room path").into_response());
+ };
+ let Some(nav) = ThreadNav::from_room_id(&room_id) else {
+ return Err((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 Err(room_not_found_page(jar, uri).into_response());
+ }
+ if !room_scope_has_garden_content(&reduced, &nav) {
+ drop(reduced);
+ return Err(room_not_found_page(jar, uri).into_response());
+ }
+ drop(reduced);
+ Ok((state, nav))
+}
+
+/// `GET /r/:room_key/q/:collection`
+pub async fn room_question_page(
+ State(state): State,
+ Path((room_key, collection)): Path<(String, String)>,
+ headers: HeaderMap,
+ jar: CookieJar,
+ uri: Uri,
+) -> impl IntoResponse {
+ let (state, nav) = match room_question_gate(state, &room_key, &headers, &jar, &uri).await {
+ Ok(v) => v,
+ Err(r) => return r,
+ };
+ question_inner(state, collection, None, nav, headers, jar, uri).await
+}
+
+/// `GET /r/:room_key/q/:collection/:aspect`
+pub async fn room_question_aspect_page(
+ State(state): State,
+ Path((room_key, collection, aspect)): Path<(String, String, String)>,
+ headers: HeaderMap,
+ jar: CookieJar,
+ uri: Uri,
+) -> impl IntoResponse {
+ let (state, nav) = match room_question_gate(state, &room_key, &headers, &jar, &uri).await {
+ Ok(v) => v,
+ Err(r) => return r,
+ };
+ question_inner(state, collection, Some(aspect), nav, headers, jar, uri).await
+}
diff --git a/server/src/html/garden/render.rs b/server/src/html/garden/render.rs
index 7608eb68bd744036be2dc6531cb0c35542a83656..118adb17b41355c9f6eb12ca240986ccf3360ce0 100644
--- a/server/src/html/garden/render.rs
+++ b/server/src/html/garden/render.rs
@@ -7,15 +7,8 @@ use maud::html;
use crate::{
html::{
- cli_panel,
- forum::ThreadNav,
- layout,
- now_ms,
- format_ratio_current_markup, ratio_pct,
- recency_color_style,
- render_item_body_in_scope,
- theme_from_jar,
- theme_next_from_uri,
+ cli_panel, format_ratio_current_markup, forum::ThreadNav, layout, now_ms, ratio_pct,
+ recency_color_style, render_item_body_in_scope, theme_from_jar, theme_next_from_uri,
},
middleware::canonical_view_url,
path_types::ItemId,
@@ -28,7 +21,7 @@ use super::{
access::content_for_garden_view,
browse::{garden_layout_meta, scoped_bc_path_for, GardenBrowsePath},
copy::garden_rank_copy_button_markup,
- external::{external_frame_allowed, external_source_href, github_resolver_controls},
+ external::{external_frame_allowed, external_resolver_controls, external_source_href},
item::{
child_depth_from_uri, garden_depth_select_markup, item_code_label, item_display_path,
item_href,
@@ -93,7 +86,7 @@ pub(super) fn aspect_ranking_sections_markup(
) -> maud::Markup {
html! {
@for aspect in aspects {
- section class="ont-tab-panel ont-tab-panel-aspect" {
+ section class="ont-tab-panel ont-tab-panel-aspect" id=(format!("aspect-{}", aspect.slug)) {
h4 class="ont-aspect-heading" { ":" (aspect.slug) }
@if let Some(prompt) = &aspect.prompt {
p class="muted ont-aspect-prompt" { (prompt) }
@@ -209,7 +202,7 @@ pub(super) async fn render_scope_view(
}
}
- @if let Some(markup) = github_resolver_controls(&model.item, &nav, &next_for_pin) {
+ @if let Some(markup) = external_resolver_controls(&model.item, &nav, &next_for_pin) {
(markup)
}
@@ -360,4 +353,3 @@ pub(super) async fn render_scope_view(
Html(page.into_string()).into_response()
}
-
diff --git a/server/src/html/garden/routes.rs b/server/src/html/garden/routes.rs
index 918a4c7547a54f9898ba450bab7e932f56302b56..fb9c31df78ea411085ed35da9e5282fedb898db2 100644
--- a/server/src/html/garden/routes.rs
+++ b/server/src/html/garden/routes.rs
@@ -9,13 +9,11 @@ use maud::html;
use crate::{
api::optional_principal,
html::{
- breadcrumb_path::{ExternalOntologyPath, OntologyPath},
bc_path, bc_path_external,
+ breadcrumb_path::{ExternalOntologyPath, OntologyPath},
cli_panel,
forum::ThreadNav,
- layout,
- theme_from_jar,
- theme_next_from_uri,
+ layout, theme_from_jar, theme_next_from_uri,
},
middleware::canonical_view_url,
path_types::ItemId,
@@ -27,14 +25,34 @@ use crate::{
};
use super::{
- access::{content_for_garden_view, room_not_found_page, room_scope_has_garden_content, user_can_view_room},
- browse::{GardenBrowsePath, scoped_bc_path_external},
+ access::{
+ content_for_garden_view, room_not_found_page, room_scope_has_garden_content,
+ user_can_view_room,
+ },
+ browse::{scoped_bc_path_external, GardenBrowsePath},
copy::garden_rank_copy_button_markup,
item::{child_depth_from_uri, garden_depth_select_markup, item_display_path, item_href},
item_page::aspect_rankings_for_parent,
render::render_scope_view,
};
+/// 308 to `canonical`, keeping a non-empty query string.
+fn redirect_permanent_preserving_query(canonical: &str, uri: &Uri) -> axum::response::Redirect {
+ match uri.query() {
+ Some(q) if !q.is_empty() => {
+ axum::response::Redirect::permanent(&format!("{canonical}?{q}"))
+ }
+ _ => axum::response::Redirect::permanent(canonical),
+ }
+}
+
+/// `GET /~/` → `/~`, `GET /-/` → `/-`, and the room-scoped twins. Query is preserved.
+pub async fn redirect_strip_trailing_slash(uri: Uri) -> impl IntoResponse {
+ let path = uri.path().trim_end_matches('/');
+ let canonical = if path.is_empty() { "/" } else { path };
+ redirect_permanent_preserving_query(canonical, &uri)
+}
+
pub async fn garden_index(
State(state): State,
jar: CookieJar,
@@ -47,8 +65,7 @@ pub async fn garden_index(
let content = reduced.public();
let root = ItemId::ontology_root();
let child_rankings = if child_depth > 1 {
- let items =
- resolve_scope_recursive(content, &[root.as_str().to_string()], child_depth);
+ let items = resolve_scope_recursive(content, &[root.as_str().to_string()], child_depth);
build_rankings_for_item_set(content, &items)
} else {
build_children_rankings(content, &root)
@@ -108,7 +125,7 @@ pub async fn garden_index(
}
}
@for aspect in &aspect_rankings {
- section class="ont-tab-panel ont-tab-panel-aspect" {
+ section class="ont-tab-panel ont-tab-panel-aspect" id=(format!("aspect-{}", aspect.slug)) {
h4 class="ont-aspect-heading" { ":" (aspect.slug) }
@if let Some(prompt) = &aspect.prompt {
p class="muted ont-aspect-prompt" { (prompt) }
@@ -149,11 +166,7 @@ pub async fn ontology_path(
uri: Uri,
) -> impl IntoResponse {
if let Some(canonical) = OntologyPath::nested_redirect_target(&path) {
- let loc = match uri.query() {
- Some(q) if !q.is_empty() => format!("{canonical}?{q}"),
- _ => canonical,
- };
- return axum::response::Redirect::permanent(&loc).into_response();
+ return redirect_permanent_preserving_query(&canonical, &uri).into_response();
}
let path = OntologyPath::from_input(&path);
render_scope_view(
@@ -248,11 +261,7 @@ pub async fn external_ontology_path(
uri: Uri,
) -> impl IntoResponse {
if let Some(canonical) = ExternalOntologyPath::legacy_redirect_target(&path) {
- let loc = match uri.query() {
- Some(q) if !q.is_empty() => format!("{canonical}?{q}"),
- _ => canonical,
- };
- return axum::response::Redirect::permanent(&loc).into_response();
+ return redirect_permanent_preserving_query(&canonical, &uri).into_response();
}
let path = ExternalOntologyPath::from_input(&path);
render_scope_view(
@@ -418,11 +427,7 @@ pub async fn room_external_ontology_path(
.strip_prefix("/-/")
.unwrap_or(canonical_tail.trim_start_matches('/'));
let base = format!("{}-/{tail}", nav.garden_root_url().trim_end_matches('~'));
- let loc = match uri.query() {
- Some(q) if !q.is_empty() => format!("{base}?{q}"),
- _ => base,
- };
- return axum::response::Redirect::permanent(&loc).into_response();
+ return redirect_permanent_preserving_query(&base, &uri).into_response();
}
let path = ExternalOntologyPath::from_input(&path);
render_scope_view(state, GardenBrowsePath::External(path), nav, jar, uri)
@@ -460,15 +465,10 @@ pub async fn room_ontology_path(
} else {
canonical
};
- let loc = match uri.query() {
- Some(q) if !q.is_empty() => format!("{loc}?{q}"),
- _ => loc,
- };
- return axum::response::Redirect::permanent(&loc).into_response();
+ return redirect_permanent_preserving_query(&loc, &uri).into_response();
}
let path = OntologyPath::from_input(&path);
render_scope_view(state, GardenBrowsePath::Tilde(path), nav, jar, uri)
.await
.into_response()
}
-
diff --git a/server/src/html/garden/tests.rs b/server/src/html/garden/tests.rs
index 2c94e26930f7d22f09aa12dbd9d32f3abbcf058d..a4cc7ca2bd3db25ee51897e3b7cd329fee0556ee 100644
--- a/server/src/html/garden/tests.rs
+++ b/server/src/html/garden/tests.rs
@@ -1,17 +1,18 @@
use super::{
access::content_for_garden_view,
- external::{external_frame_allowed, external_resolver_status_markup, external_source_href},
browse::scoped_bc_containment,
+ external::{external_frame_allowed, external_resolver_status_markup, external_source_href},
item_page::{
build_item_page_view_model, containment_crumb_chain, item_relations_markup,
sibling_nav_markup,
},
- render::aspect_ranking_sections_markup,
pin::ont_pin_vote_controls,
+ question::{collection_is_known, parse_collection_leaf, question_headline},
+ render::aspect_ranking_sections_markup,
vote::{
canonical_edge_items, edge_vote_count_for_pair, edge_vote_entries_for_pair,
ratios_for_compare_page, sort_votes_for_compare_display, suggest_next_vote_pair,
- vote_compare_item_card, vote_pool_href,
+ vote_compare_item_card, vote_pool_href, QuestionHeadline,
},
};
use crate::{
@@ -66,7 +67,11 @@ fn vote_edge_compare_sorts_rows_by_strength_for_page_left() {
let sorted = sort_votes_for_compare_display(raw, &page_left, &page_right);
// 8:2 is stored reduced as 4:1; still a stronger left share than 1:9.
let (r0_l, r0_r) = ratios_for_compare_page(&sorted[0], &page_left, &page_right);
- assert_eq!((r0_l, r0_r), (4, 1), "stronger left weight should sort first");
+ assert_eq!(
+ (r0_l, r0_r),
+ (4, 1),
+ "stronger left weight should sort first"
+ );
let (r1_l, r1_r) = ratios_for_compare_page(&sorted[1], &page_left, &page_right);
assert_eq!((r1_l, r1_r), (1, 9));
}
@@ -125,29 +130,30 @@ fn suggest_next_vote_pair_prefers_unvoted_sibling_pair() {
#[test]
fn external_resolver_status_markup_reports_success_and_refresh() {
- let stats = crate::resolvers::GithubResolveStats {
+ let stats = crate::resolvers::ResolveStats {
imported: 2,
deleted: 0,
kept: 0,
};
- let html =
- external_resolver_status_markup(Ok(stats), "/-/https://github.com/o/r").into_string();
+ let html = external_resolver_status_markup(Ok((stats, "GitHub")), "/-/https://github.com/o/r")
+ .into_string();
assert!(html.contains("Imported 2 GitHub items."));
assert!(html.contains("href=\"/-/https://github.com/o/r\""));
}
#[test]
fn external_resolver_status_markup_reports_deletes() {
- let stats = crate::resolvers::GithubResolveStats {
+ let stats = crate::resolvers::ResolveStats {
imported: 1,
deleted: 2,
kept: 3,
};
- let html = external_resolver_status_markup(Ok(stats), "/-/https://github.com/o/r/issues")
- .into_string();
+ let html =
+ external_resolver_status_markup(Ok((stats, "GitHub")), "/-/https://github.com/o/r/issues")
+ .into_string();
assert!(html.contains("Imported 1 GitHub item."));
assert!(html.contains("Removed 2 closed/stale items."));
- assert!(html.contains("Kept 3 still-open."));
+ assert!(html.contains("Kept 3 current."));
}
#[test]
@@ -256,7 +262,10 @@ fn sibling_nav_markup_includes_rank_and_winner_percentile() {
let mid = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/b", 1);
let mid_nav = mid.sibling_nav.expect("sibling nav");
let mid_html = sibling_nav_markup(&nav, &mid_nav, &mid.item).into_string();
- assert!(mid_html.contains("rank: 2/3"), "missing mid rank in {mid_html}");
+ assert!(
+ mid_html.contains("rank: 2/3"),
+ "missing mid rank in {mid_html}"
+ );
assert!(
!mid_html.contains("percentile"),
"non-winner should omit percentile: {mid_html}"
@@ -310,7 +319,10 @@ fn item_page_renders_aspect_section_below_canonical() {
model.aspect_rankings[0].prompt.as_deref(),
Some("winner is more beautiful")
);
- assert_eq!(model.aspect_rankings[0].rankings.component_rankings.len(), 1);
+ assert_eq!(
+ model.aspect_rankings[0].rankings.component_rankings.len(),
+ 1
+ );
let canon_top = model.child_rankings.component_rankings[0].ranked[0]
.item
.as_str();
@@ -342,6 +354,61 @@ fn item_page_renders_aspect_section_below_canonical() {
html.contains("ont-ranking-list"),
"missing ranking list: {html}"
);
+ assert!(
+ html.contains("id=\"aspect-beauty\""),
+ "missing aspect section anchor: {html}"
+ );
+}
+
+#[test]
+fn parse_collection_leaf_maps_bare_name_to_tilde_item() {
+ let id = parse_collection_leaf("psalms").expect("psalms");
+ assert_eq!(id, ItemId::parse("~psalms").unwrap().ontology_leaf());
+ assert_eq!(
+ parse_collection_leaf("~psalms"),
+ parse_collection_leaf("psalms")
+ );
+ assert!(parse_collection_leaf("").is_none());
+ assert!(parse_collection_leaf("~").is_none());
+ assert!(parse_collection_leaf("~/psalms").is_none());
+ assert!(parse_collection_leaf("foo/bar").is_none());
+}
+
+#[test]
+fn question_headline_uses_scope_body_then_fallback_and_aspect_prompt() {
+ let mut reduced = ReducerState::default();
+ apply_ingest(
+ &mut reduced,
+ 1,
+ "~/psalms {Which psalm is greater?}\n\
+ ~/psalms/a {alpha}\n\
+ ~/lonely\n\
+ :beauty {more beautiful}\n",
+ );
+ let content = content_for_garden_view(&reduced, &ScopeId::Public);
+ let psalms = ItemId::parse("~psalms").unwrap().ontology_leaf();
+ let lonely = ItemId::parse("~lonely").unwrap().ontology_leaf();
+ match question_headline(content, &psalms, None) {
+ QuestionHeadline::Body(b) => assert_eq!(b, "Which psalm is greater?"),
+ QuestionHeadline::Fallback(s) => panic!("expected body headline, got {s}"),
+ }
+ match question_headline(content, &lonely, None) {
+ QuestionHeadline::Fallback(s) => assert_eq!(s, "Which is greater: lonely?"),
+ QuestionHeadline::Body(b) => panic!("expected fallback, got body {b}"),
+ }
+ match question_headline(content, &psalms, Some("beauty")) {
+ QuestionHeadline::Body(b) => assert_eq!(b, "more beautiful"),
+ QuestionHeadline::Fallback(s) => panic!("expected aspect prompt, got {s}"),
+ }
+ match question_headline(content, &psalms, Some("speed")) {
+ QuestionHeadline::Fallback(s) => assert_eq!(s, ":speed — which wins?"),
+ QuestionHeadline::Body(b) => panic!("expected aspect fallback, got {b}"),
+ }
+ assert!(collection_is_known(content, &psalms));
+ assert!(!collection_is_known(
+ content,
+ &ItemId::parse("~no-such-q").unwrap().ontology_leaf()
+ ));
}
#[test]
@@ -382,10 +449,7 @@ fn item_page_model_builds_ranked_child_components() {
.collect();
assert_eq!(
names,
- vec![
- "https://slug.social/~/a",
- "https://slug.social/~/b"
- ]
+ vec!["https://slug.social/~/a", "https://slug.social/~/b"]
);
use crate::path_types::ItemId;
assert!(
@@ -471,8 +535,7 @@ fn item_page_model_normalizes_legacy_tilde_root_storage_url() {
1,
"@00000000-0000-0000-0000-000000000000:test:local/test\n~/x {x}\n",
);
- let model =
- build_item_page_view_model(&reduced, &ScopeId::Public, "https://slug.social/~/", 1);
+ let model = build_item_page_view_model(&reduced, &ScopeId::Public, "https://slug.social/~/", 1);
assert_eq!(model.child_rankings.unranked_items.len(), 1);
assert_eq!(
model.child_rankings.unranked_items[0].as_str(),
@@ -551,16 +614,10 @@ fn vote_compare_item_card_renders_github_import_markup() {
use base64::{engine::general_purpose::STANDARD, Engine as _};
let payload = STANDARD.encode(json.to_string().as_bytes());
let body = format!("```slug-github-card\n{payload}\n```");
- let html = vote_compare_item_card(
- &nav,
- &item,
- Some(&body),
- "vote-compare-left",
- None,
- )
- .into_string();
+ let html =
+ vote_compare_item_card(&nav, &item, Some(&body), "vote-compare-left", None).into_string();
assert!(
- html.contains("github-import-card"),
+ html.contains("import-card"),
"expected rich GitHub card markup, got: {html}"
);
assert!(html.contains("item-body-rich"));
@@ -580,7 +637,10 @@ fn item_page_sibling_nav_implies_vote_on_item_pool_href() {
~/topic/b {beta}\n",
);
let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a", 1);
- assert!(model.sibling_nav.is_some(), "expected sibling nav for ~/topic/a");
+ assert!(
+ model.sibling_nav.is_some(),
+ "expected sibling nav for ~/topic/a"
+ );
let parent = ItemId::parse(&model.item)
.and_then(|i| i.parent())
.expect("item with siblings has a parent");
@@ -596,7 +656,8 @@ fn item_page_sibling_nav_implies_vote_on_item_pool_href() {
|| href.contains("%7E"),
"pool should target parent path, got {href}"
);
- let markup = ont_pin_vote_controls(&nav, &model.item, None, "/~/topic/a", Some(&href)).into_string();
+ let markup =
+ ont_pin_vote_controls(&nav, &model.item, None, "/~/topic/a", Some(&href)).into_string();
assert!(
markup.contains("data-testid=\"vote-on-this-item\""),
"expected vote-on-this-item CTA, got {markup}"
@@ -671,11 +732,12 @@ fn item_page_crumb_picks_strongest_parent_lists_alternates() {
let content = content_for_garden_view(&reduced, &ScopeId::Public);
let luke = ItemId::parse("~luke").unwrap().ontology_leaf();
let chain = containment_crumb_chain(content, &luke);
+ assert_eq!(chain.last().map(|id| id.last_segment()), Some("luke"));
assert_eq!(
- chain.last().map(|id| id.last_segment()),
- Some("luke")
+ chain[0].last_segment(),
+ "jedi",
+ "stronger jedi parent first"
);
- assert_eq!(chain[0].last_segment(), "jedi", "stronger jedi parent first");
let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~luke", 1);
assert_eq!(model.alternate_scopes.len(), 1);
@@ -685,7 +747,11 @@ fn item_page_crumb_picks_strongest_parent_lists_alternates() {
#[test]
fn item_page_renders_memberships_suspended_borders_and_journal() {
let mut reduced = ReducerState::default();
- apply_ingest(&mut reduced, 1, "~jedi { j }\n~luke { l }\n{ in }\n~luke <: ~jedi\n");
+ apply_ingest(
+ &mut reduced,
+ 1,
+ "~jedi { j }\n~luke { l }\n{ in }\n~luke <: ~jedi\n",
+ );
apply_ingest(&mut reduced, 2, "{ out }\n~luke !<: ~jedi\n");
let suspended = build_item_page_view_model(&reduced, &ScopeId::Public, "~luke", 1);
assert!(suspended.memberships.is_empty());
@@ -707,10 +773,16 @@ fn item_page_renders_memberships_suspended_borders_and_journal() {
assert_eq!(breached.memberships.len(), 1);
assert_eq!(breached.fallen_journal.len(), 1);
let html = item_relations_markup(&breached, &ThreadNav::public(), 10_000).into_string();
- assert!(html.contains("ont-tab-panel-memberships"), "missing memberships: {html}");
+ assert!(
+ html.contains("ont-tab-panel-memberships"),
+ "missing memberships: {html}"
+ );
assert!(html.contains("memberships"));
assert!(html.contains("containment 2"));
- assert!(html.contains("ont-fallen-borders"), "missing journal: {html}");
+ assert!(
+ html.contains("ont-fallen-borders"),
+ "missing journal: {html}"
+ );
assert!(html.contains("fallen borders"));
}
@@ -733,7 +805,13 @@ fn containment_breadcrumb_emits_leaf_hrefs() {
let luke = ItemId::parse("~luke").unwrap().ontology_leaf();
let path = crate::html::breadcrumb_path::OntologyPath::from_item(luke.clone());
let html = scoped_bc_containment(&path, &[jedi, luke], &ThreadNav::public()).into_string();
- assert!(html.contains("href=\"/~/jedi\""), "missing jedi crumb: {html}");
- assert!(html.contains("href=\"/~/luke\""), "missing luke crumb: {html}");
+ assert!(
+ html.contains("href=\"/~/jedi\""),
+ "missing jedi crumb: {html}"
+ );
+ assert!(
+ html.contains("href=\"/~/luke\""),
+ "missing luke crumb: {html}"
+ );
assert!(!html.contains("/~/x/"));
}
diff --git a/server/src/html/garden/vote.rs b/server/src/html/garden/vote.rs
index d7f501298c69954cbca9f63b0b4d8ad8681681a6..84c476676faa3514ed04b4fdf387b8f910cdad4e 100644
--- a/server/src/html/garden/vote.rs
+++ b/server/src/html/garden/vote.rs
@@ -14,13 +14,9 @@ use crate::{
canonical_path::canonicalize_tag,
form_template::template_json_compact,
html::{
- forum::ThreadNav,
- layout_full_bleed_chromeless,
- format_ratio, ratio_pct, render_item_body_in_scope,
- theme_from_jar, theme_next_from_uri,
- user_can_post_room,
- ui_action::UI_RPC_FIELD,
- JsBuilder,
+ format_ratio, forum::ThreadNav, layout_full_bleed_chromeless, ratio_pct,
+ render_item_body_in_scope, theme_from_jar, theme_next_from_uri, ui_action::UI_RPC_FIELD,
+ user_can_post_room, JsBuilder,
},
middleware::canonical_view_url,
path_types::ItemId,
@@ -30,10 +26,60 @@ use crate::{
};
use super::{
- access::{content_for_garden_view, room_not_found_page, room_scope_has_garden_content, user_can_view_room},
+ 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},
};
+/// Prompt shown as the `/q/` headline (scope body, or restrained fallback copy).
+pub(super) enum QuestionHeadline {
+ Body(String),
+ Fallback(String),
+}
+
+/// Shareable question page: compare machinery seeded by (scope, optional aspect).
+pub(super) struct QuestionCtx {
+ pub aspect: Option,
+ pub question_path: String,
+ pub garden_href: String,
+ pub thread_href: String,
+ pub headline: QuestionHeadline,
+ pub title: String,
+}
+
+fn is_question_href(path: &str) -> bool {
+ let path = path.split(['?', '#']).next().unwrap_or(path);
+ path.starts_with("/q/") || path.contains("/q/")
+}
+
+fn question_header_markup(
+ question: &QuestionCtx,
+ garden_root: &str,
+ item_bodies: Option<&HashMap>,
+) -> maud::Markup {
+ html! {
+ header class="vote-question" {
+ @match &question.headline {
+ QuestionHeadline::Body(body) => {
+ div class="vote-question-headline" {
+ (render_item_body_in_scope(body, garden_root, item_bodies))
+ }
+ }
+ QuestionHeadline::Fallback(text) => {
+ h1 class="vote-question-headline" { (text) }
+ }
+ }
+ nav class="vote-question-links muted" {
+ a data-testid="question-ranking-link" href=(question.garden_href) { "ranking" }
+ " · "
+ a data-testid="question-thread-link" href=(question.thread_href) { "thread" }
+ }
+ }
+ }
+}
+
fn pick_autothread_for_vote_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> String {
let cands: HashSet = content
.item_threads
@@ -214,12 +260,14 @@ pub(crate) async fn vote_compare_post_success_js(
pool: Option<&ItemId>,
_post_id: &str,
_post_idx: Option,
+ next: &str,
) -> 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);
+ let next_override = is_question_href(next).then_some(next);
+ let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref(), pool, next_override);
drop(reduced);
JsBuilder::new()
.morph_inner_selector("#vote-edge-history-region", edge_history)
@@ -271,8 +319,13 @@ fn vote_compare_nav_markup(
nav: &ThreadNav,
next_pair: Option<&(ItemId, ItemId)>,
pool: Option<&ItemId>,
+ next_pair_href_override: Option<&str>,
) -> maud::Markup {
- let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None, pool));
+ let next_pair_href = if let Some(over) = next_pair_href_override.filter(|s| !s.is_empty()) {
+ next_pair.and(Some(over.to_string()))
+ } else {
+ 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 {
@@ -361,7 +414,7 @@ pub async fn vote_compare_page(
uri: Uri,
) -> impl IntoResponse {
let nav = ThreadNav::public();
- vote_compare_inner(state, q, nav, headers, jar, uri).await
+ vote_compare_inner(state, q, nav, headers, jar, uri, None).await
}
pub async fn room_vote_compare_page(
@@ -389,16 +442,17 @@ pub async fn room_vote_compare_page(
return room_not_found_page(&jar, &uri).into_response();
}
drop(reduced);
- vote_compare_inner(state, q, nav, headers, jar, uri).await
+ vote_compare_inner(state, q, nav, headers, jar, uri, None).await
}
-async fn vote_compare_inner(
+pub(super) async fn vote_compare_inner(
state: AppState,
q: VoteCompareQuery,
nav: ThreadNav,
headers: HeaderMap,
jar: CookieJar,
uri: Uri,
+ question: Option,
) -> axum::response::Response {
let pool_id: Option = match q.pool.as_deref() {
Some(p) => match ItemId::parse(p.trim()) {
@@ -443,10 +497,18 @@ async fn vote_compare_inner(
drop(reduced);
match pair {
Some(p) => p,
- None => return (StatusCode::BAD_REQUEST, "no pairs available in pool").into_response(),
+ 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(),
+ _ => {
+ return (
+ StatusCode::BAD_REQUEST,
+ "provide both left and right, or just pool",
+ )
+ .into_response()
+ }
};
let reduced = state.reduced.read().await;
@@ -469,7 +531,10 @@ async fn vote_compare_inner(
.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 mut thread_tags = vote_thread_tags_for_pair(content, &left, &right);
+ if !auto_thread.is_empty() && !thread_tags.iter().any(|t| t == &auto_thread) {
+ thread_tags.insert(0, auto_thread.clone());
+ }
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();
@@ -477,17 +542,26 @@ async fn vote_compare_inner(
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 title = question
+ .as_ref()
+ .map(|q| q.title.clone())
+ .unwrap_or_else(|| {
+ 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!({
+ let aspect_slug = question
+ .as_ref()
+ .and_then(|q| q.aspect.clone())
+ .filter(|s| !s.is_empty());
+ let mut rpc_val = json!({
"action": "vote_compare_post",
"room": nav.room_wire,
"thread_tag": {"$form": "thread_tag"},
@@ -499,12 +573,25 @@ async fn vote_compare_inner(
"next": next_path,
"pool": pool_id.as_ref().map(|p| p.as_str()),
"form_action": "/ui",
- }))
- .expect("vote compare rpc json");
+ });
+ if aspect_slug.is_some() {
+ rpc_val["aspect"] = json!({"$form": "aspect"});
+ }
+ let rpc_json = template_json_compact(&rpc_val).expect("vote compare rpc json");
+ let next_pair_override = question.as_ref().map(|q| q.question_path.as_str());
+ let view_class = if question.is_some() {
+ "view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen view-vote-question"
+ } else {
+ "view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen"
+ };
let body = html! {
section class="vote-compare-shell" {
- h2 { "compare" }
+ @if let Some(qctx) = &question {
+ (question_header_markup(qctx, nav.garden_root_url(), Some(&item_bodies_for_cards)))
+ } @else {
+ h2 { "compare" }
+ }
div class="vote-compare-pair" {
(vote_compare_item_card(
&nav,
@@ -522,13 +609,16 @@ async fn vote_compare_inner(
Some(&item_bodies_for_cards),
))
}
- (vote_compare_nav_markup(&nav, next_pair.as_ref(), pool_id.as_ref()))
+ (vote_compare_nav_markup(&nav, next_pair.as_ref(), pool_id.as_ref(), next_pair_override))
div id="vote-edge-history-region" {
(edge_history)
}
@if show_vote_form && logged_in {
form id="vote-compare-form" method="POST" action="/ui" data-draft-key=(format!("vote:{}/{}/{}", nav.room_wire, left.as_str(), right.as_str())) {
input type="hidden" name=(UI_RPC_FIELD) value=(rpc_json);
+ @if let Some(aspect) = &aspect_slug {
+ input type="hidden" name="aspect" value=(aspect);
+ }
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" {
@@ -581,7 +671,7 @@ async fn vote_compare_inner(
let page = layout_full_bleed_chromeless(
&title,
- "view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen",
+ view_class,
body,
Some(view_count),
theme_from_jar(&jar),
@@ -589,4 +679,3 @@ async fn vote_compare_inner(
);
Html(page.into_string()).into_response()
}
-
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 26b3bdd914e93909a3f3146d532702d5d19ac2c3..d8666d3a340d9acc78d749a6a7a6f11f39ea87a9 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -33,12 +33,12 @@ pub(crate) use forum::{
thread_latest_page_region, thread_region_page_morphs, ThreadRegionPageMorphs,
};
-pub use forum::user_profile_page;
pub use forum::user_can_view_room;
+pub use forum::user_profile_page;
pub(crate) use forum::{
fragment_new_thread_slot, login_to_post_hint_markup, room_members_section_markup,
- thread_ui_collapse_redacted_post, thread_ui_copy_thread, thread_ui_expand_post_full, thread_ui_expand_redacted_post,
- user_can_post_room,
+ thread_ui_collapse_redacted_post, thread_ui_copy_thread, thread_ui_expand_post_full,
+ thread_ui_expand_redacted_post, user_can_post_room,
};
pub(crate) use garden::{
encode_pin_cookie_value, external_resolver_status_markup, garden_ui_copy_rank,
@@ -46,8 +46,9 @@ pub(crate) use garden::{
};
pub use garden::{
external_garden_index, external_ontology_path, garden_index, ontology_path,
- room_external_garden_index, room_external_ontology_path, room_garden_index, room_ontology_path,
- room_vote_compare_page, vote_compare_page,
+ question_aspect_page, question_page, redirect_strip_trailing_slash, room_external_garden_index,
+ room_external_ontology_path, room_garden_index, room_ontology_path, room_question_aspect_page,
+ room_question_page, room_vote_compare_page, vote_compare_page,
};
pub use routing::RouteContext;
pub use search::{search_page, search_results_fragment};
@@ -1082,7 +1083,7 @@ mod authorship_tests {
let now = 7 * 24 * 3600 * 1000;
assert_eq!(recency_freshness_pct(now, now), 100);
assert_eq!(recency_freshness_pct(now, now - 1000), 99); // ~1s old
- // Mid-week ≈ 50%
+ // Mid-week ≈ 50%
assert_eq!(
recency_freshness_pct(now, now - 3 * 24 * 3600 * 1000 - 12 * 3600 * 1000),
50
@@ -1123,12 +1124,10 @@ mod authorship_tests {
"7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet-4.5"
.to_string(),
);
- let d2 = Some(
- "7a3b9c2d-1234-5678-90ab-cdef12345678:cursor:anthropic/claude-opus-4".to_string(),
- );
- let other = Some(
- "aec1e31c-36db-4a58-a53e-43525337f6b4:cursor:anthropic/claude-opus-4".to_string(),
- );
+ let d2 =
+ Some("7a3b9c2d-1234-5678-90ab-cdef12345678:cursor:anthropic/claude-opus-4".to_string());
+ let other =
+ Some("aec1e31c-36db-4a58-a53e-43525337f6b4:cursor:anthropic/claude-opus-4".to_string());
let a1 = authorship_attr("alice", &d1);
let a2 = authorship_attr("alice", &d2);
let a3 = authorship_attr("bob", &other);
diff --git a/server/src/html/search.rs b/server/src/html/search.rs
index 3064eeac031d37d0dcb54e62c983dbc03d2cbc3e..54f0fa9129a9957c09e766dcffb266ddf8df18bd 100644
--- a/server/src/html/search.rs
+++ b/server/src/html/search.rs
@@ -86,14 +86,20 @@ struct SearchResults {
fn can_view_scope(state: &ReducerState, scope: &ScopeId, principal: Option<&str>) -> bool {
match scope {
ScopeId::Public => true,
- ScopeId::Room(room_id) => principal.is_some_and(|u| state.user_has_cap(room_id, u, ThreadCapability::View)),
+ ScopeId::Room(room_id) => {
+ principal.is_some_and(|u| state.user_has_cap(room_id, u, ThreadCapability::View))
+ }
}
}
fn search(state: &ReducerState, q: &str, limit: usize, principal: Option<&str>) -> SearchResults {
let words = tokenize(q);
if words.is_empty() {
- return SearchResults { items: vec![], threads: vec![], posts: vec![] };
+ return SearchResults {
+ items: vec![],
+ threads: vec![],
+ posts: vec![],
+ };
}
let public = state.public();
@@ -121,10 +127,13 @@ fn search(state: &ReducerState, q: &str, limit: usize, principal: Option<&str>)
}
}
if score > 0 {
- scored_items.push((score, ItemRow {
- path: item.as_str().to_string(),
- body: public.item_bodies.get(item).cloned(),
- }));
+ scored_items.push((
+ score,
+ ItemRow {
+ path: item.as_str().to_string(),
+ body: public.item_bodies.get(item).cloned(),
+ },
+ ));
}
}
@@ -145,12 +154,15 @@ fn search(state: &ReducerState, q: &str, limit: usize, principal: Option<&str>)
.get(&(ScopeId::Public, tag.clone()))
.map(|q| q.len())
.unwrap_or(0);
- scored_threads.push((score, ThreadRow {
- tag: tag.clone(),
- subtitle: None,
- post_count,
- last_activity: thread_state.last_activity_ts,
- }));
+ scored_threads.push((
+ score,
+ ThreadRow {
+ tag: tag.clone(),
+ subtitle: None,
+ post_count,
+ last_activity: thread_state.last_activity_ts,
+ },
+ ));
}
}
@@ -166,18 +178,20 @@ fn search(state: &ReducerState, q: &str, limit: usize, principal: Option<&str>)
}
}
if score > 0 {
- let Some((scope, tag)) = state
- .ingests_by_scope_thread
- .iter()
- .find_map(|((scope, tag), ids)| {
- if ids.contains(id) {
- Some((scope.clone(), tag.clone()))
- } else {
- None
- }
- }) else {
- continue;
- };
+ let Some((scope, tag)) =
+ state
+ .ingests_by_scope_thread
+ .iter()
+ .find_map(|((scope, tag), ids)| {
+ if ids.contains(id) {
+ Some((scope.clone(), tag.clone()))
+ } else {
+ None
+ }
+ })
+ else {
+ continue;
+ };
if !can_view_scope(state, &scope, principal) {
continue;
}
@@ -185,17 +199,23 @@ fn search(state: &ReducerState, q: &str, limit: usize, principal: Option<&str>)
ScopeId::Public => format!("#{tag}"),
ScopeId::Room(rid) => format!("{rid}/#{tag}"),
};
- scored_posts.push((score, PostRow {
- thread,
- actor_display: authorship_address(&ingest.principal, &ingest.delegate),
- text: ingest.raw.clone(),
- ts: ingest.ts,
- }));
+ scored_posts.push((
+ score,
+ PostRow {
+ thread,
+ actor_display: authorship_address(&ingest.principal, &ingest.delegate),
+ text: ingest.raw.clone(),
+ ts: ingest.ts,
+ },
+ ));
}
}
scored_items.sort_by(|a, b| b.0.cmp(&a.0));
- scored_threads.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.last_activity.cmp(&a.1.last_activity)));
+ scored_threads.sort_by(|a, b| {
+ b.0.cmp(&a.0)
+ .then_with(|| b.1.last_activity.cmp(&a.1.last_activity))
+ });
scored_posts.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.ts.cmp(&a.1.ts)));
scored_items.truncate(limit);
@@ -213,17 +233,25 @@ fn search(state: &ReducerState, q: &str, limit: usize, principal: Option<&str>)
/// Snap a byte offset to the nearest char boundary at or before `pos`.
fn floor_char_boundary(s: &str, pos: usize) -> usize {
- if pos >= s.len() { return s.len(); }
+ if pos >= s.len() {
+ return s.len();
+ }
let mut i = pos;
- while i > 0 && !s.is_char_boundary(i) { i -= 1; }
+ while i > 0 && !s.is_char_boundary(i) {
+ i -= 1;
+ }
i
}
/// Snap a byte offset to the nearest char boundary at or after `pos`.
fn ceil_char_boundary(s: &str, pos: usize) -> usize {
- if pos >= s.len() { return s.len(); }
+ if pos >= s.len() {
+ return s.len();
+ }
let mut i = pos;
- while i < s.len() && !s.is_char_boundary(i) { i += 1; }
+ while i < s.len() && !s.is_char_boundary(i) {
+ i += 1;
+ }
i
}
@@ -242,7 +270,10 @@ fn highlight_snippet(text: &str, words: &[String], max_len: usize) -> String {
let start = if raw_start > 0 {
let snapped = ceil_char_boundary(&escaped_full, raw_start);
// Try to snap to a word boundary (space)
- escaped_full[snapped..].find(' ').map(|i| snapped + i + 1).unwrap_or(snapped)
+ escaped_full[snapped..]
+ .find(' ')
+ .map(|i| snapped + i + 1)
+ .unwrap_or(snapped)
} else {
0
};
@@ -268,9 +299,13 @@ fn highlight_snippet(text: &str, words: &[String], max_len: usize) -> String {
if marks.is_empty() {
let mut result = String::new();
- if start > 0 { result.push('\u{2026}'); }
+ if start > 0 {
+ result.push('\u{2026}');
+ }
result.push_str(snippet);
- if end < escaped_full.len() { result.push('\u{2026}'); }
+ if end < escaped_full.len() {
+ result.push('\u{2026}');
+ }
return result;
}
@@ -287,7 +322,9 @@ fn highlight_snippet(text: &str, words: &[String], max_len: usize) -> String {
}
let mut result = String::new();
- if start > 0 { result.push('\u{2026}'); }
+ if start > 0 {
+ result.push('\u{2026}');
+ }
let mut cursor = 0;
for (s, e) in &merged {
result.push_str(&snippet[cursor..*s]);
@@ -297,7 +334,9 @@ fn highlight_snippet(text: &str, words: &[String], max_len: usize) -> String {
cursor = *e;
}
result.push_str(&snippet[cursor..]);
- if end < escaped_full.len() { result.push('\u{2026}'); }
+ if end < escaped_full.len() {
+ result.push('\u{2026}');
+ }
result
}
@@ -414,7 +453,11 @@ pub async fn search_page(
let principal = optional_principal(&headers, &jar, &reduced);
search(&reduced, &query, 50, principal.as_deref())
} else {
- SearchResults { items: vec![], threads: vec![], posts: vec![] }
+ SearchResults {
+ items: vec![],
+ threads: vec![],
+ posts: vec![],
+ }
};
let url_key = canonical_view_url(&uri);
@@ -454,7 +497,11 @@ pub async fn search_results_fragment(
let principal = optional_principal(&headers, &jar, &reduced);
search(&reduced, &query, 50, principal.as_deref())
} else {
- SearchResults { items: vec![], threads: vec![], posts: vec![] }
+ SearchResults {
+ items: vec![],
+ threads: vec![],
+ posts: vec![],
+ }
};
Html(render_search_results(&results, &query).into_string())
diff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs
index 7dc3db062954370b5504fc311c0ab24bab569f80..83dbdbd71fae74654c24326ae5928b1638bbd084 100644
--- a/server/src/html/ui_action.rs
+++ b/server/src/html/ui_action.rs
@@ -53,6 +53,10 @@ pub enum HtmlUiAction {
/// Pool parent item path, if the vote was initiated from a pool URL.
#[serde(default)]
pool: Option,
+ /// Aspect slug when the vote was cast from `/q/:collection/:aspect`.
+ /// Empty / omitted → canonical ranking (same as `/vote`).
+ #[serde(default)]
+ aspect: Option,
#[serde(default = "default_ui_form_action")]
form_action: String,
},
@@ -131,10 +135,7 @@ pub enum HtmlUiAction {
external_hosts: bool,
},
/// Publish a private-room thread to the public forum (Manage only).
- GraduateThread {
- room: String,
- thread_tag: String,
- },
+ GraduateThread { room: String, thread_tag: String },
}
fn default_garden_rank_depth() -> usize {
@@ -316,6 +317,51 @@ mod tests {
);
}
+ #[test]
+ fn vote_compare_post_round_trip_with_aspect_form_hole() {
+ let template = serde_json::json!({
+ "action": "vote_compare_post",
+ "room": "public",
+ "thread_tag": {"$form": "thread_tag"},
+ "left_item": "~/a",
+ "right_item": "~/b",
+ "ratio_left": {"$form": "ratio_left"},
+ "ratio_right": {"$form": "ratio_right"},
+ "explanation": {"$form": "explanation"},
+ "next": "/q/psalms/beauty",
+ "aspect": {"$form": "aspect"},
+ "form_action": "/ui",
+ });
+ let mut form = HashMap::new();
+ form.insert(
+ UI_RPC_FIELD.to_string(),
+ serde_json::to_string(&template).unwrap(),
+ );
+ form.insert("thread_tag".into(), "psalms".into());
+ form.insert("ratio_left".into(), "2".into());
+ form.insert("ratio_right".into(), "1".into());
+ form.insert("explanation".into(), "prefer a".into());
+ form.insert("aspect".into(), "beauty".into());
+
+ let a = parse_html_ui_from_form(&form).unwrap();
+ assert_eq!(
+ a,
+ HtmlUiAction::VoteComparePost {
+ room: "public".into(),
+ thread_tag: "psalms".into(),
+ left_item: "~/a".into(),
+ right_item: "~/b".into(),
+ ratio_left: "2".into(),
+ ratio_right: "1".into(),
+ explanation: "prefer a".into(),
+ next: "/q/psalms/beauty".into(),
+ pool: None,
+ aspect: Some("beauty".into()),
+ form_action: "/ui".into(),
+ }
+ );
+ }
+
#[test]
fn copy_garden_rank_defaults_depth_and_external_hosts() {
let template = serde_json::json!({
diff --git a/server/src/identity.rs b/server/src/identity.rs
index ad654ae813f8f6fe6323746e2544250687d3d47f..8474b21a1ba1184a18e4a982fe297f7278ca9738 100644
--- a/server/src/identity.rs
+++ b/server/src/identity.rs
@@ -9,9 +9,7 @@ pub fn parse_username(input: &str) -> Result {
return Err("username must not be empty".to_string());
}
if s.contains('@') {
- return Err(
- "username must not contain '@' — use stored form (e.g. `tommy`)".to_string(),
- );
+ return Err("username must not contain '@' — use stored form (e.g. `tommy`)".to_string());
}
let u = s.to_lowercase();
validate_username_naked(&u)?;
@@ -38,9 +36,7 @@ pub fn parse_agent(input: &str) -> Result {
return Err("agent id must not be empty".to_string());
}
if s.contains('@') {
- return Err(
- "agent id must not contain '@' — use `uuid:rig:provider/model`".to_string(),
- );
+ return Err("agent id must not contain '@' — use `uuid:rig:provider/model`".to_string());
}
let a = s.to_lowercase();
validate_agent_naked(&a)?;
diff --git a/server/src/lib.rs b/server/src/lib.rs
index 511651c1bf21a11dc597e36eafc151487c2f82df..a96322df85c6b289d5e89f7092b4f3f0bee4ad76 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -56,6 +56,7 @@ pub fn create_app_state(cfg: AppConfig) -> AppState {
views,
resolver_runs: Arc::new(RwLock::new(HashMap::new())),
github_resolver: Arc::new(crate::resolvers::GitHubResolver::from_env()),
+ arena_resolver: Arc::new(crate::resolvers::ArenaResolver::from_env()),
};
tokio::spawn(crate::api::write_actor::writer_actor(
write_rx,
@@ -90,16 +91,39 @@ pub fn create_app(state: AppState) -> Router {
"/r/:room_key/vote",
get(crate::html::room_vote_compare_page),
)
+ .route("/q/:collection", get(crate::html::question_page))
+ .route(
+ "/q/:collection/:aspect",
+ get(crate::html::question_aspect_page),
+ )
+ .route(
+ "/r/:room_key/q/:collection",
+ get(crate::html::room_question_page),
+ )
+ .route(
+ "/r/:room_key/q/:collection/:aspect",
+ get(crate::html::room_question_aspect_page),
+ )
.route("/~", get(crate::html::garden_index))
+ .route("/~/", get(crate::html::redirect_strip_trailing_slash))
.route("/~/*path", get(crate::html::ontology_path))
.route("/-", get(crate::html::external_garden_index))
+ .route("/-/", get(crate::html::redirect_strip_trailing_slash))
.route("/-/*path", get(crate::html::external_ontology_path))
.route("/r/:room_key/~", get(crate::html::room_garden_index))
+ .route(
+ "/r/:room_key/~/",
+ get(crate::html::redirect_strip_trailing_slash),
+ )
.route("/r/:room_key/~/*path", get(crate::html::room_ontology_path))
.route(
"/r/:room_key/-",
get(crate::html::room_external_garden_index),
)
+ .route(
+ "/r/:room_key/-/",
+ get(crate::html::redirect_strip_trailing_slash),
+ )
.route(
"/r/:room_key/-/*path",
get(crate::html::room_external_ontology_path),
diff --git a/server/src/main.rs b/server/src/main.rs
index edef020df44bfc5c88fdacdc4e798ec41bb079f1..db778bd9dac33b3c08c13a63ce6c8cd5c4234f69 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -1,11 +1,7 @@
use axum::Router;
use tracing_subscriber::EnvFilter;
-use slugsocial_server::{
- create_app, create_app_state,
- event_log::EventLog,
- state::AppConfig,
-};
+use slugsocial_server::{create_app, create_app_state, event_log::EventLog, state::AppConfig};
fn env_var(name: &str) -> Option {
std::env::var(name).ok().filter(|s| !s.trim().is_empty())
@@ -22,7 +18,8 @@ async fn main() -> Result<(), Box> {
.init();
let data_dir = env_var("SLUG_DATA_DIR").unwrap_or_else(|| "/data".to_string());
- let event_log_path = env_var("SLUG_EVENT_LOG").unwrap_or_else(|| format!("{data_dir}/events.jsonl"));
+ let event_log_path =
+ env_var("SLUG_EVENT_LOG").unwrap_or_else(|| format!("{data_dir}/events.jsonl"));
eprintln!("[boot] data_dir={data_dir} event_log_path={event_log_path}");
@@ -48,9 +45,7 @@ async fn main() -> Result<(), Box> {
// Single source of truth for routing lives in the library (`create_app`).
let app: Router = create_app(state);
- let port: u16 = env_var("PORT")
- .and_then(|s| s.parse().ok())
- .unwrap_or(8080);
+ let port: u16 = env_var("PORT").and_then(|s| s.parse().ok()).unwrap_or(8080);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!(%addr, "listening");
eprintln!("[boot] binding {addr}");
@@ -93,5 +88,3 @@ async fn shutdown_signal() {
tracing::info!("shutdown");
}
}
-
-
diff --git a/server/src/mcp/mod.rs b/server/src/mcp/mod.rs
index 93f9e57e9058ff352a24256ccfee1ee3a56535c4..175fcb823ea209bfca02b78bca04dc46d2a25f79 100644
--- a/server/src/mcp/mod.rs
+++ b/server/src/mcp/mod.rs
@@ -1331,10 +1331,7 @@ async fn get_feed(state: &AppState, headers: &HeaderMap, args: &Value) -> Value
}
}
-async fn feed_posts_json(
- state: &AppState,
- posts: impl IntoIterator- ,
-) -> Value {
+async fn feed_posts_json(state: &AppState, posts: impl IntoIterator
- ) -> Value {
let reduced = state.reduced.read().await;
let items: Vec
= posts
.into_iter()
diff --git a/server/src/mcp/oauth.rs b/server/src/mcp/oauth.rs
index 2b36aafa1d97d258bb750f3d3bd65e96b5bb860d..10788013ddbc8f11d9add2b5a478033dbc13c76e 100644
--- a/server/src/mcp/oauth.rs
+++ b/server/src/mcp/oauth.rs
@@ -469,7 +469,9 @@ mod tests {
assert!(redirect_uri_allowed("http://127.0.0.1:9/cb"));
assert!(redirect_uri_allowed("http://localhost:3118/callback"));
assert!(!redirect_uri_allowed("https://evil.example/cb"));
- assert!(!redirect_uri_allowed("https://notclaude.ai/api/mcp/auth_callback"));
+ assert!(!redirect_uri_allowed(
+ "https://notclaude.ai/api/mcp/auth_callback"
+ ));
assert!(!redirect_uri_allowed("/local"));
}
}
diff --git a/server/src/offline.rs b/server/src/offline.rs
index ac299eb83dcb0edc2875b7f638c44d8d68167e7e..5037c813a8c4f6092b96e5d78e7dcc5de990e4dc 100644
--- a/server/src/offline.rs
+++ b/server/src/offline.rs
@@ -4,14 +4,14 @@ use std::collections::HashSet;
use std::path::Path;
use serde::Serialize;
-use slug_types::{CheckScopeRanking, RankComponent, RankRow, paths::GardenItemUrl};
+use slug_types::{paths::GardenItemUrl, CheckScopeRanking, RankComponent, RankRow};
use crate::{
api::{resolve_item, validate_ingest_document},
dsl,
events::{Event, Ingest},
path_types::ItemId,
- reducer::{ReducerState, ScopeId, scope_from_room_wire},
+ reducer::{scope_from_room_wire, ReducerState, ScopeId},
scope_rank::build_children_rankings,
};
@@ -182,9 +182,7 @@ pub fn rankings_for_document(
.map(|(parent, aspect)| {
let empty = crate::reducer::GroupState::new();
let scoped = if let Some(ref slug) = aspect {
- let group = scoped_content
- .aspect_group(&parent, slug)
- .unwrap_or(&empty);
+ let group = scoped_content.aspect_group(&parent, slug).unwrap_or(&empty);
build_rankings_for_group_and_items(group, &scoped_content.members_of(&parent))
} else {
build_children_rankings(scoped_content, &parent)
@@ -228,15 +226,16 @@ fn compile_document_inner(
) -> Result {
let room_key = room.trim();
let scope = scope_from_room_wire(room_key);
- let validated = validate_ingest_document(base, text, &scope).map_err(|(_, message, hint)| {
- let parse_error = dsl::parse_full(text).err().map(|e| e.to_string());
- CompileError {
- ok: false,
- error: message,
- hint,
- parse_error,
- }
- })?;
+ let validated =
+ validate_ingest_document(base, text, &scope).map_err(|(_, message, hint)| {
+ let parse_error = dsl::parse_full(text).err().map(|e| e.to_string());
+ CompileError {
+ ok: false,
+ error: message,
+ hint,
+ parse_error,
+ }
+ })?;
let event = Event::Ingest(Ingest {
ts: validated.ts,
@@ -307,14 +306,20 @@ fn replay_events(events: &[(usize, Event)]) -> ReducerState {
}
/// Replay a JSONL event log into reducer state (same rules as server boot).
-pub fn load_reducer_from_jsonl(path: &Path) -> Result<(ReducerState, Vec), std::io::Error> {
+pub fn load_reducer_from_jsonl(
+ path: &Path,
+) -> Result<(ReducerState, Vec), std::io::Error> {
let (_total_lines, events, bad_json_lines) = load_events_from_jsonl(path)?;
Ok((replay_events(&events), bad_json_lines))
}
/// Find one ingest in a log and compile it against all prior events as base state.
-pub fn compile_ingest_from_log(path: &Path, ingest_id: &str) -> Result {
- let (_total_lines, events, bad_json_lines) = load_events_from_jsonl(path).map_err(CompileIngestError::Io)?;
+pub fn compile_ingest_from_log(
+ path: &Path,
+ ingest_id: &str,
+) -> Result {
+ let (_total_lines, events, bad_json_lines) =
+ load_events_from_jsonl(path).map_err(CompileIngestError::Io)?;
if !bad_json_lines.is_empty() {
return Err(CompileIngestError::Compile(CompileError {
ok: false,
@@ -340,8 +345,14 @@ pub fn compile_ingest_from_log(path: &Path, ingest_id: &str) -> Result> = HashMap::new();
+ let mut ingests_by_scope_thread: HashMap<(ScopeId, String), VecDeque> =
+ HashMap::new();
let thread = "debate-1".to_string();
let id1 = "ing-1".to_string();
let id2 = "ing-2".to_string();
@@ -280,7 +284,9 @@ mod tests {
nav!(ingests_by_scope_thread, keypath(k.clone()), push_front(id1));
nav!(ingests_by_scope_thread, keypath(k), push_front(id2));
- let q = ingests_by_scope_thread.get(&(ScopeId::Public, "debate-1".to_string())).unwrap();
+ let q = ingests_by_scope_thread
+ .get(&(ScopeId::Public, "debate-1".to_string()))
+ .unwrap();
assert_eq!(q[0], "ing-2"); // most recent first
assert_eq!(q[1], "ing-1");
}
@@ -313,13 +319,19 @@ mod tests {
// First bump
let ts_entry = threads.entry(thread.clone()).or_default();
let new_ts: i64 = 100;
- nav!(ts_entry.last_activity_ts, selected(new_ts > ts_entry.last_activity_ts, setval(new_ts)));
+ nav!(
+ ts_entry.last_activity_ts,
+ selected(new_ts > ts_entry.last_activity_ts, setval(new_ts))
+ );
assert_eq!(threads.get("debate-1").unwrap().last_activity_ts, 100);
// No-op bump (older ts)
let ts_entry = threads.entry(thread).or_default();
let old_ts: i64 = 50;
- nav!(ts_entry.last_activity_ts, selected(old_ts > ts_entry.last_activity_ts, setval(old_ts)));
+ nav!(
+ ts_entry.last_activity_ts,
+ selected(old_ts > ts_entry.last_activity_ts, setval(old_ts))
+ );
assert_eq!(threads.get("debate-1").unwrap().last_activity_ts, 100);
}
@@ -358,7 +370,10 @@ mod tests {
}
assert!(state.items.contains("ai/llm"));
- assert_eq!(state.item_bodies.get("ai/llm").unwrap(), "Large language models");
+ assert_eq!(
+ state.item_bodies.get("ai/llm").unwrap(),
+ "Large language models"
+ );
}
// -----------------------------------------------------------------------
@@ -386,7 +401,12 @@ mod tests {
.collect();
for item in &items {
- nav_each!(item_threads, keypath(item.clone()), set_elem, threads.iter().cloned());
+ nav_each!(
+ item_threads,
+ keypath(item.clone()),
+ set_elem,
+ threads.iter().cloned()
+ );
}
assert!(item_threads.get("ai/llm").unwrap().contains("debate-1"));
diff --git a/server/src/ranking.rs b/server/src/ranking.rs
index f69c23938c83d2dae22eacf185c3626c594a74c5..81d97089d7e6802cd7a874cd7ada848cc721cbaa 100644
--- a/server/src/ranking.rs
+++ b/server/src/ranking.rs
@@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet};
use crate::path_types::ItemId;
use crate::reducer::GroupState;
-use crate::stationary::{self, RankChain, SolveOptions, Solution};
+use crate::stationary::{self, RankChain, Solution, SolveOptions};
#[derive(Debug, Clone)]
pub struct RankedItem {
@@ -80,12 +80,8 @@ pub fn compute_group_ranking(group: &mut GroupState, max_iters: usize, tol: f64)
return;
}
- let scores = compute_scores_from_edges(
- n,
- group.edges.iter().map(|(&k, &w)| (k, w)),
- max_iters,
- tol,
- );
+ 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;
}
@@ -102,7 +98,11 @@ pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec, max_iters: usize, tol: f64) -> Vec {
+fn compute_scores_from_edges(
+ n: usize,
+ edges: impl Iterator- ,
+ max_iters: usize,
+ tol: f64,
+) -> Vec
{
solve_scores_from_edges(n, edges, max_iters, tol).pi
}
/// 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 {
+pub fn ranked_items_subset(
+ group: &GroupState,
+ idxs: &[usize],
+ max_iters: usize,
+ tol: f64,
+) -> Vec {
if idxs.is_empty() {
return vec![];
}
@@ -291,18 +301,14 @@ pub fn rank_partition(
if gi == UNASSIGNED || gi != slot_group[dst] {
continue;
}
- buckets[gi as usize].push((
- (slot_local[src] as usize, slot_local[dst] as usize),
- w,
- ));
+ buckets[gi as usize].push(((slot_local[src] as usize, slot_local[dst] as usize), w));
}
groups
.iter()
.zip(buckets)
.map(|(nodes, edges)| {
- let solved =
- solve_scores_from_edges(nodes.len(), edges.into_iter(), max_iters, tol);
+ let solved = solve_scores_from_edges(nodes.len(), edges.into_iter(), max_iters, tol);
// Sort on log scores (same reason as `ranked_items_subset`): deep
// preference chains underflow f64 mass while log-space still orders.
let mut items: Vec<(RankedItem, f64)> = nodes
@@ -458,7 +464,11 @@ mod tests {
}
let solved = solve_scores_from_edges(n, edges.into_iter(), 10000, 1e-8);
assert!(solved.converged, "chain n={n} reported non-convergence");
- assert!(solved.residual < 1e-10, "chain n={n}: {:e}", solved.residual);
+ assert!(
+ solved.residual < 1e-10,
+ "chain n={n}: {:e}",
+ solved.residual
+ );
assert_eq!(solved.pi.len(), n);
assert_eq!(solved.log_pi.len(), n);
}
@@ -509,8 +519,14 @@ mod tests {
.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"]);
+ assert_eq!(
+ comp0,
+ vec!["https://slug.social/a", "https://slug.social/b"]
+ );
+ assert_eq!(
+ comp1,
+ vec!["https://slug.social/c", "https://slug.social/d"]
+ );
}
/// `rank_partition` exists purely to avoid the O(components x edges) cost of
@@ -576,8 +592,10 @@ mod tests {
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());
+ 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.
@@ -593,5 +611,3 @@ mod tests {
}
}
}
-
-
diff --git a/server/src/reducer.rs b/server/src/reducer.rs
index 434871e2bbe29cb832ce5cf8ab1b147c016d63fe..c6b0b9d468014b557dacbd08c52eb061497b9d5b 100644
--- a/server/src/reducer.rs
+++ b/server/src/reducer.rs
@@ -120,7 +120,11 @@ impl GroupState {
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) };
+ 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;
@@ -244,15 +248,13 @@ pub struct RoomTimelineEntry {
pub kind: RoomTimelineKind,
}
-#[derive(Debug, Clone)]
-#[derive(Default)]
+#[derive(Debug, Clone, 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)]
pub(crate) struct RankPositionCache {
generation: u64,
@@ -312,7 +314,8 @@ pub struct ContentState {
impl ContentState {
pub fn aspect_group(&self, parent: &ItemId, aspect: &str) -> Option<&GroupState> {
- self.aspect_groups.get(&(parent.ontology_leaf(), aspect.to_string()))
+ self.aspect_groups
+ .get(&(parent.ontology_leaf(), aspect.to_string()))
}
pub fn aspect_prompt(&self, aspect: &str) -> Option<&str> {
@@ -455,10 +458,7 @@ impl ContentState {
.entry(child.clone())
.or_default()
.insert(parent.clone());
- self.item_children
- .entry(parent)
- .or_default()
- .insert(child);
+ self.item_children.entry(parent).or_default().insert(child);
} else {
if let Some(set) = self.members_by_scope.get_mut(&parent) {
set.remove(&child);
@@ -496,7 +496,6 @@ pub struct ReducerState {
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>,
@@ -567,9 +566,7 @@ impl ReducerState {
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",
- )
+ .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.
@@ -590,9 +587,8 @@ impl ReducerState {
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))
- }
+ ScopeId::Room(rid) => viewer
+ .is_some_and(|u| self.user_has_cap(rid, u, ThreadCapability::View)),
}
})
})
@@ -658,7 +654,9 @@ impl ReducerState {
}
pub fn public(&self) -> &ContentState {
- self.content.get(&ScopeId::Public).expect("public scope missing")
+ self.content
+ .get(&ScopeId::Public)
+ .expect("public scope missing")
}
/// Register parent→child edges for the full ancestor chain of **URL / external**
@@ -676,7 +674,9 @@ impl ReducerState {
.entry(parent.clone())
.or_default()
.insert(child);
- if !is_new { break; }
+ if !is_new {
+ break;
+ }
child = parent;
}
}
@@ -688,10 +688,7 @@ impl ReducerState {
/// 1-indexed rank within its own connected component, for every active member of
/// `scope`. Items with no votes connecting them to a sibling are absent.
- fn scope_positions(
- group: &GroupState,
- members: &HashSet,
- ) -> HashMap {
+ fn scope_positions(group: &GroupState, members: &HashSet) -> HashMap {
if members.is_empty() {
return HashMap::new();
}
@@ -716,7 +713,11 @@ impl ReducerState {
);
let comps_global: Vec> = comps
.iter()
- .map(|c| c.iter().filter_map(|&l| sibling_idxs.get(l).copied()).collect())
+ .map(|c| {
+ c.iter()
+ .filter_map(|&l| sibling_idxs.get(l).copied())
+ .collect()
+ })
.collect();
let mut out = HashMap::new();
@@ -889,7 +890,11 @@ impl ReducerState {
if let Some(body_text) = body {
if !body_text.trim().is_empty() {
- nav!(content.item_bodies, keypath(item.clone()), setval(body_text));
+ nav!(
+ content.item_bodies,
+ keypath(item.clone()),
+ setval(body_text)
+ );
}
}
}
@@ -909,12 +914,7 @@ impl ReducerState {
ingest_items.insert(child_id.clone());
ingest_items.insert(parent_id.clone());
content.apply_containment_claim(
- child_id,
- parent_id,
- border,
- sugar,
- ing.ts,
- &ing.id,
+ child_id, parent_id, border, sugar, ing.ts, &ing.id,
);
}
dsl::Stmt::Aspect { slug, prompt } => {
@@ -972,7 +972,11 @@ impl ReducerState {
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()));
+ nav!(
+ content.item_votes,
+ keypath(it.clone()),
+ push_front(vote.clone())
+ );
}
}
}
@@ -981,7 +985,11 @@ impl ReducerState {
}
for item in ingest_items.iter() {
- nav!(content.item_snippets, keypath(item.clone()), push_front(ing.id.clone()));
+ nav!(
+ content.item_snippets,
+ keypath(item.clone()),
+ push_front(ing.id.clone())
+ );
}
for item in ingest_items.iter() {
@@ -1017,18 +1025,22 @@ impl ReducerState {
.map(|s| content.members_of(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(),
- });
+ 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(),
+ });
}
}
@@ -1075,11 +1087,9 @@ impl ReducerState {
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.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);
@@ -1123,15 +1133,14 @@ impl ReducerState {
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 {
+ 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);
}
@@ -1155,20 +1164,35 @@ impl ReducerState {
self.ingests_by_id.insert(ing.id.clone(), ing.clone());
- let ft = self.forum_threads.entry(scope_thread_key.clone()).or_default();
+ 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_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.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));
+ nav!(
+ self.actor_last_post_ts,
+ keypath(ing.principal.clone()),
+ setval(ing.ts)
+ );
}
Event::PostRedacted(pr) => {
self.redacted_posts.insert(pr.post_id.clone());
@@ -1182,7 +1206,8 @@ impl ReducerState {
}
Event::GrantAdded(ga) => {
let room_id = ga.room_id.clone();
- let caps = self.grants
+ let caps = self
+ .grants
.entry(ga.room_id)
.or_default()
.entry(ga.username.clone())
@@ -1253,7 +1278,8 @@ impl ReducerState {
Event::ThreadGraduated(tg) => {
let room_id = tg.source_room_id.trim().to_string();
let tag = canonicalize_tag(&tg.thread_tag);
- self.graduated_threads.insert((room_id.clone(), tag.clone()));
+ self.graduated_threads
+ .insert((room_id.clone(), tag.clone()));
self.room_timeline
.entry(room_id)
.or_default()
@@ -1335,11 +1361,7 @@ mod rank_position_cache_tests {
)
.unwrap();
assert_eq!(
- content
- .rank_position_cache
- .as_ref()
- .unwrap()
- .recomputations,
+ content.rank_position_cache.as_ref().unwrap().recomputations,
2,
"adding an isolate must not invalidate ranked positions"
);
@@ -1418,13 +1440,12 @@ mod aspect_tests {
assert_eq!(speed.recent_votes[0].ratio_left, 4);
assert_eq!(speed.recent_votes[0].ratio_right, 1);
- assert_eq!(
- content.aspect_prompt("beauty"),
- Some("more beautiful")
- );
+ assert_eq!(content.aspect_prompt("beauty"), Some("more beautiful"));
assert_eq!(content.aspect_prompt("speed"), Some("faster"));
assert!(content.item_votes.values().all(|votes| {
- votes.iter().all(|v| v.ratio_left == 3 && v.ratio_right == 1)
+ votes
+ .iter()
+ .all(|v| v.ratio_left == 3 && v.ratio_right == 1)
}));
}
@@ -1435,13 +1456,16 @@ mod aspect_tests {
"first",
":beauty { first prompt }\n~/songs/a { a }",
)));
- state.apply_event(Event::Ingest(ingest(
- "second",
- ":beauty { second prompt }",
- )));
- assert_eq!(state.public().aspect_prompt("beauty"), Some("second prompt"));
+ state.apply_event(Event::Ingest(ingest("second", ":beauty { second prompt }")));
+ assert_eq!(
+ state.public().aspect_prompt("beauty"),
+ Some("second prompt")
+ );
state.apply_event(Event::Ingest(ingest("empty", ":beauty { }")));
- assert_eq!(state.public().aspect_prompt("beauty"), Some("second prompt"));
+ assert_eq!(
+ state.public().aspect_prompt("beauty"),
+ Some("second prompt")
+ );
}
#[test]
@@ -1455,7 +1479,10 @@ mod aspect_tests {
"aspects",
":beauty\n{ pretty }\n~/songs/a 2:1 ~/songs/b",
)));
- assert!(state.public().aspect_group(&parent_songs(), "beauty").is_some());
+ assert!(state
+ .public()
+ .aspect_group(&parent_songs(), "beauty")
+ .is_some());
assert_eq!(state.public().ranking_group.voted_pairs.len(), 1);
state.apply_event(Event::PostRedacted(PostRedacted {
@@ -1527,8 +1554,12 @@ mod aspect_tests {
.content_for_scope(&ScopeId::Room("aa11bb/studio".into()))
.expect("private scope");
let pub_content = state.public();
- assert!(priv_content.aspect_group(&parent_songs(), "beauty").is_some());
- assert!(pub_content.aspect_group(&parent_songs(), "beauty").is_some());
+ assert!(priv_content
+ .aspect_group(&parent_songs(), "beauty")
+ .is_some());
+ assert!(pub_content
+ .aspect_group(&parent_songs(), "beauty")
+ .is_some());
assert_eq!(
priv_content.aspect_prompt("beauty"),
pub_content.aspect_prompt("beauty")
@@ -1633,11 +1664,7 @@ mod containment_tests {
#[test]
fn sugar_weights_are_idempotent() {
let mut state = ReducerState::default();
- state.apply_event(Event::Ingest(ingest_at(
- "one",
- 1,
- "~/jedi/luke { l }",
- )));
+ state.apply_event(Event::Ingest(ingest_at("one", 1, "~/jedi/luke { l }")));
state.apply_event(Event::Ingest(ingest_at(
"two",
2,
@@ -1647,7 +1674,10 @@ mod containment_tests {
let st = c.border_state(&item("~luke"), &item("~jedi")).unwrap();
assert_eq!(st.containment_weight, 1);
assert_eq!(st.status, MembershipStatus::Active);
- assert_eq!(c.item_bodies.get(&item("~luke")).map(String::as_str), Some("l again"));
+ assert_eq!(
+ c.item_bodies.get(&item("~luke")).map(String::as_str),
+ Some("l again")
+ );
}
#[test]
@@ -1673,20 +1703,15 @@ mod containment_tests {
#[test]
fn leaf_collision_merges_two_old_paths() {
let mut state = ReducerState::default();
- state.apply_event(Event::Ingest(ingest_at(
- "x",
- 1,
- "~/x/luke { from x }",
- )));
- state.apply_event(Event::Ingest(ingest_at(
- "y",
- 2,
- "~/y/luke { from y }",
- )));
+ state.apply_event(Event::Ingest(ingest_at("x", 1, "~/x/luke { from x }")));
+ state.apply_event(Event::Ingest(ingest_at("y", 2, "~/y/luke { from y }")));
let c = state.public();
assert!(c.items.contains(&item("~luke")));
assert!(!c.items.contains(&ItemId::parse("~/x/luke").unwrap()));
- assert_eq!(c.item_bodies.get(&item("~luke")).map(String::as_str), Some("from y"));
+ assert_eq!(
+ c.item_bodies.get(&item("~luke")).map(String::as_str),
+ Some("from y")
+ );
assert_eq!(c.members_of(&item("~x")), vec![item("~luke")]);
assert_eq!(c.members_of(&item("~y")), vec![item("~luke")]);
let mut scopes = c.scopes_of(&item("~luke"));
diff --git a/server/src/resolvers/arena.rs b/server/src/resolvers/arena.rs
new file mode 100644
index 0000000000000000000000000000000000000000..afaac209d056c113160b8032865f6d5c384e27eb
--- /dev/null
+++ b/server/src/resolvers/arena.rs
@@ -0,0 +1,923 @@
+//! Are.na resolver: imports channel contents (blocks + nested channels) as garden items.
+//!
+//! Unlike GitHub issues, are.na block URLs (`/block/:id`) are not path-children of the
+//! channel URL, so membership is emitted as an explicit containment claim
+//! (`block <: channel`) alongside the card body. Blocks keep cross-channel identity:
+//! the same block connected in two imported channels is one item with two scopes.
+
+use async_trait::async_trait;
+use maud::html;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use super::{
+ extract_fence, now_ms, resolver_cooldown_ms, system_ingest, system_redact, ExternalResolver,
+ ResolveStats,
+};
+use crate::{path_types::ItemId, state::AppState};
+
+pub const SLUG_ARENA_SCHEMA: &str = "slug_arena_import";
+
+const ARENA_SYSTEM_PRINCIPAL: &str = "system:arena-resolver";
+const ARENA_RESOLVER_COOLDOWN_MS_DEFAULT: i64 = 15_000;
+/// Safety valve while paging channel contents (`per=100` → up to 2500 entries).
+const ARENA_MAX_PAGES: usize = 25;
+
+fn arena_cooldown_ms() -> i64 {
+ resolver_cooldown_ms(
+ "SLUG_ARENA_RESOLVER_COOLDOWN_MS",
+ ARENA_RESOLVER_COOLDOWN_MS_DEFAULT,
+ )
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum ArenaImportKind {
+ Channel,
+ Text,
+ Image,
+ Link,
+ Media,
+ Attachment,
+ Embed,
+ Block,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct ArenaImportCard {
+ pub v: u32,
+ #[serde(default)]
+ pub schema: String,
+ pub kind: ArenaImportKind,
+ pub url: String,
+ pub headline: String,
+ #[serde(default)]
+ pub sublines: Vec,
+ #[serde(default)]
+ pub excerpt: Option,
+ #[serde(default)]
+ pub image_url: Option,
+ #[serde(default)]
+ pub source_url: Option,
+}
+
+impl ArenaImportCard {
+ fn new(kind: ArenaImportKind, url: String, headline: String) -> Self {
+ Self {
+ v: 1,
+ schema: SLUG_ARENA_SCHEMA.to_string(),
+ kind,
+ url,
+ headline,
+ sublines: Vec::new(),
+ excerpt: None,
+ image_url: None,
+ source_url: None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ArenaChild {
+ pub url: String,
+ pub title: String,
+ pub card: ArenaImportCard,
+}
+
+#[derive(Clone)]
+pub struct ArenaResolver {
+ client: reqwest::Client,
+ api_base_url: String,
+ token: Option,
+}
+
+impl ArenaResolver {
+ pub fn from_env() -> Self {
+ let api_base_url = std::env::var("SLUG_ARENA_API_BASE_URL")
+ .ok()
+ .filter(|s| !s.trim().is_empty())
+ .unwrap_or_else(|| "https://api.are.na".to_string());
+ let token = std::env::var("SLUG_ARENA_TOKEN")
+ .ok()
+ .filter(|s| !s.trim().is_empty());
+ Self {
+ client: reqwest::Client::new(),
+ api_base_url: api_base_url.trim_end_matches('/').to_string(),
+ token,
+ }
+ }
+
+ pub fn can_resolve_children(&self, item: &ItemId) -> bool {
+ arena_channel_slug(item).is_some()
+ }
+
+ pub async fn list_children(&self, item: &ItemId) -> Result, String> {
+ let slug =
+ arena_channel_slug(item).ok_or_else(|| "not an are.na channel URL".to_string())?;
+ self.list_channel_contents(&slug).await
+ }
+
+ async fn get_json(&self, path: &str) -> Result {
+ let url = format!("{}/{}", self.api_base_url, path.trim_start_matches('/'));
+ let mut req = self
+ .client
+ .get(url)
+ .header(reqwest::header::USER_AGENT, "slugsocial-arena-resolver");
+ if let Some(token) = &self.token {
+ req = req.bearer_auth(token);
+ }
+ let resp = req
+ .send()
+ .await
+ .map_err(|e| format!("are.na request failed: {e}"))?;
+ let status = resp.status();
+ if !status.is_success() {
+ return Err(format!("are.na request returned {status}"));
+ }
+ resp.json::()
+ .await
+ .map_err(|e| format!("are.na response JSON failed: {e}"))
+ }
+
+ async fn list_channel_contents(&self, slug: &str) -> Result, String> {
+ let mut out = Vec::new();
+ for page in 1..=ARENA_MAX_PAGES {
+ let value = self
+ .get_json(&format!("/v3/channels/{slug}/contents?per=100&page={page}"))
+ .await?;
+ let arr = value
+ .get("data")
+ .and_then(|d| d.as_array())
+ .ok_or_else(|| "are.na response missing data array".to_string())?;
+ for entry in arr {
+ if let Some(child) = arena_child_from_entry(entry) {
+ out.push(child);
+ }
+ }
+ let has_more = value
+ .get("meta")
+ .and_then(|m| m.get("has_more_pages"))
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ if !has_more {
+ break;
+ }
+ if page == ARENA_MAX_PAGES {
+ return Err(format!(
+ "are.na list truncated after {ARENA_MAX_PAGES} pages ({} items); refine scope or raise page cap",
+ out.len()
+ ));
+ }
+ }
+ out.sort_by(|a, b| a.url.cmp(&b.url));
+ Ok(out)
+ }
+}
+
+fn arena_segments(item: &ItemId) -> Option> {
+ let url = url::Url::parse(item.as_str()).ok()?;
+ let host = url.host_str()?;
+ if host.eq_ignore_ascii_case("www.are.na") || host.eq_ignore_ascii_case("are.na") {
+ Some(
+ url.path_segments()
+ .map(|segments| {
+ segments
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_ascii_lowercase())
+ .collect::>()
+ })
+ .unwrap_or_default(),
+ )
+ } else {
+ None
+ }
+}
+
+fn arena_channel_slug(item: &ItemId) -> Option {
+ let segs = arena_segments(item)?;
+ match segs.as_slice() {
+ [channel, slug] if channel == "channel" && !slug.is_empty() => Some(slug.clone()),
+ _ => None,
+ }
+}
+
+/// Stable import thread for an are.na URL: one thread per channel.
+fn resolver_thread_tag(item: &ItemId) -> String {
+ let path = match arena_channel_slug(item) {
+ Some(slug) => format!("https://www.are.na/channel/{slug}"),
+ None => item.display_path().trim_start_matches("-/").to_string(),
+ };
+ let tail = path.replace(['/', '?'], ":");
+ format!("import:{tail}")
+}
+
+fn arena_kind_from_class(class: &str) -> ArenaImportKind {
+ match class {
+ "Text" => ArenaImportKind::Text,
+ "Image" => ArenaImportKind::Image,
+ "Link" => ArenaImportKind::Link,
+ "Media" => ArenaImportKind::Media,
+ "Attachment" => ArenaImportKind::Attachment,
+ "Embed" => ArenaImportKind::Embed,
+ _ => ArenaImportKind::Block,
+ }
+}
+
+fn arena_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
+ value
+ .get(key)
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.trim().is_empty())
+}
+
+fn arena_rich_text<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
+ value
+ .get(key)
+ .and_then(|d| d.get("markdown"))
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.trim().is_empty())
+}
+
+fn excerpt_from_arena_text(text: Option<&str>) -> Option {
+ let t = text?.trim();
+ if t.is_empty() {
+ return None;
+ }
+ let max = 1200usize;
+ if t.len() <= max {
+ Some(t.to_string())
+ } else {
+ Some(format!("{}…", t.chars().take(max).collect::()))
+ }
+}
+
+fn arena_child_from_entry(entry: &Value) -> Option {
+ let class = entry.get("type").and_then(|v| v.as_str())?;
+ if class == "Channel" {
+ let slug = arena_str(entry, "slug")?;
+ let url = format!("https://www.are.na/channel/{slug}");
+ let title = arena_str(entry, "title").unwrap_or(slug);
+ let mut card =
+ ArenaImportCard::new(ArenaImportKind::Channel, url.clone(), title.to_string());
+ if let Some(n) = entry
+ .get("counts")
+ .and_then(|c| c.get("contents"))
+ .and_then(|v| v.as_i64())
+ {
+ card.sublines.push(format!("Contents: {n}"));
+ }
+ card.excerpt = excerpt_from_arena_text(arena_rich_text(entry, "description"));
+ return Some(ArenaChild {
+ url,
+ title: title.to_string(),
+ card,
+ });
+ }
+
+ let id = entry.get("id").and_then(|v| v.as_i64())?;
+ let url = format!("https://www.are.na/block/{id}");
+ let content_md = arena_rich_text(entry, "content");
+ let title = arena_str(entry, "title").map(|s| s.to_string());
+ let headline = title
+ .clone()
+ .or_else(|| {
+ content_md.and_then(|m| {
+ let first = m.lines().next()?.trim();
+ if first.is_empty() {
+ None
+ } else {
+ let max = 80usize;
+ Some(if first.len() <= max {
+ first.to_string()
+ } else {
+ format!("{}…", first.chars().take(max).collect::())
+ })
+ }
+ })
+ })
+ .unwrap_or_else(|| format!("{class} block {id}"));
+
+ let mut card =
+ ArenaImportCard::new(arena_kind_from_class(class), url.clone(), headline.clone());
+ card.sublines.push(format!("Class: {class}"));
+ if let Some(name) = entry
+ .get("user")
+ .and_then(|u| u.get("name"))
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.trim().is_empty())
+ {
+ card.sublines.push(format!("By: {name}"));
+ }
+ if let Some(name) = entry
+ .get("connection")
+ .and_then(|c| c.get("connected_by"))
+ .and_then(|u| u.get("name"))
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.trim().is_empty())
+ {
+ card.sublines.push(format!("Connected by: {name}"));
+ }
+ if let Some(connected_at) = entry
+ .get("connection")
+ .and_then(|c| c.get("connected_at"))
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.trim().is_empty())
+ {
+ card.sublines.push(format!("Connected: {connected_at}"));
+ }
+ if let Some(source) = entry
+ .get("source")
+ .and_then(|s| s.get("url"))
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.trim().is_empty())
+ {
+ card.source_url = Some(source.to_string());
+ if let Some(host) = url::Url::parse(source)
+ .ok()
+ .and_then(|u| u.host_str().map(|h| h.to_string()))
+ {
+ card.sublines.push(format!("Source: {host}"));
+ }
+ }
+ card.image_url = entry
+ .get("image")
+ .and_then(|i| {
+ i.get("medium")
+ .and_then(|m| m.get("src"))
+ .or_else(|| i.get("src"))
+ })
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.trim().is_empty())
+ .map(|s| s.to_string());
+ // Text blocks carry their payload in `content`; other classes in `description`.
+ card.excerpt =
+ excerpt_from_arena_text(content_md.or_else(|| arena_rich_text(entry, "description")));
+
+ Some(ArenaChild {
+ url,
+ title: headline,
+ card,
+ })
+}
+
+/// Encode a card for a ```slug-arena-card``` fence (base64 for the same toggle-fence
+/// reason as GitHub cards: are.na text blocks are markdown and may contain ```).
+fn card_payload_for_dsl_fence(card: &ArenaImportCard) -> String {
+ use base64::{engine::general_purpose::STANDARD, Engine as _};
+ let json = serde_json::to_string(card).unwrap_or_else(|_| "{}".to_string());
+ STANDARD.encode(json.as_bytes())
+}
+
+fn decode_arena_card_payload(payload: &str) -> Option {
+ use base64::{engine::general_purpose::STANDARD, Engine as _};
+ let bytes = STANDARD.decode(payload.trim()).ok()?;
+ let s = std::str::from_utf8(&bytes).ok()?;
+ serde_json::from_str(s).ok()
+}
+
+/// Braces/quotes/newlines would break the leading `{ … }` explanation block.
+fn sanitize_explanation_text(raw: &str) -> String {
+ raw.replace(['{', '}'], "(")
+ .replace('"', "'")
+ .replace(['\n', '\r'], " ")
+}
+
+fn child_to_dsl(channel: &ItemId, child: &ArenaChild) -> String {
+ let payload = card_payload_for_dsl_fence(&child.card);
+ let inner = format!("```slug-arena-card\n{payload}\n```");
+ let channel_url = channel.clone().normalized_storage();
+ let label = sanitize_explanation_text(channel_url.as_str());
+ // Item body first: validation requires containment sides to be defined
+ // (here or previously) before the claim. The channel side is guaranteed by
+ // `ensure_channel_body`, not by re-declaring it here — re-declaring would
+ // clobber a user-written channel body on every block import.
+ format!(
+ "{child_url} {{\n{inner}\n}}\n\n{{ Connected in are.na channel {label}. }}\n{child_url} <: {channel_url}\n",
+ child_url = child.url,
+ channel_url = channel_url.as_str(),
+ )
+}
+
+/// Channel items are usually created (with a body) when the user pastes the URL.
+/// When the channel only exists as a ghost path-parent, define it once so the
+/// containment claims validate — and so the channel page renders a rich card.
+async fn ensure_channel_body(
+ state: &AppState,
+ room: &str,
+ thread_tag: &str,
+ channel: &ItemId,
+) -> Result<(), String> {
+ use crate::reducer::scope_from_room_wire;
+
+ let needs_body = {
+ let reduced = state.reduced.read().await;
+ let scope = scope_from_room_wire(room);
+ let in_room = reduced
+ .content_for_scope(&scope)
+ .map(|c| c.item_bodies.contains_key(channel))
+ .unwrap_or(false);
+ let in_public = reduced.public().item_bodies.contains_key(channel);
+ !in_room && !in_public
+ };
+ if !needs_body {
+ return Ok(());
+ }
+ let slug = arena_channel_slug(channel).unwrap_or_else(|| channel.last_segment().to_string());
+ let card = ArenaImportCard::new(ArenaImportKind::Channel, channel.as_str().to_string(), slug);
+ let payload = card_payload_for_dsl_fence(&card);
+ let text = format!(
+ "{url} {{\n```slug-arena-card\n{payload}\n```\n}}\n",
+ url = channel.as_str()
+ );
+ system_ingest(state, room, thread_tag, text, ARENA_SYSTEM_PRINCIPAL).await
+}
+
+/// Child URLs declared via containment claims into `channel` within one ingest body.
+fn arena_urls_declared_in_ingest(raw: &str, channel: &ItemId) -> Vec {
+ let Ok(doc) = crate::dsl::parse_full(raw) else {
+ return Vec::new();
+ };
+ let parent = channel.clone().normalized_storage();
+ let mut out = Vec::new();
+ for stmt in doc.statements {
+ let crate::dsl::Stmt::Containment {
+ child,
+ parent: p,
+ border,
+ ..
+ } = stmt
+ else {
+ continue;
+ };
+ if border {
+ continue;
+ }
+ let Some(child_id) = ItemId::parse(&child).map(|i| i.normalized_storage()) else {
+ continue;
+ };
+ let Some(p_id) = ItemId::parse(&p).map(|i| i.normalized_storage()) else {
+ continue;
+ };
+ if p_id == parent {
+ out.push(child_id.as_str().to_string());
+ }
+ }
+ out.sort();
+ out.dedup();
+ out
+}
+
+/// One post per channel entry; redact posts for entries removed from the channel
+/// (and legacy multi-entry bulk posts).
+async fn resolve_arena_channel_contents(
+ state: &AppState,
+ room: &str,
+ channel: &ItemId,
+ children: Vec,
+) -> Result {
+ use crate::canonical_path::canonicalize_tag;
+ use crate::reducer::scope_from_room_wire;
+ use std::collections::HashSet;
+
+ let live_urls: HashSet = children
+ .iter()
+ .filter_map(|c| super::normalized_child_url(&c.url))
+ .collect();
+
+ let thread_tag = resolver_thread_tag(channel);
+ let scope = scope_from_room_wire(room);
+ let tag = canonicalize_tag(&thread_tag);
+
+ let mut to_redact: Vec = Vec::new();
+ let mut covered: HashSet = HashSet::new();
+ {
+ let reduced = state.reduced.read().await;
+ let ids = reduced
+ .ingests_by_scope_thread
+ .get(&(scope, tag))
+ .cloned()
+ .unwrap_or_default();
+ for id in ids {
+ if reduced.redacted_posts.contains(&id) {
+ continue;
+ }
+ let Some(ing) = reduced.ingests_by_id.get(&id) else {
+ continue;
+ };
+ if ing.principal != ARENA_SYSTEM_PRINCIPAL {
+ continue;
+ }
+ let declared = arena_urls_declared_in_ingest(&ing.raw, channel);
+ if declared.is_empty() {
+ continue;
+ }
+ let stale = declared.iter().any(|u| !live_urls.contains(u));
+ let bulk = declared.len() > 1;
+ if stale || bulk {
+ to_redact.push(id);
+ } else {
+ covered.insert(declared[0].clone());
+ }
+ }
+ }
+
+ let mut deleted = 0usize;
+ for post_id in &to_redact {
+ system_redact(state, post_id, ARENA_SYSTEM_PRINCIPAL).await?;
+ deleted += 1;
+ }
+
+ let mut imported = 0usize;
+ let mut kept = 0usize;
+ for child in &children {
+ let Some(url) = super::normalized_child_url(&child.url) else {
+ continue;
+ };
+ if covered.contains(&url) {
+ kept += 1;
+ continue;
+ }
+ system_ingest(
+ state,
+ room,
+ &thread_tag,
+ child_to_dsl(channel, child),
+ ARENA_SYSTEM_PRINCIPAL,
+ )
+ .await?;
+ imported += 1;
+ }
+
+ Ok(ResolveStats {
+ imported,
+ deleted,
+ kept,
+ })
+}
+
+pub async fn resolve_arena_children(
+ state: &AppState,
+ room: &str,
+ item: &ItemId,
+) -> Result {
+ if !state.arena_resolver.can_resolve_children(item) {
+ return Err(
+ "are.na resolver handles channel URLs (https://www.are.na/channel/…) only".to_string(),
+ );
+ }
+
+ let key = format!("arena:{}:{}", room.trim(), item.as_str());
+ let now = now_ms();
+ {
+ let mut runs = state.resolver_runs.write().await;
+ if let Some(last) = runs.get(&key) {
+ let remaining = arena_cooldown_ms() - (now - *last);
+ if remaining > 0 {
+ return Err(format!(
+ "are.na resolver cooldown: try again in {}s",
+ (remaining + 999) / 1000
+ ));
+ }
+ }
+ runs.insert(key, now);
+ }
+
+ let channel = item.clone().normalized_storage();
+ let children = state.arena_resolver.list_children(&channel).await?;
+ let thread_tag = resolver_thread_tag(&channel);
+ ensure_channel_body(state, room, &thread_tag, &channel).await?;
+ resolve_arena_channel_contents(state, room, &channel, children).await
+}
+
+fn parse_arena_import_from_body(body: &str) -> Option {
+ let payload = extract_fence(body.trim(), "slug-arena-card")?;
+ let c = decode_arena_card_payload(payload)?;
+ (c.v == 1 && (c.schema.is_empty() || c.schema == SLUG_ARENA_SCHEMA)).then_some(c)
+}
+
+fn kind_badge(kind: &ArenaImportKind) -> &'static str {
+ match kind {
+ ArenaImportKind::Channel => "Are.na · channel",
+ ArenaImportKind::Text => "Are.na · text",
+ ArenaImportKind::Image => "Are.na · image",
+ ArenaImportKind::Link => "Are.na · link",
+ ArenaImportKind::Media => "Are.na · media",
+ ArenaImportKind::Attachment => "Are.na · attachment",
+ ArenaImportKind::Embed => "Are.na · embed",
+ ArenaImportKind::Block => "Are.na · block",
+ }
+}
+
+fn render_arena_card(card: &ArenaImportCard) -> maud::Markup {
+ html! {
+ article.import-card.arena-import-card {
+ header.import-card__hdr {
+ span class="import-card__badge" { (kind_badge(&card.kind)) }
+ h3.import-card__title { (card.headline.as_str()) }
+ }
+ @if let Some(img) = &card.image_url {
+ p.import-card__image {
+ a href=(card.url.as_str()) rel="noopener noreferrer" target="_blank" {
+ img src=(img.as_str()) alt=(card.headline.as_str()) loading="lazy";
+ }
+ }
+ }
+ @if !card.sublines.is_empty() {
+ ul.import-card__meta {
+ @for line in &card.sublines {
+ li { (line.as_str()) }
+ }
+ }
+ }
+ @if let Some(ex) = &card.excerpt {
+ div.import-card__excerpt {
+ @for block in ex.split("\n\n") {
+ @if !block.trim().is_empty() {
+ p { (block) }
+ }
+ }
+ }
+ }
+ p.import-card__link {
+ a href=(card.url.as_str()) rel="noopener noreferrer" target="_blank" {
+ "Open on Are.na"
+ }
+ @if let Some(src) = &card.source_url {
+ " · "
+ a href=(src.as_str()) rel="noopener noreferrer" target="_blank" {
+ "Source"
+ }
+ }
+ }
+ }
+ }
+}
+
+/// Rich HTML for bodies that contain an [`ArenaImportCard`] fence.
+pub fn try_render_arena_import_markup(raw: &str) -> Option {
+ let card = parse_arena_import_from_body(raw)?;
+ Some(render_arena_card(&card))
+}
+
+#[async_trait]
+impl ExternalResolver for ArenaResolver {
+ fn domain_match(&self) -> &'static str {
+ "are.na"
+ }
+
+ fn normalize(&self, path: &str) -> String {
+ path.to_string()
+ }
+
+ async fn fetch_body(&self, _item: &ItemId) -> Result {
+ Err("are.na fetch_body not implemented".to_string())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn channel_item() -> ItemId {
+ ItemId::parse("https://www.are.na/channel/arena-influences").unwrap()
+ }
+
+ fn block_child(id: i64, class: &str) -> ArenaChild {
+ let entry = serde_json::json!({
+ "id": id,
+ "type": class,
+ "title": null,
+ "content": (class == "Text").then(|| serde_json::json!({"markdown": "quote body"})),
+ "description": null,
+ "user": {"name": "Ada"},
+ "connection": {"connected_by": {"name": "Charles"}, "connected_at": "2024-01-01T00:00:00Z"},
+ });
+ arena_child_from_entry(&entry).expect("block entry maps to child")
+ }
+
+ #[test]
+ fn arena_segments_parse_channel_urls() {
+ let item = ItemId::parse("https://www.are.na/channel/arena-influences").unwrap();
+ assert_eq!(
+ arena_segments(&item),
+ Some(vec!["channel".to_string(), "arena-influences".to_string()])
+ );
+ let bare = ItemId::parse("https://are.na/channel/arena-influences").unwrap();
+ assert_eq!(
+ arena_channel_slug(&bare),
+ Some("arena-influences".to_string())
+ );
+ let block = ItemId::parse("https://www.are.na/block/123").unwrap();
+ assert_eq!(arena_channel_slug(&block), None);
+ }
+
+ #[test]
+ fn resolver_thread_tag_is_one_per_channel() {
+ let channel = channel_item();
+ assert_eq!(
+ resolver_thread_tag(&channel),
+ "import:https:::www.are.na:channel:arena-influences"
+ );
+ }
+
+ #[test]
+ fn channel_entry_maps_to_channel_card() {
+ let entry = serde_json::json!({
+ "id": 9530,
+ "type": "Channel",
+ "title": "Adam Curtis",
+ "slug": "adam-curtis",
+ "counts": {"contents": 33},
+ "description": {"markdown": "docs"},
+ });
+ let child = arena_child_from_entry(&entry).expect("channel entry");
+ assert_eq!(child.url, "https://www.are.na/channel/adam-curtis");
+ assert_eq!(child.card.kind, ArenaImportKind::Channel);
+ assert!(child.card.sublines.iter().any(|l| l.contains("33")));
+ }
+
+ #[test]
+ fn text_block_headline_falls_back_to_content_first_line() {
+ let child = block_child(4929062, "Text");
+ assert_eq!(child.url, "https://www.are.na/block/4929062");
+ assert_eq!(child.card.kind, ArenaImportKind::Text);
+ assert_eq!(child.card.headline, "quote body");
+ assert_eq!(child.card.excerpt.as_deref(), Some("quote body"));
+ assert!(child.card.sublines.iter().any(|l| l.contains("By: Ada")));
+ assert!(child
+ .card
+ .sublines
+ .iter()
+ .any(|l| l.contains("Connected by: Charles")));
+ }
+
+ #[test]
+ fn image_block_carries_image_and_source() {
+ let entry = serde_json::json!({
+ "id": 9613792,
+ "type": "Image",
+ "title": "Sitterwerk",
+ "image": {"medium": {"src": "https://images.are.na/medium.jpg"}},
+ "source": {"url": "https://example.com/page"},
+ });
+ let child = arena_child_from_entry(&entry).expect("image entry");
+ assert_eq!(child.card.kind, ArenaImportKind::Image);
+ assert_eq!(
+ child.card.image_url.as_deref(),
+ Some("https://images.are.na/medium.jpg")
+ );
+ assert_eq!(
+ child.card.source_url.as_deref(),
+ Some("https://example.com/page")
+ );
+ assert!(child
+ .card
+ .sublines
+ .iter()
+ .any(|l| l == "Source: example.com"));
+ }
+
+ #[test]
+ fn child_to_dsl_declares_containment_and_card() {
+ let channel = channel_item();
+ let dsl = child_to_dsl(&channel, &block_child(7, "Link"));
+ assert!(dsl
+ .contains("https://www.are.na/block/7 <: https://www.are.na/channel/arena-influences"));
+ assert!(dsl.contains("```slug-arena-card"));
+ // Payload is base64 so nested ``` in excerpts cannot break DSL fences.
+ assert!(!dsl.contains("\"schema\":\"slug_arena_import\""));
+ assert_eq!(
+ arena_urls_declared_in_ingest(&dsl, &channel),
+ vec!["https://www.are.na/block/7".to_string()]
+ );
+ }
+
+ #[test]
+ fn parse_accepts_slug_arena_fence() {
+ let card = ArenaImportCard::new(
+ ArenaImportKind::Image,
+ "https://www.are.na/block/1".into(),
+ "img".into(),
+ );
+ let body = format!(
+ "```slug-arena-card\n{}\n```\n",
+ card_payload_for_dsl_fence(&card)
+ );
+ let parsed = parse_arena_import_from_body(&body).expect("parses");
+ assert_eq!(parsed, card);
+ }
+
+ #[test]
+ fn parse_rejects_raw_json_slug_arena_fence() {
+ let card = ArenaImportCard::new(
+ ArenaImportKind::Text,
+ "https://www.are.na/block/2".into(),
+ "txt".into(),
+ );
+ let body = format!(
+ "```slug-arena-card\n{}\n```",
+ serde_json::to_string(&card).unwrap()
+ );
+ assert!(
+ parse_arena_import_from_body(&body).is_none(),
+ "raw JSON inside slug-arena-card is not accepted"
+ );
+ }
+
+ #[test]
+ fn child_to_dsl_validates_as_ingest_with_markdown_fences_in_content() {
+ let channel = channel_item();
+ let entry = serde_json::json!({
+ "id": 42,
+ "type": "Text",
+ "title": null,
+ "content": {"markdown": "Some code:\n\n```json\n{\"a\":1}\n```\n\ntrailing"},
+ });
+ let child = arena_child_from_entry(&entry).expect("text entry");
+ // Containment sides must be defined with bodies: in production the channel
+ // is defined by `ensure_channel_body` (or the user's original paste); here
+ // we prepend an equivalent channel definition to the same document.
+ let text = format!(
+ "{channel} {{are.na channel}}\n\n{child_dsl}",
+ channel = channel.as_str(),
+ child_dsl = child_to_dsl(&channel, &child)
+ );
+ let reduced = crate::reducer::ReducerState::default();
+ let validated = crate::api::validate_ingest_document(
+ &reduced,
+ &text,
+ &crate::reducer::ScopeId::Public,
+ )
+ .unwrap_or_else(|(code, msg, hint)| {
+ panic!("arena import DSL should validate; got {code} {msg} hint={hint:?}\nDSL:\n{text}")
+ });
+ let item_body = validated
+ .doc
+ .statements
+ .iter()
+ .find_map(|s| match s {
+ crate::dsl::Stmt::Item { body: Some(b), .. } if b.contains("slug-arena-card") => {
+ Some(b.as_str())
+ }
+ _ => None,
+ })
+ .unwrap_or_else(|| panic!("expected item body\nDSL:\n{text}"));
+ let parsed = parse_arena_import_from_body(item_body).unwrap_or_else(|| {
+ panic!("body should round-trip as arena card; body was:\n{item_body}\nDSL:\n{text}")
+ });
+ assert_eq!(parsed.excerpt.as_deref(), child.card.excerpt.as_deref());
+ }
+
+ #[tokio::test]
+ async fn list_channel_contents_pages_until_exhausted() {
+ use axum::{extract::Query, routing::get, Json, Router};
+ use tokio::sync::oneshot;
+
+ let (tx, rx) = oneshot::channel::<()>();
+ let app = Router::new().route(
+ "/v3/channels/foo/contents",
+ get(|Query(q): Query>| async move {
+ let page: u32 = q.get("page").and_then(|p| p.parse().ok()).unwrap_or(1);
+ let (data, has_more) = match page {
+ 1 => (
+ serde_json::json!([
+ {"id": 1, "type": "Link", "title": "one"},
+ {"id": 2, "type": "Channel", "title": "nested", "slug": "nested-chan"}
+ ]),
+ true,
+ ),
+ _ => (serde_json::json!([{"id": 3, "type": "Text", "content": {"markdown": "hi"}}]), false),
+ };
+ Json(serde_json::json!({
+ "meta": {"current_page": page, "has_more_pages": has_more},
+ "data": data,
+ }))
+ }),
+ );
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ let server = tokio::spawn(async move {
+ axum::serve(listener, app)
+ .with_graceful_shutdown(async {
+ let _ = rx.await;
+ })
+ .await
+ .unwrap();
+ });
+ let resolver = ArenaResolver {
+ client: reqwest::Client::new(),
+ api_base_url: format!("http://{addr}"),
+ token: None,
+ };
+ let item = ItemId::parse("https://www.are.na/channel/foo").unwrap();
+ let kids = resolver.list_children(&item).await.unwrap();
+ assert_eq!(kids.len(), 3);
+ assert!(kids.iter().any(|k| k.url == "https://www.are.na/block/1"));
+ assert!(kids
+ .iter()
+ .any(|k| k.url == "https://www.are.na/channel/nested-chan"));
+ assert!(kids.iter().any(|k| k.url == "https://www.are.na/block/3"));
+ let _ = tx.send(());
+ let _ = server.await;
+ }
+}
diff --git a/server/src/resolvers/default_external.rs b/server/src/resolvers/default_external.rs
index d37c222abcee3c20b22189b2822da9e9a6ff0515..d6233ff06bd8b57c607f5b164b455d1f85d8feeb 100644
--- a/server/src/resolvers/default_external.rs
+++ b/server/src/resolvers/default_external.rs
@@ -1,7 +1,7 @@
use async_trait::async_trait;
-use crate::path_types::ItemId;
use super::github::ExternalResolver;
+use crate::path_types::ItemId;
/// Placeholder until other domain-specific resolvers exist.
pub struct DefaultExternalResolver;
diff --git a/server/src/resolvers/github.rs b/server/src/resolvers/github.rs
index 2a7060eecf7daf4bbe792c9035e32b9d9fd52d8b..0982cc4c258fb69eb6febd985665e3845c837196 100644
--- a/server/src/resolvers/github.rs
+++ b/server/src/resolvers/github.rs
@@ -2,9 +2,12 @@ use async_trait::async_trait;
use maud::html;
use serde::{Deserialize, Serialize};
use serde_json::Value;
-use tokio::sync::oneshot;
-use crate::{path_types::ItemId, state::AppState, write_cmd::WriteCmd};
+use super::{
+ child_urls_declared_in_ingest, extract_fence, normalized_child_url, now_ms,
+ resolver_cooldown_ms, system_ingest, system_redact, ResolveStats,
+};
+use crate::{path_types::ItemId, state::AppState};
pub const SLUG_GITHUB_SCHEMA: &str = "slug_github_import";
@@ -15,20 +18,11 @@ const GITHUB_MAX_PAGES: usize = 3;
/// Safety valve while paging open issues (`per_page=100` → up to 100k issues).
const GITHUB_MAX_ISSUE_PAGES: usize = 1000;
-fn resolver_cooldown_ms() -> i64 {
- std::env::var("SLUG_GITHUB_RESOLVER_COOLDOWN_MS")
- .ok()
- .and_then(|s| s.parse::().ok())
- .filter(|&n| n >= 0)
- .unwrap_or(GITHUB_RESOLVER_COOLDOWN_MS_DEFAULT)
-}
-
-fn now_ms() -> i64 {
- use std::time::{SystemTime, UNIX_EPOCH};
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap_or_default()
- .as_millis() as i64
+fn github_cooldown_ms() -> i64 {
+ resolver_cooldown_ms(
+ "SLUG_GITHUB_RESOLVER_COOLDOWN_MS",
+ GITHUB_RESOLVER_COOLDOWN_MS_DEFAULT,
+ )
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -391,10 +385,7 @@ fn resolver_thread_tag(item: &ItemId) -> String {
let path = match github_segments(item).as_deref() {
Some([owner, repo, ..]) => format!("https://github.com/{owner}/{repo}"),
Some([owner]) => format!("https://github.com/{owner}"),
- _ => item
- .display_path()
- .trim_start_matches("-/")
- .to_string(),
+ _ => item.display_path().trim_start_matches("-/").to_string(),
};
let tail = path.replace(['/', '?'], ":");
format!("import:{tail}")
@@ -436,103 +427,20 @@ fn children_to_dsl(children: &[ResolvedChild]) -> String {
out
}
-fn normalized_issue_url(url: &str) -> Option {
- ItemId::parse(url).map(|id| id.normalized_storage().as_str().to_string())
-}
-
-fn issue_urls_declared_in_ingest(raw: &str, issues_parent: &ItemId) -> Vec {
- let Ok(doc) = crate::dsl::parse_full(raw) else {
- return Vec::new();
- };
- let parent = issues_parent.clone().normalized_storage();
- let mut out = Vec::new();
- for stmt in doc.statements {
- let crate::dsl::Stmt::Item { title, .. } = stmt else {
- continue;
- };
- let Some(item) = ItemId::parse(&title).map(|i| i.normalized_storage()) else {
- continue;
- };
- let Some(p) = item.parent() else {
- continue;
- };
- if p.normalized_storage() == parent {
- out.push(item.as_str().to_string());
- }
- }
- out.sort();
- out.dedup();
- out
-}
-
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
-pub struct GithubResolveStats {
- pub imported: usize,
- pub deleted: usize,
- pub kept: usize,
-}
-
-impl GithubResolveStats {
- pub fn total_touched(&self) -> usize {
- self.imported + self.deleted + self.kept
- }
-}
-
-async fn system_ingest(
- state: &AppState,
- room: &str,
- thread_tag: &str,
- text: String,
-) -> Result<(), String> {
- let (tx, rx) = oneshot::channel();
- state
- .write_tx
- .send(WriteCmd::SystemIngest {
- room: room.to_string(),
- thread_tag: thread_tag.to_string(),
- text,
- principal: GITHUB_SYSTEM_PRINCIPAL.to_string(),
- reply: tx,
- })
- .await
- .map_err(|_| "writer unavailable".to_string())?;
- rx.await
- .map_err(|_| "writer dropped".to_string())?
- .map_err(|(msg, hint)| hint.map_or(msg.clone(), |h| format!("{msg}: {h}")))?;
- Ok(())
-}
-
-async fn system_redact(state: &AppState, post_id: &str) -> Result<(), String> {
- let (tx, rx) = oneshot::channel();
- state
- .write_tx
- .send(WriteCmd::SystemRedact {
- post_id: post_id.to_string(),
- principal: GITHUB_SYSTEM_PRINCIPAL.to_string(),
- reply: tx,
- })
- .await
- .map_err(|_| "writer unavailable".to_string())?;
- rx.await
- .map_err(|_| "writer dropped".to_string())?
- .map_err(|(msg, hint)| hint.map_or(msg.clone(), |h| format!("{msg}: {h}")))?;
- Ok(())
-}
-
/// One post per open issue; redact posts for closed/missing issues (and bulk posts).
async fn resolve_github_issues(
state: &AppState,
room: &str,
issues_parent: &ItemId,
open_children: Vec,
-) -> Result {
+) -> Result {
use crate::canonical_path::canonicalize_tag;
use crate::reducer::scope_from_room_wire;
use std::collections::HashSet;
let open_urls: HashSet = open_children
.iter()
- .filter_map(|c| normalized_issue_url(&c.url))
+ .filter_map(|c| normalized_child_url(&c.url))
.collect();
let thread_tag = resolver_thread_tag(issues_parent);
@@ -558,7 +466,7 @@ async fn resolve_github_issues(
if ing.principal != GITHUB_SYSTEM_PRINCIPAL {
continue;
}
- let declared = issue_urls_declared_in_ingest(&ing.raw, issues_parent);
+ let declared = child_urls_declared_in_ingest(&ing.raw, issues_parent);
if declared.is_empty() {
continue;
}
@@ -574,25 +482,32 @@ async fn resolve_github_issues(
let mut deleted = 0usize;
for post_id in &to_redact {
- system_redact(state, post_id).await?;
+ system_redact(state, post_id, GITHUB_SYSTEM_PRINCIPAL).await?;
deleted += 1;
}
let mut imported = 0usize;
let mut kept = 0usize;
for child in &open_children {
- let Some(url) = normalized_issue_url(&child.url) else {
+ let Some(url) = normalized_child_url(&child.url) else {
continue;
};
if covered.contains(&url) {
kept += 1;
continue;
}
- system_ingest(state, room, &thread_tag, child_to_dsl(child)).await?;
+ system_ingest(
+ state,
+ room,
+ &thread_tag,
+ child_to_dsl(child),
+ GITHUB_SYSTEM_PRINCIPAL,
+ )
+ .await?;
imported += 1;
}
- Ok(GithubResolveStats {
+ Ok(ResolveStats {
imported,
deleted,
kept,
@@ -603,7 +518,7 @@ pub async fn resolve_github_children(
state: &AppState,
room: &str,
item: &ItemId,
-) -> Result {
+) -> Result {
if !state.github_resolver.can_resolve_children(item) {
return Err("no GitHub resolver for this item".to_string());
}
@@ -613,7 +528,7 @@ pub async fn resolve_github_children(
{
let mut runs = state.resolver_runs.write().await;
if let Some(last) = runs.get(&key) {
- let remaining = resolver_cooldown_ms() - (now - *last);
+ let remaining = github_cooldown_ms() - (now - *last);
if remaining > 0 {
return Err(format!(
"GitHub resolver cooldown: try again in {}s",
@@ -634,12 +549,12 @@ pub async fn resolve_github_children(
let children = state.github_resolver.list_children(item).await?;
if children.is_empty() {
- return Ok(GithubResolveStats::default());
+ return Ok(ResolveStats::default());
}
let text = children_to_dsl(&children);
let thread_tag = resolver_thread_tag(item);
- system_ingest(state, room, &thread_tag, text).await?;
- Ok(GithubResolveStats {
+ system_ingest(state, room, &thread_tag, text, GITHUB_SYSTEM_PRINCIPAL).await?;
+ Ok(ResolveStats {
imported: children.len(),
deleted: 0,
kept: 0,
@@ -692,8 +607,7 @@ fn card_for_issue(v: &Value, url: &str, kind: GithubImportKind) -> GithubImportC
}
let labels = github_labels(v);
if !labels.is_empty() {
- card.sublines
- .push(format!("Labels: {}", labels.join(", ")));
+ card.sublines.push(format!("Labels: {}", labels.join(", ")));
}
card.excerpt = excerpt_from_github_body(github_string(v, "body"));
card
@@ -786,18 +700,6 @@ fn github_labels(value: &Value) -> Vec {
.collect()
}
-fn extract_fence<'a>(body: &'a str, lang: &str) -> Option<&'a str> {
- let b = body.trim();
- let prefix = format!("```{lang}");
- let rest = b.strip_prefix(prefix.as_str())?;
- let rest = rest
- .strip_prefix('\n')
- .or_else(|| rest.strip_prefix('\r'))
- .unwrap_or(rest);
- let end = rest.find("\n```")?;
- Some(rest[..end].trim())
-}
-
fn parse_github_import_from_body(body: &str) -> Option {
let payload = extract_fence(body.trim(), "slug-github-card")?;
let c = decode_github_card_payload(payload)?;
@@ -817,20 +719,20 @@ fn kind_badge(kind: &GithubImportKind) -> &'static str {
fn render_github_card(card: &GithubImportCard) -> maud::Markup {
html! {
- article.github-import-card {
- header.github-import-card__hdr {
- span class="github-import-card__badge" { (kind_badge(&card.kind)) }
- h3.github-import-card__title { (card.headline.as_str()) }
+ article.import-card {
+ header.import-card__hdr {
+ span class="import-card__badge" { (kind_badge(&card.kind)) }
+ h3.import-card__title { (card.headline.as_str()) }
}
@if !card.sublines.is_empty() {
- ul.github-import-card__meta {
+ ul.import-card__meta {
@for line in &card.sublines {
li { (line.as_str()) }
}
}
}
@if let Some(ex) = &card.excerpt {
- div.github-import-card__excerpt {
+ div.import-card__excerpt {
@for block in ex.split("\n\n") {
@if !block.trim().is_empty() {
p { (block) }
@@ -838,7 +740,7 @@ fn render_github_card(card: &GithubImportCard) -> maud::Markup {
}
}
}
- p.github-import-card__link {
+ p.import-card__link {
a href=(card.url.as_str()) rel="noopener noreferrer" target="_blank" {
"Open on GitHub"
}
@@ -947,7 +849,7 @@ mod tests {
}
#[test]
- fn issue_urls_declared_reads_single_and_bulk_posts() {
+ fn child_urls_declared_reads_single_and_bulk_posts() {
let parent = ItemId::parse("https://github.com/o/r/issues").unwrap();
let single = child_to_dsl(&ResolvedChild {
url: "https://github.com/o/r/issues/1".into(),
@@ -959,13 +861,13 @@ mod tests {
),
});
assert_eq!(
- issue_urls_declared_in_ingest(&single, &parent),
+ child_urls_declared_in_ingest(&single, &parent),
vec!["https://github.com/o/r/issues/1".to_string()]
);
let bulk = "https://github.com/o/r/issues/1 {a}\nhttps://github.com/o/r/issues/2 {b}\n";
assert_eq!(
- issue_urls_declared_in_ingest(bulk, &parent),
+ child_urls_declared_in_ingest(bulk, &parent),
vec![
"https://github.com/o/r/issues/1".to_string(),
"https://github.com/o/r/issues/2".to_string()
@@ -1068,16 +970,13 @@ mod tests {
};
let text = child_to_dsl(&child);
let reduced = crate::reducer::ReducerState::default();
- let validated = crate::api::validate_ingest_document(
- &reduced,
- &text,
- &crate::reducer::ScopeId::Public,
- )
- .unwrap_or_else(|(code, msg, hint)| {
- panic!(
+ let validated =
+ crate::api::validate_ingest_document(&reduced, &text, &crate::reducer::ScopeId::Public)
+ .unwrap_or_else(|(code, msg, hint)| {
+ panic!(
"github import DSL should validate; got {code} {msg} hint={hint:?}\nDSL:\n{text}"
)
- });
+ });
let item_body = validated
.doc
.statements
@@ -1091,7 +990,11 @@ mod tests {
panic!("body should round-trip as github card; body was:\n{item_body}\nDSL:\n{text}")
});
assert!(
- child.card.excerpt.as_ref().is_some_and(|e| e.contains("```")),
+ child
+ .card
+ .excerpt
+ .as_ref()
+ .is_some_and(|e| e.contains("```")),
"precondition: card excerpt contains markdown fences"
);
assert_eq!(
@@ -1178,7 +1081,6 @@ mod tests {
});
}
-
#[tokio::test]
async fn list_issues_pages_until_exhausted() {
use axum::{routing::get, Json, Router};
diff --git a/server/src/resolvers/mod.rs b/server/src/resolvers/mod.rs
index 702939fa1288e7da6fab0306605cc0a4b2fa9bc4..cb13d884c001435b7be934f874f840ab8de0ef40 100644
--- a/server/src/resolvers/mod.rs
+++ b/server/src/resolvers/mod.rs
@@ -1,19 +1,175 @@
-//! Domain resolvers (GitHub, …) and matching HTML renderers for imported item bodies.
+//! Domain resolvers (GitHub, are.na, …) and matching HTML renderers for imported item bodies.
//!
-//! Resolver output is ingested as DSL; bodies may embed a `slug-github-card` fence
-//! (base64-encoded card JSON)
+//! Resolver output is ingested as DSL; bodies may embed a card fence
+//! (base64-encoded card JSON, e.g. `slug-github-card` / `slug-arena-card`)
//! envelope that [`crate::html::render_item_body_in_scope`] renders instead of a raw ``.
-pub mod github;
+pub mod arena;
pub mod default_external;
+pub mod github;
+pub use arena::{resolve_arena_children, ArenaImportCard, ArenaImportKind, ArenaResolver};
pub use default_external::DefaultExternalResolver;
pub use github::{
resolve_github_children, try_render_github_import_markup, ExternalResolver, GitHubResolver,
- GithubImportCard, GithubImportKind, GithubResolveStats, ResolvedChild,
+ GithubImportCard, GithubImportKind, ResolvedChild,
};
+use tokio::sync::oneshot;
+
+use crate::{path_types::ItemId, state::AppState, write_cmd::WriteCmd};
+
/// Extension point: add more `try_render_*` calls here as new resolvers ship.
pub fn try_render_resolver_item_body(raw: &str) -> Option {
github::try_render_github_import_markup(raw)
+ .or_else(|| arena::try_render_arena_import_markup(raw))
+}
+
+/// Dispatch an on-demand children resolve to the resolver matching the item's host.
+pub async fn resolve_external_children(
+ state: &AppState,
+ room: &str,
+ item: &ItemId,
+) -> Result {
+ let host = url::Url::parse(item.as_str())
+ .ok()
+ .and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase()))
+ .unwrap_or_default();
+ match host.as_str() {
+ "github.com" => resolve_github_children(state, room, item).await,
+ "www.are.na" | "are.na" => resolve_arena_children(state, room, item).await,
+ _ => Err("no external resolver for this item".to_string()),
+ }
+}
+
+/// Human-readable source label for resolver status text, keyed by item host.
+pub fn resolver_source_label(item: &ItemId) -> Option<&'static str> {
+ let host = url::Url::parse(item.as_str())
+ .ok()
+ .and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase()))?;
+ match host.as_str() {
+ "github.com" => Some("GitHub"),
+ "www.are.na" | "are.na" => Some("Are.na"),
+ _ => None,
+ }
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct ResolveStats {
+ pub imported: usize,
+ pub deleted: usize,
+ pub kept: usize,
+}
+
+impl ResolveStats {
+ pub fn total_touched(&self) -> usize {
+ self.imported + self.deleted + self.kept
+ }
+}
+
+pub(crate) fn now_ms() -> i64 {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis() as i64
+}
+
+pub(crate) fn resolver_cooldown_ms(env_var: &str, default_ms: i64) -> i64 {
+ std::env::var(env_var)
+ .ok()
+ .and_then(|s| s.parse::().ok())
+ .filter(|&n| n >= 0)
+ .unwrap_or(default_ms)
+}
+
+/// Append a system-principal ingest through the serialized writer.
+pub(crate) async fn system_ingest(
+ state: &AppState,
+ room: &str,
+ thread_tag: &str,
+ text: String,
+ principal: &str,
+) -> Result<(), String> {
+ let (tx, rx) = oneshot::channel();
+ state
+ .write_tx
+ .send(WriteCmd::SystemIngest {
+ room: room.to_string(),
+ thread_tag: thread_tag.to_string(),
+ text,
+ principal: principal.to_string(),
+ reply: tx,
+ })
+ .await
+ .map_err(|_| "writer unavailable".to_string())?;
+ rx.await
+ .map_err(|_| "writer dropped".to_string())?
+ .map_err(|(msg, hint)| hint.map_or(msg.clone(), |h| format!("{msg}: {h}")))?;
+ Ok(())
+}
+
+/// Redact a post as a system principal through the serialized writer.
+pub(crate) async fn system_redact(
+ state: &AppState,
+ post_id: &str,
+ principal: &str,
+) -> Result<(), String> {
+ let (tx, rx) = oneshot::channel();
+ state
+ .write_tx
+ .send(WriteCmd::SystemRedact {
+ post_id: post_id.to_string(),
+ principal: principal.to_string(),
+ reply: tx,
+ })
+ .await
+ .map_err(|_| "writer unavailable".to_string())?;
+ rx.await
+ .map_err(|_| "writer dropped".to_string())?
+ .map_err(|(msg, hint)| hint.map_or(msg.clone(), |h| format!("{msg}: {h}")))?;
+ Ok(())
+}
+
+pub(crate) fn normalized_child_url(url: &str) -> Option {
+ ItemId::parse(url).map(|id| id.normalized_storage().as_str().to_string())
+}
+
+/// URLs of direct children of `parent` declared as item statements in an ingest body.
+pub(crate) fn child_urls_declared_in_ingest(raw: &str, parent: &ItemId) -> Vec {
+ let Ok(doc) = crate::dsl::parse_full(raw) else {
+ return Vec::new();
+ };
+ let parent = parent.clone().normalized_storage();
+ let mut out = Vec::new();
+ for stmt in doc.statements {
+ let crate::dsl::Stmt::Item { title, .. } = stmt else {
+ continue;
+ };
+ let Some(item) = ItemId::parse(&title).map(|i| i.normalized_storage()) else {
+ continue;
+ };
+ let Some(p) = item.parent() else {
+ continue;
+ };
+ if p.normalized_storage() == parent {
+ out.push(item.as_str().to_string());
+ }
+ }
+ out.sort();
+ out.dedup();
+ out
+}
+
+/// Extract the payload of a leading ```lang fence from an item body.
+pub(crate) fn extract_fence<'a>(body: &'a str, lang: &str) -> Option<&'a str> {
+ let b = body.trim();
+ let prefix = format!("```{lang}");
+ let rest = b.strip_prefix(prefix.as_str())?;
+ let rest = rest
+ .strip_prefix('\n')
+ .or_else(|| rest.strip_prefix('\r'))
+ .unwrap_or(rest);
+ let end = rest.find("\n```")?;
+ Some(rest[..end].trim())
}
diff --git a/server/src/scope_rank.rs b/server/src/scope_rank.rs
index 4338b10c61af41e91d0857de6f3bfd5a6cf1e01e..0f8bf2ff363550a5ff24c89fdbd66ca5a203bc80 100644
--- a/server/src/scope_rank.rs
+++ b/server/src/scope_rank.rs
@@ -166,11 +166,12 @@ pub fn build_rankings_for_group_and_items(
}
}
- let component_rankings: Vec = rank_partition(group, &comps_global, 10000, 1e-8)
- .into_iter()
- .zip(pair_counts)
- .map(|(ranked, pairs)| ScopedComponent { pairs, ranked })
- .collect();
+ let component_rankings: Vec =
+ rank_partition(group, &comps_global, 10000, 1e-8)
+ .into_iter()
+ .zip(pair_counts)
+ .map(|(ranked, pairs)| ScopedComponent { pairs, ranked })
+ .collect();
let mut unranked_items: Vec = isolate_local_idxs
.into_iter()
@@ -364,13 +365,20 @@ mod tests {
#[test]
fn comparable_items_excludes_bodyless_organizational_nodes() {
- let folder = ItemId::parse("~/models/anthropic").unwrap().normalized_storage();
- let leaf = ItemId::parse("~/models/claude").unwrap().normalized_storage();
+ let folder = ItemId::parse("~/models/anthropic")
+ .unwrap()
+ .normalized_storage();
+ let leaf = ItemId::parse("~/models/claude")
+ .unwrap()
+ .normalized_storage();
let mut content = content_with_children(&[]);
content.items.insert(leaf.clone());
content.item_bodies.insert(leaf.clone(), "A model.".into());
- assert_eq!(comparable_items(&content, vec![folder, leaf.clone()]), vec![leaf]);
+ assert_eq!(
+ comparable_items(&content, vec![folder, leaf.clone()]),
+ vec![leaf]
+ );
}
#[test]
@@ -404,8 +412,14 @@ mod tests {
let org = ItemId::parse("https://github.com/org").unwrap();
let repo = ItemId::parse("https://github.com/org/rep").unwrap();
let mut item_children: HashMap> = HashMap::new();
- item_children.entry(gh.clone()).or_default().insert(org.clone());
- item_children.entry(org.clone()).or_default().insert(repo.clone());
+ item_children
+ .entry(gh.clone())
+ .or_default()
+ .insert(org.clone());
+ item_children
+ .entry(org.clone())
+ .or_default()
+ .insert(repo.clone());
let mut items = HashSet::new();
items.insert(repo.clone());
let content = ContentState {
diff --git a/server/src/state.rs b/server/src/state.rs
index cfa5aa8b3606333633e7516506a6158ce57b50ee..17be18a45d813eeb62a29cee2c8f95c7ff081ecf 100644
--- a/server/src/state.rs
+++ b/server/src/state.rs
@@ -4,8 +4,11 @@ use std::sync::Arc;
use tokio::sync::{broadcast, mpsc, RwLock};
use crate::{
- event_log::EventLog, events::ThreadCapability, reducer::ReducerState,
- resolvers::GitHubResolver, write_cmd::WriteCmd,
+ event_log::EventLog,
+ events::ThreadCapability,
+ reducer::ReducerState,
+ resolvers::{ArenaResolver, GitHubResolver},
+ write_cmd::WriteCmd,
};
/// Ephemeral invite link (24h TTL, in-memory only; not written to the event log).
@@ -114,6 +117,7 @@ pub struct AppState {
/// Ephemeral resolver cooldowns keyed by `room + item`: not persisted; durable results are JSONL ingests.
pub resolver_runs: Arc>>,
pub github_resolver: Arc,
+ pub arena_resolver: Arc,
}
impl AppState {
@@ -143,6 +147,7 @@ impl AppState {
views,
resolver_runs: Arc::new(RwLock::new(HashMap::new())),
github_resolver: Arc::new(GitHubResolver::from_env()),
+ arena_resolver: Arc::new(ArenaResolver::from_env()),
}
}
}
diff --git a/server/src/views.rs b/server/src/views.rs
index d4f0ffc49475f014698b4da0de6f476884430813..cd7305d4b10f7318074977d93e1784e6a3d7157e 100644
--- a/server/src/views.rs
+++ b/server/src/views.rs
@@ -29,9 +29,7 @@ impl ViewStore {
while flush_rx.recv().await.is_some() {
while flush_rx.try_recv().is_ok() {}
- let snapshot: HashMap = {
- counts_for_writer.lock().unwrap().clone()
- };
+ let snapshot: HashMap = { counts_for_writer.lock().unwrap().clone() };
let path = path.clone();
let _ = tokio::task::spawn_blocking(move || {
diff --git a/server/static/theme_default.css b/server/static/theme_default.css
index 3fbb84af0e217e32b17edd496761ace3d0357286..b0f4370037af4a5bc215b655697ac95440bf6951 100644
--- a/server/static/theme_default.css
+++ b/server/static/theme_default.css
@@ -1102,12 +1102,12 @@ body.view-vote-compare .vote-compare-shell > h2 {
flex-direction: column;
align-items: flex-end;
}
-.vote-compare-item-body .item-body-rich article.github-import-card {
+.vote-compare-item-body .item-body-rich article.import-card {
box-sizing: border-box;
width: 100%;
max-width: min(100%, 420px);
}
-.vote-compare-right .vote-compare-item-body .item-body-rich article.github-import-card {
+.vote-compare-right .vote-compare-item-body .item-body-rich article.import-card {
margin-left: auto;
}
.vote-compare-item-body-empty {
@@ -1259,6 +1259,31 @@ body.view-vote-compare-fullscreen .vote-compare-shell {
body.view-vote-compare-fullscreen .vote-compare-shell > h2 {
display: none;
}
+.vote-question {
+ padding: 16px 16px 0;
+}
+.vote-question-headline {
+ font-size: 1.25rem;
+ font-weight: 600;
+ letter-spacing: 0;
+ text-transform: none;
+ margin: 0 0 0.4rem;
+ color: inherit;
+}
+.vote-question-headline pre,
+.vote-question-headline .item-body-rich {
+ margin: 0;
+ font-size: inherit;
+ font-family: inherit;
+ white-space: pre-wrap;
+}
+.vote-question-links {
+ font-size: 12px;
+ margin: 0 0 1rem;
+}
+.vote-question-empty {
+ padding: 0 16px 16px;
+}
.vote-edge-history-title {
font-size: 12px;
@@ -1814,7 +1839,7 @@ body.view-ontology-light .rank-history-vote {
}
/* GitHub resolver import cards (rich bodies on -/ garden + vote compare) */
-article.github-import-card {
+article.import-card {
border: 1px solid var(--lo);
background: var(--g2);
border-radius: 6px;
@@ -1822,37 +1847,46 @@ article.github-import-card {
margin: 8px 0;
max-width: 100%;
}
-.github-import-card__hdr {
+.import-card__hdr {
margin-bottom: 6px;
}
-.github-import-card__badge {
+.import-card__badge {
display: block;
font-size: 0.78em;
color: var(--muted);
margin-bottom: 4px;
}
-.github-import-card__title {
+.import-card__title {
margin: 0;
font-size: 1.05em;
font-weight: 600;
}
-ul.github-import-card__meta {
+ul.import-card__meta {
margin: 8px 0 0 1.1em;
padding: 0;
font-size: 0.9em;
}
-.github-import-card__meta li {
+.import-card__meta li {
margin: 2px 0;
}
-.github-import-card__excerpt {
+.import-card__excerpt {
margin-top: 10px;
font-size: 0.92em;
white-space: pre-wrap;
}
-.github-import-card__excerpt p {
+.import-card__excerpt p {
margin: 6px 0;
}
-.github-import-card__link {
+.import-card__link {
margin-top: 12px;
font-size: 0.95em;
}
+.import-card__image {
+ margin: 8px 0 0;
+}
+.import-card__image img {
+ display: block;
+ max-width: 100%;
+ max-height: 360px;
+ border-radius: 4px;
+}
diff --git a/server/static/theme_retro.css b/server/static/theme_retro.css
index 8be9af25a4912704a28dee232e2c3acce92e292d..25dabcee7c33731cf7c8663dde92901bfe706487 100644
--- a/server/static/theme_retro.css
+++ b/server/static/theme_retro.css
@@ -302,6 +302,31 @@ body.view-vote-compare-fullscreen .vote-compare-shell {
body.view-vote-compare-fullscreen .vote-compare-shell > h2 {
display: none;
}
+.vote-question {
+ padding: 1rem 1rem 0;
+}
+.vote-question-headline {
+ font-size: 1.25rem;
+ font-weight: 600;
+ letter-spacing: 0;
+ text-transform: none;
+ margin: 0 0 0.4rem;
+ color: inherit;
+}
+.vote-question-headline pre,
+.vote-question-headline .item-body-rich {
+ margin: 0;
+ font-size: inherit;
+ font-family: inherit;
+ white-space: pre-wrap;
+}
+.vote-question-links {
+ font-size: 0.75rem;
+ margin: 0 0 1rem;
+}
+.vote-question-empty {
+ padding: 0 1rem 1rem;
+}
body.view-ontology .vote-compare-pair {
align-items: start;
display: grid;
@@ -344,46 +369,54 @@ body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich {
align-items: flex-end;
}
/* GitHub import cards (garden item bodies + vote compare) */
-article.github-import-card {
+article.import-card {
border: 1px solid #888;
padding: 0.65rem 0.75rem;
margin: 0.5rem 0;
max-width: 100%;
}
-.github-import-card__badge {
+.import-card__badge {
display: block;
font-size: 0.78em;
color: #666;
margin-bottom: 0.25rem;
}
-.github-import-card__title {
+.import-card__title {
margin: 0;
font-size: 1.05em;
}
-ul.github-import-card__meta {
+ul.import-card__meta {
margin: 0.45rem 0 0 1.1em;
padding: 0;
font-size: 0.9em;
}
-.github-import-card__excerpt {
+.import-card__excerpt {
margin-top: 0.6rem;
font-family: var(--font-prose);
font-size: 0.95em;
white-space: pre-wrap;
}
-.github-import-card__excerpt p {
+.import-card__excerpt p {
margin: 0.4rem 0;
}
-.github-import-card__link {
+.import-card__link {
margin-top: 0.65rem;
font-size: 0.95em;
}
+.import-card__image {
+ margin: 0.5rem 0 0;
+}
+.import-card__image img {
+ display: block;
+ max-width: 100%;
+ max-height: 360px;
+}
-body.view-ontology .vote-compare-item-body .item-body-rich article.github-import-card {
+body.view-ontology .vote-compare-item-body .item-body-rich article.import-card {
box-sizing: border-box;
width: 100%;
max-width: min(100%, 420px);
}
-body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich article.github-import-card {
+body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich article.import-card {
margin-left: auto;
}
diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css
index 2c44940d9ac424acb437f335da1fa0eefb3021a3..feddfc868fc2985cc8c7b6d276b35694d699e92a 100644
--- a/server/static/theme_retro_craft.css
+++ b/server/static/theme_retro_craft.css
@@ -913,6 +913,31 @@ body.view-ontology.view-vote-compare .vote-compare-shell > h2 {
body.view-ontology.view-vote-compare-fullscreen .vote-compare-shell > h2 {
display: none;
}
+.vote-question {
+ padding: 1rem 1rem 0;
+}
+.vote-question-headline {
+ font-size: 1.25rem;
+ font-weight: 600;
+ letter-spacing: 0;
+ text-transform: none;
+ margin: 0 0 0.4rem;
+ color: inherit;
+}
+.vote-question-headline pre,
+.vote-question-headline .item-body-rich {
+ margin: 0;
+ font-size: inherit;
+ font-family: inherit;
+ white-space: pre-wrap;
+}
+.vote-question-links {
+ font-size: 0.75rem;
+ margin: 0 0 1rem;
+}
+.vote-question-empty {
+ padding: 0 1rem 1rem;
+}
body.view-ontology .vote-compare-pair {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
@@ -972,47 +997,55 @@ body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich {
align-items: flex-end;
}
/* GitHub import cards (garden item bodies + vote compare) */
-article.github-import-card {
+article.import-card {
border: 1px solid #888;
padding: 0.65rem 0.75rem;
margin: 0.5rem 0;
max-width: 100%;
}
-.github-import-card__badge {
+.import-card__badge {
display: block;
font-size: 0.78em;
color: #666;
margin-bottom: 0.25rem;
}
-.github-import-card__title {
+.import-card__title {
margin: 0;
font-size: 1.05em;
}
-ul.github-import-card__meta {
+ul.import-card__meta {
margin: 0.45rem 0 0 1.1em;
padding: 0;
font-size: 0.9em;
}
-.github-import-card__excerpt {
+.import-card__excerpt {
margin-top: 0.6rem;
font-family: var(--font-prose);
font-size: 0.95em;
white-space: pre-wrap;
}
-.github-import-card__excerpt p {
+.import-card__excerpt p {
margin: 0.4rem 0;
}
-.github-import-card__link {
+.import-card__link {
margin-top: 0.65rem;
font-size: 0.95em;
}
+.import-card__image {
+ margin: 0.5rem 0 0;
+}
+.import-card__image img {
+ display: block;
+ max-width: 100%;
+ max-height: 360px;
+}
-body.view-ontology .vote-compare-item-body .item-body-rich article.github-import-card {
+body.view-ontology .vote-compare-item-body .item-body-rich article.import-card {
box-sizing: border-box;
width: 100%;
max-width: min(100%, 420px);
}
-body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich article.github-import-card {
+body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich article.import-card {
margin-left: auto;
}
body.view-ontology .vote-compare-item-body-empty {
diff --git a/server/tests/basic.rs b/server/tests/basic.rs
index 1556e0e1cd325e011d0a9492a69510c80b001664..4fb5d2ed120b59ec3f246b7b96f80556beb44164 100644
--- a/server/tests/basic.rs
+++ b/server/tests/basic.rs
@@ -1,12 +1,11 @@
use slugsocial_server::{
- event_log::EventLog,
canonical_path::{canonicalize_item, canonicalize_tag},
+ event_log::EventLog,
events::{Event, GrantAdded, Ingest, RoomCreated},
ranking::ranked_items,
reducer::{GroupState, ReducerState, ScopeId},
};
-
use slugsocial_server::path_types::ItemId;
use tempfile::TempDir;
@@ -64,10 +63,7 @@ fn reducer_external_namespace_ranking() {
.collect();
assert_eq!(
names,
- vec![
- "https://github.com/iss/1",
- "https://github.com/iss/2"
- ]
+ vec!["https://github.com/iss/1", "https://github.com/iss/2"]
);
}
@@ -77,7 +73,10 @@ fn reducer_and_ranking_linear_chain() {
let mut state = ReducerState::default();
// First ingest: define items + vote a > b.
- state.apply_event(ingest_event(1, "~/t/a {a}\n~/t/b {b}\n{because}\n~/t/a 3:1 ~/t/b\n"));
+ state.apply_event(ingest_event(
+ 1,
+ "~/t/a {a}\n~/t/b {b}\n{because}\n~/t/a 3:1 ~/t/b\n",
+ ));
// Second ingest: define c + vote b > c.
state.apply_event(ingest_event(2, "~/t/c {c}\n{because}\n~/t/b 3:1 ~/t/c\n"));
@@ -113,15 +112,16 @@ fn reducer_canonicalizes_identifiers() {
#[test]
fn reducer_handles_item_and_body_from_ingest() {
let mut state = ReducerState::default();
- state.apply_event(ingest_event(
- 1,
- "~/t/test-item {Description here}\n",
- ));
+ state.apply_event(ingest_event(1, "~/t/test-item {Description here}\n"));
let content = state.public();
- assert!(content.items.contains(&item_id("https://slug.social/~/test-item")));
+ assert!(content
+ .items
+ .contains(&item_id("https://slug.social/~/test-item")));
assert_eq!(
- content.item_bodies.get(&item_id("https://slug.social/~/test-item")),
+ content
+ .item_bodies
+ .get(&item_id("https://slug.social/~/test-item")),
Some(&"Description here".to_string())
);
assert!(content
@@ -141,12 +141,23 @@ fn reducer_indexes_item_threads_and_vote_thread() {
state.apply_event(Event::Ingest(ev));
let content = state.public();
- let threads_for_insertion = content.item_threads.get(&item_id("https://slug.social/~/insertion")).unwrap();
+ let threads_for_insertion = content
+ .item_threads
+ .get(&item_id("https://slug.social/~/insertion"))
+ .unwrap();
assert!(threads_for_insertion.contains("sorting-hat"));
- let threads_for_mergesort = content.item_threads.get(&item_id("https://slug.social/~/mergesort")).unwrap();
+ let threads_for_mergesort = content
+ .item_threads
+ .get(&item_id("https://slug.social/~/mergesort"))
+ .unwrap();
assert!(threads_for_mergesort.contains("sorting-hat"));
- let vote = content.item_votes.get(&item_id("https://slug.social/~/insertion")).unwrap().front().unwrap();
+ let vote = content
+ .item_votes
+ .get(&item_id("https://slug.social/~/insertion"))
+ .unwrap()
+ .front()
+ .unwrap();
assert_eq!(vote.thread_tag, "sorting-hat");
}
@@ -321,9 +332,12 @@ async fn event_log_handles_corrupt_lines() {
let log = EventLog::new(&log_path);
// Write valid events using the log itself, then manually corrupt one line.
- log.append(&ingest_event(1, "~/a {x}\n~/b {y}\n{because}\n~/a 2:1 ~/b\n"))
- .await
- .unwrap();
+ log.append(&ingest_event(
+ 1,
+ "~/a {x}\n~/b {y}\n{because}\n~/a 2:1 ~/b\n",
+ ))
+ .await
+ .unwrap();
use std::fs;
use std::io::Write;
@@ -346,12 +360,19 @@ async fn event_log_handles_corrupt_lines() {
#[tokio::test]
async fn event_log_creates_parent_dirs() {
let tmp = TempDir::new().unwrap();
- let log_path = tmp.path().join("subdir").join("nested").join("events.jsonl");
+ let log_path = tmp
+ .path()
+ .join("subdir")
+ .join("nested")
+ .join("events.jsonl");
let log = EventLog::new(&log_path);
- log.append(&ingest_event(1, "~/a {x}\n~/b {y}\n{because}\n~/a 2:1 ~/b\n"))
- .await
- .unwrap();
+ log.append(&ingest_event(
+ 1,
+ "~/a {x}\n~/b {y}\n{because}\n~/a 2:1 ~/b\n",
+ ))
+ .await
+ .unwrap();
assert!(log_path.exists());
}
@@ -427,7 +448,10 @@ fn canonicalization_is_consistent() {
assert_eq!(canonicalize_item("/item"), "https://slug.social/item");
assert_eq!(canonicalize_item("item"), "https://slug.social/item");
assert_eq!(canonicalize_item("ITEM"), "https://slug.social/item");
- assert_eq!(canonicalize_item("~/music/song"), "https://slug.social/~/music/song");
+ assert_eq!(
+ canonicalize_item("~/music/song"),
+ "https://slug.social/~/music/song"
+ );
assert_eq!(
canonicalize_item("https://slug.social/~/music/song"),
"https://slug.social/~/music/song"
@@ -453,14 +477,8 @@ fn ranking_repeated_votes_normalized() {
1,
"~/norm/a {a}\n~/norm/b {b}\n{vote1}\n~/norm/a 3:1 ~/norm/b\n",
));
- state_many.apply_event(ingest_event(
- 2,
- "{vote2}\n~/norm/a 3:1 ~/norm/b\n",
- ));
- state_many.apply_event(ingest_event(
- 3,
- "{vote3}\n~/norm/a 3:1 ~/norm/b\n",
- ));
+ state_many.apply_event(ingest_event(2, "{vote2}\n~/norm/a 3:1 ~/norm/b\n"));
+ state_many.apply_event(ingest_event(3, "{vote3}\n~/norm/a 3:1 ~/norm/b\n"));
let mut group_once = state_once.public().ranking_group.clone();
let mut group_many = state_many.public().ranking_group.clone();
@@ -475,8 +493,12 @@ fn ranking_repeated_votes_normalized() {
// Scores should be identical (normalization makes repeated votes idempotent).
let eps = 1e-6;
- assert!((ranked_once[0].score - ranked_many[0].score).abs() < eps,
- "scores differ: once={:.6} many={:.6}", ranked_once[0].score, ranked_many[0].score);
+ assert!(
+ (ranked_once[0].score - ranked_many[0].score).abs() < eps,
+ "scores differ: once={:.6} many={:.6}",
+ ranked_once[0].score,
+ ranked_many[0].score
+ );
}
// ============================================================================
@@ -500,10 +522,8 @@ fn reducer_malformed_ingest_is_skipped_no_panic() {
#[test]
fn dsl_parse_rejects_zero_zero_vote_ratio() {
- let err = slugsocial_server::dsl::parse_full(
- "~/t/a {a}\n~/t/b {b}\n{zero}\n~/t/a 0:0 ~/t/b\n",
- )
- .expect_err("0:0 vote must be rejected by the parser");
+ let err = slugsocial_server::dsl::parse_full("~/t/a {a}\n~/t/b {b}\n{zero}\n~/t/a 0:0 ~/t/b\n")
+ .expect_err("0:0 vote must be rejected by the parser");
let slugsocial_server::dsl::DslError::Parse(msg) = err;
assert!(
msg.contains("≥ 1"),
@@ -586,14 +606,10 @@ fn reducer_negative_ratio_clamped_to_zero() {
assert!(group.edges.is_empty());
}
-
#[test]
fn reducer_deep_path_ancestor_materialization_four_levels() {
let mut state = ReducerState::default();
- state.apply_event(ingest_event(
- 1,
- "~/a/b/c/d {leaf}\n",
- ));
+ state.apply_event(ingest_event(1, "~/a/b/c/d {leaf}\n"));
// Ancestor chain: parent() now walks purely within the ontology namespace.
// The spurious "" → "https://slug.social" levels are gone; root is "https://slug.social/~".
let content = state.public();
@@ -624,7 +640,10 @@ fn ranking_single_node_scores_one() {
group.ensure_item_pub("solo");
let ranked = ranked_items(&mut group, 1000, 1e-9);
assert_eq!(ranked.len(), 1);
- assert!((ranked[0].score - 1.0).abs() < 1e-9, "single node should score 1.0");
+ assert!(
+ (ranked[0].score - 1.0).abs() < 1e-9,
+ "single node should score 1.0"
+ );
}
#[test]
@@ -661,18 +680,21 @@ fn ranking_convergence_tolerance_triggers_early_exit() {
#[test]
fn test_item_body_overwrite() {
let mut state = ReducerState::default();
- state.apply_event(ingest_event(
- 1,
- "~/t/x {first}\n",
- ));
- assert_eq!(state.public().item_bodies.get(&item_id("https://slug.social/~/x")), Some(&"first".to_string()));
+ state.apply_event(ingest_event(1, "~/t/x {first}\n"));
+ assert_eq!(
+ state
+ .public()
+ .item_bodies
+ .get(&item_id("https://slug.social/~/x")),
+ Some(&"first".to_string())
+ );
- state.apply_event(ingest_event(
- 2,
- "~/t/x {second}\n",
- ));
+ state.apply_event(ingest_event(2, "~/t/x {second}\n"));
assert_eq!(
- state.public().item_bodies.get(&item_id("https://slug.social/~/x")),
+ state
+ .public()
+ .item_bodies
+ .get(&item_id("https://slug.social/~/x")),
Some(&"second".to_string()),
"last writer should win for item bodies"
);
@@ -681,14 +703,18 @@ fn test_item_body_overwrite() {
#[test]
fn test_empty_body_not_stored() {
let mut state = ReducerState::default();
- state.apply_event(ingest_event(
- 1,
- "~/t/blank { }\n",
- ));
+ state.apply_event(ingest_event(1, "~/t/blank { }\n"));
let content = state.public();
- assert!(content.items.contains(&item_id("https://slug.social/~/blank")), "item should exist");
assert!(
- !content.item_bodies.contains_key(&item_id("https://slug.social/~/blank")),
+ content
+ .items
+ .contains(&item_id("https://slug.social/~/blank")),
+ "item should exist"
+ );
+ assert!(
+ !content
+ .item_bodies
+ .contains_key(&item_id("https://slug.social/~/blank")),
"whitespace-only body should not be stored"
);
}
@@ -696,16 +722,14 @@ fn test_empty_body_not_stored() {
#[test]
fn test_duplicate_items_across_ingests() {
let mut state = ReducerState::default();
- state.apply_event(ingest_event(
- 1,
- "~/t/dup {first}\n",
- ));
- state.apply_event(ingest_event(
- 2,
- "~/t/dup {second}\n",
- ));
+ state.apply_event(ingest_event(1, "~/t/dup {first}\n"));
+ state.apply_event(ingest_event(2, "~/t/dup {second}\n"));
let content = state.public();
- let count = content.items.iter().filter(|i| i.as_str() == "https://slug.social/~/dup").count();
+ let count = content
+ .items
+ .iter()
+ .filter(|i| i.as_str() == "https://slug.social/~/dup")
+ .count();
assert_eq!(count, 1, "items set should deduplicate across ingests");
}
@@ -713,10 +737,7 @@ fn test_duplicate_items_across_ingests() {
fn test_ingests_ordered_chronological() {
let mut state = ReducerState::default();
for ts in [100, 200, 300] {
- state.apply_event(ingest_event(
- ts,
- "~/t/a {a}\n",
- ));
+ state.apply_event(ingest_event(ts, "~/t/a {a}\n"));
}
assert_eq!(state.ingests_ordered.len(), 3);
assert_eq!(state.ingests_ordered[0], "test-100");
@@ -727,10 +748,7 @@ fn test_ingests_ordered_chronological() {
#[test]
fn test_actor_last_post_ts() {
let mut state = ReducerState::default();
- state.apply_event(ingest_event(
- 42,
- "~/t/a {a}\n",
- ));
+ state.apply_event(ingest_event(42, "~/t/a {a}\n"));
// actor_last_post_ts is keyed by principal username.
assert_eq!(
state.actor_last_post_ts.get("test"),
@@ -743,14 +761,20 @@ fn test_actor_last_post_ts() {
fn test_thread_timestamp_bump() {
let mut state = ReducerState::default();
let key = (ScopeId::Public, "my-thread".to_string());
- let mut ev1 = match ingest_event(100, "~/t/a {a}\n") { Event::Ingest(i) => i, _ => unreachable!() };
+ let mut ev1 = match ingest_event(100, "~/t/a {a}\n") {
+ Event::Ingest(i) => i,
+ _ => unreachable!(),
+ };
ev1.thread_tag = "my-thread".to_string();
ev1.principal = "alice".to_string();
state.apply_event(Event::Ingest(ev1));
assert_eq!(state.forum_threads.get(&key).unwrap().last_activity_ts, 100);
assert_eq!(state.forum_threads.get(&key).unwrap().last_actor, "alice");
- let mut ev2 = match ingest_event(200, "~/t/b {b}\n") { Event::Ingest(i) => i, _ => unreachable!() };
+ let mut ev2 = match ingest_event(200, "~/t/b {b}\n") {
+ Event::Ingest(i) => i,
+ _ => unreachable!(),
+ };
ev2.thread_tag = "my-thread".to_string();
ev2.principal = "bob".to_string();
state.apply_event(Event::Ingest(ev2));
@@ -758,7 +782,10 @@ fn test_thread_timestamp_bump() {
assert_eq!(state.forum_threads.get(&key).unwrap().last_actor, "bob");
// Ingest with earlier timestamp should NOT regress ts or actor
- let mut ev3 = match ingest_event(150, "~/t/c {c}\n") { Event::Ingest(i) => i, _ => unreachable!() };
+ let mut ev3 = match ingest_event(150, "~/t/c {c}\n") {
+ Event::Ingest(i) => i,
+ _ => unreachable!(),
+ };
ev3.thread_tag = "my-thread".to_string();
ev3.principal = "charlie".to_string();
state.apply_event(Event::Ingest(ev3));
@@ -792,7 +819,9 @@ fn test_thread_id_is_used_for_votes_and_indexes() {
.front()
.unwrap();
assert_eq!(vote.thread_tag, "first");
- assert!(state.ingests_by_scope_thread.contains_key(&(ScopeId::Public, "first".to_string())));
+ assert!(state
+ .ingests_by_scope_thread
+ .contains_key(&(ScopeId::Public, "first".to_string())));
assert!(state
.public()
.item_threads
@@ -808,11 +837,17 @@ fn test_rank_history_created_for_voted_items() {
"~/t/a {a}\n~/t/b {b}\n{reason}\n~/t/a 3:1 ~/t/b\n",
));
assert!(
- state.public().rank_history.contains_key(&item_id("https://slug.social/~/a")),
+ state
+ .public()
+ .rank_history
+ .contains_key(&item_id("https://slug.social/~/a")),
"rank_history should have entry for voted item a"
);
assert!(
- state.public().rank_history.contains_key(&item_id("https://slug.social/~/b")),
+ state
+ .public()
+ .rank_history
+ .contains_key(&item_id("https://slug.social/~/b")),
"rank_history should have entry for voted item b"
);
}
@@ -820,12 +855,12 @@ fn test_rank_history_created_for_voted_items() {
#[test]
fn test_rank_history_not_created_for_unvoted_items() {
let mut state = ReducerState::default();
- state.apply_event(ingest_event(
- 1,
- "~/t/c {just a definition}\n",
- ));
+ state.apply_event(ingest_event(1, "~/t/c {just a definition}\n"));
assert!(
- !state.public().rank_history.contains_key(&item_id("https://slug.social/~/c")),
+ !state
+ .public()
+ .rank_history
+ .contains_key(&item_id("https://slug.social/~/c")),
"rank_history should NOT have entry for item with no votes"
);
}
@@ -837,7 +872,11 @@ fn test_rank_history_first_entry_delta_zero() {
1,
"~/t/a {a}\n~/t/b {b}\n{reason}\n~/t/a 3:1 ~/t/b\n",
));
- let history_a = state.public().rank_history.get(&item_id("https://slug.social/~/a")).unwrap();
+ let history_a = state
+ .public()
+ .rank_history
+ .get(&item_id("https://slug.social/~/a"))
+ .unwrap();
assert_eq!(history_a.len(), 1);
assert_eq!(
history_a[0].scope_rank_delta, 0,
@@ -860,7 +899,10 @@ fn test_multi_actor_in_single_ingest() {
fn test_ingests_by_thread_ordering() {
let mut state = ReducerState::default();
for ts in [100, 200, 300] {
- let mut ev = match ingest_event(ts, "~/t/a {a}\n") { Event::Ingest(i) => i, _ => unreachable!() };
+ let mut ev = match ingest_event(ts, "~/t/a {a}\n") {
+ Event::Ingest(i) => i,
+ _ => unreachable!(),
+ };
ev.thread_tag = "order-thread".to_string();
state.apply_event(Event::Ingest(ev));
}
@@ -957,9 +999,17 @@ fn room_last_activity_ts_tracks_newest_post() {
/// Returns (total, posts) where `total` is the count of visible (non-redacted)
/// matching posts and `posts` is the page of IDs up to `limit`.
fn feed_query(state: &ReducerState, cutoff: i64, limit: usize) -> (usize, Vec) {
- let matching: Vec<&str> = state.ingests_ordered.iter().rev()
+ let matching: Vec<&str> = state
+ .ingests_ordered
+ .iter()
+ .rev()
.map(|id| id.as_str())
- .take_while(|id| state.ingests_by_id.get(*id).is_some_and(|ing| ing.ts > cutoff))
+ .take_while(|id| {
+ state
+ .ingests_by_id
+ .get(*id)
+ .is_some_and(|ing| ing.ts > cutoff)
+ })
.filter(|id| {
state.ingests_by_id.get(*id).is_some_and(|ing| {
let scope = slugsocial_server::reducer::scope_from_room_wire(&ing.room_id);
@@ -972,7 +1022,8 @@ fn feed_query(state: &ReducerState, cutoff: i64, limit: usize) -> (usize, Vec = matching.into_iter()
+ let posts: Vec = matching
+ .into_iter()
.take(limit)
.map(|id| id.to_string())
.collect();
@@ -1006,8 +1057,14 @@ fn feed_total_excludes_redacted_posts() {
let (total, posts) = feed_query(&state, 0, 10);
assert_eq!(total, 3, "total must exclude redacted posts");
assert_eq!(posts.len(), 3, "returned posts must exclude redacted posts");
- assert!(!posts.contains(&"test-2".to_string()), "redacted post test-2 must not appear");
- assert!(!posts.contains(&"test-4".to_string()), "redacted post test-4 must not appear");
+ assert!(
+ !posts.contains(&"test-2".to_string()),
+ "redacted post test-2 must not appear"
+ );
+ assert!(
+ !posts.contains(&"test-4".to_string()),
+ "redacted post test-4 must not appear"
+ );
}
#[test]
@@ -1036,11 +1093,11 @@ fn feed_limit_applied_after_redacted_filter() {
let (total, posts) = feed_query(&state, 0, 2);
assert_eq!(total, 3, "total must count only non-redacted posts");
assert_eq!(
- posts.len(), 2,
+ posts.len(),
+ 2,
"limit should be applied AFTER filtering redacted posts, not before"
);
// The 2 most recent non-redacted posts (reverse chronological order).
assert_eq!(posts[0], "test-3");
assert_eq!(posts[1], "test-2");
}
-
diff --git a/server/tests/dsl_fixtures.rs b/server/tests/dsl_fixtures.rs
index 3db406596571872eb3b02bc69e8f14b9021ae12a..cf4f9b4adfcff1e8787c11c519c9c79747abdb67 100644
--- a/server/tests/dsl_fixtures.rs
+++ b/server/tests/dsl_fixtures.rs
@@ -39,7 +39,10 @@ fn parses_big_book_fixture_with_attached_bodies() {
match s {
dsl::Stmt::Item { title, body } => {
items += 1;
- assert!(!title.contains("__BLOCK_"), "title should not include block tokens");
+ assert!(
+ !title.contains("__BLOCK_"),
+ "title should not include block tokens"
+ );
assert!(
body.as_ref().map(|b| !b.is_empty()).unwrap_or(false),
"items in big book should have bodies"
@@ -69,5 +72,3 @@ fn parses_external_dash_vote_line() {
}]
);
}
-
-
diff --git a/server/tests/integration_html.rs b/server/tests/integration_html.rs
index 7617a89ad38dcc9b35727568eaabe84b2305d611..8f895001b646196873004be7273ca19cb58761f2 100644
--- a/server/tests/integration_html.rs
+++ b/server/tests/integration_html.rs
@@ -38,7 +38,59 @@ async fn test_nested_tilde_url_permanently_redirects_to_leaf() {
.send()
.await
.unwrap();
- assert!(leaf.status().is_success(), "leaf page should render: {}", leaf.status());
+ assert!(
+ leaf.status().is_success(),
+ "leaf page should render: {}",
+ leaf.status()
+ );
+}
+
+#[tokio::test]
+async fn test_garden_trailing_slash_permanently_redirects_to_canonical() {
+ let (addr, _tmp, _log, _state, _handle) = create_test_server_with_state().await;
+ let client = reqwest::Client::builder()
+ .redirect(reqwest::redirect::Policy::none())
+ .build()
+ .unwrap();
+
+ let root = client
+ .get(format!("http://{addr}/~/?depth=2"))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(root.status(), reqwest::StatusCode::PERMANENT_REDIRECT);
+ let root_loc = root
+ .headers()
+ .get(reqwest::header::LOCATION)
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or("");
+ assert_eq!(root_loc, "/~?depth=2");
+
+ let leaf = client
+ .get(format!("http://{addr}/~/luke/?depth=2"))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(leaf.status(), reqwest::StatusCode::PERMANENT_REDIRECT);
+ let leaf_loc = leaf
+ .headers()
+ .get(reqwest::header::LOCATION)
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or("");
+ assert_eq!(leaf_loc, "/~/luke?depth=2");
+
+ let ext = client
+ .get(format!("http://{addr}/-/"))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(ext.status(), reqwest::StatusCode::PERMANENT_REDIRECT);
+ let ext_loc = ext
+ .headers()
+ .get(reqwest::header::LOCATION)
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or("");
+ assert_eq!(ext_loc, "/-");
}
/// Thread connective tissue: item_threads and VoteData.thread_tag are exposed by item, pair, and matchup RPC.
@@ -186,9 +238,7 @@ https://github.com/ghvotehi/a/issues/10 {{\n\
id: "ing-vote-github-cards".to_string(),
raw,
principal: "testuser".to_string(),
- delegate: Some(
- "00000000-0000-0000-0000-000000000000:test:local/test".to_string(),
- ),
+ delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()),
room_id: "public".to_string(),
thread_tag: "gh-vote-cards".to_string(),
}));
@@ -214,7 +264,7 @@ https://github.com/ghvotehi/a/issues/10 {{\n\
.unwrap();
assert!(resp.status().is_success(), "{}", resp.status());
let body = resp.text().await.unwrap();
- let n_cards = body.matches("github-import-card").count();
+ let n_cards = body.matches("import-card").count();
assert!(
n_cards >= 2,
"expected two GitHub import cards on vote compare, count={n_cards}, snippet={}",
@@ -231,3 +281,228 @@ async fn test_search_handles_multibyte_unicode() {
// HTML search pages are offline during the auth-v3 refactor.
}
+fn question_garden_ingest(thread_tag: &str) -> Event {
+ Event::Ingest(Ingest {
+ ts: 20,
+ id: format!("ing-q-{thread_tag}"),
+ raw: "@00000000-0000-0000-0000-000000000000:test:local/test\n\
+~/psalms {Which psalm is greater?}\n\
+~/psalms/psalm-23 {The Lord is my shepherd}\n\
+~/psalms/psalm-1 {Blessed is the man}\n\
+:beauty {more beautiful}\n\
+~/lonely {Nothing to judge yet}\n"
+ .to_string(),
+ principal: "testuser".to_string(),
+ delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()),
+ room_id: "public".to_string(),
+ thread_tag: thread_tag.to_string(),
+ })
+}
+
+#[tokio::test]
+async fn test_question_page_renders_prompt_and_seeds_thread_tag() {
+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
+ {
+ let mut w = state.reduced.write().await;
+ w.apply_event(question_garden_ingest("psalms-seed"));
+ }
+ let client = reqwest::Client::new();
+ let guest = client
+ .get(format!("http://{addr}/q/psalms"))
+ .send()
+ .await
+ .unwrap();
+ assert!(guest.status().is_success(), "{}", guest.status());
+ let guest_body = guest.text().await.unwrap();
+ assert!(
+ guest_body.contains("Which psalm is greater?"),
+ "prompt headline missing: {}",
+ guest_body.chars().take(1200).collect::()
+ );
+ assert!(guest_body.contains("vote-compare-pair"));
+ assert!(guest_body.contains("view-vote-question"));
+ assert!(
+ guest_body.contains("href=\"/~/psalms\""),
+ "ranking link missing: {}",
+ guest_body.chars().take(1500).collect::()
+ );
+ assert!(guest_body.contains("href=\"/t/psalms\""));
+ let login_href = format!("/login?next={}", urlencoding::encode("/q/psalms"));
+ assert!(
+ guest_body.contains(&login_href),
+ "guest login next should be the /q/ url: {}",
+ guest_body.chars().take(2000).collect::()
+ );
+
+ let authed = client
+ .get(format!("http://{addr}/q/psalms"))
+ .header("Authorization", format!("Bearer {}", test_bearer()))
+ .send()
+ .await
+ .unwrap();
+ assert!(authed.status().is_success(), "{}", authed.status());
+ let body = authed.text().await.unwrap();
+ assert!(
+ body.contains("value=\"psalms\""),
+ "thread_tag should be seeded to the collection leaf: {}",
+ body.chars().take(2000).collect::()
+ );
+}
+
+#[tokio::test]
+async fn test_question_aspect_page_seeds_aspect_and_uses_prompt() {
+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
+ {
+ let mut w = state.reduced.write().await;
+ w.apply_event(question_garden_ingest("psalms-seed"));
+ }
+ let client = reqwest::Client::new();
+ let guest = client
+ .get(format!("http://{addr}/q/psalms/beauty"))
+ .send()
+ .await
+ .unwrap();
+ assert!(guest.status().is_success(), "{}", guest.status());
+ let guest_body = guest.text().await.unwrap();
+ assert!(
+ guest_body.contains("more beautiful"),
+ "aspect prompt missing: {}",
+ guest_body.chars().take(1200).collect::()
+ );
+ assert!(guest_body.contains("href=\"/~/psalms#aspect-beauty\""));
+ assert!(guest_body.contains("href=\"/t/psalms\""));
+ let login_href = format!("/login?next={}", urlencoding::encode("/q/psalms/beauty"));
+ assert!(
+ guest_body.contains(&login_href),
+ "guest login next should be the /q/aspect url: {}",
+ guest_body.chars().take(2000).collect::()
+ );
+
+ let authed = client
+ .get(format!("http://{addr}/q/psalms/beauty"))
+ .header("Authorization", format!("Bearer {}", test_bearer()))
+ .send()
+ .await
+ .unwrap();
+ assert!(authed.status().is_success(), "{}", authed.status());
+ let body = authed.text().await.unwrap();
+ assert!(body.contains("name=\"aspect\""));
+ assert!(
+ body.contains("value=\"beauty\""),
+ "aspect hidden field missing: {}",
+ body.chars().take(2000).collect::()
+ );
+ assert!(
+ body.contains("value=\"psalms\""),
+ "thread_tag should be seeded: {}",
+ body.chars().take(2000).collect::()
+ );
+}
+
+#[tokio::test]
+async fn test_question_empty_scope_has_honest_empty_state() {
+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
+ {
+ let mut w = state.reduced.write().await;
+ w.apply_event(question_garden_ingest("psalms-seed"));
+ }
+ let client = reqwest::Client::new();
+ let resp = client
+ .get(format!("http://{addr}/q/lonely"))
+ .send()
+ .await
+ .unwrap();
+ assert!(resp.status().is_success(), "{}", resp.status());
+ let body = resp.text().await.unwrap();
+ assert!(
+ body.contains("Nothing to judge yet"),
+ "empty-scope prompt missing: {}",
+ body.chars().take(1200).collect::()
+ );
+ assert!(body.contains("vote-question-empty"));
+ assert!(!body.contains("vote-compare-pair"));
+ assert!(body.contains("href=\"/~/lonely\""));
+ assert!(body.contains("href=\"/t/lonely\""));
+}
+
+#[tokio::test]
+async fn test_question_unknown_collection_is_not_found() {
+ let (addr, _tmp, _log, _state, _handle) = create_test_server_with_state().await;
+ let client = reqwest::Client::new();
+ let resp = client
+ .get(format!("http://{addr}/q/no-such-collection-xyz"))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
+}
+
+#[tokio::test]
+async fn test_question_invalid_aspect_slug_is_not_found() {
+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
+ {
+ let mut w = state.reduced.write().await;
+ w.apply_event(question_garden_ingest("psalms-seed"));
+ }
+ let client = reqwest::Client::new();
+ let resp = client
+ .get(format!("http://{addr}/q/psalms/NOT_VALID"))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
+}
+
+#[tokio::test]
+async fn test_room_question_page_uses_room_prefixes() {
+ let (addr, _tmp, _log, _handle) = create_test_server().await;
+ let client = reqwest::Client::new();
+ let bearer = test_bearer();
+
+ let create = rpc_batch(
+ &client,
+ addr,
+ Some(&bearer),
+ serde_json::json!([{ "RoomCreate": { "slug": "q-room" } }]),
+ )
+ .await;
+ let room_id = create["results"][0]["result"]["RoomCreated"]["room_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let room_seg = slug_types::room_route_segment(&room_id).unwrap();
+
+ let posted = rpc_batch(
+ &client,
+ addr,
+ Some(&bearer),
+ serde_json::json!([{
+ "Post": {
+ "room": room_id,
+ "thread_tag": "psalms",
+ "text": "~/psalms {Which psalm is greater?}\n~/psalms/a {alpha}\n~/psalms/b {beta}\n:beauty {more beautiful}\n"
+ }
+ }]),
+ )
+ .await;
+ assert_eq!(posted["results"][0]["ok"], true, "room seed post: {posted}");
+
+ let resp = client
+ .get(format!("http://{addr}/r/{room_seg}/q/psalms/beauty"))
+ .header("Authorization", format!("Bearer {bearer}"))
+ .send()
+ .await
+ .unwrap();
+ assert!(resp.status().is_success(), "{}", resp.status());
+ let body = resp.text().await.unwrap();
+ assert!(
+ body.contains("more beautiful"),
+ "room aspect prompt missing: {}",
+ body.chars().take(1500).collect::()
+ );
+ assert!(body.contains(&format!("href=\"/r/{room_seg}/~/psalms#aspect-beauty\"")));
+ assert!(body.contains(&format!("href=\"/r/{room_seg}/t/psalms\"")));
+ assert!(body.contains("name=\"aspect\""));
+ assert!(body.contains("value=\"beauty\""));
+ assert!(body.contains("value=\"psalms\""));
+}
diff --git a/server/tests/integration_mcp.rs b/server/tests/integration_mcp.rs
index 90ca24ccee44a8a75075f33fca53673054679eca..014b1ed7f0efc80325664ce09287225601517635 100644
--- a/server/tests/integration_mcp.rs
+++ b/server/tests/integration_mcp.rs
@@ -335,9 +335,7 @@ async fn mcp_read_and_write_tools_round_trip() {
assert_eq!(minted["isError"], false, "{minted}");
assert_eq!(minted["structuredContent"]["phase"], "ready");
assert_eq!(minted["structuredContent"]["user"], "testuser");
- let minted_delegate = minted["structuredContent"]["delegate"]
- .as_str()
- .unwrap();
+ let minted_delegate = minted["structuredContent"]["delegate"].as_str().unwrap();
assert!(
minted_delegate.contains(":cursor:anthropic/claude-sonnet-4.5"),
"{minted_delegate}"
@@ -576,7 +574,10 @@ async fn mcp_oauth_accepts_claude_redirect() {
"https://claude.ai/oauth/mcp-oauth-client-metadata",
),
("redirect_uri", "https://claude.ai/api/mcp/auth_callback"),
- ("code_challenge", "LGZJYPXoCfqeQ2pG8EKrCEHgLugRSKQ1j3qQQB8GYeU"),
+ (
+ "code_challenge",
+ "LGZJYPXoCfqeQ2pG8EKrCEHgLugRSKQ1j3qQQB8GYeU",
+ ),
("code_challenge_method", "S256"),
("state", "B9ix7zIQCbjJTQCfADcaXjg0VrzzLftlF61gz0nbDm0"),
("scope", "slug.read slug.write"),
@@ -873,7 +874,9 @@ async fn mcp_whoami_and_private_room_round_trip() {
.any(|t| t["thread"].as_str() == Some("#private-demo")),
"{threads:?}"
);
- let recent = room["structuredContent"]["recent_posts"].as_array().unwrap();
+ let recent = room["structuredContent"]["recent_posts"]
+ .as_array()
+ .unwrap();
let recent_hit = recent
.iter()
.find(|p| p["post_id"].as_str() == Some(post_id.as_str()))
@@ -909,7 +912,10 @@ async fn mcp_whoami_and_private_room_round_trip() {
)
.await;
assert_eq!(since_delegate["isError"], false, "{since_delegate}");
- assert_eq!(since_delegate["structuredContent"]["delegate"], TEST_DELEGATE);
+ assert_eq!(
+ since_delegate["structuredContent"]["delegate"],
+ TEST_DELEGATE
+ );
let since_posts = since_delegate["structuredContent"]["posts"]
.as_array()
.unwrap();
diff --git a/server/tests/integration_rooms.rs b/server/tests/integration_rooms.rs
index 69343d3c644c620c9e0e876f4c932db3f0ca5060..bb6cd34985d5a50b7064b2ce2119ab7ae0096d3f 100644
--- a/server/tests/integration_rooms.rs
+++ b/server/tests/integration_rooms.rs
@@ -481,17 +481,12 @@ async fn test_feed_uses_delegate_ingest_position_when_multi_user_timestamps_coll
let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
seed_test_identity(&state, "bob", "bobtok", "bobsecret").await;
let client = reqwest::Client::new();
- let alice_delegate =
- "00000000-0000-0000-0000-0000000000c1:feedtest:local/alice-model";
- let bob_delegate =
- "00000000-0000-0000-0000-0000000000c2:feedtest:local/bob-model";
+ let alice_delegate = "00000000-0000-0000-0000-0000000000c1:feedtest:local/alice-model";
+ let bob_delegate = "00000000-0000-0000-0000-0000000000c2:feedtest:local/bob-model";
{
let mut reduced = state.reduced.write().await;
- for (agent, username) in [
- (alice_delegate, "testuser"),
- (bob_delegate, "bob"),
- ] {
+ for (agent, username) in [(alice_delegate, "testuser"), (bob_delegate, "bob")] {
reduced.apply_event(Event::AgentBound(AgentBound {
ts: 1,
agent: agent.to_string(),
@@ -554,19 +549,14 @@ async fn test_feed_multi_user_private_room_visibility_and_revoked_anchor_access(
seed_test_identity(&state, "bob", "bobtok", "bobsecret").await;
seed_test_identity(&state, "carol", "caroltok", "carolsecret").await;
let client = reqwest::Client::new();
- let alice_delegate =
- "00000000-0000-0000-0000-0000000000d1:feedtest:local/alice-model";
- let bob_delegate =
- "00000000-0000-0000-0000-0000000000d2:feedtest:local/bob-model";
+ let alice_delegate = "00000000-0000-0000-0000-0000000000d1:feedtest:local/alice-model";
+ let bob_delegate = "00000000-0000-0000-0000-0000000000d2:feedtest:local/bob-model";
let room_alice = "alice01/alice-room";
let room_bob = "bob0001/bob-room";
{
let mut reduced = state.reduced.write().await;
- for (agent, username) in [
- (alice_delegate, "testuser"),
- (bob_delegate, "bob"),
- ] {
+ for (agent, username) in [(alice_delegate, "testuser"), (bob_delegate, "bob")] {
reduced.apply_event(Event::AgentBound(AgentBound {
ts: 1,
agent: agent.to_string(),
@@ -604,7 +594,14 @@ async fn test_feed_multi_user_private_room_visibility_and_revoked_anchor_access(
thread_tag: "feed-permissions".into(),
}));
};
- add_ingest(10, "prehistory", "must remain before alice anchor", "carol", None, "public");
+ add_ingest(
+ 10,
+ "prehistory",
+ "must remain before alice anchor",
+ "carol",
+ None,
+ "public",
+ );
add_ingest(
11,
"alice-private-anchor",
@@ -621,9 +618,30 @@ async fn test_feed_multi_user_private_room_visibility_and_revoked_anchor_access(
Some(bob_delegate),
"public",
);
- add_ingest(13, "alice-room-change", "visible only to alice", "carol", None, room_alice);
- add_ingest(14, "bob-room-change", "visible only to bob", "carol", None, room_bob);
- add_ingest(15, "public-change", "visible to everyone", "carol", None, "public");
+ add_ingest(
+ 13,
+ "alice-room-change",
+ "visible only to alice",
+ "carol",
+ None,
+ room_alice,
+ );
+ add_ingest(
+ 14,
+ "bob-room-change",
+ "visible only to bob",
+ "carol",
+ None,
+ room_bob,
+ );
+ add_ingest(
+ 15,
+ "public-change",
+ "visible to everyone",
+ "carol",
+ None,
+ "public",
+ );
reduced.apply_event(Event::GrantRevoked(GrantRevoked {
ts: 16,
@@ -853,6 +871,34 @@ async fn test_private_room_post_links_use_private_garden_routes() {
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(loc, format!("/r/{room_seg}/~/item"));
+
+ let root_slash = no_follow
+ .get(format!("http://{addr}/r/{room_seg}/~/"))
+ .header("Authorization", format!("Bearer {bearer}"))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(root_slash.status(), reqwest::StatusCode::PERMANENT_REDIRECT);
+ let root_loc = root_slash
+ .headers()
+ .get(reqwest::header::LOCATION)
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or("");
+ assert_eq!(root_loc, format!("/r/{room_seg}/~"));
+
+ let leaf_slash = no_follow
+ .get(format!("http://{addr}/r/{room_seg}/~/item/"))
+ .header("Authorization", format!("Bearer {bearer}"))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(leaf_slash.status(), reqwest::StatusCode::PERMANENT_REDIRECT);
+ let leaf_loc = leaf_slash
+ .headers()
+ .get(reqwest::header::LOCATION)
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or("");
+ assert_eq!(leaf_loc, format!("/r/{room_seg}/~/item"));
}
#[tokio::test]
@@ -1052,11 +1098,18 @@ async fn test_thread_graduate_private_to_public() {
}]),
)
.await;
- assert_eq!(public_item["results"][0]["ok"], true, "{:?}", public_item["results"][0]);
+ assert_eq!(
+ public_item["results"][0]["ok"], true,
+ "{:?}",
+ public_item["results"][0]
+ );
let body = public_item["results"][0]["result"]["GardenItem"]["body"]
.as_str()
.unwrap_or("");
- assert!(body.contains("first item"), "expected graduated item body, got {body:?}");
+ assert!(
+ body.contains("first item"),
+ "expected graduated item body, got {body:?}"
+ );
let post_again = rpc_batch(
&client,
@@ -1092,6 +1145,8 @@ async fn test_thread_graduate_private_to_public() {
)
.await;
assert_eq!(grad_again["results"][0]["ok"], false);
- assert_eq!(grad_again["results"][0]["error"], "thread already graduated");
+ assert_eq!(
+ grad_again["results"][0]["error"],
+ "thread already graduated"
+ );
}
-
diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs
index fc3a0d7d0892a4551220ee02a01ac4d48ade02f4..f6e6baeb3ec3447b6b0e193fdf52766b82f508f0 100644
--- a/server/tests/integration_ui.rs
+++ b/server/tests/integration_ui.rs
@@ -603,7 +603,10 @@ async fn test_thread_view_offset_snaps_to_page_boundaries() {
.text()
.await
.unwrap();
- assert!(clamped.contains("11–11 / 11"), "huge offset clamps to latest");
+ assert!(
+ clamped.contains("11–11 / 11"),
+ "huge offset clamps to latest"
+ );
}
fn ui_vote_compare_post_rpc(
@@ -915,8 +918,7 @@ async fn test_vote_compare_post_rejects_over_max_ratio() {
let client = reqwest::Client::new();
let bearer = test_bearer();
- let rpc =
- ui_vote_compare_post_rpc("public", "test-vote", "~/a", "~/b", "101", "1", "prefer a");
+ let rpc = ui_vote_compare_post_rpc("public", "test-vote", "~/a", "~/b", "101", "1", "prefer a");
let resp = client
.post(format!("http://{addr}/ui"))
.header("Authorization", format!("Bearer {bearer}"))
@@ -955,11 +957,7 @@ async fn test_copy_garden_rank_returns_clipboard_js_with_markdown() {
.await;
assert_eq!(seed["results"][0]["ok"], true, "seed: {:?}", seed);
- let page = client
- .get(format!("http://{addr}/~"))
- .send()
- .await
- .unwrap();
+ let page = client.get(format!("http://{addr}/~")).send().await.unwrap();
assert!(page.status().is_success());
let html = page.text().await.unwrap();
assert!(
@@ -998,6 +996,8 @@ async fn test_copy_garden_rank_returns_clipboard_js_with_markdown() {
js.contains("1. ~/copy-a") && js.contains("2. ~/copy-b") && js.contains("- ~/copy-c"),
"expected concise markdown ranking in clipboard payload, got: {js}"
);
- assert!(js.contains("\"copied\""), "expected button label flip to copied");
+ assert!(
+ js.contains("\"copied\""),
+ "expected button label flip to copied"
+ );
}
-
diff --git a/sorterc/src/main.rs b/sorterc/src/main.rs
index 5687c850d16973d28749380b8dc89f3bcebbfc56..27990cf5b994dfd3cf5d9492c56513e4e11d76e4 100644
--- a/sorterc/src/main.rs
+++ b/sorterc/src/main.rs
@@ -59,8 +59,7 @@ fn read_input(path: &Path) -> Result {
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf)
} else {
- std::fs::read_to_string(path)
- .with_context(|| format!("read {}", path.display()))
+ std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))
}
}
@@ -68,7 +67,10 @@ fn load_base_state(base: Option<&Path>) -> Result {
let Some(path) = base else {
return Ok(ReducerState::default());
};
- eprintln!("sorterc: replaying {} for base state (may take a while on large logs)…", path.display());
+ eprintln!(
+ "sorterc: replaying {} for base state (may take a while on large logs)…",
+ path.display()
+ );
let (state, bad_lines) = offline::load_reducer_from_jsonl(path)
.with_context(|| format!("load base jsonl {}", path.display()))?;
if !bad_lines.is_empty() {
diff --git a/test/browser_arena_resolver.clj b/test/browser_arena_resolver.clj
new file mode 100644
index 0000000000000000000000000000000000000000..71dbb44157c81bea725c5f5dc730571e4cdef772
--- /dev/null
+++ b/test/browser_arena_resolver.clj
@@ -0,0 +1,168 @@
+(ns test.browser-arena-resolver
+ "Playwright coverage for on-demand are.na channel imports."
+ (:require [babashka.fs :as fs]
+ [cheshire.core :as json]
+ [clojure.string :as str]
+ [clojure.test :refer [deftest is]]
+ [com.blockether.spel.core :as core]
+ [com.blockether.spel.locator :as locator]
+ [com.blockether.spel.page :as page]
+ [org.httpkit.server :as http]
+ [test.common :as common]
+ [test.oauth :as oauth]))
+
+(defn- wait-for-text [pg selector expected timeout-ms]
+ (let [deadline (+ (System/currentTimeMillis) timeout-ms)]
+ (loop []
+ (let [text (locator/text-content (page/locator pg selector))]
+ (if (and (string? text) (str/includes? text expected))
+ true
+ (if (< (System/currentTimeMillis) deadline)
+ (do (Thread/sleep 200) (recur))
+ false))))))
+
+(defn- wait-for-http-text [url expected timeout-ms]
+ (let [deadline (+ (System/currentTimeMillis) timeout-ms)]
+ (loop []
+ (let [text (try
+ (:body (oauth/http-get url))
+ (catch Exception _ nil))]
+ (if (and (string? text) (str/includes? text expected))
+ true
+ (if (< (System/currentTimeMillis) deadline)
+ (do (Thread/sleep 200) (recur))
+ false))))))
+
+(defn- wait-for-http-absent [url needle timeout-ms]
+ (let [deadline (+ (System/currentTimeMillis) timeout-ms)]
+ (loop []
+ (let [text (try
+ (:body (oauth/http-get url))
+ (catch Exception _ nil))]
+ (if (and (string? text) (not (str/includes? text needle)))
+ true
+ (if (< (System/currentTimeMillis) deadline)
+ (do (Thread/sleep 200) (recur))
+ false))))))
+
+(defn- block-json [id title type]
+ {:id id
+ :type type
+ :title title
+ :user {:name "Ada"}
+ :connection {:connected_by {:name "Charles"}
+ :connected_at "2024-01-01T00:00:00Z"}})
+
+(defn- start-mock-arena [port !entries]
+ (let [!paths (atom [])
+ handler (fn [req]
+ (swap! !paths conj (:uri req))
+ (case (:uri req)
+ "/v3/channels/my-chan/contents"
+ {:status 200
+ :headers {"Content-Type" "application/json"}
+ :body (json/generate-string
+ {:meta {:current_page 1 :has_more_pages false}
+ :data (vec @!entries)})}
+
+ {:status 404 :body "not found"}))
+ stop-fn (http/run-server handler {:port port})]
+ {:stop-fn stop-fn :paths !paths}))
+
+(defn arena-resolver-flow! []
+ (println "\n━━━ browser are.na resolver: on-demand channel import ━━━\n")
+
+ (common/letlocals
+ (bind build (common/run-cargo-build-release! ["slugsocial-server"]))
+ (is (zero? (:exit build)) "cargo build succeeds")
+ (bind server-bin "target/release/slugsocial-server")
+
+ (bind tmp-dir (str (fs/create-temp-dir {:prefix "slug-browser-arena-resolver-"})))
+ (bind slug-port (common/pick-port))
+ (bind google-port (common/pick-port))
+ (bind arena-port (common/pick-port))
+ (bind base-url (str "http://127.0.0.1:" slug-port))
+ (bind google-url (str "http://127.0.0.1:" google-port))
+ (bind arena-url (str "http://127.0.0.1:" arena-port))
+
+ (bind !server (atom nil))
+ (bind !google (atom nil))
+ (bind !arena (atom nil))
+ (bind !entries (atom [(block-json 1 "First block" "Link")
+ (block-json 2 "Second block" "Image")
+ {:id 9 :type "Channel" :title "Nested" :slug "nested-chan"
+ :counts {:contents 3}}]))
+ (bind server-env (assoc (common/slug-server-env tmp-dir base-url google-url slug-port)
+ "SLUG_ARENA_API_BASE_URL" arena-url
+ "SLUG_ARENA_RESOLVER_COOLDOWN_MS" "0"))
+ (try
+ (reset! !google (oauth/start-mock-google google-port
+ :google-users ["google-user-alice"]))
+ (reset! !arena (start-mock-arena arena-port !entries))
+ (reset! !server (common/start-server server-bin server-env))
+ (is (common/wait-for-server base-url 10000) "server responds to /healthz")
+
+ (let [alice-token (oauth/fetch-bearer-token! base-url :username "alice")
+ seed-resp (oauth/http-post-json
+ (str base-url "/api/v0/rpc")
+ [{"Post" {"room" "public"
+ "thread_tag" "arena-resolver-seed"
+ "text" "https://www.are.na/channel/my-chan {Seeded are.na channel}"
+ "return_rank_diff" false}}]
+ :headers {"Authorization" (str "Bearer " alice-token)})
+ seed-json (json/parse-string (:body seed-resp) false)]
+ (is (true? (get-in seed-json ["results" 0 "ok"])) "seed pasted are.na channel")
+
+ (core/with-playwright [pw]
+ (core/with-browser [browser (core/launch-chromium pw {:headless true :channel "chrome"})]
+ (core/with-context [ctx (core/new-context browser)]
+ (core/with-page [pg (core/new-page-from-context ctx)]
+ (page/navigate pg (str base-url "/login"))
+ (is (wait-for-text pg "body" "@alice" 15000) "alice session after login")
+
+ (page/navigate pg (str base-url "/-/https://www.are.na/channel/my-chan"))
+ (is (wait-for-text pg "#external-resolver-panel" "Are.na resolver" 15000)
+ "channel page shows are.na resolver panel")
+ (locator/click (page/locator pg "[data-testid=\"arena-resolve-children\"]"))
+ (is (wait-for-http-text (str base-url "/-/https://www.are.na/channel/my-chan")
+ "-/https://www.are.na/block/1"
+ 15000)
+ "channel resolver imports block 1")
+ (is (wait-for-http-text (str base-url "/-/https://www.are.na/channel/my-chan")
+ "-/https://www.are.na/block/2"
+ 15000)
+ "channel resolver imports block 2")
+ (is (wait-for-http-text (str base-url "/-/https://www.are.na/channel/my-chan")
+ "-/https://www.are.na/channel/nested-chan"
+ 15000)
+ "channel resolver imports nested channels")
+
+ ;; Block 2 leaves the channel on are.na; refresh should redact its system post.
+ (reset! !entries [(block-json 1 "First block" "Link")])
+ (page/navigate pg (str base-url "/-/https://www.are.na/channel/my-chan"))
+ (locator/click (page/locator pg "[data-testid=\"arena-resolve-children\"]"))
+ (is (wait-for-http-absent (str base-url "/-/https://www.are.na/channel/my-chan")
+ "-/https://www.are.na/block/2"
+ 15000)
+ "removed block leaves the garden after refresh")
+ (is (wait-for-http-absent (str base-url "/-/https://www.are.na/channel/my-chan")
+ "-/https://www.are.na/channel/nested-chan"
+ 15000)
+ "removed nested channel leaves the garden after refresh")
+ (is (wait-for-http-text (str base-url "/-/https://www.are.na/channel/my-chan")
+ "-/https://www.are.na/block/1"
+ 15000)
+ "still-connected block kept after refresh")))))
+
+ (is (some #{"/v3/channels/my-chan/contents"} @(:paths @!arena))
+ "mock are.na saw channel contents request")
+ (is (>= (count (filter #{"/v3/channels/my-chan/contents"} @(:paths @!arena))) 2)
+ "contents endpoint hit on import and refresh"))
+
+ (finally
+ (when-some [s @!server] (common/kill-server s))
+ (when-some [g @!google] ((:stop-fn g)))
+ (when-some [a @!arena] ((:stop-fn a)))))))
+
+(deftest browser-arena-resolver-test
+ (arena-resolver-flow!))
diff --git a/types/src/item_id.rs b/types/src/item_id.rs
index 8c0e0c0a278a31e293814bd8bf58ee2e4bbf2de4..0b1ef5b195ff49c822686ebc547ef25ed0c6701f 100644
--- a/types/src/item_id.rs
+++ b/types/src/item_id.rs
@@ -227,8 +227,6 @@ impl<'de> Deserialize<'de> for ItemId {
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
- ItemId::parse(&s).ok_or_else(|| {
- serde::de::Error::custom(format!("invalid item id: {s:?}"))
- })
+ ItemId::parse(&s).ok_or_else(|| serde::de::Error::custom(format!("invalid item id: {s:?}")))
}
}
diff --git a/types/src/item_wire.rs b/types/src/item_wire.rs
index 1edb559bb1720d20081a87fc28b43d84199c817b..3e5cb2d8ea12b45e8d60690dc49a0d0fb4d81bd3 100644
--- a/types/src/item_wire.rs
+++ b/types/src/item_wire.rs
@@ -134,7 +134,10 @@ pub fn canonicalize_item(input: &str) -> String {
}
let is_tilde = s.starts_with("~/");
- let rest = s.strip_prefix("~/").or_else(|| s.strip_prefix("/")).unwrap_or(&s);
+ let rest = s
+ .strip_prefix("~/")
+ .or_else(|| s.strip_prefix("/"))
+ .unwrap_or(&s);
let tail = rest
.split('/')
@@ -170,13 +173,21 @@ pub fn item_path_segments(input: &str) -> Vec {
if let Some(rest) = canonical.strip_prefix("https://") {
let (host, tail) = rest.split_once('/').map_or((rest, ""), |(h, t)| (h, t));
let mut out = vec![format!("https://{}", host)];
- out.extend(tail.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string()));
+ out.extend(
+ tail.split('/')
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string()),
+ );
return out;
}
if let Some(rest) = canonical.strip_prefix("http://") {
let (host, tail) = rest.split_once('/').map_or((rest, ""), |(h, t)| (h, t));
let mut out = vec![format!("http://{}", host)];
- out.extend(tail.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string()));
+ out.extend(
+ tail.split('/')
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string()),
+ );
return out;
}
diff --git a/types/src/lib.rs b/types/src/lib.rs
index 31d4cf0c656c72a89199708d7a70b9c990e33756..9a6da984fde1c7c32b57e1df6f4ce0a83868d942 100644
--- a/types/src/lib.rs
+++ b/types/src/lib.rs
@@ -1,11 +1,11 @@
use serde::{Deserialize, Serialize};
-pub mod url_normalize;
-pub mod item_wire;
pub mod item_id;
+pub mod item_wire;
pub mod paths;
-pub mod timeago;
pub mod thread_xml;
+pub mod timeago;
+pub mod url_normalize;
pub use item_id::ItemId;
pub use item_wire::{
@@ -13,9 +13,9 @@ pub use item_wire::{
repair_collapsed_http_scheme, SLUG_TILDE_ONTOLOGY_ROOT,
};
pub use paths::{
- canonicalize_tag, validate_thread_tag, ForumThreadUrl, GardenItemUrl, RelativePath,
- room_id_from_route_segment, room_route_segment, ROOM_SHORT_ID_LEN,
- TildeHttpPathTail, TildeOntologyPath, TildePath, tilde_http_path_to_item_id,
+ canonicalize_tag, room_id_from_route_segment, room_route_segment, tilde_http_path_to_item_id,
+ validate_thread_tag, ForumThreadUrl, GardenItemUrl, RelativePath, TildeHttpPathTail,
+ TildeOntologyPath, TildePath, ROOM_SHORT_ID_LEN,
};
pub use url_normalize::normalize_http_identity_url;
@@ -224,7 +224,9 @@ pub struct ItemResponse {
pub threads: Vec,
}
-fn is_zero(n: &usize) -> bool { *n == 0 }
+fn is_zero(n: &usize) -> bool {
+ *n == 0
+}
#[derive(Debug, Serialize, Deserialize)]
pub struct RecentVotesResponse {
diff --git a/types/src/thread_xml.rs b/types/src/thread_xml.rs
index e7b42646de727c7660793b83fff3fef0403797be..0c72aa3be0c6269da22f5a7986536e14843bff14 100644
--- a/types/src/thread_xml.rs
+++ b/types/src/thread_xml.rs
@@ -94,9 +94,9 @@ mod tests {
#[test]
fn post_empty_delegate_when_human() {
let xml = format_post(0, "5s", "bob", None, "hi");
- assert!(xml.starts_with(
- ""
- ));
+ assert!(
+ xml.starts_with("")
+ );
}
#[test]