diff --git a/README.md b/README.md index 945aa7e..c6e9648 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ The project is under active development. Core blogging workflows are broadly ava ## Available Features - Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars, dialogs, tasks, embedded Wry previews, and a live Pico CSS theme editor. -- Post and translation authoring with change-aware draft/published/archive lifecycle, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, links, media, and batch gallery-image import. +- Post and translation authoring with change-aware draft/published/archive lifecycle, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, live link/backlink graphs, media, and batch gallery-image import. - Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors. - WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping. - Post, Liquid template, and Lua script editing with dedicated syntax highlighting and explicit syntax-check feedback, including publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync. diff --git a/crates/bds-core/src/engine/metadata_diff.rs b/crates/bds-core/src/engine/metadata_diff.rs index 87c9a4d..206687e 100644 --- a/crates/bds-core/src/engine/metadata_diff.rs +++ b/crates/bds-core/src/engine/metadata_diff.rs @@ -1243,12 +1243,38 @@ mod tests { #[test] fn repairs_one_post_diff_from_file_to_database() { let (db, dir) = setup(); + let database_target = create_post( + db.conn(), + dir.path(), + "p1", + "Database Target", + None, + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); + let filesystem_target = create_post( + db.conn(), + dir.path(), + "p1", + "Filesystem Target", + None, + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); let post = create_post( db.conn(), dir.path(), "p1", "Database Title", - Some("body"), + Some("[database](/2024/01/01/database-target)"), vec![], vec![], None, @@ -1259,7 +1285,16 @@ mod tests { let published = publish_post(db.conn(), dir.path(), &post.id).unwrap(); let path = dir.path().join(&published.file_path); let content = fs::read_to_string(&path).unwrap(); - fs::write(&path, content.replace("Database Title", "Filesystem Title")).unwrap(); + fs::write( + &path, + content + .replace("Database Title", "Filesystem Title") + .replace( + "[database](/2024/01/01/database-target)", + "[filesystem](/2024/01/01/filesystem-target)", + ), + ) + .unwrap(); let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); let item = report .diffs @@ -1280,6 +1315,11 @@ mod tests { qp::get_post_by_id(db.conn(), &post.id).unwrap().title, "Filesystem Title" ); + let links = + crate::db::queries::post_link::list_links_by_source(db.conn(), &post.id).unwrap(); + assert_eq!(links.len(), 1); + assert_eq!(links[0].target_post_id, filesystem_target.id); + assert_ne!(links[0].target_post_id, database_target.id); } #[test] diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index e2bff5f..b1869d0 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -1,7 +1,10 @@ +use std::collections::HashSet; use std::fs; use std::path::Path; +use std::sync::OnceLock; use crate::db::DbConnection as Connection; +use regex::Regex; use uuid::Uuid; use walkdir::WalkDir; @@ -156,6 +159,13 @@ pub fn update_post( let reopen_published = published_metadata_changed || (post.status == PostStatus::Published && content.is_some_and(|value| published_body.as_deref() != Some(value))); + let content_changed = content.is_some_and(|value| { + if post.status == PostStatus::Published { + published_body.as_deref() != Some(value) + } else { + post.content.as_deref() != Some(value) + } + }); let rewrite_published_template = post.status == PostStatus::Published && !reopen_published && template_slug @@ -204,6 +214,10 @@ pub fn update_post( post.updated_at = now_unix_ms(); qp::update_post(conn, &post)?; + if content_changed { + sync_post_links(conn, &post, post.content.as_deref().unwrap_or_default())?; + } + if rewrite_published_template { rewrite_published_post(conn, data_dir, &post.id)?; } @@ -343,26 +357,7 @@ fn publish_post_in_savepoint( 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.clone(), - target_post_id: target.id.clone(), - link_text: Some(link_text.clone()), - created_at: now, - }; - let _ = ql::insert_post_link(conn, &link); - } - } + sync_post_links(conn, &post, &body)?; // Re-index FTS fts_index_post(conn, data_dir, &post)?; @@ -521,6 +516,8 @@ pub fn discard_post_draft(conn: &Connection, data_dir: &Path, post_id: &str) -> conn.begin_savepoint()?; match (|| { qp::update_post(conn, &post)?; + let body = resolve_post_fts_content(data_dir, &post)?.unwrap_or_default(); + sync_post_links(conn, &post, &body)?; fts_index_post(conn, data_dir, &post)?; Ok(post) })() { @@ -867,6 +864,10 @@ pub fn rebuild_posts_from_filesystem_with_progress( } } + // Resolve links after every canonical post exists so filesystem order cannot + // make links to later files disappear. + rebuild_all_links(conn, data_dir, project_id)?; + // Re-index FTS for all posts in this project let posts = qp::list_posts_by_project(conn, project_id)?; for post in &posts { @@ -876,58 +877,31 @@ pub fn rebuild_posts_from_filesystem_with_progress( Ok(report) } -/// Rebuild the inter-post link graph for all published posts in a project. -/// Reads each published post's content from the filesystem, parses links, -/// and recreates post_links records. +/// Rebuild the inter-post link graph for every post in a project, resolving +/// draft bodies from the database and published bodies from the filesystem. pub fn rebuild_all_links( conn: &Connection, data_dir: &Path, project_id: &str, ) -> EngineResult { let posts = qp::list_posts_by_project(conn, project_id)?; - let now = now_unix_ms(); let mut link_count = 0; for post in &posts { - // Delete existing links from this post - ql::delete_links_by_source(conn, &post.id)?; - // Get post content: from DB or filesystem - let content = if let Some(ref c) = post.content { - c.clone() + let content = if let Some(ref content) = post.content { + content.clone() } else if !post.file_path.is_empty() { let abs_path = data_dir.join(&post.file_path); - if abs_path.exists() { - if let Ok(raw) = fs::read_to_string(&abs_path) { - if let Ok((_fm, body)) = read_post_file(&raw) { - body - } else { - continue; - } - } else { - continue; - } - } else { - continue; - } + fs::read_to_string(&abs_path) + .ok() + .and_then(|raw| read_post_file(&raw).ok().map(|(_, body)| body)) + .unwrap_or_default() } else { - continue; + String::new() }; - let parsed = parse_post_links(&content); - for (target_slug, link_text) in &parsed { - if let Ok(target) = qp::get_post_by_project_and_slug(conn, project_id, target_slug) { - let link = PostLink { - id: Uuid::new_v4().to_string(), - source_post_id: post.id.clone(), - target_post_id: target.id.clone(), - link_text: Some(link_text.clone()), - created_at: now, - }; - let _ = ql::insert_post_link(conn, &link); - link_count += 1; - } - } + link_count += sync_post_links(conn, post, &content)?; } Ok(link_count) @@ -935,48 +909,125 @@ pub fn rebuild_all_links( // --- 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; +/// Replace one post's outgoing link graph from its resolved body. +pub fn sync_post_links(conn: &Connection, post: &Post, body: &str) -> EngineResult { + conn.begin_savepoint()?; + let result = (|| { + ql::delete_links_by_source(conn, &post.id)?; + let now = now_unix_ms(); + let mut inserted = HashSet::new(); + let mut link_count = 0; + for (target_slug, link_text) in parse_post_links(body) { + let target = + match qp::get_post_by_project_and_slug(conn, &post.project_id, &target_slug) { + Ok(target) => target, + Err(diesel::result::Error::NotFound) => continue, + Err(error) => return Err(error.into()), + }; + if !inserted.insert((target.id.clone(), link_text.clone())) { + continue; } + ql::insert_post_link( + conn, + &PostLink { + id: Uuid::new_v4().to_string(), + source_post_id: post.id.clone(), + target_post_id: target.id, + link_text, + created_at: now, + }, + )?; + link_count += 1; + } + Ok(link_count) + })(); + match result { + Ok(link_count) => { + conn.release_savepoint()?; + Ok(link_count) + } + Err(error) => { + let _ = conn.rollback_savepoint(); + Err(error) } } - links +} + +/// Parse Markdown and HTML links into bDS2-compatible post slugs and labels. +fn parse_post_links(content: &str) -> Vec<(String, Option)> { + static MARKDOWN_LINK: OnceLock = OnceLock::new(); + static HTML_LINK: OnceLock = OnceLock::new(); + let markdown = MARKDOWN_LINK + .get_or_init(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").expect("valid Markdown link regex")); + let html = HTML_LINK.get_or_init(|| { + Regex::new(r#"(?is)]*href=["']([^"']+)["'][^>]*>(.*?)"#) + .expect("valid HTML link regex") + }); + let markdown_links = markdown.captures_iter(content).filter_map(|captures| { + parsed_post_link(captures.get(2)?.as_str(), captures.get(1)?.as_str()) + }); + let html_links = html.captures_iter(content).filter_map(|captures| { + parsed_post_link(captures.get(1)?.as_str(), captures.get(2)?.as_str()) + }); + markdown_links.chain(html_links).collect() +} + +fn parsed_post_link(href: &str, text: &str) -> Option<(String, Option)> { + static HTML_TAG: OnceLock = OnceLock::new(); + let base = url::Url::parse("https://ruds.invalid/").expect("valid internal link base"); + let url = base.join(href.trim()).ok()?; + if !matches!(url.scheme(), "http" | "https") { + return None; + } + let slug = post_slug_from_path(url.path())?; + let plain_text = HTML_TAG + .get_or_init(|| Regex::new(r"<[^>]+>").expect("valid HTML tag regex")) + .replace_all(text, ""); + let plain_text = plain_text.trim(); + Some(( + slug.to_string(), + (!plain_text.is_empty()).then(|| plain_text.to_string()), + )) +} + +fn post_slug_from_path(path: &str) -> Option<&str> { + let segments = path + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + match segments.as_slice() { + [year, month, day, slug] + if is_year(year) && is_month_or_day(month) && is_month_or_day(day) => + { + Some(slug) + } + [language, year, month, day, slug] + if is_language(language) + && is_year(year) + && is_month_or_day(month) + && is_month_or_day(day) => + { + Some(slug) + } + [slug] => Some(slug), + [language, slug] if is_language(language) => Some(slug), + _ => None, + } +} + +fn is_year(value: &str) -> bool { + value.len() == 4 && value.chars().all(|character| character.is_ascii_digit()) +} + +fn is_month_or_day(value: &str) -> bool { + value.len() == 2 && value.chars().all(|character| character.is_ascii_digit()) +} + +fn is_language(value: &str) -> bool { + value.len() == 2 + && value + .chars() + .all(|character| character.is_ascii_lowercase()) } /// Publish a single translation: write file, clear content, set status. @@ -1121,7 +1172,7 @@ pub(crate) fn rebuild_canonical_post( post.content = if status == PostStatus::Published { None } else { - Some(body) + Some(body.clone()) }; post.status = status; post.author = fm.author; @@ -1136,9 +1187,10 @@ pub(crate) fn rebuild_canonical_post( post.updated_at = fm.updated_at; post.published_at = fm.published_at; qp::update_post(conn, &post)?; + sync_post_links(conn, &post, &body)?; Ok(false) } - Err(_) => { + Err(diesel::result::Error::NotFound) => { // Insert new post let post = Post { id: fm.id, @@ -1149,7 +1201,7 @@ pub(crate) fn rebuild_canonical_post( content: if status == PostStatus::Published { None } else { - Some(body) + Some(body.clone()) }, status, author: fm.author, @@ -1170,8 +1222,10 @@ pub(crate) fn rebuild_canonical_post( published_at: fm.published_at, }; qp::insert_post(conn, &post)?; + sync_post_links(conn, &post, &body)?; Ok(true) } + Err(error) => Err(error.into()), } } @@ -1516,6 +1570,91 @@ mod tests { assert!(updated.do_not_translate); } + #[test] + fn draft_content_updates_replace_outgoing_post_links_without_publishing() { + let (db, dir) = setup(); + let first = create_post( + db.conn(), + dir.path(), + "p1", + "First Target", + Some("first"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); + let second = create_post( + db.conn(), + dir.path(), + "p1", + "Second Target", + Some("second"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); + let source = create_post( + db.conn(), + dir.path(), + "p1", + "Source", + Some("no links"), + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); + + update_post( + db.conn(), + dir.path(), + &source.id, + None, + None, + None, + Some("[first](/2024/01/01/first-target) [first](/2024/01/01/first-target) [second](/2024/01/01/second-target)"), + None, + None, + None, + None, + None, + None, + ) + .unwrap(); + let links = ql::list_links_by_source(db.conn(), &source.id).unwrap(); + assert_eq!(links.len(), 2); + assert!(links.iter().any(|link| link.target_post_id == first.id)); + assert!(links.iter().any(|link| link.target_post_id == second.id)); + + update_post( + db.conn(), + dir.path(), + &source.id, + None, + None, + None, + Some("[second](/2024/01/01/second-target)"), + None, + None, + None, + None, + None, + None, + ) + .unwrap(); + let links = ql::list_links_by_source(db.conn(), &source.id).unwrap(); + assert_eq!(links.len(), 1); + assert_eq!(links[0].target_post_id, second.id); + } + #[test] fn identical_published_update_stays_published() { let (db, dir) = setup(); @@ -1730,12 +1869,38 @@ mod tests { #[test] fn discard_post_draft_restores_published_state() { let (db, dir) = setup(); + let published_target = create_post( + db.conn(), + dir.path(), + "p1", + "Published Target", + None, + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); + let draft_target = create_post( + db.conn(), + dir.path(), + "p1", + "Draft Target", + None, + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); let post = create_post( db.conn(), dir.path(), "p1", "Discard Me", - Some("published body"), + Some("[published](/2024/01/01/published-target)"), vec!["one".into()], vec!["cat".into()], Some("Alice"), @@ -1752,7 +1917,7 @@ mod tests { Some("Changed Title"), None, Some(Some("changed excerpt")), - Some("draft body"), + Some("[draft](/2024/01/01/draft-target)"), Some(vec!["two".into()]), Some(vec!["other".into()]), Some(Some("Bob")), @@ -1763,7 +1928,13 @@ mod tests { .unwrap(); assert_eq!(updated.status, PostStatus::Draft); - assert_eq!(updated.content.as_deref(), Some("draft body")); + assert_eq!( + updated.content.as_deref(), + Some("[draft](/2024/01/01/draft-target)") + ); + let draft_links = ql::list_links_by_source(db.conn(), &post.id).unwrap(); + assert_eq!(draft_links.len(), 1); + assert_eq!(draft_links[0].target_post_id, draft_target.id); let discarded = discard_post_draft(db.conn(), dir.path(), &post.id).unwrap(); assert_eq!(discarded.status, PostStatus::Published); @@ -1773,6 +1944,9 @@ mod tests { assert_eq!(discarded.categories, vec!["cat"]); assert_eq!(discarded.content, None); assert_eq!(discarded.language.as_deref(), Some("en")); + let restored_links = ql::list_links_by_source(db.conn(), &post.id).unwrap(); + assert_eq!(restored_links.len(), 1); + assert_eq!(restored_links[0].target_post_id, published_target.id); assert!(!discarded.do_not_translate); } @@ -2514,11 +2688,15 @@ mod tests { #[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 content = "See [dated](/2024/01/15/hello-world), [localized](/de/2024/02/01/test-post/), [short](/notes), [localized short](/fr/article), and HTML post."; let links = parse_post_links(content); - assert_eq!(links.len(), 2); + assert_eq!(links.len(), 5); assert_eq!(links[0].0, "hello-world"); - assert_eq!(links[0].1, "my post"); + assert_eq!(links[0].1.as_deref(), Some("dated")); assert_eq!(links[1].0, "test-post"); + assert_eq!(links[2].0, "notes"); + assert_eq!(links[3].0, "article"); + assert_eq!(links[4].0, "html-post"); + assert_eq!(links[4].1.as_deref(), Some("HTML post")); } } diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index d87f433..922f552 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -10766,6 +10766,50 @@ mod tests { assert_eq!(saved.tags, vec!["rust".to_string(), "lua".to_string()]); } + #[test] + fn draft_editor_save_refreshes_live_outlinks_without_publishing() { + let (db, project, tmp) = setup(); + let target = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Link Target", + Some("Target body"), + Vec::new(), + Vec::new(), + None, + Some("en"), + None, + ) + .unwrap(); + let source = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Link Source", + Some("No links"), + Vec::new(), + Vec::new(), + None, + Some("en"), + None, + ) + .unwrap(); + let mut app = make_app(db, project, &tmp); + open_post_editor(&mut app, &source); + let editor = app.post_editors.get_mut(&source.id).unwrap(); + editor.content = "[target](/2024/01/01/link-target)".to_string(); + editor.is_dirty = true; + + app.persist_post_editor_state(&source.id).unwrap(); + + let editor = &app.post_editors[&source.id]; + assert_eq!(editor.status, PostStatus::Draft); + assert_eq!(editor.outlinks.len(), 1); + assert_eq!(editor.outlinks[0].post_id, target.id); + assert_eq!(editor.outlinks[0].title, "Link Target"); + } + #[test] fn manual_translation_save_reopens_and_refreshes_published_canonical_editor() { let (db, project, tmp) = setup();