diff --git a/src/hooks/comment_storage.rs b/src/hooks/comment_storage.rs new file mode 100644 index 0000000..8bf4a56 --- /dev/null +++ b/src/hooks/comment_storage.rs @@ -0,0 +1,188 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +const AUTHOR_KEY: &str = "yggdrasil-comment-author"; +const PENDING_KEY: &str = "yggdrasil-pending-comments"; +const TTL_DAYS: i64 = 7; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorInfo { + pub name: String, + pub email: String, + #[serde(default)] + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingComment { + pub id: i64, + pub parent_id: Option, + pub depth: i32, + pub author_name: String, + pub author_url: Option, + pub avatar_url: String, + pub content_md: String, + pub created_at: String, + pub stored_at: String, +} + +type PendingMap = std::collections::HashMap>; + +fn read_storage(key: &str) -> Option { + #[cfg(target_arch = "wasm32")] + { + let window = web_sys::window()?; + let storage = window.local_storage().ok()??; + storage.get_item(key).ok()? + } + #[cfg(not(target_arch = "wasm32"))] + { + None + } +} + +fn write_storage(key: &str, value: &str) { + #[cfg(target_arch = "wasm32")] + { + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = window.local_storage() { + let _ = storage.set_item(key, value); + } + } + } +} + +fn now_iso() -> String { + Utc::now().to_rfc3339() +} + +fn is_expired(stored_at: &str) -> bool { + let Ok(dt) = DateTime::parse_from_rfc3339(stored_at) else { + return true; + }; + let Ok(now) = DateTime::parse_from_rfc3339(&now_iso()) else { + return false; + }; + (now - dt).num_days() > TTL_DAYS +} + +pub fn save_author(name: &str, email: &str, url: &str) { + let info = AuthorInfo { + name: name.to_string(), + email: email.to_string(), + url: url.to_string(), + }; + if let Ok(json) = serde_json::to_string(&info) { + write_storage(AUTHOR_KEY, &json); + } +} + +pub fn load_author() -> Option { + let json = read_storage(AUTHOR_KEY)?; + serde_json::from_str(&json).ok() +} + +pub fn save_pending_comment(post_id: i32, comment: PendingComment) { + let mut map: PendingMap = load_all_pending(); + let key = post_id.to_string(); + let list = map.entry(key).or_default(); + + if list.iter().any(|c| c.id == comment.id) { + return; + } + list.push(comment); + + if let Ok(json) = serde_json::to_string(&map) { + write_storage(PENDING_KEY, &json); + } +} + +pub fn load_pending_comments(post_id: i32) -> Vec { + let mut map = load_all_pending(); + let key = post_id.to_string(); + + let comments = map.remove(&key).unwrap_or_default(); + let non_expired: Vec = comments + .into_iter() + .filter(|c| !is_expired(&c.stored_at)) + .collect(); + + if !non_expired.is_empty() { + map.insert(key, non_expired.clone()); + } + if let Ok(json) = serde_json::to_string(&map) { + write_storage(PENDING_KEY, &json); + } + + non_expired +} + +pub fn remove_pending_ids(post_id: i32, ids: &[i64]) { + let mut map = load_all_pending(); + let key = post_id.to_string(); + + let should_remove = if let Some(comments) = map.get_mut(&key) { + comments.retain(|c| !ids.contains(&c.id)); + comments.is_empty() + } else { + false + }; + if should_remove { + map.remove(&key); + } + + if let Ok(json) = serde_json::to_string(&map) { + write_storage(PENDING_KEY, &json); + } +} + +pub fn prune_all_expired() { + let mut map = load_all_pending(); + let mut changed = false; + + let keys: Vec = map.keys().cloned().collect(); + for key in keys { + let should_remove = if let Some(comments) = map.get_mut(&key) { + let before = comments.len(); + comments.retain(|c| !is_expired(&c.stored_at)); + if comments.len() != before { + changed = true; + } + comments.is_empty() + } else { + false + }; + if should_remove { + map.remove(&key); + changed = true; + } + } + + if changed { + if let Ok(json) = serde_json::to_string(&map) { + write_storage(PENDING_KEY, &json); + } + } +} + +fn load_all_pending() -> PendingMap { + let json = match read_storage(PENDING_KEY) { + Some(j) => j, + None => return PendingMap::new(), + }; + serde_json::from_str(&json).unwrap_or_default() +} + +pub fn escape_html(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +pub fn render_pending_content(md: &str) -> String { + let escaped = escape_html(md); + escaped.replace('\n', "
") +} diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 5d29053..247a499 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -1 +1,2 @@ pub mod delayed_loading; +pub mod comment_storage;