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(info: &AuthorInfo) {
100 if let Ok(json) = serde_json::to_string(info) {
101 write_storage(AUTHOR_KEY, &json);
102 }
103}
104
105pub fn load_author() -> Option<AuthorInfo> {
107 let json = read_storage(AUTHOR_KEY)?;
108 serde_json::from_str(&json).ok()
109}
110
111pub fn save_pending_comment(post_id: i32, comment: PendingComment) {
115 let mut map: PendingMap = load_all_pending();
116 let key = post_id.to_string();
117 let list = map.entry(key).or_default();
118
119 if list.iter().any(|c| c.id == comment.id) {
121 return;
122 }
123 list.push(comment);
124
125 if let Ok(json) = serde_json::to_string(&map) {
126 write_storage(PENDING_KEY, &json);
127 }
128}
129
130pub fn load_pending_comments(post_id: i32) -> Vec<PendingComment> {
134 let mut map = load_all_pending();
135 let key = post_id.to_string();
136
137 let comments = map.remove(&key).unwrap_or_default();
138 let original_len = comments.len();
139 let non_expired: Vec<PendingComment> = comments
140 .into_iter()
141 .filter(|c| !is_expired(&c.stored_at))
142 .collect();
143
144 let pruned = non_expired.len() != original_len;
146 if !non_expired.is_empty() {
147 map.insert(key, non_expired.clone());
148 }
149 if pruned || non_expired.is_empty() {
150 if let Ok(json) = serde_json::to_string(&map) {
151 write_storage(PENDING_KEY, &json);
152 }
153 }
154
155 non_expired
156}
157
158pub fn remove_pending_ids(post_id: i32, ids: &[i64]) {
162 let mut map = load_all_pending();
163 let key = post_id.to_string();
164
165 let should_remove = if let Some(comments) = map.get_mut(&key) {
166 comments.retain(|c| !ids.contains(&c.id));
167 comments.is_empty()
168 } else {
169 false
170 };
171 if should_remove {
172 map.remove(&key);
173 }
174
175 if let Ok(json) = serde_json::to_string(&map) {
176 write_storage(PENDING_KEY, &json);
177 }
178}
179
180pub fn prune_all_expired() {
182 let mut map = load_all_pending();
183 let mut changed = false;
184
185 let keys: Vec<String> = map.keys().cloned().collect();
186 for key in keys {
187 let should_remove = if let Some(comments) = map.get_mut(&key) {
188 let before = comments.len();
189 comments.retain(|c| !is_expired(&c.stored_at));
190 if comments.len() != before {
191 changed = true;
192 }
193 comments.is_empty()
194 } else {
195 false
196 };
197 if should_remove {
198 map.remove(&key);
199 changed = true;
200 }
201 }
202
203 if changed {
204 if let Ok(json) = serde_json::to_string(&map) {
205 write_storage(PENDING_KEY, &json);
206 }
207 }
208}
209
210fn load_all_pending() -> PendingMap {
212 let json = match read_storage(PENDING_KEY) {
213 Some(j) => j,
214 None => return PendingMap::new(),
215 };
216 serde_json::from_str(&json).unwrap_or_default()
217}
218
219pub fn render_pending_content(md: &str) -> String {
221 let escaped = crate::utils::html::escape_html(md);
222 escaped.replace('\n', "<br>")
223}