From 9a72287fc6dd3747ac7049ddf6b285a2b9131aac Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 19 Jul 2026 09:24:39 +0200 Subject: [PATCH] feat: implement the bDS2-compatible core Lua API --- Cargo.lock | 1 + README.md | 2 +- RUST_PLAN_CORE.md | 26 +- RUST_PLAN_EXTENSION.md | 9 +- crates/bds-core/Cargo.toml | 1 + .../examples/generate_scripting_docs.rs | 12 + crates/bds-core/src/db/connection.rs | 33 + crates/bds-core/src/engine/blogmark.rs | 134 +- crates/bds-core/src/engine/search.rs | 203 +- crates/bds-core/src/engine/task.rs | 29 + crates/bds-core/src/model/generation.rs | 11 + crates/bds-core/src/render/macros.rs | 19 +- crates/bds-core/src/render/mod.rs | 1 + crates/bds-core/src/render/page_renderer.rs | 98 +- crates/bds-core/src/render/site.rs | 21 +- crates/bds-core/src/scripting/core_host.rs | 1845 +++++++ crates/bds-core/src/scripting/manifest.rs | 430 ++ crates/bds-core/src/scripting/mod.rs | 482 +- crates/bds-ui/src/app.rs | 52 +- crates/bds-ui/src/app/editor_handlers.rs | 26 +- crates/bds-ui/src/platform/menu.rs | 34 + crates/bds-ui/src/platform/mod.rs | 1 + crates/bds-ui/src/platform/script_host.rs | 130 + docs/scripting/API_REFERENCE.md | 4761 +++++++++++++++++ docs/scripting/README.md | 11 + docs/scripting/TYPES.md | 325 ++ docs/scripting/api.json | 1702 ++++++ docs/scripting/bds2-core-signatures.json | 1 + docs/scripting/completions.json | 1686 ++++++ docs/scripting/examples/macro.lua | 3 + docs/scripting/examples/transform.lua | 6 + docs/scripting/examples/utility.lua | 7 + locales/ui/de.ftl | 1 + locales/ui/en.ftl | 1 + locales/ui/es.ftl | 1 + locales/ui/fr.ftl | 1 + locales/ui/it.ftl | 1 + 37 files changed, 11990 insertions(+), 117 deletions(-) create mode 100644 crates/bds-core/examples/generate_scripting_docs.rs create mode 100644 crates/bds-core/src/scripting/core_host.rs create mode 100644 crates/bds-core/src/scripting/manifest.rs create mode 100644 crates/bds-ui/src/platform/script_host.rs create mode 100644 docs/scripting/API_REFERENCE.md create mode 100644 docs/scripting/README.md create mode 100644 docs/scripting/TYPES.md create mode 100644 docs/scripting/api.json create mode 100644 docs/scripting/bds2-core-signatures.json create mode 100644 docs/scripting/completions.json create mode 100644 docs/scripting/examples/macro.lua create mode 100644 docs/scripting/examples/transform.lua create mode 100644 docs/scripting/examples/utility.lua diff --git a/Cargo.lock b/Cargo.lock index 323b9a4..19d6d6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -797,6 +797,7 @@ name = "bds-core" version = "0.1.0" dependencies = [ "axum", + "base64", "chrono", "deunicode", "diesel", diff --git a/README.md b/README.md index 4055b14..fb7fc16 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The project is under active development. Core blogging workflows are broadly ava - Native Iced desktop workspace with localized menus, tabs, sidebars, dialogs, tasks, and embedded Wry previews. - Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, and media. - Media import, thumbnails, metadata translations, filters, validation, and post assignment. -- Template and Lua script management using a custom Ropey/Syntect/Cosmic Text editor. +- Template and Lua script management 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. - SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search. - Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation. - Local preview in the app or system browser. diff --git a/RUST_PLAN_CORE.md b/RUST_PLAN_CORE.md index 2c07e62..0e63e0c 100644 --- a/RUST_PLAN_CORE.md +++ b/RUST_PLAN_CORE.md @@ -6,7 +6,7 @@ RuDS is the native Rust replacement for bDS2. Core covers the complete everyday The behavioural contract is `specs/*.allium`. When a spec is ambiguous, `../bDS2` is the reference implementation. [RUST_PLAN_EXTENSION.md](RUST_PLAN_EXTENSION.md) contains optional and advanced surfaces. -Status in this document describes the current source code as of 2026-07-18. It deliberately does not track build runs, test runs, release gates, or implementation history. +Status in this document describes the current source code as of 2026-07-19. It deliberately does not track build runs, test runs, release gates, or implementation history. ## Non-Negotiable Constraints @@ -58,6 +58,7 @@ Available: - Native file/folder dialogs and recent-project handling. - macOS open-file and URL lifecycle plumbing. - Localized UI separate from project content language. +- Desktop-backed Lua application capabilities for clipboard, folders, preview targeting, title-bar metrics, renderer readiness, and supported menu actions. Open: @@ -126,27 +127,26 @@ Available: - One managed publish job processes the HTML, thumbnail, and media targets in sequence, matching bDS2. Parallel target uploads are not a parity requirement. -### Lua scripting — Partly done +### Lua scripting — Done Available: - Sandboxed vendored Lua 5.4 runtime with cancellation and execution limits. - Application log, progress, and toast functions. -- User macro and transform execution, including Blogmark transforms. +- Project-scoped bDS2-compatible core `bds.*` APIs with matching Lua signatures and failure values. +- User macro and transform execution, including project capabilities during rendered macros and Blogmark transforms. +- Managed Blogmark transform cancellation and live, non-duplicated progress reporting. +- Project-scoped post and media reindexing from Lua without disturbing other projects. - Lua script persistence, rebuild, metadata diff, validation, and editor support. - Fixed `.lua` script file contract. - -Open: - -- Expose the bDS2-compatible core `bds.*` API for post, media, tag, project, and other script-visible data. -- Generate and bundle the Lua API reference, canonical type reference, and macro/transform/utility examples under `docs/scripting/`. -- Add a documentation-sync check tied to the exposed API. +- Generated and bundled API/type references plus executable macro, transform, and utility examples under `docs/scripting/`. +- Manifest-driven runtime, documentation, and completion data with drift checks. +- `bds.sync` and `bds.embeddings` remain extension APIs and are not exposed by core. ## Remaining Core Blocks -1. Complete the Lua host API and scripting documentation. -2. Finish the linked-image authoring workflow. -3. Add generation section-task grouping. -4. Return normalized token accounting from one-shot AI calls. +1. Finish the linked-image authoring workflow. +2. Add generation section-task grouping. +3. Return normalized token accounting from one-shot AI calls. Core is feature-complete when these blocks are closed and the implementation continues to satisfy the Allium and bDS2 compatibility contracts. diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md index 3ac47a2..5a6abf2 100644 --- a/RUST_PLAN_EXTENSION.md +++ b/RUST_PLAN_EXTENSION.md @@ -4,7 +4,7 @@ Extensions add migration, collaboration, automation, alternate clients, and advanced discovery on top of the core blogging workflow. They must reuse core engines and formats, remain reachable through real UI or automation surfaces, and preserve airplane-mode and localization rules. -Status describes the current source code as of 2026-07-18. Core one-shot AI, publishing, rendering, and integrity work is tracked in [RUST_PLAN_CORE.md](RUST_PLAN_CORE.md). +Status describes the current source code as of 2026-07-19. Core one-shot AI, publishing, rendering, and integrity work is tracked in [RUST_PLAN_CORE.md](RUST_PLAN_CORE.md). ## Current Extension Status @@ -17,6 +17,7 @@ Done: Open: - `GitEngine`, repository status, history, diff view, commit, fetch, pull, push, and LFS command integration. +- Expose `bds.sync` after the shared Git/synchronization workflow exists. - Replace the Git sidebar and Git log placeholders with working flows. ### WordPress import — Open @@ -46,6 +47,7 @@ Open: - Embedding generation and persistence. - Vector index and semantic search. +- Expose `bds.embeddings` after those engines exist. - Duplicate detection, dismissed-pair handling, metadata integrity, and UI. - Replace the Find Duplicates placeholder. @@ -61,7 +63,7 @@ Open: - Browsable Lua API documentation and examples. - Replace Documentation and API Documentation placeholders. -The generated Lua documentation itself is a core requirement. +The generated Lua documentation and examples are complete core functionality; this section tracks only their in-app browsing experience. ### Menu editor and deep links — Partly done @@ -85,11 +87,12 @@ Open: - Domain event bus from `events.allium` for desktop, CLI, TUI, and future remote clients. - Replace the MCP settings placeholder. -### Blogmark and transform pipeline — Mostly done +### Blogmark and transform pipeline — Done Done: - Blogmark bookmarklet copy, `ruds://new-post` parsing, content capture, post import, transform selection, and Lua transform execution. +- Project-scoped `bds.*` capabilities, managed task progress, and operator cancellation during transform execution. - bDS2-compatible delivery behavior without adding unsupported deep-link actions. ### Headless server — Open diff --git a/crates/bds-core/Cargo.toml b/crates/bds-core/Cargo.toml index cf4ba34..feb4ecc 100644 --- a/crates/bds-core/Cargo.toml +++ b/crates/bds-core/Cargo.toml @@ -34,6 +34,7 @@ diesel_migrations = { workspace = true } libsqlite3-sys = { workspace = true } mlua = { workspace = true } url = { workspace = true } +base64 = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/crates/bds-core/examples/generate_scripting_docs.rs b/crates/bds-core/examples/generate_scripting_docs.rs new file mode 100644 index 0000000..0c508b3 --- /dev/null +++ b/crates/bds-core/examples/generate_scripting_docs.rs @@ -0,0 +1,12 @@ +use std::fs; +use std::path::Path; + +fn main() -> Result<(), Box> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let docs = root.join("docs/scripting"); + let manifest = bds_core::scripting::api_manifest(); + fs::write(docs.join("API_REFERENCE.md"), manifest.render_reference())?; + fs::write(docs.join("TYPES.md"), manifest.render_types())?; + fs::write(docs.join("completions.json"), manifest.render_completions())?; + Ok(()) +} diff --git a/crates/bds-core/src/db/connection.rs b/crates/bds-core/src/db/connection.rs index 039dac5..09e1ee8 100644 --- a/crates/bds-core/src/db/connection.rs +++ b/crates/bds-core/src/db/connection.rs @@ -3,6 +3,7 @@ use std::path::Path; use diesel::connection::SimpleConnection; use diesel::prelude::*; +use diesel::sql_types::Text; use crate::db::migrations; @@ -17,6 +18,12 @@ pub enum DatabaseError { /// Shared synchronous Diesel connection used by the engine query API. pub struct DbConnection(RefCell); +#[derive(QueryableByName)] +struct DatabasePathRow { + #[diesel(sql_type = Text)] + file: String, +} + impl DbConnection { pub fn with( &self, @@ -45,6 +52,20 @@ impl DbConnection { .borrow_mut() .batch_execute("ROLLBACK TO bds_operation; RELEASE bds_operation") } + + pub(crate) fn database_path(&self) -> diesel::QueryResult { + self.with(|conn| { + diesel::sql_query("SELECT file FROM pragma_database_list WHERE name = 'main'") + .get_result::(conn) + .and_then(|row| { + if row.file.is_empty() { + Err(diesel::result::Error::NotFound) + } else { + Ok(row.file.into()) + } + }) + }) + } } /// Database wrapper managing a SQLite connection. @@ -101,4 +122,16 @@ mod tests { .unwrap(); assert_eq!(result, 1); } + + #[test] + fn reports_the_disk_database_path() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("project.sqlite3"); + let db = Database::open(&path).unwrap(); + + assert_eq!( + db.conn().database_path().unwrap().canonicalize().unwrap(), + path.canonicalize().unwrap() + ); + } } diff --git a/crates/bds-core/src/engine/blogmark.rs b/crates/bds-core/src/engine/blogmark.rs index 9acb6f3..95e6f20 100644 --- a/crates/bds-core/src/engine/blogmark.rs +++ b/crates/bds-core/src/engine/blogmark.rs @@ -1,5 +1,6 @@ use std::fs; use std::path::Path; +use std::sync::Arc; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -9,7 +10,7 @@ use crate::db::DbConnection as Connection; use crate::db::queries::script as script_queries; use crate::engine::{EngineError, EngineResult}; use crate::model::{Post, Script, ScriptKind}; -use crate::scripting::{self, ExecutionControl, ExecutionKind}; +use crate::scripting::{self, CoreHost, ExecutionControl, ExecutionKind, HostApi, UnavailableHost}; const MAX_TITLE_LENGTH: usize = 200; const MAX_URL_LENGTH: usize = 2_048; @@ -80,6 +81,27 @@ pub fn receive_deep_link( data_dir: &Path, project_id: &str, raw: &str, +) -> EngineResult { + let host = CoreHost::from_connection(conn, project_id, data_dir) + .map(|host| Arc::new(host) as Arc) + .unwrap_or_else(|_| Arc::new(UnavailableHost)); + receive_deep_link_with_host( + conn, + data_dir, + project_id, + raw, + &ExecutionControl::default(), + host, + ) +} + +pub fn receive_deep_link_with_host( + conn: &Connection, + data_dir: &Path, + project_id: &str, + raw: &str, + control: &ExecutionControl, + host: Arc, ) -> EngineResult { let mut candidate = parse_deep_link(raw)?; if let Some(target) = &candidate.project_id @@ -98,7 +120,10 @@ pub fn receive_deep_link( )); } let (mut candidate, toasts, transform_errors) = - run_transforms(conn, data_dir, project_id, candidate)?; + run_transforms(conn, data_dir, project_id, candidate, control, host)?; + if control.is_cancelled() { + return Err(EngineError::Validation("script cancelled".into())); + } if candidate.categories.is_empty() { let metadata = crate::engine::meta::read_project_json(data_dir)?; if let Some(category) = metadata @@ -133,6 +158,8 @@ fn run_transforms( data_dir: &Path, project_id: &str, candidate: BlogmarkCandidate, + control: &ExecutionControl, + host: Arc, ) -> EngineResult<(BlogmarkCandidate, Vec, Vec)> { let mut transforms = script_queries::list_scripts_by_project(conn, project_id)? .into_iter() @@ -149,6 +176,9 @@ fn run_transforms( let mut toasts = Vec::new(); let mut errors = Vec::new(); for script in transforms { + if control.is_cancelled() { + return Err(EngineError::Validation("script cancelled".into())); + } if script.entrypoint.trim().is_empty() { continue; } @@ -157,12 +187,13 @@ fn run_transforms( "source": "blogmark", "url": current.get("url").cloned().unwrap_or(Value::Null), }); - match scripting::execute_many( + match scripting::execute_many_with_host( &source, &script.entrypoint, &[current.clone(), context], ExecutionKind::Transform, - &ExecutionControl::default(), + control, + Arc::clone(&host), ) { Ok(execution) => { let (next, returned_toasts) = split_transform_result(execution.value, ¤t); @@ -172,6 +203,9 @@ fn run_transforms( execution.toasts.into_iter().chain(returned_toasts), ); } + Err(_error) if control.is_cancelled() => { + return Err(EngineError::Validation("script cancelled".into())); + } Err(error) => errors.push(format!("{}: {error}", script.slug)), } } @@ -287,6 +321,22 @@ mod tests { use crate::model::ScriptKind; use tempfile::TempDir; + struct PostsHost; + + impl HostApi for PostsHost { + fn call( + &self, + namespace: &str, + method: &str, + _arguments: Vec, + ) -> Result { + match (namespace, method) { + ("posts", "get_all") => Ok(json!([{"id":"one"}, {"id":"two"}])), + _ => Err("unsupported test capability".into()), + } + } + } + #[test] fn parses_and_hardens_blogmark_links() { let candidate = parse_deep_link( @@ -358,4 +408,80 @@ mod tests { assert_eq!(result.toasts, vec!["done"]); assert!(result.transform_errors.is_empty()); } + + #[test] + fn transforms_use_the_supplied_project_host() { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + crate::db::fts::ensure_fts_tables(db.conn()).unwrap(); + let directory = TempDir::new().unwrap(); + let project = crate::engine::project::create_project( + db.conn(), + "Blog", + Some(directory.path().to_str().unwrap()), + ) + .unwrap(); + crate::engine::script::create_script( + db.conn(), + &project.id, + "Count posts", + ScriptKind::Transform, + "function main(data) data.title = tostring(#bds.posts.get_all()) .. ' posts'; return data end", + None, + ) + .unwrap(); + + let result = receive_deep_link_with_host( + db.conn(), + directory.path(), + &project.id, + "ruds://new-post?title=Example", + &ExecutionControl::default(), + Arc::new(PostsHost), + ) + .unwrap(); + + assert_eq!(result.post.title, "2 posts"); + } + + #[test] + fn cancellation_stops_transform_import_before_post_creation() { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + crate::db::fts::ensure_fts_tables(db.conn()).unwrap(); + let directory = TempDir::new().unwrap(); + let project = crate::engine::project::create_project( + db.conn(), + "Blog", + Some(directory.path().to_str().unwrap()), + ) + .unwrap(); + crate::engine::script::create_script( + db.conn(), + &project.id, + "Cancelled", + ScriptKind::Transform, + "function main(data) return data end", + None, + ) + .unwrap(); + let control = ExecutionControl::default(); + control.cancel(); + + assert!( + receive_deep_link_with_host( + db.conn(), + directory.path(), + &project.id, + "ruds://new-post?title=Example", + &control, + Arc::new(PostsHost), + ) + .is_err() + ); + assert_eq!( + crate::db::queries::post::count_posts_by_project(db.conn(), &project.id).unwrap(), + 0 + ); + } } diff --git a/crates/bds-core/src/engine/search.rs b/crates/bds-core/src/engine/search.rs index 96b2df7..55d752b 100644 --- a/crates/bds-core/src/engine/search.rs +++ b/crates/bds-core/src/engine/search.rs @@ -93,6 +93,26 @@ pub fn rebuild_search_index( } } +/// Reindex one project without disturbing rows belonging to other projects. +pub fn reindex_project( + conn: &Connection, + project_id: &str, + on_item: Option, +) -> EngineResult { + let project = project_q::get_project_by_id(conn, project_id)?; + let total = post_q::count_posts_by_project(conn, project_id)? as usize + + media_q::count_media_by_project(conn, project_id)? as usize; + let mut current = 0; + index_project( + conn, + project_id, + project.data_path.as_deref(), + total, + &mut current, + on_item.as_ref(), + ) +} + fn index_all_projects( conn: &Connection, on_item: Option<&ItemProgressFn>, @@ -112,71 +132,94 @@ fn index_all_projects( }; for project in projects { - let main_language = project - .data_path - .as_deref() - .and_then(|path| crate::engine::meta::read_project_json(Path::new(path)).ok()) - .and_then(|metadata| metadata.main_language) - .unwrap_or_else(|| "en".to_string()); + let indexed = index_project( + conn, + &project.id, + project.data_path.as_deref(), + total, + &mut current, + on_item, + )?; + report.posts_indexed += indexed.posts_indexed; + report.media_indexed += indexed.media_indexed; + } - for post in post_q::list_posts_by_project(conn, &project.id)? { - current += 1; - if let Some(callback) = on_item { - callback(current, total, &post.title); - } - let translations = post_translation::list_post_translations_by_post(conn, &post.id)?; - let translation_data = translations - .iter() - .map(|translation| fts::PostTranslationFts { - title: translation.title.clone(), - excerpt: translation.excerpt.clone(), - content: translation.content.clone(), - language: translation.language.clone(), - }) - .collect::>(); - fts::index_post( - conn, - &post.id, - &post.title, - post.excerpt.as_deref(), - post.content.as_deref(), - &post.tags, - &post.categories, - &translation_data, - post.language.as_deref().unwrap_or(&main_language), - )?; - report.posts_indexed += 1; - } + Ok(report) +} - for media in media_q::list_media_by_project(conn, &project.id)? { - current += 1; - if let Some(callback) = on_item { - callback(current, total, &media.original_name); - } - let translations = - media_translation::list_media_translations_by_media(conn, &media.id)?; - let translation_data = translations - .iter() - .map(|translation| fts::MediaTranslationFts { - title: translation.title.clone(), - alt: translation.alt.clone(), - caption: translation.caption.clone(), - language: translation.language.clone(), - }) - .collect::>(); - fts::index_media( - conn, - &media.id, - media.title.as_deref(), - media.alt.as_deref(), - media.caption.as_deref(), - &media.original_name, - &media.tags, - &translation_data, - media.language.as_deref().unwrap_or(&main_language), - )?; - report.media_indexed += 1; +fn index_project( + conn: &Connection, + project_id: &str, + data_path: Option<&str>, + total: usize, + current: &mut usize, + on_item: Option<&ItemProgressFn>, +) -> EngineResult { + let main_language = data_path + .and_then(|path| crate::engine::meta::read_project_json(Path::new(path)).ok()) + .and_then(|metadata| metadata.main_language) + .unwrap_or_else(|| "en".to_string()); + let mut report = ReindexReport { + posts_indexed: 0, + media_indexed: 0, + }; + + for post in post_q::list_posts_by_project(conn, project_id)? { + *current += 1; + if let Some(callback) = on_item { + callback(*current, total, &post.title); } + let translations = post_translation::list_post_translations_by_post(conn, &post.id)?; + let translation_data = translations + .iter() + .map(|translation| fts::PostTranslationFts { + title: translation.title.clone(), + excerpt: translation.excerpt.clone(), + content: translation.content.clone(), + language: translation.language.clone(), + }) + .collect::>(); + fts::index_post( + conn, + &post.id, + &post.title, + post.excerpt.as_deref(), + post.content.as_deref(), + &post.tags, + &post.categories, + &translation_data, + post.language.as_deref().unwrap_or(&main_language), + )?; + report.posts_indexed += 1; + } + + for media in media_q::list_media_by_project(conn, project_id)? { + *current += 1; + if let Some(callback) = on_item { + callback(*current, total, &media.original_name); + } + let translations = media_translation::list_media_translations_by_media(conn, &media.id)?; + let translation_data = translations + .iter() + .map(|translation| fts::MediaTranslationFts { + title: translation.title.clone(), + alt: translation.alt.clone(), + caption: translation.caption.clone(), + language: translation.language.clone(), + }) + .collect::>(); + fts::index_media( + conn, + &media.id, + media.title.as_deref(), + media.alt.as_deref(), + media.caption.as_deref(), + &media.original_name, + &media.tags, + &translation_data, + media.language.as_deref().unwrap_or(&main_language), + )?; + report.media_indexed += 1; } Ok(report) @@ -286,4 +329,40 @@ mod tests { assert_eq!(report.posts_indexed, 2); assert_eq!(results.len(), 2); } + + #[test] + fn project_reindex_leaves_other_project_rows_intact() { + let (db, first_project_id) = setup(); + let first_dir = tempfile::tempdir().unwrap(); + let second_dir = tempfile::tempdir().unwrap(); + let second = engine::project::create_project( + db.conn(), + "Second Project", + Some(second_dir.path().to_str().unwrap()), + ) + .unwrap(); + for (project_id, data_dir, title) in [ + (&first_project_id, first_dir.path(), "First Searchable"), + (&second.id, second_dir.path(), "Second Searchable"), + ] { + engine::post::create_post( + db.conn(), + data_dir, + project_id, + title, + Some("body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + } + + reindex_project(db.conn(), &first_project_id, None).unwrap(); + let results = crate::db::fts::search_posts(db.conn(), "searchable", "en").unwrap(); + + assert_eq!(results.len(), 2); + } } diff --git a/crates/bds-core/src/engine/task.rs b/crates/bds-core/src/engine/task.rs index 63d7911..e0f5560 100644 --- a/crates/bds-core/src/engine/task.rs +++ b/crates/bds-core/src/engine/task.rs @@ -179,6 +179,16 @@ impl TaskManager { .unwrap_or(false) } + /// Shared cancellation flag for a worker owned by this task. + pub fn cancellation_flag(&self, task_id: TaskId) -> Option> { + self.tasks + .lock() + .unwrap() + .iter() + .find(|task| task.id == task_id) + .map(|task| Arc::clone(&task.cancel_flag)) + } + /// Return the current status of a task. pub fn status(&self, task_id: TaskId) -> Option { let tasks = self.tasks.lock().unwrap(); @@ -216,6 +226,14 @@ impl TaskManager { }); } + /// Remove every finished task while preserving running and queued work. + pub fn clear_completed(&self) { + self.tasks + .lock() + .unwrap() + .retain(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running)); + } + /// Update progress for a running task. Throttled to at most once per 250ms. pub fn report_progress(&self, task_id: TaskId, progress: Option, message: Option) { let mut tasks = self.tasks.lock().unwrap(); @@ -403,6 +421,17 @@ mod tests { assert_eq!(mgr.snapshots().len(), 10); } + #[test] + fn clear_completed_preserves_active_tasks() { + let mgr = TaskManager::new(2); + let done = mgr.submit("done"); + let running = mgr.submit("running"); + mgr.complete(done); + mgr.clear_completed(); + assert_eq!(mgr.status(done), None); + assert_eq!(mgr.status(running), Some(TaskStatus::Running)); + } + #[test] fn completing_task_starts_next_queued() { let mgr = TaskManager::new(1); diff --git a/crates/bds-core/src/model/generation.rs b/crates/bds-core/src/model/generation.rs index 01ef2f8..17cc695 100644 --- a/crates/bds-core/src/model/generation.rs +++ b/crates/bds-core/src/model/generation.rs @@ -135,6 +135,17 @@ pub struct PublishingPreferences { pub ssh_mode: SshMode, } +impl Default for PublishingPreferences { + fn default() -> Self { + Self { + ssh_host: None, + ssh_user: None, + ssh_remote_path: None, + ssh_mode: default_ssh_mode(), + } + } +} + fn default_ssh_mode() -> SshMode { SshMode::Scp } diff --git a/crates/bds-core/src/render/macros.rs b/crates/bds-core/src/render/macros.rs index a25d1e6..2d776f5 100644 --- a/crates/bds-core/src/render/macros.rs +++ b/crates/bds-core/src/render/macros.rs @@ -1,11 +1,23 @@ use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; use serde_json::{Map, Value as JsonValue}; -#[derive(Debug, Clone, Default)] +#[derive(Clone)] pub(crate) struct MacroRenderContext { pub roots: Map, pub post_id: Option, + pub host: Arc, +} + +impl Default for MacroRenderContext { + fn default() -> Self { + Self { + roots: Map::new(), + post_id: None, + host: Arc::new(crate::scripting::UnavailableHost), + } + } } pub(crate) fn expand_builtin_macros(markdown: &str, context: &MacroRenderContext) -> String { @@ -77,12 +89,13 @@ fn render_script_macro( "translations": context.roots.get("translations").cloned().unwrap_or(JsonValue::Array(Vec::new())), }); let params = serde_json::to_value(args).ok()?; - match crate::scripting::execute_many( + match crate::scripting::execute_many_with_host( source, entrypoint, &[params, env], crate::scripting::ExecutionKind::Macro, &crate::scripting::ExecutionControl::default(), + Arc::clone(&context.host), ) { Ok(result) => Some(match result.value { JsonValue::Null => String::new(), @@ -519,6 +532,7 @@ mod tests { &MacroRenderContext { roots, post_id: Some("post-1".to_string()), + ..MacroRenderContext::default() }, ); @@ -555,6 +569,7 @@ mod tests { &MacroRenderContext { roots, post_id: Some("post-1".into()), + ..MacroRenderContext::default() }, ); assert_eq!(rendered, ""); diff --git a/crates/bds-core/src/render/mod.rs b/crates/bds-core/src/render/mod.rs index 77b3c9e..c862e30 100644 --- a/crates/bds-core/src/render/mod.rs +++ b/crates/bds-core/src/render/mod.rs @@ -11,6 +11,7 @@ pub use generation::{ write_generated_bytes, write_generated_file, }; pub use markdown::render_markdown_to_html; +pub(crate) use page_renderer::render_liquid_template_with_host; pub use page_renderer::{RenderError, render_liquid_template}; pub use routes::{ RenderedPage, build_canonical_post_path, render_starter_list_page, diff --git a/crates/bds-core/src/render/page_renderer.rs b/crates/bds-core/src/render/page_renderer.rs index de836ca..dd4c3f2 100644 --- a/crates/bds-core/src/render/page_renderer.rs +++ b/crates/bds-core/src/render/page_renderer.rs @@ -1,8 +1,11 @@ use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; use liquid::ParserBuilder; use liquid::partials::{EagerCompiler, InMemorySource}; use liquid_core::model::ScalarCow; +use liquid_core::parser::FilterArguments; use liquid_core::{ Display_filter, Expression, Filter, FilterParameters, FilterReflection, FromFilterParameters, ParseFilter, Runtime, Value, ValueView, @@ -14,6 +17,7 @@ use thiserror::Error; use crate::i18n::translate_render; use crate::render::macros::{MacroRenderContext, expand_builtin_macros}; use crate::render::render_markdown_to_html; +use crate::scripting::{HostApi, UnavailableHost}; use crate::util::slugify; #[derive(Debug, Error)] @@ -32,6 +36,20 @@ pub fn render_liquid_template( template_source: &str, partials: &HashMap, context: &T, +) -> Result { + render_liquid_template_with_host( + template_source, + partials, + context, + Arc::new(UnavailableHost), + ) +} + +pub(crate) fn render_liquid_template_with_host( + template_source: &str, + partials: &HashMap, + context: &T, + host: Arc, ) -> Result { let mut compiled_partials: EagerCompiler = EagerCompiler::empty(); for (name, content) in partials { @@ -40,7 +58,7 @@ pub fn render_liquid_template( let parser = ParserBuilder::with_stdlib() .filter(I18n) - .filter(Markdown) + .filter(Markdown { host }) .filter(Slugify) .partials(compiled_partials) .build()?; @@ -121,20 +139,43 @@ struct MarkdownArgs { language_prefix: Option, } -#[derive(Clone, ParseFilter, FilterReflection)] +#[derive(Clone, FilterReflection)] #[filter( name = "markdown", description = "Render markdown to HTML and rewrite preview URLs.", parameters(MarkdownArgs), parsed(MarkdownFilter) )] -struct Markdown; +struct Markdown { + host: Arc, +} -#[derive(Debug, FromFilterParameters, Display_filter)] +impl ParseFilter for Markdown { + fn parse(&self, args: FilterArguments<'_>) -> liquid_core::Result> { + Ok(Box::new(MarkdownFilter { + args: MarkdownArgs::from_args(args)?, + host: Arc::clone(&self.host), + })) + } + + fn reflection(&self) -> &dyn FilterReflection { + self + } +} + +#[derive(Display_filter)] #[name = "markdown"] struct MarkdownFilter { - #[parameters] args: MarkdownArgs, + host: Arc, +} + +impl fmt::Debug for MarkdownFilter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MarkdownFilter") + .finish_non_exhaustive() + } } impl Filter for MarkdownFilter { @@ -158,6 +199,7 @@ impl Filter for MarkdownFilter { post_id: runtime .try_get(&[ScalarCow::new("post"), ScalarCow::new("id")]) .and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string())), + host: Arc::clone(&self.host), }; let expanded = expand_builtin_macros(markdown.as_str(), ¯o_context); @@ -473,8 +515,31 @@ fn trim_html_suffix(value: &str) -> String { #[cfg(test)] mod tests { - use super::{RewriteContextView, render_liquid_template, rewrite_rendered_html_urls}; + use super::{ + RewriteContextView, render_liquid_template, render_liquid_template_with_host, + rewrite_rendered_html_urls, + }; use std::collections::HashMap; + use std::sync::Arc; + + use crate::scripting::HostApi; + use serde_json::{Value, json}; + + struct PostsHost; + + impl HostApi for PostsHost { + fn call( + &self, + namespace: &str, + method: &str, + _arguments: Vec, + ) -> Result { + match (namespace, method) { + ("posts", "get_all") => Ok(json!([{"id":"one"}, {"id":"two"}])), + _ => Err("unsupported test capability".into()), + } + } + } struct TestRewriteContext { canonical_post_path_by_slug: HashMap, @@ -520,4 +585,25 @@ mod tests { assert_eq!(rendered, "ueber-die-bruecke"); } + + #[test] + fn script_macros_use_the_supplied_project_host() { + let rendered = render_liquid_template_with_host( + "{{ post.content | markdown }}", + &HashMap::new(), + &json!({ + "post": {"id": "post-1", "content": "[[count]]"}, + "macro_scripts": { + "count": { + "source": "function render() return tostring(#bds.posts.get_all()) end", + "entrypoint": "render" + } + } + }), + Arc::new(PostsHost), + ) + .unwrap(); + + assert_eq!(rendered, "

2

\n"); + } } diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs index cced03c..e814222 100644 --- a/crates/bds-core/src/render/site.rs +++ b/crates/bds-core/src/render/site.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, HashMap}; use std::error::Error; use std::fs; use std::path::Path; +use std::sync::Arc; use crate::db::DbConnection as Connection; use chrono::{Datelike, TimeZone, Utc}; @@ -16,8 +17,9 @@ use crate::model::{ }; use crate::render::{ RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path, - render_liquid_template, resolve_post_template, + render_liquid_template_with_host, resolve_post_template, }; +use crate::scripting::{CoreHost, HostApi, UnavailableHost}; use crate::util::frontmatter::{read_script_file, read_template_file, read_translation_file}; use crate::util::slugify; @@ -62,7 +64,7 @@ pub struct PreviewRenderResult { pub html: String, } -#[derive(Debug, Clone)] +#[derive(Clone)] struct TemplateBundle { post_templates: Vec