Side A establishes the actual core architecture of the project—vote reducer with rank centrality math, parser graph, form templating, and vote UI routes—complete with substantive tests and real bugfix rationale (e.g. the rank-centrality convergence fix referencing issue #146). Side B is a single self-contained Reddit API client wrapper, useful but narrower in scope and lacking tests or integration with the rest of the system shown in Side A's diff. Side A's breadth and depth of durable, load-bearing logic outweighs Side B's isolated utility module.
constitution · epochs · watch · epoch 3
c_f6d0fed9bf9a (tommy-mor) vs c_b8e80699547c (tommy-mor)
download prompt · raw event · cmp_8d3b071939d04d
council reasoning
Side A seeds the project's core lasting systems (rank-centrality with #146 regression tests and connected components in ranking.rs, full event-sourced ReducerState/GroupState in reducer.rs, the graph-based Reddit URL parser with extensive keystroke-flow tests, form $form template fill, HtmlUiAction, and the complete vote-compare page/UI), whereas Side B only adds a single clean but narrower Reddit HTTP client with rate limits and serde types. A's volume includes some pasted shell noise and note files, but the real algorithms, state machine, and UI plumbing dominate lasting value over B's useful-but-peripheral fetch layer.
Side B adds a cohesive, reusable Reddit API client with built-in rate limiting, structured response types, robust serde deserialization, and explicit handling for common API errors, providing infrastructure that is likely to be reused across the project. Side A contains a large amount of code, but it mixes speculative design documents with implementation and even includes accidental terminal transcript text embedded in source files (for example at the start/end of forms.rs and ranking.rs), which would undermine correctness despite the breadth of functionality.
sides
A — c_f6d0fed9bf9a (tommy-mor)
message
[1d9d8ade] init seed
diff preview
diff --git a/TEST.sh b/TEST.sh
new file mode 100755
index 0000000000000000000000000000000000000000..266b71f29259f5c2cad2617476ebe2ebc9596d39
--- /dev/null
+++ b/TEST.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cargo test --all
+./scripts/clj-test.sh
diff --git a/bundle.js b/bundle.js
new file mode 100644
index 0000000000000000000000000000000000000000..8d316f9a57cc7c379fe4bd8e42e092c7eac5d265
--- /dev/null
+++ b/bundle.js
@@ -0,0 +1,52 @@
+/**
+ * Slug web UI: only plumbing — fetch/eval/SSE. No product UI logic here.
+ */
+(function () {
+ function evalJs(js) {
+ if (js && String(js).trim()) {
+ eval(js);
+ }
+ }
+
+ // Theme cookie sync (runs before paint; full reload if localStorage disagrees with cookie)
+
+ function initSlugUi() {
+ // POST forms → eval response (except theme + full-navigation forms)
+ document.addEventListener('submit', async function (e) {
+ var f = e.target;
+ if (!f || f.tagName !== 'FORM') return;
+ if ((f.method || 'get').toLowerCase() !== 'post') return;
+ if (f.id === 'slug-theme-form') return;
+ if (f.getAttribute('data-navigate') === 'full') return;
+ e.preventDefault();
+ var resp = await fetch(f.action, {
+ method: 'POST',
+ body: new URLSearchParams(new FormData(f)),
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ credentials: 'same-origin',
+ });
+ evalJs(await resp.text());
+ });
+
+ // SSE: server-pushed JS
+ function connectSSE() {
+ var ssePath = window.location.pathname + window.location.search;
+ var es = new EventSource('/sse?path=' + encodeURIComponent(ssePath));
+ es.onmessage = function (e) {
+ evalJs(e.data);
+ };
+ es.onerror = function () {
+ es.close();
+ setTimeout(connectSSE, 3000);
+ };
+ }
+ connectSSE();
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initSlugUi);
+ } else {
+ initSlugUi();
+ }
+})();
+
diff --git a/clj-test.sh b/clj-test.sh
new file mode 100755
index 0000000000000000000000000000000000000000..b62a49b98ed60a65a46807b7ad80fa3142f14952
--- /dev/null
+++ b/clj-test.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/.."
+mkdir -p target
+exec clojure -M:kaocha
diff --git a/forms.rs b/forms.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d509fd889fde3fed2662ce0a39006b7a51ae762a
--- /dev/null
+++ b/forms.rs
@@ -0,0 +1,145 @@
+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ cat server/src/form_template.rs
+//! Plan2-style JSON templates with `{"$form": "field_name"}` holes, filled from
+//! `application/x-www-form-urlencoded` (or any `String` → `String` map) **before**
+//! deserializing into a typed struct.
+//!
+//! # Wire format
+//!
+//! Templates are **compact JSON** (`serde_json::to_string`): one line, no pretty
+//! printing, strings escaped per JSON rules (`\"`, `\n`, etc.). Embed that string
+//! in HTML attributes or text nodes with normal HTML escaping (e.g. maud), not
+//! bespoke encodings.
+//!
+//! # Power vs flat hidden fields
+//!
+//! A form is always a string→string map. You can fake depth with dotted keys (`a.b.c`),
+//! but one structured blob (`__rpc__` = compact JSON) gives you nested objects,
+//! arrays, and optional fields without inventing a new naming scheme each time.
+//!
+//! # Security
+//!
+//! Substitution runs **before** `serde` into your command type. It does not fix
+//! authorization: if the client can replace the hidden `__rpc__` value, they can
+//! change the command shape unless you validate (signed blob, server-side session
+//! context, or treat the blob as hints only). Same threat model as any hidden field.
+
+use serde::Serialize;
+use serde_json::Value;
+use std::collections::HashMap;
+
+/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field.
+pub fn template_json_compact<T: Serialize>(v: &T) -> serde_json::Result<String> {
+ serde_json::to_string(v)
+}
+
+/// Recursively walk the JSON AST and replace `{"$form": "key"}` with the submitted
+/// string for `key` (empty if missing). Other keys are unchanged.
+pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap<String, String>) {
+ match val {
+ Value::Object(map) => {
+ if map.len() == 1 {
+ if let Some(Value::String(field_name)) = map.get("$form") {
+ let submitted = form_data
+ .get(field_name.as_str())
+ .map(|s| s.as_str())
+ .unwrap_or("");
+ *val = Value::String(submitted.to_string());
+ return;
+ }
+ }
+ for v in map.values_mut() {
+ substitute_form_vars(v, form_data);
+ }
+ }
+ Value::Array(arr) => {
+ for v in arr.iter_mut() {
+ substitute_form_vars(v, form_data);
+ }
+ }
+ _ => {}
+ }
+}
+
+/// Parse JSON, apply [`substitute_form_vars`], return the mutated value.
+pub fn fill_template_from_form(
+ template_json: &str,
+ form_data: &HashMap<String, String>,
+) -> Result<Value, serde_json::Error> {
+ let mut v: Value = serde_json::from_str(template_json)?;
+ substitute_form_vars(&mut v, form_data);
+ Ok(v)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde::Deserialize;
+
+ #[derive(Debug, Deserialize, PartialEq, Eq)]
+ struct Demo {
+ room: String,
+ thread_tag: String,
+ nested: Nested,
+ }
+
+ #[derive(Debug, Deserialize, PartialEq, Eq)]
+ struct Nested {
+ text: String,
+ }
+
+ #[test]
+ fn holes_become_strings() {
+ let json = r#"{
+ "room": "public",
+ "thread_tag": {"$form": "tag"},
+ "nested": {"text": {"$form": "body"}}
+ }"#;
+ let mut form = HashMap::new();
+ form.insert("tag".into(), "foo".into());
+ form.insert("body".into(), "hello\nworld".into());
+
+ let v = fill_template_from_form(json, &form).unwrap();
+ let d: Demo = serde_json::from_value(v).unwrap();
+ assert_eq!(
+ d,
+ Demo {
+ room: "public".into(),
+ thread_tag: "foo".into(),
+ nested: Nested {
+ text: "hello\nworld".into(),
+ },
+ }
+ );
+ }
+
+ #[test]
+ fn missing_form_key_is_empty_string() {
+ let json = r#"{"x": {"$form": "nope"}}"#;
+ let mut form = HashMap::new();
+ form.insert("other".into(), "y".into());
+ let v = fill_template_from_form(json, &form).unwrap();
+ assert_eq!(v["x"], "");
+ }
+
+ #[test]
+ fn array_of_holes() {
+ let json = r#"{"items": [{"$form": "a"}, {"$form": "b"}]}"#;
+ let mut form = HashMap::new();
+ form.insert("a".into(), "1".into());
+ form.insert("b".into(), "2".into());
+ let v = fill_template_from_form(json, &form).unwrap();
+ assert_eq!(v["items"], serde_json::json!(["1", "2"]));
+ }
+
+ #[test]
+ fn template_json_compact_escapes_and_single_line() {
+ let s = template_json_compact(&serde_json::json!({
+ "x": "quote\"and\nnewline"
+ }))
+ .unwrap();
+ assert!(!s.contains('\n'));
+ assert!(s.contains("\\\"") || s.contains("\\n"));
+ }
+}
+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒
+
diff --git a/gameifying.tdsl b/gameifying.tdsl
new file mode 100644
index 0000000000000000000000000000000000000000..93d2a61d94fcba4370c82d9612e6bb0f0318cc15
--- /dev/null
+++ b/gameifying.tdsl
@@ -0,0 +1,2 @@
+consider gating certain views (top all time?) by a 10 day usage streak.. or something like that.
+or like a path, on homepage(?), that shows day 1: r/amitheasshole, day2: r/aww, day3: gaming, or something like that. progressive revelation + usage incentive.
diff --git a/pagerank_streaming.tdsl b/pagerank_streaming.tdsl
new file mode 100644
index 0000000000000000000000000000000000000000..0f2231b94936c17610adf653ca26b0faa6f2ac3a
--- /dev/null
+++ b/pagerank_streaming.tdsl
@@ -0,0 +1,11 @@
+eventually i want to have ranking histories.
+like "visionary" and "fraud" waxing and waning in a uplot graph over time for #elon-musk.
+there are too many query combinations to precomupte the ranking histories
+(arbitrary user filters, and tag overlap combinations maybe),
+so we're just going to have to calculate them all on demand.
+computers are fast, its okay. for each vote, we need to calculate rank centrality again.
+i was thinking, for n votes we calculate the rank centrality,
+and get node weights. we then output that data to the client (over websocket, or testable barrier).
+then we calculate n+1 votes, _but we keep the node weights in memory_
+so the rank centrality process converges faster.
+this also has the side effect of making the ranking stream in satisfyingly as you load the page.
diff --git a/parser.rs b/parser.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2b87b974f8d1dd93bee35681d87668a94e4ef349
--- /dev/null
+++ b/parser.rs
@@ -0,0 +1,1808 @@
+use std::collections::HashMap;
+use std::rc::Rc;
+use std::cell::RefCell;
+use crate::ui::action::UIAction;
+use crate::ui::types::{Suggestion, GuideOption, ScrollingSuggestion};
+
+// --- Core Abstractions ---
+
+/// Unique identifier for nodes in the graph
+type NodeId = &'static str;
+
+/// Pattern matching for edges
+#[derive(Debug, Clone)]
+pub enum EdgePattern {
+ /// Matches exact literal string
+ Literal(&'static str),
+
+ /// Matches any prefix of a string and suggests the full string
+ /// e.g., PrefixOf("reddit.com") matches "r", "re", "red", "reddit", "reddit.com"
+ PrefixOf(&'static str),
+
+ /// Captures a variable segment (e.g., subreddit name, username)
+ Variable(&'static str),
+
+ /// Matches any string (wildcard)
+ Any,
+}
+
+impl EdgePattern {
+ /// Try to match this pattern against input, return (consumed_chars, captured_value)
+ fn matches(&self, input: &str) -> Option<(usize, Option<String>)> {
+ match self {
+ EdgePattern::Literal(lit) => {
+ if input.starts_with(lit) {
+ Some((lit.len(), None))
+ } else {
+ None
+ }
+ }
+ EdgePattern::PrefixOf(target) => {
+ // Check if input is a prefix of target
+ if target.starts_with(input) && !input.is_empty() {
+ // It's a valid prefix
+ Some((input.len(), None))
+ } else if input.starts_with(target) {
+ // Full match
+ Some((target.len(), None))
+ } else {
+ None
+ }
+ }
+ EdgePattern::Variable(var_name) => {
+ // Consume until next '/' or end of string
+ let end = input.find('/').unwrap_or(input.len());
+ if end > 0 {
+ let captured = input[..end].to_string();
+ // Validate based on variable type
+ if is_valid_variable(var_name, &captured) {
+ Some((end, Some(captured)))
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ }
+ EdgePattern::Any => {
+ // Match everything until next '/' or end
+ let end = input.find('/').unwrap_or(input.len());
+ if end > 0 {
+ Some((end, Some(input[..end].to_string())))
+ } else {
+ None
+ }
+ }
+ }
+ }
+
+ /// G
… preview truncated; 148,978 characters omittedB — c_b8e80699547c (tommy-mor)
message
[432e1450] nice
diff preview
diff --git a/reddit.rs b/reddit.rs
new file mode 100644
index 0000000000000000000000000000000000000000..da27f6f76482dd27d44cad9d7b844972bf042e64
--- /dev/null
+++ b/reddit.rs
@@ -0,0 +1,420 @@
+use governor::{Quota, RateLimiter, Jitter};
+use nonzero_ext::nonzero;
+use reqwest::{Client, StatusCode};
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+use std::time::Duration;
+use anyhow::{Result, Context, anyhow};
+
+/// Reddit API client with built-in rate limiting
+pub struct RedditClient {
+ client: Client,
+ limiter: Arc<governor::DefaultDirectRateLimiter>,
+ user_agent: String,
+}
+
+impl RedditClient {
+ /// Create a new Reddit client with rate limiting
+ /// Reddit API allows 60 requests per minute for OAuth2 authenticated apps
+ /// We'll be conservative and use 50 requests per minute
+ pub fn new() -> Self {
+ // Create rate limiter: 50 requests per minute
+ let quota = Quota::per_minute(nonzero!(50u32));
+ let limiter = Arc::new(RateLimiter::direct(quota));
+
+ // Create HTTP client with timeout
+ let client = Client::builder()
+ .timeout(Duration::from_secs(30))
+ .build()
+ .expect("Failed to create HTTP client");
+
+ // Reddit requires a unique user agent
+ let user_agent = format!(
+ "rust:sorter:v{} (by /u/hourLong_arnould)",
+ env!("CARGO_PKG_VERSION")
+ );
+
+ Self {
+ client,
+ limiter,
+ user_agent,
+ }
+ }
+
+ /// Fetch hot posts from a subreddit
+ pub async fn get_subreddit_hot(&self, subreddit: &str, limit: usize) -> Result<RedditListingResponse> {
+ // Wait for rate limiter
+ self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+
+ let url = format!("https://www.reddit.com/r/{}/hot.json?limit={}", subreddit, limit);
+
+ let response = self.client
+ .get(&url)
+ .header("User-Agent", &self.user_agent)
+ .send()
+ .await
+ .context("Failed to send request to Reddit")?;
+
+ self.handle_response(response).await
+ }
+
+ /// Fetch top posts from a subreddit
+ pub async fn get_subreddit_top(&self, subreddit: &str, limit: usize, time_period: &str) -> Result<RedditListingResponse> {
+ self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+
+ let url = format!(
+ "https://www.reddit.com/r/{}/top.json?limit={}&t={}",
+ subreddit, limit, time_period
+ );
+
+ let response = self.client
+ .get(&url)
+ .header("User-Agent", &self.user_agent)
+ .send()
+ .await
+ .context("Failed to send request to Reddit")?;
+
+ self.handle_response(response).await
+ }
+
+ /// Fetch new posts from a subreddit
+ pub async fn get_subreddit_new(&self, subreddit: &str, limit: usize) -> Result<RedditListingResponse> {
+ self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+
+ let url = format!("https://www.reddit.com/r/{}/new.json?limit={}", subreddit, limit);
+
+ let response = self.client
+ .get(&url)
+ .header("User-Agent", &self.user_agent)
+ .send()
+ .await
+ .context("Failed to send request to Reddit")?;
+
+ self.handle_response(response).await
+ }
+
+ /// Fetch a specific post and its comments
+ pub async fn get_post(&self, subreddit: &str, post_id: &str) -> Result<Vec<RedditListingResponse>> {
+ self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+
+ let url = format!(
+ "https://www.reddit.com/r/{}/comments/{}.json",
+ subreddit, post_id
+ );
+
+ let response = self.client
+ .get(&url)
+ .header("User-Agent", &self.user_agent)
+ .send()
+ .await
+ .context("Failed to send request to Reddit")?;
+
+ match response.status() {
+ StatusCode::OK => {
+ let listings: Vec<RedditListingResponse> = response
+ .json()
+ .await
+ .context("Failed to parse Reddit response")?;
+ Ok(listings)
+ }
+ StatusCode::TOO_MANY_REQUESTS => {
+ Err(anyhow!("Reddit rate limit exceeded. Please try again later."))
+ }
+ StatusCode::NOT_FOUND => {
+ Err(anyhow!("Post not found: r/{}/comments/{}", subreddit, post_id))
+ }
+ status => {
+ Err(anyhow!("Reddit API error: {}", status))
+ }
+ }
+ }
+
+ /// Fetch user profile (posts and comments)
+ pub async fn get_user_profile(&self, username: &str, content_type: &str, limit: usize) -> Result<RedditListingResponse> {
+ self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+
+ let url = format!(
+ "https://www.reddit.com/user/{}/{}.json?limit={}",
+ username, content_type, limit
+ );
+
+ let response = self.client
+ .get(&url)
+ .header("User-Agent", &self.user_agent)
+ .send()
+ .await
+ .context("Failed to send request to Reddit")?;
+
+ self.handle_response(response).await
+ }
+
+ /// Handle Reddit API response with proper error checking
+ async fn handle_response(&self, response: reqwest::Response) -> Result<RedditListingResponse> {
+ match response.status() {
+ StatusCode::OK => {
+ let listing: RedditListingResponse = response
+ .json()
+ .await
+ .context("Failed to parse Reddit response")?;
+ Ok(listing)
+ }
+ StatusCode::TOO_MANY_REQUESTS => {
+ Err(anyhow!("Reddit rate limit exceeded. Please try again later."))
+ }
+ StatusCode::NOT_FOUND => {
+ Err(anyhow!("Reddit resource not found"))
+ }
+ StatusCode::FORBIDDEN => {
+ Err(anyhow!("Access forbidden. The subreddit may be private."))
+ }
+ status => {
+ Err(anyhow!("Reddit API error: {}", status))
+ }
+ }
+ }
+}
+
+// Reddit API response types
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditListingResponse {
+ pub kind: String,
+ pub data: RedditListingData,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditListingData {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub after: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub before: Option<String>,
+ pub children: Vec<RedditThing>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub modhash: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditThing {
+ pub kind: String,
+ pub data: RedditThingData,
+}
+
+/// Reddit's "Thing" data can be either a post, comment, or other types
+/// We use untagged enum to handle different data structures
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum RedditThingData {
+ Post(RedditPost),
+ Comment(RedditComment),
+ // For things we don't care about yet (like "more" comments)
+ Other(serde_json::Value),
+}
+
+/// Reddit post data with serde handling all the parsing
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditPost {
+ pub id: String,
+ pub name: String, // Full name (t3_xxx)
+ #[serde(default = "default_untitled")]
+ pub title: String,
+ #[serde(default = "default_deleted_user")]
+ pub author: String,
+ pub subreddit: String,
+ #[serde(default)]
+ pub url: String,
+ #[serde(default)]
+ pub permalink: String,
+ #[serde(default, deserialize_with = "deserialize_selftext")]
+ pub selftext: Option<String>,
+ #[serde(default)]
+ pub score: i64,
+ #[serde(default)]
+ pub num_comments: i64,
+ #[serde(default)]
+ pub created_utc: f64,
+ #[serde(default, deserialize_with = "deserialize_thumbnail")]
+ pub thumbnail: Option<String>,
+ #[serde(default)]
+ pub is_video: bool,
+ #[serde(default)]
+ pub is_self: bool,
+ // Additional useful fields
+ #[serde(default)]
+ pub ups: i64,
+ #[serde(default)]
+ pub downs: i64,
+ #[serde(default)]
+ pub upvote_ratio: f64,
+ #[serde(default)]
+ pub over_18: bool,
+ #[serde(default)]
+ pub spoiler: bool,
+ #[serde(default)]
+ pub stickied: bool,
+ #[serde(default)]
+ pub locked: bool,
+ #[serde(default)]
+ pub distinguished: Option<String>,
+}
+
+/// Reddit comment data
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditComment {
+ pub id: String,
+ pub name: String, // Full name (t1_xxx)
+ #[serde(default = "default_deleted_user")]
+ pub author: String,
+ #[serde(default)]
+ pub body: String,
+ #[serde(default)]
+ pub score: i64,
+ #[serde(default)]
+ pub created_utc: f64,
+ #[serde(default)]
+ pub parent_id: String,
+ #[serde(default)]
+ pub permalink: String,
+ #[serde(default)]
+ pub depth: i32,
+ // Additional useful fields
+ #[serde(default)]
+ pub ups: i64,
+ #[serde(default)]
+ pub downs: i64,
+ #[serde(default)]
+ pub edited: RedditEditStatus,
+ #[serde(default)]
+ pub stickied: bool,
+ #[serde(default)]
+ pub distinguished: Option<String>,
+ #[serde(default)]
+ pub is_submitter: bool,
+ #[serde(default)]
+ pub collapsed: bool,
+ #[serde(default)]
+ pub controversiality: i32,
+}
+
+/// Reddit's edit status - can be false or a timestamp
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum RedditEditStatus {
+ NotEdited(bool),
+ EditedAt(f64),
+}
+
+impl Default for RedditEditStatus {
+ fn default() -> Self {
+ RedditEditStatus::NotEdited(false)
+ }
+}
+
+// Helper functions for serde defaults
+fn default_untitled() -> String {
+ "Untitled".to_string()
+}
+
+fn default_deleted_user() -> String {
+ "[deleted]".to_string()
+}
+
+// Custom deserializer for selftext (empty strings should be None)
+fn deserialize_selftext<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ let s: Option<String> = Option::deserialize(deserializer)?;
+ Ok(s.filter(|text| !text.is_empty()))
+}
+
+// Custom deserializer for thumbnail (filter out "self", "default", empty)
+fn deserialize_thumbnail<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ let s: Option<String> = Option::deserialize(deserializer)?;
+ Ok(s.filter(|thumb| {
+ !thumb.is_empty() && thumb != "self" && thumb != "default" && thumb != "nsfw" && thumb != "spoiler"
+ }))
+}
+
+// Helper methods for extracting typed data from Things
+impl RedditThing {
+ /// Try to get this Thing as a Post
+ pub fn as_post(&self) -> Option<&RedditPost> {
+ if self.kind != "t3" {
+ return None;
+ }
+ match &self.data {
+ RedditThingData::Post(post) => Some(post),
+ _ => None,
+ }
+ }
+
+ /// Try to get this Thing as a Comment
+ pub fn as_comment(&self) -> Option<&RedditComment> {
+ if self.kind != "t1" {
+ return None;
+ }
+ match &self.data {
+ RedditThingData::Comment(comment) => Some(comment),
+ _ => None,
+ }
+ }
+
+ /// Check if th
… preview truncated; 1,666 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.