Skip to main content

yggdrasil/utils/
comment_storage.rs

1//! 评论草稿在浏览器 localStorage 中的持久化支持。
2//!
3//! 注意:所有 localStorage 读写均通过 `#[cfg(target_arch = "wasm32")]` 限定,
4//! 仅在 WASM 前端生效;服务端渲染(SSR)路径下这些函数会返回 None 或空操作。
5
6use chrono::DateTime;
7use serde::{Deserialize, Serialize};
8
9/// localStorage 中用于存储评论作者信息的键名。
10const AUTHOR_KEY: &str = "yggdrasil-comment-author";
11
12/// localStorage 中用于存储待发布评论草稿的键名。
13const PENDING_KEY: &str = "yggdrasil-pending-comments";
14
15/// 待发布评论草稿的过期时间(天)。
16const TTL_DAYS: i64 = 7;
17
18/// 评论作者信息。
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct AuthorInfo {
21    /// 昵称。
22    pub name: String,
23    /// 邮箱地址。
24    pub email: String,
25    /// 个人主页 URL。
26    #[serde(default)]
27    pub url: String,
28}
29
30/// 待发布评论草稿。
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32pub struct PendingComment {
33    /// 评论 ID。
34    pub id: i64,
35    /// 父评论 ID,顶级评论为 None。
36    pub parent_id: Option<i64>,
37    /// 评论层级深度。
38    pub depth: i32,
39    /// 作者昵称。
40    pub author_name: String,
41    /// 作者主页 URL。
42    pub author_url: Option<String>,
43    /// 头像 URL。
44    pub avatar_url: String,
45    /// Markdown 格式的评论内容。
46    pub content_md: String,
47    /// 评论创建时间(RFC3339 字符串)。
48    pub created_at: String,
49    /// 草稿存入 localStorage 的时间(RFC3339 字符串)。
50    pub stored_at: String,
51}
52
53/// 按文章 ID 组织的待发布评论草稿映射。
54type PendingMap = std::collections::HashMap<String, Vec<PendingComment>>;
55
56/// 从 localStorage 读取指定键的值。
57///
58/// 仅在 `wasm32` 目标下执行实际读取;SSR 构建下直接返回 None。
59#[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/// 将值写入 localStorage 指定键。
74///
75/// 仅在 `wasm32` 目标下执行实际写入;SSR 构建下为空操作。
76#[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
88/// 判断给定存储时间是否已经过期。
89fn 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
98/// 保存评论作者信息到 localStorage。
99pub 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
110/// 从 localStorage 读取评论作者信息。
111pub fn load_author() -> Option<AuthorInfo> {
112    let json = read_storage(AUTHOR_KEY)?;
113    serde_json::from_str(&json).ok()
114}
115
116/// 将一条待发布评论草稿保存到指定文章的草稿列表中。
117///
118/// 如果同一 ID 的草稿已存在则忽略,避免重复。
119pub 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    // 已存在相同 ID 时直接返回,避免重复保存。
125    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
135/// 加载指定文章下所有未过期的待发布评论草稿。
136///
137/// 读取时会自动清理过期草稿,并将结果写回 localStorage。
138pub 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    // 若有草稿被清理,或该文章已无草稿,都需要把更新后的映射写回 localStorage。
150    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
163/// 从指定文章的草稿列表中移除指定 ID 的评论。
164///
165/// 若移除后该文章无草稿,则删除该文章对应的键。
166pub 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
185/// 清理所有文章中已过期的待发布评论草稿。
186pub 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
215/// 加载全部待发布评论草稿映射。
216fn 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
224/// 将待发布评论的 Markdown 内容渲染为安全的 HTML(纯文本 + 换行转 `<br>`)。
225pub fn render_pending_content(md: &str) -> String {
226    let escaped = crate::utils::html::escape_html(md);
227    escaped.replace('\n', "<br>")
228}