1#[cfg(feature = "server")]
7use crate::api::error::AppError;
8#[cfg(feature = "server")]
9use crate::models::post::{Post, PostListItem, PostStatus};
10#[cfg(feature = "server")]
11use crate::utils::text::{auto_summary, count_words, reading_time};
12
13#[cfg(feature = "server")]
15pub(super) use crate::api::auth::get_current_admin_user;
16
17#[cfg(feature = "server")]
22pub(super) fn row_to_post_list_item(row: &tokio_postgres::Row) -> PostListItem {
23 let id: i32 = row.get("id");
24 let status_str: String = row.get("status");
25 let status = PostStatus::from_str(&status_str).unwrap_or(PostStatus::Draft);
26
27 let mut tags: Vec<String> = row.try_get::<_, Vec<String>>("tags").unwrap_or_default();
29 tags.retain(|t| !t.is_empty());
30
31 let word_count: i32 = row.get("word_count");
32 let reading_time: i32 = row.get("reading_time");
33
34 PostListItem {
35 id,
36 author_id: row.get("author_id"),
37 title: row.get("title"),
38 slug: row.get("slug"),
39 summary: row.get("summary"),
40 status,
41 published_at: row.get("published_at"),
42 created_at: row.get("created_at"),
43 updated_at: row.get("updated_at"),
44 deleted_at: row.try_get("deleted_at").ok(),
45 tags,
46 cover_image: row.get("cover_image"),
47 reading_time: reading_time.max(1) as u32,
48 word_count: word_count.max(0) as u32,
49 }
50}
51
52#[cfg(feature = "server")]
57pub(super) async fn row_to_post_full(row: &tokio_postgres::Row) -> Post {
58 let id: i32 = row.get("id");
59 let role_str: String = row.get("status");
60 let status = PostStatus::from_str(&role_str).unwrap_or(PostStatus::Draft);
61
62 let mut tags: Vec<String> = row.try_get::<_, Vec<String>>("tags").unwrap_or_default();
64 tags.retain(|t| !t.is_empty());
65
66 let prev_post = if let Ok(prev_title) = row.try_get::<_, String>("prev_title") {
68 if let Ok(prev_slug) = row.try_get::<_, String>("prev_slug") {
69 Some(crate::models::post::PostNav {
70 title: prev_title,
71 slug: prev_slug,
72 })
73 } else {
74 None
75 }
76 } else {
77 None
78 };
79
80 let next_post = if let Ok(next_title) = row.try_get::<_, String>("next_title") {
82 if let Ok(next_slug) = row.try_get::<_, String>("next_slug") {
83 Some(crate::models::post::PostNav {
84 title: next_title,
85 slug: next_slug,
86 })
87 } else {
88 None
89 }
90 } else {
91 None
92 };
93
94 let content_md: String = row.get("content_md");
96 let stored_word_count: i32 = row.get("word_count");
97 let stored_reading_time: i32 = row.get("reading_time");
98 let (word_count, reading_time) = if stored_word_count > 0 && stored_reading_time > 0 {
99 (stored_word_count as u32, stored_reading_time as u32)
100 } else {
101 let wc = count_words(&content_md);
102 (wc, reading_time(wc))
103 };
104
105 let content_html: Option<String> = row.get("content_html");
106 let toc_html_row: Option<String> = row.get("toc_html");
107
108 let (content_html, toc_html) = if let Some(html) = content_html {
110 (html, toc_html_row)
111 } else {
112 let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
113 (
114 rendered.html,
115 if rendered.toc_html.is_empty() {
116 None
117 } else {
118 Some(rendered.toc_html)
119 },
120 )
121 };
122
123 Post {
124 id,
125 author_id: row.get("author_id"),
126 title: row.get("title"),
127 slug: row.get("slug"),
128 summary: row.get("summary"),
129 content_md,
130 content_html: Some(content_html),
131 status,
132 published_at: row.get("published_at"),
133 created_at: row.get("created_at"),
134 updated_at: row.get("updated_at"),
135 deleted_at: row.try_get("deleted_at").ok(),
136 tags,
137 cover_image: row.get("cover_image"),
138 reading_time,
139 word_count,
140 toc_html,
141 prev_post,
142 next_post,
143 }
144}
145
146#[cfg(feature = "server")]
151pub(crate) async fn sync_tags(
152 tx: &deadpool_postgres::Transaction<'_>,
153 post_id: i32,
154 tags: &[String],
155) -> Result<(), AppError> {
156 for tag_name in tags {
157 let tag_id: i32 = {
158 let row = tx
160 .query_opt(
161 "INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id",
162 &[&tag_name.as_str()],
163 )
164 .await
165 .map_err(AppError::tx)?;
166
167 match row {
168 Some(r) => r.get(0),
169 None => {
170 let row = tx
172 .query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name.as_str()])
173 .await
174 .map_err(AppError::query)?;
175 row.map(|r| r.get(0))
176 .ok_or(AppError::NotFound("标签不存在"))?
177 }
178 }
179 };
180
181 tx.execute(
182 "INSERT INTO post_tags (post_id, tag_id) VALUES ($1, $2)",
183 &[&post_id, &tag_id],
184 )
185 .await
186 .map_err(AppError::tx)?;
187 }
188
189 Ok(())
190}
191
192#[cfg(feature = "server")]
194pub(crate) fn clean_tags(tags: &[String]) -> Vec<String> {
195 let mut seen = std::collections::HashSet::new();
196 tags.iter()
197 .map(|t| t.trim().to_string())
198 .filter(|t| !t.is_empty())
199 .filter(|t| seen.insert(t.to_lowercase()))
200 .collect()
201}
202#[cfg(feature = "server")]
203pub(crate) async fn fetch_post_tags(
205 client: &impl deadpool_postgres::GenericClient,
206 post_id: i32,
207) -> Result<Vec<String>, AppError> {
208 let rows = client
209 .query(
210 "SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = $1",
211 &[&post_id],
212 )
213 .await
214 .map_err(AppError::query)?;
215 Ok(rows.iter().map(|r| r.get(0)).collect())
216}
217#[cfg(feature = "server")]
218pub(crate) async fn fetch_post_tags_batch(
220 client: &impl deadpool_postgres::GenericClient,
221 post_ids: &[i32],
222) -> Result<Vec<String>, AppError> {
223 let rows = client
224 .query(
225 "SELECT DISTINCT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ANY($1)",
226 &[&post_ids],
227 )
228 .await
229 .map_err(AppError::query)?;
230 Ok(rows.iter().map(|r| r.get(0)).collect())
231}
232
233#[cfg(feature = "server")]
237static ASSET_PATH_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
238 regex::Regex::new(r#"/uploads/(\d{4}/\d{2}/\d{2}/[^\"'\s?#\)]+)"#)
239 .expect("ASSET_PATH_RE 正则模式应在编译期通过校验")
240});
241
242#[cfg(feature = "server")]
247pub(crate) fn extract_asset_paths(content_html: &str, cover_image: Option<&str>) -> Vec<String> {
248 let mut seen = std::collections::HashSet::new();
249 let mut paths: Vec<String> = ASSET_PATH_RE
250 .captures_iter(content_html)
251 .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
252 .filter(|p| seen.insert(p.clone()))
253 .collect();
254 if let Some(cover) = cover_image {
255 if let Some(rel) = cover
256 .strip_prefix("/uploads/")
257 .map(|p| p.split('?').next().unwrap_or(p))
258 {
259 if seen.insert(rel.to_string()) {
260 paths.push(rel.to_string());
261 }
262 }
263 }
264 paths
265}
266
267#[cfg(feature = "server")]
273pub(crate) async fn sync_asset_refs(
274 tx: &deadpool_postgres::Transaction<'_>,
275 post_id: i32,
276 content_html: &str,
277 cover_image: Option<&str>,
278) -> Result<(), AppError> {
279 tx.execute("DELETE FROM asset_refs WHERE post_id = $1", &[&post_id])
280 .await
281 .map_err(AppError::tx)?;
282
283 let paths = extract_asset_paths(content_html, cover_image);
284 if !paths.is_empty() {
285 tx.execute(
286 "INSERT INTO asset_refs (asset_id, post_id) \
287 SELECT id, $1 FROM assets WHERE path = ANY($2) \
288 ON CONFLICT DO NOTHING",
289 &[&post_id, &paths],
290 )
291 .await
292 .map_err(AppError::tx)?;
293 }
294 Ok(())
295}
296
297#[cfg(feature = "server")]
302pub(crate) struct RenderedFields {
303 pub content_html: String,
304 pub toc_html: Option<String>,
305 pub auto_summary: String,
306 pub status: PostStatus,
307 pub cover_image: Option<String>,
308 pub word_count: i32,
309 pub reading_time: i32,
310}
311
312#[cfg(feature = "server")]
318pub(crate) async fn render_post_fields(
319 content_md: &str,
320 status_str: &str,
321 cover_image: Option<&str>,
322) -> Result<RenderedFields, AppError> {
323 let md_for_render = content_md.to_string();
324 let rendered = tokio::task::spawn_blocking(move || {
325 crate::api::markdown::render_markdown_enhanced(&md_for_render)
326 })
327 .await
328 .map_err(|_| AppError::Internal("Markdown 渲染任务失败"))?;
329
330 let toc_html = if rendered.toc_html.is_empty() {
331 None
332 } else {
333 Some(rendered.toc_html)
334 };
335
336 let auto_summary = auto_summary(content_md);
337 let status = PostStatus::from_str(status_str).unwrap_or(PostStatus::Draft);
338 let cover_image = cover_image
339 .filter(|s| !s.trim().is_empty())
340 .map(str::to_string);
341
342 let word_count = count_words(content_md);
343 let reading_time = reading_time(word_count);
344
345 Ok(RenderedFields {
346 content_html: rendered.html,
347 toc_html,
348 auto_summary,
349 status,
350 cover_image,
351 word_count: word_count as i32,
352 reading_time: reading_time as i32,
353 })
354}
355
356#[cfg(feature = "server")]
361pub(crate) async fn render_post_fields_minimal(
362 content_md: &str,
363) -> Result<(String, Option<String>, i32, i32), AppError> {
364 let md_for_render = content_md.to_string();
365 let rendered = tokio::task::spawn_blocking(move || {
366 crate::api::markdown::render_markdown_enhanced(&md_for_render)
367 })
368 .await
369 .map_err(|_| AppError::Internal("Markdown 渲染任务失败"))?;
370
371 let toc_html = if rendered.toc_html.is_empty() {
372 None
373 } else {
374 Some(rendered.toc_html)
375 };
376
377 let word_count = count_words(content_md);
378 let reading_time = reading_time(word_count);
379
380 Ok((
381 rendered.html,
382 toc_html,
383 word_count as i32,
384 reading_time as i32,
385 ))
386}
387
388#[cfg(all(test, feature = "server"))]
389mod tests {
390 use super::{clean_tags, extract_asset_paths};
391
392 #[test]
393 fn clean_tags_trims_whitespace() {
394 let input = vec![" rust ".to_string(), "\t\nwasm\t".to_string()];
395 assert_eq!(
396 clean_tags(&input),
397 vec!["rust".to_string(), "wasm".to_string()]
398 );
399 }
400
401 #[test]
402 fn clean_tags_filters_empty_strings() {
403 let input = vec![
404 "".to_string(),
405 " ".to_string(),
406 "\t".to_string(),
407 "valid".to_string(),
408 ];
409 assert_eq!(clean_tags(&input), vec!["valid".to_string()]);
410 }
411
412 #[test]
413 fn clean_tags_removes_duplicates_case_insensitive() {
414 let input = vec![
415 "rust".to_string(),
416 " rust ".to_string(),
417 "Rust".to_string(),
418 "wasm".to_string(),
419 ];
420 assert_eq!(
421 clean_tags(&input),
422 vec!["rust".to_string(), "wasm".to_string()]
423 );
424 }
425
426 #[test]
427 fn clean_tags_keeps_already_clean_input() {
428 let input = vec!["rust".to_string(), "wasm".to_string(), "dioxus".to_string()];
429 assert_eq!(
430 clean_tags(&input),
431 vec!["rust".to_string(), "wasm".to_string(), "dioxus".to_string()]
432 );
433 }
434
435 #[test]
438 fn extract_asset_paths_from_blur_img_html() {
439 let html = r#"<span class="blur-img"><img class="blur-img-placeholder" src="/uploads/2026/07/24/a.webp?w=20"><img class="blur-img-full" data-src="/uploads/2026/07/24/a.webp?w=800"></span>"#;
441 assert_eq!(
442 extract_asset_paths(html, None),
443 vec!["2026/07/24/a.webp".to_string()]
444 );
445 }
446
447 #[test]
448 fn extract_asset_paths_multiple_and_cover() {
449 let html = r#"<p><img src="/uploads/2026/07/24/a.webp"></p><p><img src="/uploads/2026/06/01/b.png?w=800"></p><p><img src="https://cdn.example.com/x.webp"></p>"#;
450 let paths = extract_asset_paths(html, Some("/uploads/2026/07/24/cover.jpg?w=600"));
451 assert_eq!(paths.len(), 3);
452 assert!(paths.contains(&"2026/07/24/a.webp".to_string()));
453 assert!(paths.contains(&"2026/06/01/b.png".to_string()));
454 assert!(paths.contains(&"2026/07/24/cover.jpg".to_string()));
455 }
456
457 #[test]
458 fn extract_asset_paths_cover_dedup_and_external() {
459 let html = r#"<img src="/uploads/2026/07/24/a.webp">"#;
461 assert_eq!(
462 extract_asset_paths(html, Some("/uploads/2026/07/24/a.webp")),
463 vec!["2026/07/24/a.webp".to_string()]
464 );
465 assert!(extract_asset_paths(html, Some("https://example.com/c.webp")).len() == 1);
466 assert!(extract_asset_paths(html, None).len() == 1);
467 }
468
469 #[test]
470 fn extract_asset_paths_empty() {
471 assert!(extract_asset_paths("<p>no image</p>", None).is_empty());
472 }
473}