1#![allow(clippy::unused_unit, deprecated)]
8
9#[cfg(feature = "server")]
10use dioxus::prelude::*;
11
12#[cfg(feature = "server")]
13use pinyin::ToPinyin;
14
15#[cfg(feature = "server")]
16pub fn slugify(title: &str) -> String {
28 let mut out = String::with_capacity(title.len());
29 let mut prev_dash = true;
32 let mut len = 0usize;
33
34 fn push_char(out: &mut String, len: &mut usize, c: char) -> bool {
36 if *len >= 100 {
37 return false;
38 }
39 out.push(c);
40 *len += 1;
41 true
42 }
43
44 for c in title.chars() {
45 if let Some(py) = c.to_pinyin() {
47 for pc in py.plain().chars() {
49 if !push_char(&mut out, &mut len, pc) {
50 break;
51 }
52 }
53 if !push_char(&mut out, &mut len, '-') {
56 break;
57 }
58 prev_dash = true;
59 } else {
60 let lc = c.to_ascii_lowercase();
62 if lc.is_ascii_alphanumeric() {
63 if !push_char(&mut out, &mut len, lc) {
64 break;
65 }
66 prev_dash = false;
67 } else if lc == '_' {
68 if !push_char(&mut out, &mut len, '_') {
70 break;
71 }
72 prev_dash = false;
73 } else {
74 if !prev_dash {
77 if !push_char(&mut out, &mut len, '-') {
78 break;
79 }
80 prev_dash = true;
81 }
82 }
83 }
84 }
85
86 while out.ends_with('-') {
88 out.pop();
89 }
90
91 if out.is_empty() {
92 return chrono::Utc::now().timestamp().to_string();
93 }
94
95 out
96}
97
98#[cfg(feature = "server")]
99pub fn is_valid_slug(slug: &str) -> bool {
101 if slug.is_empty() || slug.len() > 200 {
102 return false;
103 }
104 slug.chars()
105 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
106}
107
108#[cfg(feature = "server")]
109pub async fn ensure_unique_slug(
117 tx: &deadpool_postgres::Transaction<'_>,
118 base: &str,
119 exclude_id: Option<i32>,
120) -> Result<String, ServerFnError> {
121 use crate::api::error::AppError;
122
123 let mut candidate = base.to_string();
124 let mut suffix = 2;
125
126 loop {
127 let exists = if let Some(exclude) = exclude_id {
129 tx.query_opt(
130 "SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL AND id != $2",
131 &[&candidate, &exclude],
132 )
133 .await
134 .map_err(AppError::query)?
135 .is_some()
136 } else {
137 tx.query_opt(
138 "SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL",
139 &[&candidate],
140 )
141 .await
142 .map_err(AppError::query)?
143 .is_some()
144 };
145
146 if !exists {
147 return Ok(candidate);
148 }
149
150 candidate = format!("{}-{}", base, suffix);
151 suffix += 1;
152
153 if candidate.len() > 200 {
155 return Err(AppError::Internal("无法生成唯一 slug").into());
156 }
157 }
158}
159
160#[cfg(all(test, feature = "server"))]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn slugify_ascii_title() {
166 assert_eq!(slugify("Hello World"), "hello-world");
167 }
168
169 #[test]
170 fn slugify_special_characters() {
171 assert_eq!(slugify("Hello, World! (2024)"), "hello-world-2024");
172 }
173
174 #[test]
175 fn slugify_chinese_characters() {
176 assert_eq!(slugify("你好世界"), "ni-hao-shi-jie");
178 assert_eq!(slugify("你好世界 hello"), "ni-hao-shi-jie-hello");
179 }
180
181 #[test]
182 fn slugify_mixed_chinese_ascii() {
183 assert_eq!(slugify("Rust 入门指南"), "rust-ru-men-zhi-nan");
185 }
186
187 #[test]
188 fn slugify_chinese_with_punctuation() {
189 assert_eq!(slugify("你好,世界!"), "ni-hao-shi-jie");
191 }
192
193 #[test]
194 fn slugify_collapses_dashes() {
195 assert_eq!(slugify("a---b"), "a-b");
196 }
197
198 #[test]
199 fn slugify_empty_returns_timestamp() {
200 let slug = slugify("");
201 let _: i64 = slug.parse().expect("should be a valid timestamp");
202 }
203
204 #[test]
205 fn slugify_truncates_at_100_chars() {
206 let long_title = "a".repeat(200);
207 assert!(slugify(&long_title).len() <= 100);
208 }
209
210 #[test]
211 fn slugify_preserves_underscores() {
212 assert_eq!(slugify("hello_world"), "hello_world");
213 }
214
215 #[test]
216 fn is_valid_slug_normal() {
217 assert!(is_valid_slug("hello-world_123"));
218 }
219
220 #[test]
221 fn is_valid_slug_rejects_empty() {
222 assert!(!is_valid_slug(""));
223 }
224
225 #[test]
226 fn is_valid_slug_rejects_too_long() {
227 let long_slug = "a".repeat(201);
228 assert!(!is_valid_slug(&long_slug));
229 }
230
231 #[test]
232 fn is_valid_slug_accepts_max_length() {
233 let slug = "a".repeat(200);
234 assert!(is_valid_slug(&slug));
235 }
236
237 #[test]
238 fn is_valid_slug_rejects_special_chars() {
239 assert!(!is_valid_slug("hello world"));
240 assert!(!is_valid_slug("hello.world"));
241 assert!(!is_valid_slug("hello!world"));
242 }
243
244 #[test]
245 fn is_valid_slug_accepts_chinese() {
246 assert!(is_valid_slug("你好-world"));
247 }
248
249 #[test]
250 fn slugify_all_special_characters_returns_timestamp() {
251 let slug = slugify("!@#$%^&*()+=[]{}|\\;:'\",.<>/?`~");
252 let _: i64 = slug.parse().expect("should be a valid timestamp");
253 }
254
255 #[test]
256 fn slugify_only_whitespace_returns_timestamp() {
257 let slug = slugify(" \t\n ");
258 let _: i64 = slug.parse().expect("should be a valid timestamp");
259 }
260
261 #[test]
262 fn slugify_leading_and_trailing_dashes() {
263 assert_eq!(slugify("-hello-world-"), "hello-world");
264 assert_eq!(slugify("---hello---world---"), "hello-world");
265 }
266
267 #[test]
268 fn is_valid_slug_mixed_chinese_and_digits() {
269 assert!(is_valid_slug("你好123"));
270 assert!(is_valid_slug("123你好456"));
271 }
272
273 #[test]
274 fn is_valid_slug_exact_200_char_boundary() {
275 let slug = "a".repeat(200);
276 assert!(is_valid_slug(&slug));
277 let slug = "a".repeat(201);
278 assert!(!is_valid_slug(&slug));
279 }
280}