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(info: &AuthorInfo) {
100    if let Ok(json) = serde_json::to_string(info) {
101        write_storage(AUTHOR_KEY, &json);
102    }
103}
104
105/// 从 localStorage 读取评论作者信息。
106pub fn load_author() -> Option<AuthorInfo> {
107    let json = read_storage(AUTHOR_KEY)?;
108    serde_json::from_str(&json).ok()
109}
110
111/// 将一条待发布评论草稿保存到指定文章的草稿列表中。
112///
113/// 如果同一 ID 的草稿已存在则忽略,避免重复。
114pub 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    // 已存在相同 ID 时直接返回,避免重复保存。
120    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
130/// 加载指定文章下所有未过期的待发布评论草稿。
131///
132/// 读取时会自动清理过期草稿,并将结果写回 localStorage。
133pub 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    // 若有草稿被清理,或该文章已无草稿,都需要把更新后的映射写回 localStorage。
145    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
158/// 从指定文章的草稿列表中移除指定 ID 的评论。
159///
160/// 若移除后该文章无草稿,则删除该文章对应的键。
161pub 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
180/// 清理所有文章中已过期的待发布评论草稿。
181pub 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
210/// 加载全部待发布评论草稿映射。
211fn 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
219/// 将待发布评论的 Markdown 内容渲染为安全的 HTML(纯文本 + 换行转 `<br>`)。
220pub fn render_pending_content(md: &str) -> String {
221    let escaped = crate::utils::html::escape_html(md);
222    escaped.replace('\n', "<br>")
223}