1use crate::Database;
7use anyhow::Result;
8use rusqlite::Connection;
9use serde::Serialize;
10
11#[derive(Debug, Clone, Serialize)]
13pub struct ContentVersion {
14 pub id: i64,
15 pub content_id: i64,
16 pub version_number: i64,
17 pub title: String,
18 pub slug: String,
19 pub body_markdown: String,
20 pub excerpt: Option<String>,
21 pub featured_image: Option<String>,
22 pub metadata: serde_json::Value,
23 pub tags: Vec<String>,
24 pub created_by: Option<i64>,
25 pub created_at: String,
26}
27
28#[derive(Debug, Clone, Serialize)]
30pub struct VersionSummary {
31 pub id: i64,
32 pub version_number: i64,
33 pub title: String,
34 pub created_by_username: Option<String>,
35 pub created_at: String,
36 pub changes_summary: String,
37}
38
39#[derive(Debug, Clone, Serialize)]
41pub struct VersionDiff {
42 pub old_version: ContentVersion,
43 pub new_version: ContentVersion,
44 pub title_changed: bool,
45 pub slug_changed: bool,
46 pub excerpt_changed: bool,
47 pub tags_changed: bool,
48 pub body_diff: Vec<DiffLine>,
49}
50
51#[derive(Debug, Clone, Serialize)]
53pub struct DiffLine {
54 pub line_type: DiffLineType,
55 pub content: String,
56}
57
58#[derive(Debug, Clone, Serialize, PartialEq)]
59#[serde(rename_all = "lowercase")]
60pub enum DiffLineType {
61 Same,
62 Added,
63 Removed,
64}
65
66pub fn create_version(db: &Database, content_id: i64, user_id: Option<i64>) -> Result<i64> {
69 let conn = db.get()?;
70
71 let (title, slug, body_markdown, excerpt, featured_image, metadata): (
73 String,
74 String,
75 String,
76 Option<String>,
77 Option<String>,
78 String,
79 ) = conn.query_row(
80 "SELECT title, slug, body_markdown, excerpt, featured_image, metadata FROM content WHERE id = ?",
81 [content_id],
82 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?)),
83 )?;
84
85 let tags = get_content_tags(&conn, content_id)?;
87 let tags_json = serde_json::to_string(&tags)?;
88
89 let version_number = next_version_number(&conn, content_id)?;
91
92 conn.execute(
94 r#"
95 INSERT INTO content_versions
96 (content_id, version_number, title, slug, body_markdown, excerpt, featured_image, metadata, tags_json, created_by)
97 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
98 "#,
99 rusqlite::params![
100 content_id,
101 version_number,
102 title,
103 slug,
104 body_markdown,
105 excerpt,
106 featured_image,
107 metadata,
108 tags_json,
109 user_id,
110 ],
111 )?;
112
113 let version_id = conn.last_insert_rowid();
114 tracing::debug!(
115 "Created version {} (v{}) for content {}",
116 version_id,
117 version_number,
118 content_id
119 );
120
121 Ok(version_id)
122}
123
124pub fn list_versions(
126 db: &Database,
127 content_id: i64,
128 limit: usize,
129 offset: usize,
130) -> Result<Vec<VersionSummary>> {
131 let conn = db.get()?;
132
133 let mut stmt = conn.prepare(
134 r#"
135 SELECT
136 cv.id,
137 cv.version_number,
138 cv.title,
139 u.username,
140 cv.created_at,
141 cv.body_markdown,
142 cv.slug,
143 cv.excerpt,
144 cv.tags_json
145 FROM content_versions cv
146 LEFT JOIN users u ON cv.created_by = u.id
147 WHERE cv.content_id = ?1
148 ORDER BY cv.version_number DESC
149 LIMIT ?2 OFFSET ?3
150 "#,
151 )?;
152
153 let versions: Vec<VersionSummary> = stmt
154 .query_map(rusqlite::params![content_id, limit, offset], |row| {
155 Ok((
156 row.get::<_, i64>(0)?,
157 row.get::<_, i64>(1)?,
158 row.get::<_, String>(2)?,
159 row.get::<_, Option<String>>(3)?,
160 row.get::<_, String>(4)?,
161 row.get::<_, String>(5)?,
162 row.get::<_, String>(6)?,
163 row.get::<_, Option<String>>(7)?,
164 row.get::<_, String>(8)?,
165 ))
166 })?
167 .collect::<Result<Vec<_>, _>>()?
168 .into_iter()
169 .map(
170 |(id, version_number, title, username, created_at, body, _slug, _excerpt, _tags)| {
171 let line_count = body.lines().count();
172 let changes_summary = format!("{} lines", line_count);
173
174 VersionSummary {
175 id,
176 version_number,
177 title,
178 created_by_username: username,
179 created_at,
180 changes_summary,
181 }
182 },
183 )
184 .collect();
185
186 Ok(versions)
187}
188
189pub fn get_version(db: &Database, version_id: i64) -> Result<ContentVersion> {
191 let conn = db.get()?;
192
193 let version = conn.query_row(
194 r#"
195 SELECT id, content_id, version_number, title, slug, body_markdown,
196 excerpt, featured_image, metadata, tags_json, created_by, created_at
197 FROM content_versions
198 WHERE id = ?1
199 "#,
200 [version_id],
201 |row| {
202 Ok(ContentVersion {
203 id: row.get(0)?,
204 content_id: row.get(1)?,
205 version_number: row.get(2)?,
206 title: row.get(3)?,
207 slug: row.get(4)?,
208 body_markdown: row.get(5)?,
209 excerpt: row.get(6)?,
210 featured_image: row.get(7)?,
211 metadata: serde_json::from_str(&row.get::<_, String>(8)?).unwrap_or_default(),
212 tags: serde_json::from_str(&row.get::<_, String>(9)?).unwrap_or_default(),
213 created_by: row.get(10)?,
214 created_at: row.get(11)?,
215 })
216 },
217 )?;
218
219 Ok(version)
220}
221
222pub fn get_version_by_number(
224 db: &Database,
225 content_id: i64,
226 version_number: i64,
227) -> Result<ContentVersion> {
228 let conn = db.get()?;
229
230 let version = conn.query_row(
231 r#"
232 SELECT id, content_id, version_number, title, slug, body_markdown,
233 excerpt, featured_image, metadata, tags_json, created_by, created_at
234 FROM content_versions
235 WHERE content_id = ?1 AND version_number = ?2
236 "#,
237 [content_id, version_number],
238 |row| {
239 Ok(ContentVersion {
240 id: row.get(0)?,
241 content_id: row.get(1)?,
242 version_number: row.get(2)?,
243 title: row.get(3)?,
244 slug: row.get(4)?,
245 body_markdown: row.get(5)?,
246 excerpt: row.get(6)?,
247 featured_image: row.get(7)?,
248 metadata: serde_json::from_str(&row.get::<_, String>(8)?).unwrap_or_default(),
249 tags: serde_json::from_str(&row.get::<_, String>(9)?).unwrap_or_default(),
250 created_by: row.get(10)?,
251 created_at: row.get(11)?,
252 })
253 },
254 )?;
255
256 Ok(version)
257}
258
259pub fn restore_version(
262 db: &Database,
263 content_id: i64,
264 version_id: i64,
265 user_id: Option<i64>,
266) -> Result<()> {
267 create_version(db, content_id, user_id)?;
269
270 let version = get_version(db, version_id)?;
272
273 if version.content_id != content_id {
274 anyhow::bail!("Version does not belong to this content");
275 }
276
277 let mut conn = db.get()?;
278 let tx = conn.transaction()?;
279
280 tx.execute(
282 r#"
283 UPDATE content
284 SET title = ?1, slug = ?2, body_markdown = ?3, excerpt = ?4,
285 featured_image = ?5, metadata = ?6
286 WHERE id = ?7
287 "#,
288 rusqlite::params![
289 version.title,
290 version.slug,
291 version.body_markdown,
292 version.excerpt,
293 version.featured_image,
294 serde_json::to_string(&version.metadata)?,
295 content_id,
296 ],
297 )?;
298
299 let renderer = crate::services::markdown::MarkdownRenderer::new();
301 let body_html = renderer.render(&version.body_markdown);
302
303 tx.execute(
304 "UPDATE content SET body_html = ?1 WHERE id = ?2",
305 rusqlite::params![body_html, content_id],
306 )?;
307
308 tx.execute(
310 "DELETE FROM content_tags WHERE content_id = ?1",
311 [content_id],
312 )?;
313
314 for tag_name in &version.tags {
315 let tag_id: i64 =
317 match tx.query_row("SELECT id FROM tags WHERE name = ?1", [tag_name], |row| {
318 row.get(0)
319 }) {
320 Ok(id) => id,
321 Err(_) => {
322 let slug = crate::services::slug::generate_slug(tag_name);
323 tx.execute(
324 "INSERT INTO tags (name, slug) VALUES (?1, ?2)",
325 rusqlite::params![tag_name, slug],
326 )?;
327 tx.last_insert_rowid()
328 }
329 };
330
331 tx.execute(
332 "INSERT OR IGNORE INTO content_tags (content_id, tag_id) VALUES (?1, ?2)",
333 [content_id, tag_id],
334 )?;
335 }
336
337 tx.commit()?;
338
339 tracing::info!(
340 "Restored content {} to version {} (v{})",
341 content_id,
342 version_id,
343 version.version_number
344 );
345
346 Ok(())
347}
348
349pub fn diff_versions(
351 db: &Database,
352 old_version_id: i64,
353 new_version_id: i64,
354) -> Result<VersionDiff> {
355 let old_version = get_version(db, old_version_id)?;
356 let new_version = get_version(db, new_version_id)?;
357
358 let title_changed = old_version.title != new_version.title;
359 let slug_changed = old_version.slug != new_version.slug;
360 let excerpt_changed = old_version.excerpt != new_version.excerpt;
361 let tags_changed = old_version.tags != new_version.tags;
362
363 let body_diff = compute_line_diff(&old_version.body_markdown, &new_version.body_markdown);
364
365 Ok(VersionDiff {
366 old_version,
367 new_version,
368 title_changed,
369 slug_changed,
370 excerpt_changed,
371 tags_changed,
372 body_diff,
373 })
374}
375
376pub fn cleanup_old_versions(db: &Database, content_id: i64, keep_count: usize) -> Result<usize> {
378 if keep_count == 0 {
379 return Ok(0); }
381
382 let conn = db.get()?;
383
384 let mut stmt = conn.prepare(
386 r#"
387 SELECT id FROM content_versions
388 WHERE content_id = ?1
389 ORDER BY version_number DESC
390 LIMIT -1 OFFSET ?2
391 "#,
392 )?;
393
394 let ids_to_delete: Vec<i64> = stmt
395 .query_map(rusqlite::params![content_id, keep_count], |row| row.get(0))?
396 .collect::<Result<Vec<_>, _>>()?;
397
398 if ids_to_delete.is_empty() {
399 return Ok(0);
400 }
401
402 let deleted = ids_to_delete.len();
403
404 for id in ids_to_delete {
405 conn.execute("DELETE FROM content_versions WHERE id = ?1", [id])?;
406 }
407
408 tracing::debug!(
409 "Cleaned up {} old versions for content {}",
410 deleted,
411 content_id
412 );
413
414 Ok(deleted)
415}
416
417pub fn count_versions(db: &Database, content_id: i64) -> Result<i64> {
419 let conn = db.get()?;
420 let count: i64 = conn.query_row(
421 "SELECT COUNT(*) FROM content_versions WHERE content_id = ?1",
422 [content_id],
423 |row| row.get(0),
424 )?;
425 Ok(count)
426}
427
428pub fn get_latest_version_number(db: &Database, content_id: i64) -> Result<Option<i64>> {
430 let conn = db.get()?;
431 let version: Option<i64> = conn
432 .query_row(
433 "SELECT MAX(version_number) FROM content_versions WHERE content_id = ?1",
434 [content_id],
435 |row| row.get(0),
436 )
437 .ok();
438 Ok(version)
439}
440
441fn next_version_number(conn: &Connection, content_id: i64) -> Result<i64> {
444 let max: Option<i64> = conn
445 .query_row(
446 "SELECT MAX(version_number) FROM content_versions WHERE content_id = ?1",
447 [content_id],
448 |row| row.get(0),
449 )
450 .unwrap_or(None);
451
452 Ok(max.unwrap_or(0) + 1)
453}
454
455fn get_content_tags(conn: &Connection, content_id: i64) -> Result<Vec<String>> {
456 let mut stmt = conn.prepare(
457 "SELECT t.name FROM tags t JOIN content_tags ct ON t.id = ct.tag_id WHERE ct.content_id = ?1",
458 )?;
459
460 let tags: Vec<String> = stmt
461 .query_map([content_id], |row| row.get(0))?
462 .collect::<Result<Vec<_>, _>>()?;
463
464 Ok(tags)
465}
466
467fn compute_line_diff(old_text: &str, new_text: &str) -> Vec<DiffLine> {
469 let old_lines: Vec<&str> = old_text.lines().collect();
470 let new_lines: Vec<&str> = new_text.lines().collect();
471
472 let m = old_lines.len();
473 let n = new_lines.len();
474
475 let mut lcs = vec![vec![0usize; n + 1]; m + 1];
477 for i in 1..=m {
478 for j in 1..=n {
479 if old_lines[i - 1] == new_lines[j - 1] {
480 lcs[i][j] = lcs[i - 1][j - 1] + 1;
481 } else {
482 lcs[i][j] = lcs[i - 1][j].max(lcs[i][j - 1]);
483 }
484 }
485 }
486
487 let mut i = m;
489 let mut j = n;
490 let mut result = Vec::new();
491
492 while i > 0 || j > 0 {
493 if i > 0 && j > 0 && old_lines[i - 1] == new_lines[j - 1] {
494 result.push(DiffLine {
495 line_type: DiffLineType::Same,
496 content: old_lines[i - 1].to_string(),
497 });
498 i -= 1;
499 j -= 1;
500 } else if j > 0 && (i == 0 || lcs[i][j - 1] >= lcs[i - 1][j]) {
501 result.push(DiffLine {
502 line_type: DiffLineType::Added,
503 content: new_lines[j - 1].to_string(),
504 });
505 j -= 1;
506 } else {
507 result.push(DiffLine {
508 line_type: DiffLineType::Removed,
509 content: old_lines[i - 1].to_string(),
510 });
511 i -= 1;
512 }
513 }
514
515 result.reverse();
516 result
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 #[test]
524 fn test_compute_line_diff_no_changes() {
525 let text = "line 1\nline 2\nline 3";
526 let diff = compute_line_diff(text, text);
527
528 assert_eq!(diff.len(), 3);
529 assert!(diff.iter().all(|d| d.line_type == DiffLineType::Same));
530 }
531
532 #[test]
533 fn test_compute_line_diff_added_line() {
534 let old = "line 1\nline 2";
535 let new = "line 1\nline 2\nline 3";
536 let diff = compute_line_diff(old, new);
537
538 assert_eq!(diff.len(), 3);
539 assert_eq!(diff[0].line_type, DiffLineType::Same);
540 assert_eq!(diff[1].line_type, DiffLineType::Same);
541 assert_eq!(diff[2].line_type, DiffLineType::Added);
542 assert_eq!(diff[2].content, "line 3");
543 }
544
545 #[test]
546 fn test_compute_line_diff_removed_line() {
547 let old = "line 1\nline 2\nline 3";
548 let new = "line 1\nline 3";
549 let diff = compute_line_diff(old, new);
550
551 let removed_count = diff
552 .iter()
553 .filter(|d| d.line_type == DiffLineType::Removed)
554 .count();
555 assert_eq!(removed_count, 1);
556 }
557
558 #[test]
559 fn test_compute_line_diff_modified_line() {
560 let old = "hello world";
561 let new = "hello rust";
562 let diff = compute_line_diff(old, new);
563
564 assert!(diff
566 .iter()
567 .any(|d| d.line_type == DiffLineType::Removed && d.content == "hello world"));
568 assert!(diff
569 .iter()
570 .any(|d| d.line_type == DiffLineType::Added && d.content == "hello rust"));
571 }
572}