1515 lines
46 KiB
Rust
1515 lines
46 KiB
Rust
use std::fs;
|
|
use std::path::Path;
|
|
|
|
use rusqlite::{params, Connection};
|
|
use uuid::Uuid;
|
|
use walkdir::WalkDir;
|
|
|
|
use crate::db::fts;
|
|
use crate::db::queries::post as qp;
|
|
use crate::db::queries::post_link as ql;
|
|
use crate::db::queries::post_translation as qt;
|
|
use crate::engine::{EngineError, EngineResult};
|
|
use crate::model::{Post, PostLink, PostStatus, PostTranslation};
|
|
use crate::util::frontmatter::{
|
|
read_post_file, read_translation_file, write_post_file, write_translation_file,
|
|
};
|
|
use crate::util::{
|
|
atomic_write_str, content_hash, ensure_unique, now_unix_ms, post_file_path, slugify,
|
|
translation_file_path,
|
|
};
|
|
|
|
/// Report returned by `rebuild_posts_from_filesystem`.
|
|
#[derive(Debug, Default)]
|
|
pub struct RebuildReport {
|
|
pub posts_created: usize,
|
|
pub posts_updated: usize,
|
|
pub translations_created: usize,
|
|
pub translations_updated: usize,
|
|
pub errors: Vec<String>,
|
|
}
|
|
|
|
/// Create a new draft post.
|
|
pub fn create_post(
|
|
conn: &Connection,
|
|
_data_dir: &Path,
|
|
project_id: &str,
|
|
title: &str,
|
|
content: Option<&str>,
|
|
tags: Vec<String>,
|
|
categories: Vec<String>,
|
|
author: Option<&str>,
|
|
language: Option<&str>,
|
|
template_slug: Option<&str>,
|
|
) -> EngineResult<Post> {
|
|
let id = Uuid::new_v4().to_string();
|
|
let slug_source = if title.is_empty() { "untitled" } else { title };
|
|
let base_slug = slugify(slug_source);
|
|
let base_slug = if base_slug.is_empty() {
|
|
"untitled".to_string()
|
|
} else {
|
|
base_slug
|
|
};
|
|
let slug = ensure_unique(&base_slug, |candidate| {
|
|
qp::get_post_by_project_and_slug(conn, project_id, candidate).is_ok()
|
|
});
|
|
|
|
let now = now_unix_ms();
|
|
let post = Post {
|
|
id,
|
|
project_id: project_id.to_string(),
|
|
title: title.to_string(),
|
|
slug,
|
|
excerpt: None,
|
|
content: content.map(|s| s.to_string()),
|
|
status: PostStatus::Draft,
|
|
author: author.map(|s| s.to_string()),
|
|
language: language.map(|s| s.to_string()),
|
|
do_not_translate: false,
|
|
template_slug: template_slug.map(|s| s.to_string()),
|
|
file_path: String::new(),
|
|
checksum: None,
|
|
tags,
|
|
categories,
|
|
published_title: None,
|
|
published_content: None,
|
|
published_tags: None,
|
|
published_categories: None,
|
|
published_excerpt: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
published_at: None,
|
|
};
|
|
|
|
qp::insert_post(conn, &post)?;
|
|
|
|
// Index for FTS
|
|
fts_index_post(conn, &post)?;
|
|
|
|
Ok(post)
|
|
}
|
|
|
|
/// Update a post's fields.
|
|
pub fn update_post(
|
|
conn: &Connection,
|
|
_data_dir: &Path,
|
|
post_id: &str,
|
|
title: Option<&str>,
|
|
slug: Option<&str>,
|
|
excerpt: Option<Option<&str>>,
|
|
content: Option<&str>,
|
|
tags: Option<Vec<String>>,
|
|
categories: Option<Vec<String>>,
|
|
author: Option<Option<&str>>,
|
|
language: Option<Option<&str>>,
|
|
template_slug: Option<Option<&str>>,
|
|
do_not_translate: Option<bool>,
|
|
) -> EngineResult<Post> {
|
|
let mut post = qp::get_post_by_id(conn, post_id)?;
|
|
|
|
// Slug frozen after first publish
|
|
if slug.is_some() && post.published_at.is_some() {
|
|
return Err(EngineError::Conflict(
|
|
"slug cannot be changed after publishing".to_string(),
|
|
));
|
|
}
|
|
|
|
// Slug uniqueness check
|
|
if let Some(new_slug) = slug {
|
|
if new_slug != post.slug {
|
|
if qp::get_post_by_project_and_slug(conn, &post.project_id, new_slug).is_ok() {
|
|
return Err(EngineError::Conflict(format!(
|
|
"slug '{new_slug}' already exists in this project"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(t) = title {
|
|
post.title = t.to_string();
|
|
}
|
|
if let Some(s) = slug {
|
|
post.slug = s.to_string();
|
|
}
|
|
if let Some(exc) = excerpt {
|
|
post.excerpt = exc.map(|s| s.to_string());
|
|
}
|
|
if let Some(c) = content {
|
|
post.content = Some(c.to_string());
|
|
}
|
|
if let Some(t) = tags {
|
|
post.tags = t;
|
|
}
|
|
if let Some(c) = categories {
|
|
post.categories = c;
|
|
}
|
|
if let Some(a) = author {
|
|
post.author = a.map(|s| s.to_string());
|
|
}
|
|
if let Some(l) = language {
|
|
post.language = l.map(|s| s.to_string());
|
|
}
|
|
if let Some(ts) = template_slug {
|
|
post.template_slug = ts.map(|s| s.to_string());
|
|
}
|
|
if let Some(dnt) = do_not_translate {
|
|
post.do_not_translate = dnt;
|
|
}
|
|
|
|
// Auto-transition published or archived post back to draft on content/metadata change
|
|
if post.status == PostStatus::Published || post.status == PostStatus::Archived {
|
|
// Reload content from filesystem if content field is NULL (published state)
|
|
if post.content.is_none() && !post.file_path.is_empty() {
|
|
let abs_path = _data_dir.join(&post.file_path);
|
|
if abs_path.exists() {
|
|
if let Ok(file_content) = fs::read_to_string(&abs_path) {
|
|
if let Ok((_fm, body)) = read_post_file(&file_content) {
|
|
post.content = Some(body);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
post.status = PostStatus::Draft;
|
|
}
|
|
|
|
post.updated_at = now_unix_ms();
|
|
qp::update_post(conn, &post)?;
|
|
|
|
// Re-index FTS
|
|
fts_index_post(conn, &post)?;
|
|
|
|
Ok(post)
|
|
}
|
|
|
|
/// Publish a post: write file, clear content, set published_at.
|
|
pub fn publish_post(
|
|
conn: &Connection,
|
|
data_dir: &Path,
|
|
post_id: &str,
|
|
) -> EngineResult<Post> {
|
|
let mut post = qp::get_post_by_id(conn, post_id)?;
|
|
|
|
// Require Draft or Archived status
|
|
match post.status {
|
|
PostStatus::Draft | PostStatus::Archived => {}
|
|
PostStatus::Published => {
|
|
return Err(EngineError::Conflict(
|
|
"post is already published".to_string(),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Compute file_path from created_at + slug
|
|
// Use a savepoint for atomicity
|
|
// Note: savepoint auto-rolls-back if not released (on error propagation)
|
|
conn.execute_batch("SAVEPOINT publish_post")?;
|
|
let rel_path = post_file_path(post.created_at, &post.slug);
|
|
let abs_path = data_dir.join(&rel_path);
|
|
|
|
// Get body: from post.content (draft) or read from existing file (re-publish after archive)
|
|
let body = if let Some(ref c) = post.content {
|
|
c.clone()
|
|
} else if abs_path.exists() {
|
|
let file_content = fs::read_to_string(&abs_path)?;
|
|
let (_fm, body) = read_post_file(&file_content)
|
|
.map_err(|e| EngineError::Parse(e))?;
|
|
body
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
// Write frontmatter+body to filesystem
|
|
let now = now_unix_ms();
|
|
let published_at = post.published_at.unwrap_or(now);
|
|
post.published_at = Some(published_at);
|
|
post.status = PostStatus::Published;
|
|
post.file_path = rel_path.clone();
|
|
post.updated_at = now;
|
|
|
|
let file_content = write_post_file(&post, &body);
|
|
atomic_write_str(&abs_path, &file_content)?;
|
|
|
|
// Compute checksum
|
|
let hash = content_hash(file_content.as_bytes());
|
|
post.checksum = Some(hash);
|
|
|
|
// Set published snapshot fields
|
|
let tags_json = serde_json::to_string(&post.tags).unwrap_or_else(|_| "[]".into());
|
|
let cats_json =
|
|
serde_json::to_string(&post.categories).unwrap_or_else(|_| "[]".into());
|
|
post.published_title = Some(post.title.clone());
|
|
post.published_content = Some(body.clone());
|
|
post.published_tags = Some(tags_json.clone());
|
|
post.published_categories = Some(cats_json.clone());
|
|
post.published_excerpt = post.excerpt.clone();
|
|
|
|
qp::set_published_snapshot(
|
|
conn,
|
|
post_id,
|
|
&post.title,
|
|
&body,
|
|
&tags_json,
|
|
&cats_json,
|
|
post.excerpt.as_deref(),
|
|
published_at,
|
|
now,
|
|
)?;
|
|
|
|
// Set file_path and checksum in DB
|
|
qp::set_post_file_path(conn, post_id, &post.file_path, now)?;
|
|
conn.execute(
|
|
"UPDATE posts SET checksum = ?1 WHERE id = ?2",
|
|
params![post.checksum, post_id],
|
|
)?;
|
|
|
|
// Clear content in DB
|
|
qp::clear_post_content(conn, post_id, now)?;
|
|
post.content = None;
|
|
|
|
// Set status = Published
|
|
qp::update_post_status(conn, post_id, &PostStatus::Published, now)?;
|
|
|
|
// Publish all translations
|
|
let translations = qt::list_post_translations_by_post(conn, post_id)?;
|
|
for mut t in translations {
|
|
publish_translation(conn, data_dir, &mut t, &post)?;
|
|
}
|
|
|
|
// Parse inter-post links and update link graph
|
|
ql::delete_links_by_source(conn, post_id)?;
|
|
let link_body = if let Some(ref pc) = post.published_content {
|
|
pc.as_str()
|
|
} else {
|
|
""
|
|
};
|
|
let parsed_links = parse_post_links(link_body);
|
|
for (target_slug, link_text) in &parsed_links {
|
|
if let Ok(target) = qp::get_post_by_project_and_slug(conn, &post.project_id, target_slug) {
|
|
let link = PostLink {
|
|
id: Uuid::new_v4().to_string(),
|
|
source_post_id: post_id.to_string(),
|
|
target_post_id: target.id.clone(),
|
|
link_text: Some(link_text.clone()),
|
|
created_at: now,
|
|
};
|
|
let _ = ql::insert_post_link(conn, &link);
|
|
}
|
|
}
|
|
|
|
// Re-index FTS
|
|
fts_index_post(conn, &post)?;
|
|
|
|
conn.execute_batch("RELEASE publish_post")?;
|
|
|
|
Ok(post)
|
|
}
|
|
|
|
/// Archive a post.
|
|
pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<()> {
|
|
let mut post = qp::get_post_by_id(conn, post_id)?;
|
|
if post.status == PostStatus::Archived {
|
|
return Err(EngineError::Conflict(
|
|
"post is already archived".to_string(),
|
|
));
|
|
}
|
|
// Reload content from filesystem if transitioning from published (content is NULL)
|
|
if post.status == PostStatus::Published && post.content.is_none() && !post.file_path.is_empty() {
|
|
let abs_path = data_dir.join(&post.file_path);
|
|
if abs_path.exists() {
|
|
if let Ok(file_content) = fs::read_to_string(&abs_path) {
|
|
if let Ok((_fm, body)) = read_post_file(&file_content) {
|
|
post.content = Some(body);
|
|
qp::update_post(conn, &post)?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let now = now_unix_ms();
|
|
qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete a post and all related data.
|
|
pub fn delete_post(
|
|
conn: &Connection,
|
|
data_dir: &Path,
|
|
post_id: &str,
|
|
) -> EngineResult<()> {
|
|
let post = qp::get_post_by_id(conn, post_id)?;
|
|
|
|
// Delete .md file if exists
|
|
if !post.file_path.is_empty() {
|
|
let abs_path = data_dir.join(&post.file_path);
|
|
if abs_path.exists() {
|
|
fs::remove_file(&abs_path)?;
|
|
}
|
|
}
|
|
|
|
// Delete all translation files
|
|
let translations = qt::list_post_translations_by_post(conn, post_id)?;
|
|
for t in &translations {
|
|
if !t.file_path.is_empty() {
|
|
let abs_path = data_dir.join(&t.file_path);
|
|
if abs_path.exists() {
|
|
fs::remove_file(&abs_path)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delete all translations from DB
|
|
qt::delete_all_translations_for_post(conn, post_id)?;
|
|
|
|
// Delete post links (source and target)
|
|
ql::delete_links_by_source(conn, post_id)?;
|
|
conn.execute(
|
|
"DELETE FROM post_links WHERE target_post_id = ?1",
|
|
params![post_id],
|
|
)?;
|
|
|
|
// Delete post-media associations
|
|
conn.execute(
|
|
"DELETE FROM post_media WHERE post_id = ?1",
|
|
params![post_id],
|
|
)?;
|
|
|
|
// Remove from FTS
|
|
fts::remove_post_from_index(conn, post_id)?;
|
|
|
|
// Delete post from DB
|
|
qp::delete_post(conn, post_id)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Compute the canonical URL for a post: /{YYYY}/{MM}/{DD}/{slug}
|
|
pub fn canonical_url(created_at_ms: i64, slug: &str) -> String {
|
|
let (y, m, d) = crate::util::timestamp::year_month_day_from_unix_ms(created_at_ms);
|
|
format!("/{y}/{m}/{d}/{slug}")
|
|
}
|
|
|
|
/// Upsert a translation for a post.
|
|
pub fn upsert_translation(
|
|
conn: &Connection,
|
|
_data_dir: &Path,
|
|
post_id: &str,
|
|
language: &str,
|
|
title: &str,
|
|
excerpt: Option<&str>,
|
|
content: Option<&str>,
|
|
) -> EngineResult<PostTranslation> {
|
|
let post = qp::get_post_by_id(conn, post_id)?;
|
|
if post.do_not_translate {
|
|
return Err(EngineError::Validation(
|
|
"cannot create translation for a do-not-translate post".to_string(),
|
|
));
|
|
}
|
|
let now = now_unix_ms();
|
|
|
|
// Check if translation already exists
|
|
let existing = qt::get_post_translation_by_post_and_language(conn, post_id, language);
|
|
match existing {
|
|
Ok(mut t) => {
|
|
// Update existing
|
|
t.title = title.to_string();
|
|
t.excerpt = excerpt.map(|s| s.to_string());
|
|
if let Some(c) = content {
|
|
t.content = Some(c.to_string());
|
|
}
|
|
t.updated_at = now;
|
|
qt::update_post_translation(conn, &t)?;
|
|
|
|
// Re-index FTS for parent post
|
|
fts_index_post(conn, &post)?;
|
|
|
|
Ok(t)
|
|
}
|
|
Err(_) => {
|
|
// Create new
|
|
let id = Uuid::new_v4().to_string();
|
|
let t = PostTranslation {
|
|
id,
|
|
project_id: post.project_id.clone(),
|
|
translation_for: post_id.to_string(),
|
|
language: language.to_string(),
|
|
title: title.to_string(),
|
|
excerpt: excerpt.map(|s| s.to_string()),
|
|
content: content.map(|s| s.to_string()),
|
|
status: PostStatus::Draft,
|
|
file_path: String::new(),
|
|
checksum: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
published_at: None,
|
|
};
|
|
qt::insert_post_translation(conn, &t)?;
|
|
|
|
// Re-index FTS for parent post
|
|
fts_index_post(conn, &post)?;
|
|
|
|
Ok(t)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Delete a translation.
|
|
pub fn delete_translation(
|
|
conn: &Connection,
|
|
data_dir: &Path,
|
|
translation_id: &str,
|
|
) -> EngineResult<()> {
|
|
let t = qt::get_post_translation_by_id(conn, translation_id)?;
|
|
|
|
// Delete file if exists
|
|
if !t.file_path.is_empty() {
|
|
let abs_path = data_dir.join(&t.file_path);
|
|
if abs_path.exists() {
|
|
fs::remove_file(&abs_path)?;
|
|
}
|
|
}
|
|
|
|
qt::delete_post_translation(conn, translation_id)?;
|
|
|
|
// Re-index FTS for parent post
|
|
if let Ok(post) = qp::get_post_by_id(conn, &t.translation_for) {
|
|
fts_index_post(conn, &post)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Rebuild posts from filesystem. Walk posts/ dir, parse .md files, upsert into DB.
|
|
pub fn rebuild_posts_from_filesystem(
|
|
conn: &Connection,
|
|
data_dir: &Path,
|
|
project_id: &str,
|
|
) -> EngineResult<RebuildReport> {
|
|
rebuild_posts_from_filesystem_with_progress(conn, data_dir, project_id, None)
|
|
}
|
|
|
|
/// Per-item progress callback: (current_item, total_items, item_description).
|
|
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
|
|
|
|
/// Like `rebuild_posts_from_filesystem` but with optional per-item progress.
|
|
pub fn rebuild_posts_from_filesystem_with_progress(
|
|
conn: &Connection,
|
|
data_dir: &Path,
|
|
project_id: &str,
|
|
on_item: Option<ItemProgressFn>,
|
|
) -> EngineResult<RebuildReport> {
|
|
let mut report = RebuildReport::default();
|
|
let posts_dir = data_dir.join("posts");
|
|
|
|
if !posts_dir.exists() {
|
|
return Ok(report);
|
|
}
|
|
|
|
// Collect all .md files
|
|
let mut canonical_files = Vec::new();
|
|
let mut translation_files = Vec::new();
|
|
|
|
for entry in WalkDir::new(&posts_dir)
|
|
.into_iter()
|
|
.filter_map(|e| e.ok())
|
|
{
|
|
let path = entry.path();
|
|
if !path.is_file() {
|
|
continue;
|
|
}
|
|
let ext = path.extension().and_then(|e| e.to_str());
|
|
if ext != Some("md") {
|
|
continue;
|
|
}
|
|
|
|
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
|
if is_translation_filename(stem) {
|
|
translation_files.push(path.to_path_buf());
|
|
} else {
|
|
canonical_files.push(path.to_path_buf());
|
|
}
|
|
}
|
|
|
|
let total = canonical_files.len() + translation_files.len();
|
|
|
|
// Process canonical posts first
|
|
for (i, path) in canonical_files.iter().enumerate() {
|
|
if let Some(ref cb) = on_item {
|
|
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
|
|
cb(i + 1, total, name);
|
|
}
|
|
match rebuild_canonical_post(conn, data_dir, project_id, path) {
|
|
Ok(created) => {
|
|
if created {
|
|
report.posts_created += 1;
|
|
} else {
|
|
report.posts_updated += 1;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
report.errors.push(format!("{}: {e}", path.display()));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process translations
|
|
let offset = canonical_files.len();
|
|
for (i, path) in translation_files.iter().enumerate() {
|
|
if let Some(ref cb) = on_item {
|
|
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
|
|
cb(offset + i + 1, total, name);
|
|
}
|
|
match rebuild_translation(conn, data_dir, project_id, path) {
|
|
Ok(created) => {
|
|
if created {
|
|
report.translations_created += 1;
|
|
} else {
|
|
report.translations_updated += 1;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
report.errors.push(format!("{}: {e}", path.display()));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-index FTS for all posts in this project
|
|
let posts = qp::list_posts_by_project(conn, project_id)?;
|
|
for post in &posts {
|
|
fts_index_post(conn, post)?;
|
|
}
|
|
|
|
Ok(report)
|
|
}
|
|
|
|
// --- Internal helpers ---
|
|
|
|
/// Parse inter-post links from markdown content.
|
|
/// Looks for markdown links that reference canonical post URLs: [text](/YYYY/MM/DD/slug)
|
|
fn parse_post_links(content: &str) -> Vec<(String, String)> {
|
|
let mut links = Vec::new();
|
|
// Match markdown links: [text](/YYYY/MM/DD/slug) or [text](/YYYY/MM/DD/slug/)
|
|
// Simple manual parsing since we don't have regex crate
|
|
// Look for patterns like [...](...) where the URL matches /YYYY/MM/DD/slug
|
|
for line in content.lines() {
|
|
let mut pos = 0;
|
|
while pos < line.len() {
|
|
if let Some(bracket_start) = line[pos..].find('[') {
|
|
let abs_start = pos + bracket_start;
|
|
if let Some(bracket_end) = line[abs_start..].find("](") {
|
|
let text_end = abs_start + bracket_end;
|
|
let link_text = &line[abs_start + 1..text_end];
|
|
let url_start = text_end + 2;
|
|
if let Some(paren_end) = line[url_start..].find(')') {
|
|
let url = &line[url_start..url_start + paren_end];
|
|
// Check if URL matches /YYYY/MM/DD/slug pattern
|
|
let parts: Vec<&str> = url.trim_end_matches('/').split('/').collect();
|
|
if parts.len() == 5 && parts[0].is_empty()
|
|
&& parts[1].len() == 4 && parts[1].chars().all(|c| c.is_ascii_digit())
|
|
&& parts[2].len() == 2 && parts[2].chars().all(|c| c.is_ascii_digit())
|
|
&& parts[3].len() == 2 && parts[3].chars().all(|c| c.is_ascii_digit())
|
|
{
|
|
links.push((parts[4].to_string(), link_text.to_string()));
|
|
}
|
|
pos = url_start + paren_end + 1;
|
|
continue;
|
|
}
|
|
}
|
|
pos = abs_start + 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
links
|
|
}
|
|
|
|
/// Publish a single translation: write file, clear content, set status.
|
|
fn publish_translation(
|
|
conn: &Connection,
|
|
data_dir: &Path,
|
|
t: &mut PostTranslation,
|
|
post: &Post,
|
|
) -> EngineResult<()> {
|
|
let rel_path = translation_file_path(post.created_at, &post.slug, &t.language);
|
|
let abs_path = data_dir.join(&rel_path);
|
|
|
|
// Get body
|
|
let body = if let Some(ref c) = t.content {
|
|
c.clone()
|
|
} else if abs_path.exists() {
|
|
let file_content = fs::read_to_string(&abs_path)?;
|
|
let (_fm, body) =
|
|
read_translation_file(&file_content).map_err(|e| EngineError::Parse(e))?;
|
|
body
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let now = now_unix_ms();
|
|
t.status = PostStatus::Published;
|
|
t.file_path = rel_path;
|
|
t.published_at = Some(t.published_at.unwrap_or(now));
|
|
t.updated_at = now;
|
|
|
|
let file_content = write_translation_file(t, &body);
|
|
atomic_write_str(&abs_path, &file_content)?;
|
|
|
|
let hash = content_hash(file_content.as_bytes());
|
|
t.checksum = Some(hash);
|
|
t.content = None;
|
|
|
|
qt::update_post_translation(conn, t)?;
|
|
// Clear content after update (the update already set content=None via the struct)
|
|
// but we also do an explicit clear to be safe
|
|
conn.execute(
|
|
"UPDATE post_translations SET content = NULL WHERE id = ?1",
|
|
params![t.id],
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Index a post in FTS, gathering translation texts.
|
|
fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> {
|
|
let translations = qt::list_post_translations_by_post(conn, &post.id).unwrap_or_default();
|
|
let translation_data: Vec<fts::PostTranslationFts> = translations
|
|
.iter()
|
|
.map(|t| fts::PostTranslationFts {
|
|
title: t.title.clone(),
|
|
excerpt: t.excerpt.clone(),
|
|
content: t.content.clone(),
|
|
language: t.language.clone(),
|
|
})
|
|
.collect();
|
|
|
|
let lang = post.language.as_deref().unwrap_or("en");
|
|
fts::index_post(
|
|
conn,
|
|
&post.id,
|
|
&post.title,
|
|
post.excerpt.as_deref(),
|
|
post.content.as_deref(),
|
|
&post.tags,
|
|
&post.categories,
|
|
&translation_data,
|
|
lang,
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Check if a file stem looks like a translation filename: `{slug}.{lang}`
|
|
/// where lang is a 2-letter code. We look for a dot followed by exactly 2 lowercase letters.
|
|
fn is_translation_filename(stem: &str) -> bool {
|
|
if let Some(dot_pos) = stem.rfind('.') {
|
|
let suffix = &stem[dot_pos + 1..];
|
|
suffix.len() == 2 && suffix.chars().all(|c| c.is_ascii_lowercase())
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Rebuild a canonical post from a .md file. Returns true if created, false if updated.
|
|
fn rebuild_canonical_post(
|
|
conn: &Connection,
|
|
data_dir: &Path,
|
|
project_id: &str,
|
|
path: &Path,
|
|
) -> EngineResult<bool> {
|
|
let content = fs::read_to_string(path)?;
|
|
let (fm, body) = read_post_file(&content).map_err(|e| EngineError::Parse(e))?;
|
|
|
|
let rel_path = path
|
|
.strip_prefix(data_dir)
|
|
.unwrap_or(path)
|
|
.to_string_lossy()
|
|
.to_string();
|
|
|
|
let hash = content_hash(content.as_bytes());
|
|
let now = now_unix_ms();
|
|
|
|
let status = match fm.status.as_str() {
|
|
"published" => PostStatus::Published,
|
|
"archived" => PostStatus::Archived,
|
|
_ => PostStatus::Draft,
|
|
};
|
|
|
|
// Check if post exists in DB by id
|
|
let existing = qp::get_post_by_id(conn, &fm.id);
|
|
match existing {
|
|
Ok(mut post) => {
|
|
// Update existing post
|
|
post.title = fm.title;
|
|
post.slug = fm.slug;
|
|
post.excerpt = fm.excerpt;
|
|
post.content = if status == PostStatus::Published {
|
|
None
|
|
} else {
|
|
Some(body)
|
|
};
|
|
post.status = status;
|
|
post.author = fm.author;
|
|
post.language = fm.language;
|
|
post.do_not_translate = fm.do_not_translate;
|
|
post.template_slug = fm.template_slug;
|
|
post.file_path = rel_path;
|
|
post.checksum = Some(hash);
|
|
post.tags = fm.tags;
|
|
post.categories = fm.categories;
|
|
post.created_at = fm.created_at;
|
|
post.updated_at = now;
|
|
post.published_at = fm.published_at;
|
|
qp::update_post(conn, &post)?;
|
|
Ok(false)
|
|
}
|
|
Err(_) => {
|
|
// Insert new post
|
|
let post = Post {
|
|
id: fm.id,
|
|
project_id: project_id.to_string(),
|
|
title: fm.title,
|
|
slug: fm.slug,
|
|
excerpt: fm.excerpt,
|
|
content: if status == PostStatus::Published {
|
|
None
|
|
} else {
|
|
Some(body)
|
|
},
|
|
status,
|
|
author: fm.author,
|
|
language: fm.language,
|
|
do_not_translate: fm.do_not_translate,
|
|
template_slug: fm.template_slug,
|
|
file_path: rel_path,
|
|
checksum: Some(hash),
|
|
tags: fm.tags,
|
|
categories: fm.categories,
|
|
published_title: None,
|
|
published_content: None,
|
|
published_tags: None,
|
|
published_categories: None,
|
|
published_excerpt: None,
|
|
created_at: fm.created_at,
|
|
updated_at: now,
|
|
published_at: fm.published_at,
|
|
};
|
|
qp::insert_post(conn, &post)?;
|
|
Ok(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rebuild a translation from a .{lang}.md file. Returns true if created, false if updated.
|
|
fn rebuild_translation(
|
|
conn: &Connection,
|
|
data_dir: &Path,
|
|
project_id: &str,
|
|
path: &Path,
|
|
) -> EngineResult<bool> {
|
|
let content = fs::read_to_string(path)?;
|
|
let (fm, body) =
|
|
read_translation_file(&content).map_err(|e| EngineError::Parse(e))?;
|
|
|
|
let rel_path = path
|
|
.strip_prefix(data_dir)
|
|
.unwrap_or(path)
|
|
.to_string_lossy()
|
|
.to_string();
|
|
|
|
let hash = content_hash(content.as_bytes());
|
|
let now = now_unix_ms();
|
|
|
|
// Check if parent post exists
|
|
let parent = qp::get_post_by_id(conn, &fm.translation_for);
|
|
if parent.is_err() {
|
|
return Err(EngineError::NotFound(format!(
|
|
"parent post '{}' not found for translation",
|
|
fm.translation_for
|
|
)));
|
|
}
|
|
let parent = parent.unwrap();
|
|
|
|
// Determine status from parent
|
|
let status = if parent.status == PostStatus::Published {
|
|
PostStatus::Published
|
|
} else {
|
|
PostStatus::Draft
|
|
};
|
|
|
|
// Check if translation exists
|
|
let existing =
|
|
qt::get_post_translation_by_post_and_language(conn, &fm.translation_for, &fm.language);
|
|
match existing {
|
|
Ok(mut t) => {
|
|
t.title = fm.title;
|
|
t.excerpt = fm.excerpt;
|
|
t.content = if status == PostStatus::Published {
|
|
None
|
|
} else {
|
|
Some(body)
|
|
};
|
|
t.status = status;
|
|
t.file_path = rel_path;
|
|
t.checksum = Some(hash);
|
|
t.updated_at = now;
|
|
qt::update_post_translation(conn, &t)?;
|
|
Ok(false)
|
|
}
|
|
Err(_) => {
|
|
let t = PostTranslation {
|
|
id: Uuid::new_v4().to_string(),
|
|
project_id: project_id.to_string(),
|
|
translation_for: fm.translation_for,
|
|
language: fm.language,
|
|
title: fm.title,
|
|
excerpt: fm.excerpt,
|
|
content: if status == PostStatus::Published {
|
|
None
|
|
} else {
|
|
Some(body)
|
|
},
|
|
status,
|
|
file_path: rel_path,
|
|
checksum: Some(hash),
|
|
created_at: now,
|
|
updated_at: now,
|
|
published_at: if parent.published_at.is_some() {
|
|
Some(now)
|
|
} else {
|
|
None
|
|
},
|
|
};
|
|
qt::insert_post_translation(conn, &t)?;
|
|
Ok(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::db::fts::ensure_fts_tables;
|
|
use crate::db::queries::project::{insert_project, make_test_project};
|
|
use crate::db::Database;
|
|
use tempfile::TempDir;
|
|
|
|
fn setup() -> (Database, TempDir) {
|
|
let mut db = Database::open_in_memory().unwrap();
|
|
db.migrate().unwrap();
|
|
ensure_fts_tables(db.conn()).unwrap();
|
|
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
|
|
let dir = TempDir::new().unwrap();
|
|
(db, dir)
|
|
}
|
|
|
|
#[test]
|
|
fn create_post_generates_slug_and_draft() {
|
|
let (db, dir) = setup();
|
|
let post = create_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
"Hello World",
|
|
Some("body text"),
|
|
vec!["rust".into()],
|
|
vec!["tech".into()],
|
|
Some("Alice"),
|
|
Some("en"),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(post.slug, "hello-world");
|
|
assert_eq!(post.status, PostStatus::Draft);
|
|
assert_eq!(post.title, "Hello World");
|
|
assert_eq!(post.tags, vec!["rust"]);
|
|
assert_eq!(post.categories, vec!["tech"]);
|
|
assert_eq!(post.content.as_deref(), Some("body text"));
|
|
assert!(post.file_path.is_empty());
|
|
assert!(post.published_at.is_none());
|
|
|
|
// Verify it's in DB
|
|
let fetched = qp::get_post_by_id(db.conn(), &post.id).unwrap();
|
|
assert_eq!(fetched.slug, "hello-world");
|
|
assert_eq!(fetched.status, PostStatus::Draft);
|
|
}
|
|
|
|
#[test]
|
|
fn create_post_empty_title_uses_untitled() {
|
|
let (db, dir) = setup();
|
|
let post = create_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
"",
|
|
None,
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(post.slug, "untitled");
|
|
}
|
|
|
|
#[test]
|
|
fn create_post_unique_slugs() {
|
|
let (db, dir) = setup();
|
|
let p1 = create_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
"Dupe",
|
|
None,
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let p2 = create_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
"Dupe",
|
|
None,
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(p1.slug, "dupe");
|
|
assert_eq!(p2.slug, "dupe-2");
|
|
}
|
|
|
|
#[test]
|
|
fn update_post_changes_fields() {
|
|
let (db, dir) = setup();
|
|
let post = create_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
"Original",
|
|
Some("body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
let updated = update_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
&post.id,
|
|
Some("Updated Title"),
|
|
Some("new-slug"),
|
|
Some(Some("new excerpt")),
|
|
Some("new body"),
|
|
Some(vec!["tag1".into()]),
|
|
Some(vec!["cat1".into()]),
|
|
Some(Some("Bob")),
|
|
Some(Some("de")),
|
|
None,
|
|
Some(true),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(updated.title, "Updated Title");
|
|
assert_eq!(updated.slug, "new-slug");
|
|
assert_eq!(updated.excerpt.as_deref(), Some("new excerpt"));
|
|
assert_eq!(updated.content.as_deref(), Some("new body"));
|
|
assert_eq!(updated.tags, vec!["tag1"]);
|
|
assert_eq!(updated.categories, vec!["cat1"]);
|
|
assert_eq!(updated.author.as_deref(), Some("Bob"));
|
|
assert_eq!(updated.language.as_deref(), Some("de"));
|
|
assert!(updated.do_not_translate);
|
|
}
|
|
|
|
#[test]
|
|
fn update_post_slug_frozen_after_publish() {
|
|
let (db, dir) = setup();
|
|
let post = create_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
"Frozen Slug",
|
|
Some("body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
// Publish the post
|
|
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
|
|
|
// Archive so we can try updating
|
|
archive_post(db.conn(), dir.path(), &post.id).unwrap();
|
|
|
|
// Try to change slug - should fail
|
|
let result = update_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
&post.id,
|
|
None,
|
|
Some("different-slug"),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
|
|
assert!(result.is_err());
|
|
match result.unwrap_err() {
|
|
EngineError::Conflict(_) => {} // expected
|
|
other => panic!("expected Conflict, got: {other}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn publish_post_writes_file_and_clears_content() {
|
|
let (db, dir) = setup();
|
|
let post = create_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
"Publish Me",
|
|
Some("my body content"),
|
|
vec!["rust".into()],
|
|
vec!["tech".into()],
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
let published = publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
|
|
|
// Status should be Published
|
|
assert_eq!(published.status, PostStatus::Published);
|
|
|
|
// Content should be None in DB
|
|
let from_db = qp::get_post_by_id(db.conn(), &post.id).unwrap();
|
|
assert!(from_db.content.is_none());
|
|
|
|
// file_path should be set
|
|
assert!(!from_db.file_path.is_empty());
|
|
|
|
// published_at should be set
|
|
assert!(from_db.published_at.is_some());
|
|
|
|
// Published snapshot fields should be set
|
|
assert_eq!(from_db.published_title.as_deref(), Some("Publish Me"));
|
|
assert!(from_db.published_content.is_some());
|
|
|
|
// File should exist on disk
|
|
let abs_path = dir.path().join(&from_db.file_path);
|
|
assert!(abs_path.exists());
|
|
|
|
// File should contain frontmatter and body
|
|
let file_content = fs::read_to_string(&abs_path).unwrap();
|
|
assert!(file_content.contains("my body content"));
|
|
assert!(file_content.contains("Publish Me"));
|
|
}
|
|
|
|
#[test]
|
|
fn publish_preserves_published_at_on_republish() {
|
|
let (db, dir) = setup();
|
|
let post = create_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
"Republish",
|
|
Some("body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
let published = publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
|
let original_published_at = published.published_at.unwrap();
|
|
|
|
// Archive then re-publish
|
|
archive_post(db.conn(), dir.path(), &post.id).unwrap();
|
|
|
|
// Brief delay to ensure now() is different
|
|
let republished = publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
|
assert_eq!(
|
|
republished.published_at.unwrap(),
|
|
original_published_at,
|
|
"published_at should be preserved on re-publish"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn delete_post_removes_everything() {
|
|
let (db, dir) = setup();
|
|
let post = create_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
"Delete Me",
|
|
Some("body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
// Add a translation
|
|
upsert_translation(
|
|
db.conn(),
|
|
dir.path(),
|
|
&post.id,
|
|
"de",
|
|
"German Title",
|
|
None,
|
|
Some("German body"),
|
|
)
|
|
.unwrap();
|
|
|
|
// Publish to create files
|
|
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
|
|
|
// Verify files exist
|
|
let from_db = qp::get_post_by_id(db.conn(), &post.id).unwrap();
|
|
let post_file = dir.path().join(&from_db.file_path);
|
|
assert!(post_file.exists());
|
|
|
|
// Delete
|
|
delete_post(db.conn(), dir.path(), &post.id).unwrap();
|
|
|
|
// Post should be gone from DB
|
|
assert!(qp::get_post_by_id(db.conn(), &post.id).is_err());
|
|
|
|
// Translations should be gone
|
|
let trans = qt::list_post_translations_by_post(db.conn(), &post.id).unwrap();
|
|
assert!(trans.is_empty());
|
|
|
|
// Post file should be gone
|
|
assert!(!post_file.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn upsert_translation_create_and_update() {
|
|
let (db, dir) = setup();
|
|
let post = create_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
"Parent",
|
|
Some("body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
// Create translation
|
|
let t = upsert_translation(
|
|
db.conn(),
|
|
dir.path(),
|
|
&post.id,
|
|
"de",
|
|
"German Title",
|
|
Some("excerpt"),
|
|
Some("Inhalt"),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(t.language, "de");
|
|
assert_eq!(t.title, "German Title");
|
|
|
|
// Update same translation
|
|
let t2 = upsert_translation(
|
|
db.conn(),
|
|
dir.path(),
|
|
&post.id,
|
|
"de",
|
|
"Neuer Titel",
|
|
None,
|
|
Some("Neuer Inhalt"),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(t2.id, t.id);
|
|
assert_eq!(t2.title, "Neuer Titel");
|
|
}
|
|
|
|
#[test]
|
|
fn delete_translation_removes_file_and_db() {
|
|
let (db, dir) = setup();
|
|
let post = create_post(
|
|
db.conn(),
|
|
dir.path(),
|
|
"p1",
|
|
"Parent",
|
|
Some("body"),
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
let t = upsert_translation(
|
|
db.conn(),
|
|
dir.path(),
|
|
&post.id,
|
|
"de",
|
|
"German",
|
|
None,
|
|
Some("Inhalt"),
|
|
)
|
|
.unwrap();
|
|
|
|
// Publish to create files
|
|
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
|
|
|
// Translation should have a file now
|
|
let t_from_db = qt::get_post_translation_by_id(db.conn(), &t.id).unwrap();
|
|
assert!(!t_from_db.file_path.is_empty());
|
|
let t_file = dir.path().join(&t_from_db.file_path);
|
|
assert!(t_file.exists());
|
|
|
|
// Delete translation
|
|
delete_translation(db.conn(), dir.path(), &t.id).unwrap();
|
|
|
|
// Should be gone from DB
|
|
assert!(qt::get_post_translation_by_id(db.conn(), &t.id).is_err());
|
|
|
|
// File should be gone
|
|
assert!(!t_file.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn rebuild_from_filesystem() {
|
|
let (db, dir) = setup();
|
|
|
|
// Create fixture files
|
|
let posts_dir = dir.path().join("posts").join("2024").join("01");
|
|
fs::create_dir_all(&posts_dir).unwrap();
|
|
|
|
// Write a canonical post
|
|
let post_content = "---\n\
|
|
id: rebuild-post-1\n\
|
|
title: Rebuilt Post\n\
|
|
slug: rebuilt-post\n\
|
|
status: published\n\
|
|
createdAt: '2024-01-15T12:00:00.000Z'\n\
|
|
updatedAt: '2024-01-15T12:00:00.000Z'\n\
|
|
tags:\n - test\n\
|
|
categories: []\n\
|
|
publishedAt: '2024-01-15T12:00:00.000Z'\n\
|
|
---\nHello from rebuild!\n";
|
|
fs::write(posts_dir.join("rebuilt-post.md"), post_content).unwrap();
|
|
|
|
// Write a translation
|
|
let trans_content = "---\n\
|
|
translationFor: rebuild-post-1\n\
|
|
language: de\n\
|
|
title: Wiederhergestellter Beitrag\n\
|
|
---\nHallo vom Rebuild!\n";
|
|
fs::write(posts_dir.join("rebuilt-post.de.md"), trans_content).unwrap();
|
|
|
|
// Run rebuild
|
|
let report =
|
|
rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
|
|
|
assert_eq!(report.posts_created, 1);
|
|
assert_eq!(report.translations_created, 1);
|
|
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
|
|
|
// Verify post in DB
|
|
let post = qp::get_post_by_id(db.conn(), "rebuild-post-1").unwrap();
|
|
assert_eq!(post.title, "Rebuilt Post");
|
|
assert_eq!(post.slug, "rebuilt-post");
|
|
assert_eq!(post.tags, vec!["test"]);
|
|
|
|
// Verify translation in DB
|
|
let trans = qt::get_post_translation_by_post_and_language(
|
|
db.conn(),
|
|
"rebuild-post-1",
|
|
"de",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(trans.title, "Wiederhergestellter Beitrag");
|
|
|
|
// Run rebuild again - should update, not create
|
|
let report2 =
|
|
rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
|
|
assert_eq!(report2.posts_created, 0);
|
|
assert_eq!(report2.posts_updated, 1);
|
|
assert_eq!(report2.translations_created, 0);
|
|
assert_eq!(report2.translations_updated, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn is_translation_filename_works() {
|
|
assert!(is_translation_filename("hello.de"));
|
|
assert!(is_translation_filename("hello.en"));
|
|
assert!(is_translation_filename("my-post.fr"));
|
|
assert!(!is_translation_filename("hello"));
|
|
// Note: "md" is 2 lowercase letters so is_translation_filename("hello.md") = true.
|
|
// In practice this never arises because file_stem() of "hello.md" is "hello",
|
|
// not "hello.md". Only "hello.md.md" would produce stem "hello.md".
|
|
assert!(!is_translation_filename("hello.123"));
|
|
assert!(!is_translation_filename("hello.D"));
|
|
}
|
|
|
|
#[test]
|
|
fn do_not_translate_guard_rejects_translation() {
|
|
let (db, dir) = setup();
|
|
let post = create_post(
|
|
db.conn(), dir.path(), "p1", "No Translate", Some("body"),
|
|
vec![], vec![], None, None, None,
|
|
).unwrap();
|
|
// Set do_not_translate
|
|
update_post(
|
|
db.conn(), dir.path(), &post.id,
|
|
None, None, None, None, None, None, None, None, None, Some(true),
|
|
).unwrap();
|
|
|
|
let result = upsert_translation(
|
|
db.conn(), dir.path(), &post.id, "de", "German", None, Some("Inhalt"),
|
|
);
|
|
assert!(result.is_err());
|
|
match result.unwrap_err() {
|
|
EngineError::Validation(_) => {}
|
|
other => panic!("expected Validation, got: {other}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn update_post_slug_uniqueness_enforced() {
|
|
let (db, dir) = setup();
|
|
create_post(
|
|
db.conn(), dir.path(), "p1", "First", Some("body"),
|
|
vec![], vec![], None, None, None,
|
|
).unwrap();
|
|
let second = create_post(
|
|
db.conn(), dir.path(), "p1", "Second", Some("body"),
|
|
vec![], vec![], None, None, None,
|
|
).unwrap();
|
|
|
|
let result = update_post(
|
|
db.conn(), dir.path(), &second.id,
|
|
None, Some("first"), None, None, None, None, None, None, None, None,
|
|
);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn update_published_post_transitions_to_draft() {
|
|
let (db, dir) = setup();
|
|
let post = create_post(
|
|
db.conn(), dir.path(), "p1", "Published", Some("body"),
|
|
vec![], vec![], None, None, None,
|
|
).unwrap();
|
|
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
|
|
|
// Archive then update to test auto-draft
|
|
archive_post(db.conn(), dir.path(), &post.id).unwrap();
|
|
// Re-publish
|
|
let _ = publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
|
|
|
// Now update the published post
|
|
let updated = update_post(
|
|
db.conn(), dir.path(), &post.id,
|
|
Some("New Title"), None, None, Some("new body"), None, None, None, None, None, None,
|
|
).unwrap();
|
|
assert_eq!(updated.status, PostStatus::Draft);
|
|
}
|
|
|
|
#[test]
|
|
fn canonical_url_format() {
|
|
// 2005-11-13T12:00:00.000Z = 1131883200000
|
|
let url = canonical_url(1131883200000, "esmeralda");
|
|
assert_eq!(url, "/2005/11/13/esmeralda");
|
|
}
|
|
|
|
#[test]
|
|
fn publish_post_already_published_rejected() {
|
|
let (db, dir) = setup();
|
|
let post = create_post(
|
|
db.conn(), dir.path(), "p1", "Double Pub", Some("body"),
|
|
vec![], vec![], None, None, None,
|
|
).unwrap();
|
|
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
|
|
|
let result = publish_post(db.conn(), dir.path(), &post.id);
|
|
assert!(result.is_err());
|
|
match result.unwrap_err() {
|
|
EngineError::Conflict(_) => {}
|
|
other => panic!("expected Conflict, got: {other}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn publish_post_updates_link_graph() {
|
|
let (db, dir) = setup();
|
|
// Create target post first
|
|
let target = create_post(
|
|
db.conn(), dir.path(), "p1", "Target Post", Some("target body"),
|
|
vec![], vec![], None, None, None,
|
|
).unwrap();
|
|
publish_post(db.conn(), dir.path(), &target.id).unwrap();
|
|
|
|
// Create source post with a link to target
|
|
let target_url = canonical_url(target.created_at, &target.slug);
|
|
let body = format!("Check out [this post]({target_url}) for more.");
|
|
let source = create_post(
|
|
db.conn(), dir.path(), "p1", "Source Post", Some(&body),
|
|
vec![], vec![], None, None, None,
|
|
).unwrap();
|
|
publish_post(db.conn(), dir.path(), &source.id).unwrap();
|
|
|
|
// Verify link was created
|
|
let links = ql::list_links_by_source(db.conn(), &source.id).unwrap();
|
|
assert_eq!(links.len(), 1);
|
|
assert_eq!(links[0].target_post_id, target.id);
|
|
}
|
|
|
|
#[test]
|
|
fn archive_from_published_status() {
|
|
let (db, dir) = setup();
|
|
let post = create_post(
|
|
db.conn(), dir.path(), "p1", "To Archive", Some("body"),
|
|
vec![], vec![], None, None, None,
|
|
).unwrap();
|
|
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
|
archive_post(db.conn(), dir.path(), &post.id).unwrap();
|
|
|
|
let from_db = qp::get_post_by_id(db.conn(), &post.id).unwrap();
|
|
assert_eq!(from_db.status, PostStatus::Archived);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_post_links_extracts_canonical_urls() {
|
|
let content = "See [my post](/2024/01/15/hello-world) and [another](/2024/02/01/test-post/) for more.";
|
|
let links = parse_post_links(content);
|
|
assert_eq!(links.len(), 2);
|
|
assert_eq!(links[0].0, "hello-world");
|
|
assert_eq!(links[0].1, "my post");
|
|
assert_eq!(links[1].0, "test-post");
|
|
}
|
|
}
|