1use crate::models::{
2 Content, ContentStatus, ContentSummary, ContentType, ContentWithTags, CreateContent, Tag,
3 UpdateContent, UserSummary,
4};
5use crate::services::markdown::MarkdownRenderer;
6use crate::services::slug::{generate_slug, validate_slug};
7use crate::Database;
8use anyhow::{bail, Result};
9use once_cell::sync::Lazy;
10use regex::Regex;
11
12static SNIPPET_REGEX: Lazy<Regex> = Lazy::new(|| {
13 Regex::new(r#"\[snippet\s+slug="([^"]+)"\]"#).expect("Invalid snippet regex pattern")
14});
15
16const MAX_TITLE_LENGTH: usize = 500;
17const MAX_BODY_LENGTH: usize = 500_000;
18const MAX_EXCERPT_LENGTH: usize = 2000;
19
20fn validate_content_input(title: &str, body: &str, excerpt: Option<&str>) -> Result<()> {
21 if title.is_empty() {
22 bail!("Title cannot be empty");
23 }
24 if title.len() > MAX_TITLE_LENGTH {
25 bail!("Title must be {} characters or less", MAX_TITLE_LENGTH);
26 }
27 if body.len() > MAX_BODY_LENGTH {
28 bail!(
29 "Content body must be {} characters or less",
30 MAX_BODY_LENGTH
31 );
32 }
33 if let Some(exc) = excerpt {
34 if exc.len() > MAX_EXCERPT_LENGTH {
35 bail!("Excerpt must be {} characters or less", MAX_EXCERPT_LENGTH);
36 }
37 }
38 Ok(())
39}
40
41pub fn create_content(
42 db: &Database,
43 input: CreateContent,
44 author_id: Option<i64>,
45 excerpt_length: usize,
46) -> Result<i64> {
47 validate_content_input(&input.title, &input.body_markdown, input.excerpt.as_deref())?;
48
49 let renderer = MarkdownRenderer::new();
50 let slug = input.slug.unwrap_or_else(|| generate_slug(&input.title));
51
52 if !validate_slug(&slug) {
53 bail!(
54 "Invalid slug: must be 1-200 characters, lowercase letters, numbers, and hyphens only"
55 );
56 }
57
58 let mut conn = db.get()?;
59 let tx = conn.transaction()?;
60
61 let existing: Option<i64> = tx
63 .query_row("SELECT id FROM content WHERE slug = ?", [&slug], |row| {
64 row.get(0)
65 })
66 .ok();
67 if existing.is_some() {
68 bail!("A post or page with the slug '{}' already exists", slug);
69 }
70
71 let processed_markdown = process_snippet_shortcodes(db, &input.body_markdown);
73 let body_html = renderer.render(&processed_markdown);
74 let excerpt = input.excerpt.or_else(|| {
75 if input.body_markdown.is_empty() {
76 None
77 } else {
78 Some(renderer.generate_excerpt(&input.body_markdown, excerpt_length))
79 }
80 });
81
82 let published_at = if input.status == ContentStatus::Published {
83 Some(chrono::Utc::now().to_rfc3339())
84 } else {
85 None
86 };
87
88 let scheduled_at = if input.status == ContentStatus::Scheduled {
89 match &input.scheduled_at {
90 Some(dt) if !dt.is_empty() => {
91 if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(dt) {
93 if parsed <= chrono::Utc::now() {
94 bail!("Scheduled time must be in the future");
95 }
96 Some(dt.clone())
97 } else if let Ok(parsed) =
98 chrono::NaiveDateTime::parse_from_str(dt, "%Y-%m-%dT%H:%M")
99 {
100 let utc_time = parsed.and_utc();
102 if utc_time <= chrono::Utc::now() {
103 bail!("Scheduled time must be in the future");
104 }
105 Some(utc_time.to_rfc3339())
106 } else {
107 bail!("Invalid scheduled_at timestamp format. Use ISO 8601 format (e.g., 2024-01-15T10:30:00Z)");
108 }
109 }
110 _ => bail!("Scheduled status requires a scheduled_at timestamp"),
111 }
112 } else {
113 None
114 };
115
116 let reading_time = renderer.calculate_reading_time(&input.body_markdown);
118 let mut metadata = input.metadata.unwrap_or(serde_json::json!({}));
119 metadata["reading_time_minutes"] = serde_json::json!(reading_time);
120
121 tx.execute(
122 r#"
123 INSERT INTO content (slug, title, content_type, body_markdown, body_html, excerpt, featured_image, status, scheduled_at, published_at, author_id, metadata)
124 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
125 "#,
126 (
127 &slug,
128 &input.title,
129 input.content_type.to_string(),
130 &input.body_markdown,
131 &body_html,
132 &excerpt,
133 &input.featured_image,
134 input.status.to_string(),
135 &scheduled_at,
136 &published_at,
137 author_id,
138 serde_json::to_string(&metadata)?,
139 ),
140 )?;
141
142 let content_id = tx.last_insert_rowid();
143
144 for tag_name in input.tags {
145 let tag_slug = generate_slug(&tag_name);
146 tx.execute(
147 "INSERT OR IGNORE INTO tags (name, slug) VALUES (?, ?)",
148 (&tag_name, &tag_slug),
149 )?;
150 tx.execute(
151 "INSERT OR IGNORE INTO content_tags (content_id, tag_id) SELECT ?, id FROM tags WHERE slug = ?",
152 (content_id, &tag_slug),
153 )?;
154 }
155
156 tx.commit()?;
157 Ok(content_id)
158}
159
160pub fn update_content(
161 db: &Database,
162 id: i64,
163 input: UpdateContent,
164 _excerpt_length: usize, user_id: Option<i64>,
166 version_retention: usize,
167) -> Result<()> {
168 if let Err(e) = super::versions::create_version(db, id, user_id) {
170 tracing::warn!("Failed to create version snapshot: {}", e);
171 }
173
174 let renderer = MarkdownRenderer::new();
175 let mut conn = db.get()?;
176
177 let current: Content = conn.query_row(
178 "SELECT id, slug, title, content_type, body_markdown, body_html, excerpt, featured_image, status, scheduled_at, published_at, author_id, metadata, created_at, updated_at FROM content WHERE id = ?",
179 [id],
180 row_to_content,
181 )?;
182
183 let title = input.title.unwrap_or(current.title);
184 let original_slug = current.slug.clone();
185 let slug = input.slug.unwrap_or(current.slug);
186 let body_markdown = input.body_markdown.unwrap_or(current.body_markdown);
187
188 validate_content_input(&title, &body_markdown, input.excerpt.as_deref())?;
189
190 if !validate_slug(&slug) {
191 bail!(
192 "Invalid slug: must be 1-200 characters, lowercase letters, numbers, and hyphens only"
193 );
194 }
195
196 if slug != original_slug {
198 let existing: Option<i64> = conn
199 .query_row("SELECT id FROM content WHERE slug = ?", [&slug], |row| {
200 row.get(0)
201 })
202 .ok();
203 if existing.is_some() {
204 bail!("A post or page with the slug '{}' already exists", slug);
205 }
206 }
207
208 let processed_markdown = process_snippet_shortcodes(db, &body_markdown);
210 let body_html = renderer.render(&processed_markdown);
211 let excerpt = match input.excerpt {
213 Some(new_excerpt) => Some(new_excerpt),
214 None => current.excerpt, };
216 let featured_image = input.featured_image.or(current.featured_image);
217 let status = input.status.unwrap_or(current.status);
218
219 let reading_time = renderer.calculate_reading_time(&body_markdown);
221 let mut metadata = current.metadata;
222 if let Some(input_meta) = input.metadata {
223 if let (Some(base), Some(updates)) = (metadata.as_object_mut(), input_meta.as_object()) {
224 for (key, value) in updates {
225 base.insert(key.clone(), value.clone());
226 }
227 }
228 }
229 metadata["reading_time_minutes"] = serde_json::json!(reading_time);
230
231 let published_at = if status == ContentStatus::Published && current.published_at.is_none() {
232 Some(chrono::Utc::now().to_rfc3339())
233 } else {
234 current.published_at
235 };
236
237 let scheduled_at = if status == ContentStatus::Scheduled {
238 match input.scheduled_at.or(current.scheduled_at) {
239 Some(dt) if !dt.is_empty() => {
240 if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(&dt) {
242 if parsed <= chrono::Utc::now() {
243 bail!("Scheduled time must be in the future");
244 }
245 Some(dt)
246 } else if let Ok(parsed) =
247 chrono::NaiveDateTime::parse_from_str(&dt, "%Y-%m-%dT%H:%M")
248 {
249 let utc_time = parsed.and_utc();
251 if utc_time <= chrono::Utc::now() {
252 bail!("Scheduled time must be in the future");
253 }
254 Some(utc_time.to_rfc3339())
255 } else {
256 bail!("Invalid scheduled_at timestamp format. Use ISO 8601 format (e.g., 2024-01-15T10:30:00Z)");
257 }
258 }
259 _ => bail!("Scheduled status requires a scheduled_at timestamp"),
260 }
261 } else {
262 None
263 };
264
265 let tx = conn.transaction()?;
266
267 tx.execute(
268 r#"
269 UPDATE content SET slug = ?, title = ?, body_markdown = ?, body_html = ?, excerpt = ?, featured_image = ?, status = ?, scheduled_at = ?, published_at = ?, metadata = ?
270 WHERE id = ?
271 "#,
272 (
273 &slug,
274 &title,
275 &body_markdown,
276 &body_html,
277 &excerpt,
278 &featured_image,
279 status.to_string(),
280 &scheduled_at,
281 &published_at,
282 serde_json::to_string(&metadata)?,
283 id,
284 ),
285 )?;
286
287 if let Some(tags) = input.tags {
288 tx.execute("DELETE FROM content_tags WHERE content_id = ?", [id])?;
289 for tag_name in tags {
290 let tag_slug = generate_slug(&tag_name);
291 tx.execute(
292 "INSERT OR IGNORE INTO tags (name, slug) VALUES (?, ?)",
293 (&tag_name, &tag_slug),
294 )?;
295 tx.execute(
296 "INSERT OR IGNORE INTO content_tags (content_id, tag_id) SELECT ?, id FROM tags WHERE slug = ?",
297 (id, &tag_slug),
298 )?;
299 }
300 }
301
302 tx.commit()?;
303
304 if version_retention > 0 {
306 if let Err(e) = super::versions::cleanup_old_versions(db, id, version_retention) {
307 tracing::warn!("Failed to cleanup old versions: {}", e);
308 }
309 }
310
311 Ok(())
312}
313
314pub fn delete_content(db: &Database, id: i64) -> Result<()> {
315 let conn = db.get()?;
316 conn.execute("DELETE FROM content WHERE id = ?", [id])?;
317 let _ = crate::services::tags::cleanup_orphaned_tags(db);
318 Ok(())
319}
320
321pub fn get_content_by_id(db: &Database, id: i64) -> Result<Option<ContentWithTags>> {
322 let conn = db.get()?;
323 let content: Option<Content> = conn
324 .query_row(
325 "SELECT id, slug, title, content_type, body_markdown, body_html, excerpt, featured_image, status, scheduled_at, published_at, author_id, metadata, created_at, updated_at FROM content WHERE id = ?",
326 [id],
327 row_to_content,
328 )
329 .ok();
330
331 match content {
332 Some(c) => Ok(Some(enrich_content(db, c)?)),
333 None => Ok(None),
334 }
335}
336
337pub fn get_content_by_slug(db: &Database, slug: &str) -> Result<Option<ContentWithTags>> {
338 let conn = db.get()?;
339 let content: Option<Content> = conn
340 .query_row(
341 "SELECT id, slug, title, content_type, body_markdown, body_html, excerpt, featured_image, status, scheduled_at, published_at, author_id, metadata, created_at, updated_at FROM content WHERE slug = ?",
342 [slug],
343 row_to_content,
344 )
345 .ok();
346
347 match content {
348 Some(c) => Ok(Some(enrich_content(db, c)?)),
349 None => Ok(None),
350 }
351}
352
353pub fn list_content(
354 db: &Database,
355 content_type: Option<ContentType>,
356 status: Option<ContentStatus>,
357 limit: usize,
358 offset: usize,
359) -> Result<Vec<ContentSummary>> {
360 let conn = db.get()?;
361
362 let mut sql = String::from(
363 "SELECT id, slug, title, content_type, excerpt, status, scheduled_at, published_at, created_at FROM content WHERE 1=1",
364 );
365 let mut params: Vec<String> = Vec::new();
366
367 if let Some(ct) = content_type {
368 sql.push_str(" AND content_type = ?");
369 params.push(ct.to_string());
370 }
371 if let Some(s) = status {
372 sql.push_str(" AND status = ?");
373 params.push(s.to_string());
374 }
375
376 sql.push_str(" ORDER BY created_at DESC LIMIT ? OFFSET ?");
377
378 let mut stmt = conn.prepare(&sql)?;
379
380 let param_refs: Vec<&dyn rusqlite::ToSql> = params
381 .iter()
382 .map(|s| s as &dyn rusqlite::ToSql)
383 .chain(std::iter::once(&limit as &dyn rusqlite::ToSql))
384 .chain(std::iter::once(&offset as &dyn rusqlite::ToSql))
385 .collect();
386
387 let content = stmt
388 .query_map(param_refs.as_slice(), |row| {
389 Ok(ContentSummary {
390 id: row.get(0)?,
391 slug: row.get(1)?,
392 title: row.get(2)?,
393 content_type: row
394 .get::<_, String>(3)?
395 .parse()
396 .unwrap_or(ContentType::Post),
397 excerpt: row.get(4)?,
398 status: row
399 .get::<_, String>(5)?
400 .parse()
401 .unwrap_or(ContentStatus::Draft),
402 scheduled_at: row.get(6)?,
403 published_at: row.get(7)?,
404 created_at: row.get(8)?,
405 })
406 })?
407 .collect::<Result<Vec<_>, _>>()?;
408
409 Ok(content)
410}
411
412pub fn list_published_content(
413 db: &Database,
414 content_type: ContentType,
415 limit: usize,
416 offset: usize,
417) -> Result<Vec<ContentWithTags>> {
418 let conn = db.get()?;
419 let mut stmt = conn.prepare(
420 "SELECT id, slug, title, content_type, body_markdown, body_html, excerpt, featured_image, status, scheduled_at, published_at, author_id, metadata, created_at, updated_at
421 FROM content WHERE content_type = ? AND status = 'published' ORDER BY published_at DESC LIMIT ? OFFSET ?",
422 )?;
423
424 let content = stmt
425 .query_map((content_type.to_string(), limit, offset), row_to_content)?
426 .collect::<Result<Vec<_>, _>>()?;
427
428 enrich_content_batch(db, content)
429}
430
431pub fn count_content(
432 db: &Database,
433 content_type: Option<ContentType>,
434 status: Option<ContentStatus>,
435) -> Result<i64> {
436 let conn = db.get()?;
437 let mut sql = String::from("SELECT COUNT(*) FROM content WHERE 1=1");
438 let mut params: Vec<String> = Vec::new();
439
440 if let Some(ct) = content_type {
441 sql.push_str(" AND content_type = ?");
442 params.push(ct.to_string());
443 }
444 if let Some(s) = status {
445 sql.push_str(" AND status = ?");
446 params.push(s.to_string());
447 }
448
449 let param_refs: Vec<&dyn rusqlite::ToSql> =
450 params.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
451 let count: i64 = conn.query_row(&sql, param_refs.as_slice(), |row| row.get(0))?;
452 Ok(count)
453}
454
455pub fn ensure_metadata_defaults(mut metadata: serde_json::Value) -> serde_json::Value {
456 if metadata.get("use_custom_code").is_none() {
459 metadata["use_custom_code"] = serde_json::json!("none");
460 } else if let Some(val) = metadata.get("use_custom_code") {
461 if val.as_str() == Some("") {
463 metadata["use_custom_code"] = serde_json::json!("none");
464 }
465 }
466 if metadata.get("custom_html").is_none() {
467 metadata["custom_html"] = serde_json::json!("");
468 }
469 if metadata.get("custom_css").is_none() {
470 metadata["custom_css"] = serde_json::json!("");
471 }
472 if metadata.get("custom_js").is_none() {
473 metadata["custom_js"] = serde_json::json!("");
474 }
475 metadata
476}
477
478fn row_to_content(row: &rusqlite::Row) -> rusqlite::Result<Content> {
479 let raw_metadata: serde_json::Value =
480 serde_json::from_str(&row.get::<_, String>(12)?).unwrap_or(serde_json::json!({}));
481 let metadata = ensure_metadata_defaults(raw_metadata);
482
483 Ok(Content {
484 id: row.get(0)?,
485 slug: row.get(1)?,
486 title: row.get(2)?,
487 content_type: row
488 .get::<_, String>(3)?
489 .parse()
490 .unwrap_or(ContentType::Post),
491 body_markdown: row.get(4)?,
492 body_html: row.get(5)?,
493 excerpt: row.get(6)?,
494 featured_image: row.get(7)?,
495 status: row
496 .get::<_, String>(8)?
497 .parse()
498 .unwrap_or(ContentStatus::Draft),
499 scheduled_at: row.get(9)?,
500 published_at: row.get(10)?,
501 author_id: row.get(11)?,
502 metadata,
503 created_at: row.get(13)?,
504 updated_at: row.get(14)?,
505 })
506}
507
508fn enrich_content(db: &Database, content: Content) -> Result<ContentWithTags> {
509 let conn = db.get()?;
510
511 let mut tag_stmt = conn.prepare(
512 "SELECT t.id, t.name, t.slug, t.created_at FROM tags t JOIN content_tags ct ON t.id = ct.tag_id WHERE ct.content_id = ?",
513 )?;
514 let tags: Vec<Tag> = tag_stmt
515 .query_map([content.id], |row| {
516 Ok(Tag {
517 id: row.get(0)?,
518 name: row.get(1)?,
519 slug: row.get(2)?,
520 created_at: row.get(3)?,
521 })
522 })?
523 .collect::<Result<Vec<_>, _>>()?;
524
525 let author = content.author_id.and_then(|aid| {
526 conn.query_row(
527 "SELECT id, username FROM users WHERE id = ?",
528 [aid],
529 |row| {
530 Ok(UserSummary {
531 id: row.get(0)?,
532 username: row.get(1)?,
533 })
534 },
535 )
536 .ok()
537 });
538
539 Ok(ContentWithTags {
540 content,
541 tags,
542 author,
543 })
544}
545
546fn enrich_content_batch(db: &Database, contents: Vec<Content>) -> Result<Vec<ContentWithTags>> {
549 if contents.is_empty() {
550 return Ok(vec![]);
551 }
552
553 let conn = db.get()?;
554
555 let content_ids: Vec<i64> = contents.iter().map(|c| c.id).collect();
557 let author_ids: Vec<i64> = contents
558 .iter()
559 .filter_map(|c| c.author_id)
560 .collect::<std::collections::HashSet<_>>()
561 .into_iter()
562 .collect();
563
564 let placeholders: String = content_ids
566 .iter()
567 .map(|_| "?")
568 .collect::<Vec<_>>()
569 .join(",");
570 let tag_sql = format!(
571 "SELECT ct.content_id, t.id, t.name, t.slug, t.created_at
572 FROM tags t
573 JOIN content_tags ct ON t.id = ct.tag_id
574 WHERE ct.content_id IN ({})",
575 placeholders
576 );
577
578 let mut tag_stmt = conn.prepare(&tag_sql)?;
579 let params: Vec<&dyn rusqlite::ToSql> = content_ids
580 .iter()
581 .map(|id| id as &dyn rusqlite::ToSql)
582 .collect();
583
584 let mut tags_by_content: std::collections::HashMap<i64, Vec<Tag>> =
585 std::collections::HashMap::new();
586
587 let tag_rows = tag_stmt.query_map(params.as_slice(), |row| {
588 Ok((
589 row.get::<_, i64>(0)?,
590 Tag {
591 id: row.get(1)?,
592 name: row.get(2)?,
593 slug: row.get(3)?,
594 created_at: row.get(4)?,
595 },
596 ))
597 })?;
598
599 for row in tag_rows {
600 let (content_id, tag) = row?;
601 tags_by_content.entry(content_id).or_default().push(tag);
602 }
603
604 let mut authors_by_id: std::collections::HashMap<i64, UserSummary> =
606 std::collections::HashMap::new();
607
608 if !author_ids.is_empty() {
609 let author_placeholders: String =
610 author_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
611 let author_sql = format!(
612 "SELECT id, username FROM users WHERE id IN ({})",
613 author_placeholders
614 );
615
616 let mut author_stmt = conn.prepare(&author_sql)?;
617 let author_params: Vec<&dyn rusqlite::ToSql> = author_ids
618 .iter()
619 .map(|id| id as &dyn rusqlite::ToSql)
620 .collect();
621
622 let author_rows = author_stmt.query_map(author_params.as_slice(), |row| {
623 Ok(UserSummary {
624 id: row.get(0)?,
625 username: row.get(1)?,
626 })
627 })?;
628
629 for row in author_rows {
630 let author = row?;
631 authors_by_id.insert(author.id, author);
632 }
633 }
634
635 let result: Vec<ContentWithTags> = contents
637 .into_iter()
638 .map(|content| {
639 let tags = tags_by_content.remove(&content.id).unwrap_or_default();
640 let author = content
641 .author_id
642 .and_then(|aid| authors_by_id.get(&aid).cloned());
643 ContentWithTags {
644 content,
645 tags,
646 author,
647 }
648 })
649 .collect();
650
651 Ok(result)
652}
653
654pub fn publish_scheduled(db: &Database) -> Result<usize> {
655 let mut conn = db.get()?;
656 let now = chrono::Utc::now().to_rfc3339();
657
658 let tx = conn.transaction()?;
659
660 let mut stmt = tx.prepare(
661 "SELECT id FROM content WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?"
662 )?;
663 let ids: Vec<i64> = stmt
664 .query_map([&now], |row| row.get(0))?
665 .collect::<Result<Vec<_>, _>>()?;
666 drop(stmt);
667
668 if ids.is_empty() {
669 return Ok(0);
670 }
671
672 for id in &ids {
673 tx.execute(
674 "UPDATE content SET status = 'published', published_at = ?, scheduled_at = NULL WHERE id = ?",
675 (&now, id),
676 )?;
677 tracing::info!("Auto-published scheduled content id={}", id);
678 }
679
680 tx.commit()?;
681 Ok(ids.len())
682}
683
684pub fn rerender_all_content(db: &Database) -> Result<usize> {
687 let renderer = super::markdown::MarkdownRenderer::new();
688 let mut conn = db.get()?;
689
690 let items: Vec<(i64, String)> = {
692 let mut stmt = conn.prepare("SELECT id, body_markdown FROM content")?;
693 let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
694 rows.collect::<Result<Vec<_>, _>>()?
695 };
696
697 let count = items.len();
698
699 let tx = conn.transaction()?;
700 for (id, markdown) in items {
701 let processed = process_snippet_shortcodes(db, &markdown);
702 let html = renderer.render(&processed);
703 tx.execute("UPDATE content SET body_html = ? WHERE id = ?", (&html, id))?;
704 }
705 tx.commit()?;
706
707 Ok(count)
708}
709
710pub fn process_snippet_shortcodes(db: &Database, markdown: &str) -> String {
714 SNIPPET_REGEX
715 .replace_all(markdown, |caps: ®ex::Captures| {
716 let slug = &caps[1];
717 match get_content_by_slug(db, slug) {
718 Ok(Some(content_with_tags))
719 if content_with_tags.content.content_type == ContentType::Snippet =>
720 {
721 content_with_tags.content.body_markdown
723 }
724 _ => caps[0].to_string(), }
726 })
727 .to_string()
728}