yggdrasil/utils/
comment_storage.rs1use chrono::DateTime;
7use serde::{Deserialize, Serialize};
8
9const AUTHOR_KEY: &str = "yggdrasil-comment-author";
11
12const PENDING_KEY: &str = "yggdrasil-pending-comments";
14
15const TTL_DAYS: i64 = 7;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct AuthorInfo {
21 pub name: String,
23 pub email: String,
25 #[serde(default)]
27 pub url: String,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32pub struct PendingComment {
33 pub id: i64,
35 pub parent_id: Option<i64>,
37 pub depth: i32,
39 pub author_name: String,
41 pub author_url: Option<String>,
43 pub avatar_url: String,
45 pub content_md: String,
47 pub created_at: String,
49 pub stored_at: String,
51}
52
53type PendingMap = std::collections::HashMap<String, Vec<PendingComment>>;
55
56#[allow(unused_variables)]
60fn read_storage(key: &str) -> Option<String> {
61 #[cfg(target_arch = "wasm32")]
62 {
63 let window = web_sys::window()?;
64 let storage = window.local_storage().ok()??;
65 storage.get_item(key).ok()?
66 }
67 #[cfg(not(target_arch = "wasm32"))]
68 {
69 None
70 }
71}
72
73#[allow(unused_variables)]
77fn write_storage(key: &str, value: &str) {
78 #[cfg(target_arch = "wasm32")]
79 {
80 if let Some(window) = web_sys::window() {
81 if let Ok(Some(storage)) = window.local_storage() {
82 let _ = storage.set_item(key, value);
83 }
84 }
85 }
86}
87
88fn is_expired(stored_at: &str) -> bool {
90 let Ok(dt) = DateTime::parse_from_rfc3339(stored_at) else {
91 return true;
92 };
93 let now_ms = crate::utils::time::now_millis();
94 let stored_ms = dt.timestamp_millis();
95 (now_ms - stored_ms) > (TTL_DAYS * 24 * 60 * 60 * 1000)
96}
97
98pub fn save_author(name: &str, email: &str, url: &str) {
100 let info = AuthorInfo {
101 name: name.to_string(),
102 email: email.to_string(),
103 url: url.to_string(),
104 };
105 if let Ok(json) = serde_json::to_string(&info) {
106 write_storage(AUTHOR_KEY, &json);
107 }
108}
109
110pub fn load_author() -> Option<AuthorInfo> {
112 let json = read_storage(AUTHOR_KEY)?;
113 serde_json::from_str(&json).ok()
114}
115
116pub fn save_pending_comment(post_id: i32, comment: PendingComment) {
120 let mut map: PendingMap = load_all_pending();
121 let key = post_id.to_string();
122 let list = map.entry(key).or_default();
123
124 if list.iter().any(|c| c.id == comment.id) {
126 return;
127 }
128 list.push(comment);
129
130 if let Ok(json) = serde_json::to_string(&map) {
131 write_storage(PENDING_KEY, &json);
132 }
133}
134
135pub fn load_pending_comments(post_id: i32) -> Vec<PendingComment> {
139 let mut map = load_all_pending();
140 let key = post_id.to_string();
141
142 let comments = map.remove(&key).unwrap_or_default();
143 let original_len = comments.len();
144 let non_expired: Vec<PendingComment> = comments
145 .into_iter()
146 .filter(|c| !is_expired(&c.stored_at))
147 .collect();
148
149 let pruned = non_expired.len() != original_len;
151 if !non_expired.is_empty() {
152 map.insert(key, non_expired.clone());
153 }
154 if pruned || non_expired.is_empty() {
155 if let Ok(json) = serde_json::to_string(&map) {
156 write_storage(PENDING_KEY, &json);
157 }
158 }
159
160 non_expired
161}
162
163pub fn remove_pending_ids(post_id: i32, ids: &[i64]) {
167 let mut map = load_all_pending();
168 let key = post_id.to_string();
169
170 let should_remove = if let Some(comments) = map.get_mut(&key) {
171 comments.retain(|c| !ids.contains(&c.id));
172 comments.is_empty()
173 } else {
174 false
175 };
176 if should_remove {
177 map.remove(&key);
178 }
179
180 if let Ok(json) = serde_json::to_string(&map) {
181 write_storage(PENDING_KEY, &json);
182 }
183}
184
185pub fn prune_all_expired() {
187 let mut map = load_all_pending();
188 let mut changed = false;
189
190 let keys: Vec<String> = map.keys().cloned().collect();
191 for key in keys {
192 let should_remove = if let Some(comments) = map.get_mut(&key) {
193 let before = comments.len();
194 comments.retain(|c| !is_expired(&c.stored_at));
195 if comments.len() != before {
196 changed = true;
197 }
198 comments.is_empty()
199 } else {
200 false
201 };
202 if should_remove {
203 map.remove(&key);
204 changed = true;
205 }
206 }
207
208 if changed {
209 if let Ok(json) = serde_json::to_string(&map) {
210 write_storage(PENDING_KEY, &json);
211 }
212 }
213}
214
215fn load_all_pending() -> PendingMap {
217 let json = match read_storage(PENDING_KEY) {
218 Some(j) => j,
219 None => return PendingMap::new(),
220 };
221 serde_json::from_str(&json).unwrap_or_default()
222}
223
224pub fn render_pending_content(md: &str) -> String {
226 let escaped = crate::utils::html::escape_html(md);
227 escaped.replace('\n', "<br>")
228}