feat: finalisation of M1
This commit is contained in:
@@ -411,7 +411,7 @@ pub fn rebuild_media_from_filesystem(
|
||||
/// Index a media item in FTS, gathering translation texts.
|
||||
fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> {
|
||||
let translations = qmt::list_media_translations_by_media(conn, &media.id).unwrap_or_default();
|
||||
let translation_texts: Vec<String> = translations
|
||||
let translation_data: Vec<(String, String)> = translations
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut parts = Vec::new();
|
||||
@@ -424,7 +424,7 @@ fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> {
|
||||
if let Some(ref caption) = t.caption {
|
||||
parts.push(caption.clone());
|
||||
}
|
||||
parts.join(" ")
|
||||
(parts.join(" "), t.language.clone())
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -437,7 +437,7 @@ fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> {
|
||||
media.caption.as_deref(),
|
||||
&media.original_name,
|
||||
&media.tags,
|
||||
&translation_texts,
|
||||
&translation_data,
|
||||
lang,
|
||||
)?;
|
||||
Ok(())
|
||||
|
||||
@@ -10,7 +10,7 @@ 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, PostStatus, PostTranslation};
|
||||
use crate::model::{Post, PostLink, PostStatus, PostTranslation};
|
||||
use crate::util::frontmatter::{
|
||||
read_post_file, read_translation_file, write_post_file, write_translation_file,
|
||||
};
|
||||
@@ -114,6 +114,17 @@ pub fn update_post(
|
||||
));
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -145,6 +156,11 @@ pub fn update_post(
|
||||
post.do_not_translate = dnt;
|
||||
}
|
||||
|
||||
// Auto-transition published post back to draft on content/metadata change
|
||||
if post.status == PostStatus::Published {
|
||||
post.status = PostStatus::Draft;
|
||||
}
|
||||
|
||||
post.updated_at = now_unix_ms();
|
||||
qp::update_post(conn, &post)?;
|
||||
|
||||
@@ -173,6 +189,9 @@ pub fn publish_post(
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -245,9 +264,32 @@ pub fn publish_post(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -308,6 +350,12 @@ pub fn delete_post(
|
||||
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,
|
||||
@@ -319,6 +367,11 @@ pub fn upsert_translation(
|
||||
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
|
||||
@@ -476,6 +529,46 @@ pub fn rebuild_posts_from_filesystem(
|
||||
|
||||
// --- 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,
|
||||
@@ -525,7 +618,7 @@ fn publish_translation(
|
||||
/// 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_texts: Vec<String> = translations
|
||||
let translation_data: Vec<(String, String)> = translations
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let mut parts = vec![t.title.clone()];
|
||||
@@ -535,7 +628,7 @@ fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> {
|
||||
if let Some(ref cnt) = t.content {
|
||||
parts.push(cnt.clone());
|
||||
}
|
||||
parts.join(" ")
|
||||
(parts.join(" "), t.language.clone())
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -548,7 +641,7 @@ fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> {
|
||||
post.content.as_deref(),
|
||||
&post.tags,
|
||||
&post.categories,
|
||||
&translation_texts,
|
||||
&translation_data,
|
||||
lang,
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -1232,4 +1325,141 @@ mod tests {
|
||||
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(), &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(), &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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn create_project(
|
||||
// Determine data directory
|
||||
let data_dir = match data_path {
|
||||
Some(p) => std::path::PathBuf::from(p),
|
||||
None => std::path::PathBuf::from(&slug),
|
||||
None => std::path::PathBuf::from("projects").join(&id),
|
||||
};
|
||||
|
||||
// Create directory structure
|
||||
@@ -72,8 +72,27 @@ pub fn list_projects(conn: &Connection) -> EngineResult<Vec<Project>> {
|
||||
}
|
||||
|
||||
/// Delete a project row (cascading handled by queries).
|
||||
pub fn delete_project(conn: &Connection, project_id: &str) -> EngineResult<()> {
|
||||
/// Rejects deletion of the currently active project.
|
||||
/// Optionally cleans up the project data directory.
|
||||
pub fn delete_project(conn: &Connection, project_id: &str, data_dir: Option<&Path>) -> EngineResult<()> {
|
||||
// Check if this is the active project (don't delete active)
|
||||
if let Ok(active) = q::get_active_project(conn) {
|
||||
if active.id == project_id {
|
||||
return Err(EngineError::Validation(
|
||||
"cannot delete the active project".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
q::delete_project(conn, project_id)?;
|
||||
|
||||
// Clean up filesystem if path provided
|
||||
if let Some(dir) = data_dir {
|
||||
if dir.exists() {
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -229,7 +248,18 @@ mod tests {
|
||||
let (db, dir) = setup();
|
||||
let p_path = dir.path().join("p");
|
||||
let p = create_project(db.conn(), "P", Some(p_path.to_str().unwrap())).unwrap();
|
||||
delete_project(db.conn(), &p.id).unwrap();
|
||||
delete_project(db.conn(), &p.id, Some(&p_path)).unwrap();
|
||||
assert!(list_projects(db.conn()).unwrap().is_empty());
|
||||
assert!(!p_path.exists(), "project directory should be cleaned up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_active_project_rejected() {
|
||||
let (db, dir) = setup();
|
||||
let p_path = dir.path().join("p");
|
||||
let p = create_project(db.conn(), "P", Some(p_path.to_str().unwrap())).unwrap();
|
||||
set_active_project(db.conn(), &p.id).unwrap();
|
||||
let result = delete_project(db.conn(), &p.id, None);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,11 +86,10 @@ pub fn delete_tag(
|
||||
let tag = tag_q::get_tag_by_id(conn, tag_id)
|
||||
.map_err(|_| EngineError::NotFound(format!("tag {tag_id}")))?;
|
||||
|
||||
// Remove tag name from all posts
|
||||
remove_tag_name_from_posts(conn, project_id, &tag.name)?;
|
||||
|
||||
let modified = remove_tag_name_from_posts(conn, project_id, &tag.name)?;
|
||||
tag_q::delete_tag(conn, tag_id)?;
|
||||
rewrite_tags_json(conn, data_dir, project_id)?;
|
||||
flush_post_frontmatter(conn, data_dir, &modified)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -109,6 +108,7 @@ pub fn rename_tag(
|
||||
// Update all posts: replace old name with new name in tag arrays
|
||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||
let now = now_unix_ms();
|
||||
let mut modified = Vec::new();
|
||||
for mut post in posts {
|
||||
if post.tags.iter().any(|t| t.eq_ignore_ascii_case(&old_name)) {
|
||||
post.tags = post
|
||||
@@ -124,6 +124,7 @@ pub fn rename_tag(
|
||||
.collect();
|
||||
post.updated_at = now;
|
||||
post_q::update_post(conn, &post)?;
|
||||
modified.push(post.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +132,7 @@ pub fn rename_tag(
|
||||
tag.updated_at = now;
|
||||
tag_q::update_tag(conn, &tag)?;
|
||||
rewrite_tags_json(conn, data_dir, project_id)?;
|
||||
flush_post_frontmatter(conn, data_dir, &modified)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -146,6 +148,7 @@ pub fn merge_tags(
|
||||
let target_tag = tag_q::get_tag_by_id(conn, target_id)
|
||||
.map_err(|_| EngineError::NotFound(format!("target tag {target_id}")))?;
|
||||
|
||||
let mut all_modified = Vec::new();
|
||||
for &source_id in source_ids {
|
||||
let source_tag = tag_q::get_tag_by_id(conn, source_id)
|
||||
.map_err(|_| EngineError::NotFound(format!("source tag {source_id}")))?;
|
||||
@@ -171,6 +174,9 @@ pub fn merge_tags(
|
||||
}
|
||||
post.updated_at = now;
|
||||
post_q::update_post(conn, &post)?;
|
||||
if !all_modified.contains(&post.id) {
|
||||
all_modified.push(post.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +184,7 @@ pub fn merge_tags(
|
||||
}
|
||||
|
||||
rewrite_tags_json(conn, data_dir, project_id)?;
|
||||
flush_post_frontmatter(conn, data_dir, &all_modified)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -240,22 +247,52 @@ pub fn rewrite_tags_json(
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Rewrite frontmatter files for posts whose tags were modified.
|
||||
/// Only rewrites published posts (that have file_path set).
|
||||
fn flush_post_frontmatter(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
post_ids: &[String],
|
||||
) -> EngineResult<()> {
|
||||
use crate::util::frontmatter::write_post_file;
|
||||
use crate::util::atomic_write_str;
|
||||
|
||||
for post_id in post_ids {
|
||||
let post = post_q::get_post_by_id(conn, post_id)?;
|
||||
if !post.file_path.is_empty() {
|
||||
let abs_path = data_dir.join(&post.file_path);
|
||||
if abs_path.exists() {
|
||||
// Read existing body from file
|
||||
let content = std::fs::read_to_string(&abs_path)?;
|
||||
let (_fm, body) = crate::util::frontmatter::read_post_file(&content)
|
||||
.map_err(|e| EngineError::Parse(e))?;
|
||||
// Rewrite with updated frontmatter
|
||||
let file_content = write_post_file(&post, &body);
|
||||
atomic_write_str(&abs_path, &file_content)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_tag_name_from_posts(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
tag_name: &str,
|
||||
) -> EngineResult<()> {
|
||||
) -> EngineResult<Vec<String>> {
|
||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||
let now = now_unix_ms();
|
||||
let mut modified = Vec::new();
|
||||
for mut post in posts {
|
||||
if post.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_name)) {
|
||||
post.tags
|
||||
.retain(|t| !t.eq_ignore_ascii_case(tag_name));
|
||||
post.updated_at = now;
|
||||
post_q::update_post(conn, &post)?;
|
||||
modified.push(post.id.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(modified)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -463,4 +500,26 @@ mod tests {
|
||||
assert_eq!(entries[0].name, "alpha");
|
||||
assert_eq!(entries[1].name, "zebra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_tag_flushes_post_frontmatter() {
|
||||
let (db, dir) = setup();
|
||||
// Create and publish a post with a tag
|
||||
use crate::db::fts::ensure_fts_tables;
|
||||
ensure_fts_tables(db.conn()).unwrap();
|
||||
let post = crate::engine::post::create_post(
|
||||
db.conn(), dir.path(), "p1", "Tagged Post", Some("body content"),
|
||||
vec!["rust".into()], vec![], None, None, None,
|
||||
).unwrap();
|
||||
crate::engine::post::publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||
|
||||
let tag = create_tag(db.conn(), dir.path(), "p1", "rust", None).unwrap();
|
||||
rename_tag(db.conn(), dir.path(), "p1", &tag.id, "golang").unwrap();
|
||||
|
||||
// Read the file from disk and verify tag was updated
|
||||
let from_db = crate::db::queries::post::get_post_by_id(db.conn(), &post.id).unwrap();
|
||||
let file_content = std::fs::read_to_string(dir.path().join(&from_db.file_path)).unwrap();
|
||||
assert!(file_content.contains("golang"), "frontmatter should contain renamed tag");
|
||||
assert!(!file_content.contains("rust"), "frontmatter should not contain old tag name");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ struct TaskEntry {
|
||||
label: String,
|
||||
status: TaskStatus,
|
||||
cancel_flag: Arc<AtomicBool>,
|
||||
progress: Option<f32>,
|
||||
message: Option<String>,
|
||||
created_at: Instant,
|
||||
}
|
||||
|
||||
/// Manages concurrent tasks with a max concurrency limit and FIFO queue.
|
||||
@@ -60,9 +63,20 @@ impl TaskManager {
|
||||
label: label.to_owned(),
|
||||
status: TaskStatus::Queued,
|
||||
cancel_flag: Arc::new(AtomicBool::new(false)),
|
||||
progress: None,
|
||||
message: None,
|
||||
created_at: Instant::now(),
|
||||
};
|
||||
|
||||
self.tasks.lock().unwrap().push(entry);
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
tasks.push(entry);
|
||||
// Auto-start if under capacity
|
||||
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
|
||||
if running < self.max_concurrent {
|
||||
if let Some(t) = tasks.iter_mut().find(|t| t.id == id && t.status == TaskStatus::Queued) {
|
||||
t.status = TaskStatus::Running;
|
||||
}
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
@@ -87,25 +101,35 @@ impl TaskManager {
|
||||
pub fn complete(&self, task_id: TaskId) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||
entry.status = TaskStatus::Completed;
|
||||
if matches!(entry.status, TaskStatus::Running) {
|
||||
entry.status = TaskStatus::Completed;
|
||||
entry.progress = Some(1.0);
|
||||
}
|
||||
}
|
||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||
}
|
||||
|
||||
/// Mark a task as failed with an error message.
|
||||
pub fn fail(&self, task_id: TaskId, error: String) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||
entry.status = TaskStatus::Failed(error);
|
||||
if matches!(entry.status, TaskStatus::Running) {
|
||||
entry.status = TaskStatus::Failed(error);
|
||||
}
|
||||
}
|
||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||
}
|
||||
|
||||
/// Cancel a task by setting its cancel flag and status.
|
||||
pub fn cancel(&self, task_id: TaskId) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||
entry.cancel_flag.store(true, Ordering::Release);
|
||||
entry.status = TaskStatus::Cancelled;
|
||||
if matches!(entry.status, TaskStatus::Running | TaskStatus::Queued) {
|
||||
entry.cancel_flag.store(true, Ordering::Release);
|
||||
entry.status = TaskStatus::Cancelled;
|
||||
}
|
||||
}
|
||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||
}
|
||||
|
||||
/// Check whether a task has been cancelled.
|
||||
@@ -153,6 +177,39 @@ impl TaskManager {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.iter().find(|t| t.status == TaskStatus::Queued).map(|t| t.id)
|
||||
}
|
||||
|
||||
/// Update progress for a running task.
|
||||
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
|
||||
if entry.status == TaskStatus::Running {
|
||||
entry.progress = progress;
|
||||
entry.message = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the current progress of a task.
|
||||
pub fn progress(&self, task_id: TaskId) -> Option<f32> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.iter().find(|t| t.id == task_id).and_then(|t| t.progress)
|
||||
}
|
||||
|
||||
/// Return the current message of a task.
|
||||
pub fn message(&self, task_id: TaskId) -> Option<String> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.iter().find(|t| t.id == task_id).and_then(|t| t.message.clone())
|
||||
}
|
||||
|
||||
/// Promote the next queued task to running if capacity allows.
|
||||
fn promote_next(tasks: &mut Vec<TaskEntry>, max_concurrent: usize) {
|
||||
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
|
||||
if running < max_concurrent {
|
||||
if let Some(t) = tasks.iter_mut().find(|t| t.status == TaskStatus::Queued) {
|
||||
t.status = TaskStatus::Running;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskManager {
|
||||
@@ -201,9 +258,7 @@ mod tests {
|
||||
fn submit_and_start() {
|
||||
let mgr = TaskManager::default();
|
||||
let id = mgr.submit("build site");
|
||||
assert_eq!(mgr.status(id), Some(TaskStatus::Queued));
|
||||
|
||||
assert!(mgr.try_start(id));
|
||||
// Auto-started since capacity allows
|
||||
assert_eq!(mgr.status(id), Some(TaskStatus::Running));
|
||||
}
|
||||
|
||||
@@ -212,26 +267,23 @@ mod tests {
|
||||
let mgr = TaskManager::new(3);
|
||||
let ids: Vec<TaskId> = (0..4).map(|i| mgr.submit(&format!("task {i}"))).collect();
|
||||
|
||||
assert!(mgr.try_start(ids[0]));
|
||||
assert!(mgr.try_start(ids[1]));
|
||||
assert!(mgr.try_start(ids[2]));
|
||||
assert!(!mgr.try_start(ids[3]));
|
||||
|
||||
// First 3 auto-started, 4th stays queued
|
||||
assert_eq!(mgr.running_count(), 3);
|
||||
assert_eq!(mgr.status(ids[3]), Some(TaskStatus::Queued));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fifo_order() {
|
||||
let mgr = TaskManager::default();
|
||||
let a = mgr.submit("first");
|
||||
let b = mgr.submit("second");
|
||||
let c = mgr.submit("third");
|
||||
let mgr = TaskManager::new(1); // limit to 1 to test FIFO
|
||||
let a = mgr.submit("first"); // auto-starts
|
||||
let b = mgr.submit("second"); // queued
|
||||
let c = mgr.submit("third"); // queued
|
||||
|
||||
assert_eq!(mgr.next_queued(), Some(a));
|
||||
mgr.try_start(a);
|
||||
assert_eq!(mgr.status(a), Some(TaskStatus::Running));
|
||||
assert_eq!(mgr.next_queued(), Some(b));
|
||||
mgr.try_start(b);
|
||||
|
||||
mgr.complete(a); // should auto-promote b
|
||||
assert_eq!(mgr.status(b), Some(TaskStatus::Running));
|
||||
assert_eq!(mgr.next_queued(), Some(c));
|
||||
}
|
||||
|
||||
@@ -239,8 +291,7 @@ mod tests {
|
||||
fn cancel_sets_flag() {
|
||||
let mgr = TaskManager::default();
|
||||
let id = mgr.submit("upload");
|
||||
mgr.try_start(id);
|
||||
|
||||
// Task is auto-started (Running)
|
||||
assert!(!mgr.is_cancelled(id));
|
||||
mgr.cancel(id);
|
||||
assert!(mgr.is_cancelled(id));
|
||||
@@ -253,41 +304,70 @@ mod tests {
|
||||
let ok = mgr.submit("good task");
|
||||
let bad = mgr.submit("bad task");
|
||||
|
||||
mgr.try_start(ok);
|
||||
mgr.try_start(bad);
|
||||
|
||||
// Both auto-started (capacity=3)
|
||||
mgr.complete(ok);
|
||||
mgr.fail(bad, "disk full".into());
|
||||
|
||||
assert_eq!(mgr.status(ok), Some(TaskStatus::Completed));
|
||||
assert_eq!(mgr.status(bad), Some(TaskStatus::Failed("disk full".into())));
|
||||
// Progress should be 1.0 on completed
|
||||
assert_eq!(mgr.progress(ok), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_removes_finished() {
|
||||
let mgr = TaskManager::default();
|
||||
let a = mgr.submit("done");
|
||||
let b = mgr.submit("broken");
|
||||
let c = mgr.submit("stopped");
|
||||
let d = mgr.submit("waiting");
|
||||
let e = mgr.submit("busy");
|
||||
let mgr = TaskManager::new(3);
|
||||
let a = mgr.submit("done"); // auto-starts
|
||||
let b = mgr.submit("broken"); // auto-starts
|
||||
let e = mgr.submit("busy"); // auto-starts
|
||||
let _c = mgr.submit("stopped"); // queued (at capacity)
|
||||
let _d = mgr.submit("waiting"); // queued
|
||||
|
||||
mgr.try_start(a);
|
||||
mgr.try_start(b);
|
||||
mgr.try_start(e);
|
||||
mgr.complete(a);
|
||||
mgr.fail(b, "oops".into());
|
||||
mgr.cancel(c);
|
||||
// c should have been auto-promoted when a completed, and again when b failed
|
||||
// After a completes: c promoted to running
|
||||
// After b fails: d promoted to running
|
||||
|
||||
mgr.drain_completed();
|
||||
|
||||
assert_eq!(mgr.status(a), None);
|
||||
assert_eq!(mgr.status(b), None);
|
||||
assert_eq!(mgr.status(c), None);
|
||||
assert_eq!(mgr.status(d), Some(TaskStatus::Queued));
|
||||
// c and d were promoted, e is still running
|
||||
assert_eq!(mgr.status(e), Some(TaskStatus::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completing_task_starts_next_queued() {
|
||||
let mgr = TaskManager::new(1);
|
||||
let a = mgr.submit("first"); // auto-starts
|
||||
let b = mgr.submit("second"); // queued
|
||||
|
||||
assert_eq!(mgr.status(a), Some(TaskStatus::Running));
|
||||
assert_eq!(mgr.status(b), Some(TaskStatus::Queued));
|
||||
|
||||
mgr.complete(a);
|
||||
assert_eq!(mgr.status(b), Some(TaskStatus::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_precondition_ignores_completed() {
|
||||
let mgr = TaskManager::default();
|
||||
let id = mgr.submit("task");
|
||||
mgr.complete(id);
|
||||
mgr.cancel(id); // should be no-op
|
||||
assert_eq!(mgr.status(id), Some(TaskStatus::Completed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_progress_updates_task() {
|
||||
let mgr = TaskManager::default();
|
||||
let id = mgr.submit("upload");
|
||||
mgr.report_progress(id, Some(0.5), Some("halfway".into()));
|
||||
assert_eq!(mgr.progress(id), Some(0.5));
|
||||
assert_eq!(mgr.message(id), Some("halfway".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_throttle_initial_reports() {
|
||||
let throttle = ProgressThrottle::new(PROGRESS_THROTTLE_MS);
|
||||
|
||||
Reference in New Issue
Block a user