feat: implement the bDS2-compatible core Lua API

This commit is contained in:
2026-07-19 09:24:39 +02:00
parent 33929fd04b
commit 9a72287fc6
37 changed files with 11990 additions and 117 deletions

1
Cargo.lock generated
View File

@@ -797,6 +797,7 @@ name = "bds-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"base64",
"chrono", "chrono",
"deunicode", "deunicode",
"diesel", "diesel",

View File

@@ -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. - 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. - Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, and media.
- Media import, thumbnails, metadata translations, filters, validation, and post assignment. - 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. - 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. - Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation.
- Local preview in the app or system browser. - Local preview in the app or system browser.

View File

@@ -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. 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 ## Non-Negotiable Constraints
@@ -58,6 +58,7 @@ Available:
- Native file/folder dialogs and recent-project handling. - Native file/folder dialogs and recent-project handling.
- macOS open-file and URL lifecycle plumbing. - macOS open-file and URL lifecycle plumbing.
- Localized UI separate from project content language. - 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: Open:
@@ -126,27 +127,26 @@ Available:
- One managed publish job processes the HTML, thumbnail, and media targets in - One managed publish job processes the HTML, thumbnail, and media targets in
sequence, matching bDS2. Parallel target uploads are not a parity requirement. sequence, matching bDS2. Parallel target uploads are not a parity requirement.
### Lua scripting — Partly done ### Lua scripting — Done
Available: Available:
- Sandboxed vendored Lua 5.4 runtime with cancellation and execution limits. - Sandboxed vendored Lua 5.4 runtime with cancellation and execution limits.
- Application log, progress, and toast functions. - 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. - Lua script persistence, rebuild, metadata diff, validation, and editor support.
- Fixed `.lua` script file contract. - Fixed `.lua` script file contract.
- Generated and bundled API/type references plus executable macro, transform, and utility examples under `docs/scripting/`.
Open: - Manifest-driven runtime, documentation, and completion data with drift checks.
- `bds.sync` and `bds.embeddings` remain extension APIs and are not exposed by core.
- 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.
## Remaining Core Blocks ## Remaining Core Blocks
1. Complete the Lua host API and scripting documentation. 1. Finish the linked-image authoring workflow.
2. Finish the linked-image authoring workflow. 2. Add generation section-task grouping.
3. Add generation section-task grouping. 3. Return normalized token accounting from one-shot AI calls.
4. 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. Core is feature-complete when these blocks are closed and the implementation continues to satisfy the Allium and bDS2 compatibility contracts.

View File

@@ -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. 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 ## Current Extension Status
@@ -17,6 +17,7 @@ Done:
Open: Open:
- `GitEngine`, repository status, history, diff view, commit, fetch, pull, push, and LFS command integration. - `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. - Replace the Git sidebar and Git log placeholders with working flows.
### WordPress import — Open ### WordPress import — Open
@@ -46,6 +47,7 @@ Open:
- Embedding generation and persistence. - Embedding generation and persistence.
- Vector index and semantic search. - Vector index and semantic search.
- Expose `bds.embeddings` after those engines exist.
- Duplicate detection, dismissed-pair handling, metadata integrity, and UI. - Duplicate detection, dismissed-pair handling, metadata integrity, and UI.
- Replace the Find Duplicates placeholder. - Replace the Find Duplicates placeholder.
@@ -61,7 +63,7 @@ Open:
- Browsable Lua API documentation and examples. - Browsable Lua API documentation and examples.
- Replace Documentation and API Documentation placeholders. - 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 ### 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. - Domain event bus from `events.allium` for desktop, CLI, TUI, and future remote clients.
- Replace the MCP settings placeholder. - Replace the MCP settings placeholder.
### Blogmark and transform pipeline — Mostly done ### Blogmark and transform pipeline — Done
Done: Done:
- Blogmark bookmarklet copy, `ruds://new-post` parsing, content capture, post import, transform selection, and Lua transform execution. - 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. - bDS2-compatible delivery behavior without adding unsupported deep-link actions.
### Headless server — Open ### Headless server — Open

View File

@@ -34,6 +34,7 @@ diesel_migrations = { workspace = true }
libsqlite3-sys = { workspace = true } libsqlite3-sys = { workspace = true }
mlua = { workspace = true } mlua = { workspace = true }
url = { workspace = true } url = { workspace = true }
base64 = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View File

@@ -0,0 +1,12 @@
use std::fs;
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
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(())
}

View File

@@ -3,6 +3,7 @@ use std::path::Path;
use diesel::connection::SimpleConnection; use diesel::connection::SimpleConnection;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sql_types::Text;
use crate::db::migrations; use crate::db::migrations;
@@ -17,6 +18,12 @@ pub enum DatabaseError {
/// Shared synchronous Diesel connection used by the engine query API. /// Shared synchronous Diesel connection used by the engine query API.
pub struct DbConnection(RefCell<SqliteConnection>); pub struct DbConnection(RefCell<SqliteConnection>);
#[derive(QueryableByName)]
struct DatabasePathRow {
#[diesel(sql_type = Text)]
file: String,
}
impl DbConnection { impl DbConnection {
pub fn with<T>( pub fn with<T>(
&self, &self,
@@ -45,6 +52,20 @@ impl DbConnection {
.borrow_mut() .borrow_mut()
.batch_execute("ROLLBACK TO bds_operation; RELEASE bds_operation") .batch_execute("ROLLBACK TO bds_operation; RELEASE bds_operation")
} }
pub(crate) fn database_path(&self) -> diesel::QueryResult<std::path::PathBuf> {
self.with(|conn| {
diesel::sql_query("SELECT file FROM pragma_database_list WHERE name = 'main'")
.get_result::<DatabasePathRow>(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. /// Database wrapper managing a SQLite connection.
@@ -101,4 +122,16 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(result, 1); 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()
);
}
} }

View File

@@ -1,5 +1,6 @@
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use std::sync::Arc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; 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::db::queries::script as script_queries;
use crate::engine::{EngineError, EngineResult}; use crate::engine::{EngineError, EngineResult};
use crate::model::{Post, Script, ScriptKind}; 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_TITLE_LENGTH: usize = 200;
const MAX_URL_LENGTH: usize = 2_048; const MAX_URL_LENGTH: usize = 2_048;
@@ -80,6 +81,27 @@ pub fn receive_deep_link(
data_dir: &Path, data_dir: &Path,
project_id: &str, project_id: &str,
raw: &str, raw: &str,
) -> EngineResult<BlogmarkImportResult> {
let host = CoreHost::from_connection(conn, project_id, data_dir)
.map(|host| Arc::new(host) as Arc<dyn HostApi>)
.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<dyn HostApi>,
) -> EngineResult<BlogmarkImportResult> { ) -> EngineResult<BlogmarkImportResult> {
let mut candidate = parse_deep_link(raw)?; let mut candidate = parse_deep_link(raw)?;
if let Some(target) = &candidate.project_id if let Some(target) = &candidate.project_id
@@ -98,7 +120,10 @@ pub fn receive_deep_link(
)); ));
} }
let (mut candidate, toasts, transform_errors) = 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() { if candidate.categories.is_empty() {
let metadata = crate::engine::meta::read_project_json(data_dir)?; let metadata = crate::engine::meta::read_project_json(data_dir)?;
if let Some(category) = metadata if let Some(category) = metadata
@@ -133,6 +158,8 @@ fn run_transforms(
data_dir: &Path, data_dir: &Path,
project_id: &str, project_id: &str,
candidate: BlogmarkCandidate, candidate: BlogmarkCandidate,
control: &ExecutionControl,
host: Arc<dyn HostApi>,
) -> EngineResult<(BlogmarkCandidate, Vec<String>, Vec<String>)> { ) -> EngineResult<(BlogmarkCandidate, Vec<String>, Vec<String>)> {
let mut transforms = script_queries::list_scripts_by_project(conn, project_id)? let mut transforms = script_queries::list_scripts_by_project(conn, project_id)?
.into_iter() .into_iter()
@@ -149,6 +176,9 @@ fn run_transforms(
let mut toasts = Vec::new(); let mut toasts = Vec::new();
let mut errors = Vec::new(); let mut errors = Vec::new();
for script in transforms { for script in transforms {
if control.is_cancelled() {
return Err(EngineError::Validation("script cancelled".into()));
}
if script.entrypoint.trim().is_empty() { if script.entrypoint.trim().is_empty() {
continue; continue;
} }
@@ -157,12 +187,13 @@ fn run_transforms(
"source": "blogmark", "source": "blogmark",
"url": current.get("url").cloned().unwrap_or(Value::Null), "url": current.get("url").cloned().unwrap_or(Value::Null),
}); });
match scripting::execute_many( match scripting::execute_many_with_host(
&source, &source,
&script.entrypoint, &script.entrypoint,
&[current.clone(), context], &[current.clone(), context],
ExecutionKind::Transform, ExecutionKind::Transform,
&ExecutionControl::default(), control,
Arc::clone(&host),
) { ) {
Ok(execution) => { Ok(execution) => {
let (next, returned_toasts) = split_transform_result(execution.value, &current); let (next, returned_toasts) = split_transform_result(execution.value, &current);
@@ -172,6 +203,9 @@ fn run_transforms(
execution.toasts.into_iter().chain(returned_toasts), 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)), Err(error) => errors.push(format!("{}: {error}", script.slug)),
} }
} }
@@ -287,6 +321,22 @@ mod tests {
use crate::model::ScriptKind; use crate::model::ScriptKind;
use tempfile::TempDir; use tempfile::TempDir;
struct PostsHost;
impl HostApi for PostsHost {
fn call(
&self,
namespace: &str,
method: &str,
_arguments: Vec<Value>,
) -> Result<Value, String> {
match (namespace, method) {
("posts", "get_all") => Ok(json!([{"id":"one"}, {"id":"two"}])),
_ => Err("unsupported test capability".into()),
}
}
}
#[test] #[test]
fn parses_and_hardens_blogmark_links() { fn parses_and_hardens_blogmark_links() {
let candidate = parse_deep_link( let candidate = parse_deep_link(
@@ -358,4 +408,80 @@ mod tests {
assert_eq!(result.toasts, vec!["done"]); assert_eq!(result.toasts, vec!["done"]);
assert!(result.transform_errors.is_empty()); 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
);
}
} }

View File

@@ -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<ItemProgressFn>,
) -> EngineResult<ReindexReport> {
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( fn index_all_projects(
conn: &Connection, conn: &Connection,
on_item: Option<&ItemProgressFn>, on_item: Option<&ItemProgressFn>,
@@ -112,71 +132,94 @@ fn index_all_projects(
}; };
for project in projects { for project in projects {
let main_language = project let indexed = index_project(
.data_path conn,
.as_deref() &project.id,
.and_then(|path| crate::engine::meta::read_project_json(Path::new(path)).ok()) project.data_path.as_deref(),
.and_then(|metadata| metadata.main_language) total,
.unwrap_or_else(|| "en".to_string()); &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)? { Ok(report)
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::<Vec<_>>();
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)? { fn index_project(
current += 1; conn: &Connection,
if let Some(callback) = on_item { project_id: &str,
callback(current, total, &media.original_name); data_path: Option<&str>,
} total: usize,
let translations = current: &mut usize,
media_translation::list_media_translations_by_media(conn, &media.id)?; on_item: Option<&ItemProgressFn>,
let translation_data = translations ) -> EngineResult<ReindexReport> {
.iter() let main_language = data_path
.map(|translation| fts::MediaTranslationFts { .and_then(|path| crate::engine::meta::read_project_json(Path::new(path)).ok())
title: translation.title.clone(), .and_then(|metadata| metadata.main_language)
alt: translation.alt.clone(), .unwrap_or_else(|| "en".to_string());
caption: translation.caption.clone(), let mut report = ReindexReport {
language: translation.language.clone(), posts_indexed: 0,
}) media_indexed: 0,
.collect::<Vec<_>>(); };
fts::index_media(
conn, for post in post_q::list_posts_by_project(conn, project_id)? {
&media.id, *current += 1;
media.title.as_deref(), if let Some(callback) = on_item {
media.alt.as_deref(), callback(*current, total, &post.title);
media.caption.as_deref(),
&media.original_name,
&media.tags,
&translation_data,
media.language.as_deref().unwrap_or(&main_language),
)?;
report.media_indexed += 1;
} }
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::<Vec<_>>();
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::<Vec<_>>();
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) Ok(report)
@@ -286,4 +329,40 @@ mod tests {
assert_eq!(report.posts_indexed, 2); assert_eq!(report.posts_indexed, 2);
assert_eq!(results.len(), 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);
}
} }

View File

@@ -179,6 +179,16 @@ impl TaskManager {
.unwrap_or(false) .unwrap_or(false)
} }
/// Shared cancellation flag for a worker owned by this task.
pub fn cancellation_flag(&self, task_id: TaskId) -> Option<Arc<AtomicBool>> {
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. /// Return the current status of a task.
pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> { pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> {
let tasks = self.tasks.lock().unwrap(); 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. /// Update progress for a running task. Throttled to at most once per 250ms.
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) { pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
let mut tasks = self.tasks.lock().unwrap(); let mut tasks = self.tasks.lock().unwrap();
@@ -403,6 +421,17 @@ mod tests {
assert_eq!(mgr.snapshots().len(), 10); 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] #[test]
fn completing_task_starts_next_queued() { fn completing_task_starts_next_queued() {
let mgr = TaskManager::new(1); let mgr = TaskManager::new(1);

View File

@@ -135,6 +135,17 @@ pub struct PublishingPreferences {
pub ssh_mode: SshMode, 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 { fn default_ssh_mode() -> SshMode {
SshMode::Scp SshMode::Scp
} }

View File

@@ -1,11 +1,23 @@
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use serde_json::{Map, Value as JsonValue}; use serde_json::{Map, Value as JsonValue};
#[derive(Debug, Clone, Default)] #[derive(Clone)]
pub(crate) struct MacroRenderContext { pub(crate) struct MacroRenderContext {
pub roots: Map<String, JsonValue>, pub roots: Map<String, JsonValue>,
pub post_id: Option<String>, pub post_id: Option<String>,
pub host: Arc<dyn crate::scripting::HostApi>,
}
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 { 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())), "translations": context.roots.get("translations").cloned().unwrap_or(JsonValue::Array(Vec::new())),
}); });
let params = serde_json::to_value(args).ok()?; let params = serde_json::to_value(args).ok()?;
match crate::scripting::execute_many( match crate::scripting::execute_many_with_host(
source, source,
entrypoint, entrypoint,
&[params, env], &[params, env],
crate::scripting::ExecutionKind::Macro, crate::scripting::ExecutionKind::Macro,
&crate::scripting::ExecutionControl::default(), &crate::scripting::ExecutionControl::default(),
Arc::clone(&context.host),
) { ) {
Ok(result) => Some(match result.value { Ok(result) => Some(match result.value {
JsonValue::Null => String::new(), JsonValue::Null => String::new(),
@@ -519,6 +532,7 @@ mod tests {
&MacroRenderContext { &MacroRenderContext {
roots, roots,
post_id: Some("post-1".to_string()), post_id: Some("post-1".to_string()),
..MacroRenderContext::default()
}, },
); );
@@ -555,6 +569,7 @@ mod tests {
&MacroRenderContext { &MacroRenderContext {
roots, roots,
post_id: Some("post-1".into()), post_id: Some("post-1".into()),
..MacroRenderContext::default()
}, },
); );
assert_eq!(rendered, "<aside>Hallo:de</aside>"); assert_eq!(rendered, "<aside>Hallo:de</aside>");

View File

@@ -11,6 +11,7 @@ pub use generation::{
write_generated_bytes, write_generated_file, write_generated_bytes, write_generated_file,
}; };
pub use markdown::render_markdown_to_html; 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 page_renderer::{RenderError, render_liquid_template};
pub use routes::{ pub use routes::{
RenderedPage, build_canonical_post_path, render_starter_list_page, RenderedPage, build_canonical_post_path, render_starter_list_page,

View File

@@ -1,8 +1,11 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use liquid::ParserBuilder; use liquid::ParserBuilder;
use liquid::partials::{EagerCompiler, InMemorySource}; use liquid::partials::{EagerCompiler, InMemorySource};
use liquid_core::model::ScalarCow; use liquid_core::model::ScalarCow;
use liquid_core::parser::FilterArguments;
use liquid_core::{ use liquid_core::{
Display_filter, Expression, Filter, FilterParameters, FilterReflection, FromFilterParameters, Display_filter, Expression, Filter, FilterParameters, FilterReflection, FromFilterParameters,
ParseFilter, Runtime, Value, ValueView, ParseFilter, Runtime, Value, ValueView,
@@ -14,6 +17,7 @@ use thiserror::Error;
use crate::i18n::translate_render; use crate::i18n::translate_render;
use crate::render::macros::{MacroRenderContext, expand_builtin_macros}; use crate::render::macros::{MacroRenderContext, expand_builtin_macros};
use crate::render::render_markdown_to_html; use crate::render::render_markdown_to_html;
use crate::scripting::{HostApi, UnavailableHost};
use crate::util::slugify; use crate::util::slugify;
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -32,6 +36,20 @@ pub fn render_liquid_template<T: Serialize>(
template_source: &str, template_source: &str,
partials: &HashMap<String, String>, partials: &HashMap<String, String>,
context: &T, context: &T,
) -> Result<String, RenderError> {
render_liquid_template_with_host(
template_source,
partials,
context,
Arc::new(UnavailableHost),
)
}
pub(crate) fn render_liquid_template_with_host<T: Serialize>(
template_source: &str,
partials: &HashMap<String, String>,
context: &T,
host: Arc<dyn HostApi>,
) -> Result<String, RenderError> { ) -> Result<String, RenderError> {
let mut compiled_partials: EagerCompiler<InMemorySource> = EagerCompiler::empty(); let mut compiled_partials: EagerCompiler<InMemorySource> = EagerCompiler::empty();
for (name, content) in partials { for (name, content) in partials {
@@ -40,7 +58,7 @@ pub fn render_liquid_template<T: Serialize>(
let parser = ParserBuilder::with_stdlib() let parser = ParserBuilder::with_stdlib()
.filter(I18n) .filter(I18n)
.filter(Markdown) .filter(Markdown { host })
.filter(Slugify) .filter(Slugify)
.partials(compiled_partials) .partials(compiled_partials)
.build()?; .build()?;
@@ -121,20 +139,43 @@ struct MarkdownArgs {
language_prefix: Option<Expression>, language_prefix: Option<Expression>,
} }
#[derive(Clone, ParseFilter, FilterReflection)] #[derive(Clone, FilterReflection)]
#[filter( #[filter(
name = "markdown", name = "markdown",
description = "Render markdown to HTML and rewrite preview URLs.", description = "Render markdown to HTML and rewrite preview URLs.",
parameters(MarkdownArgs), parameters(MarkdownArgs),
parsed(MarkdownFilter) parsed(MarkdownFilter)
)] )]
struct Markdown; struct Markdown {
host: Arc<dyn HostApi>,
}
#[derive(Debug, FromFilterParameters, Display_filter)] impl ParseFilter for Markdown {
fn parse(&self, args: FilterArguments<'_>) -> liquid_core::Result<Box<dyn Filter>> {
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"] #[name = "markdown"]
struct MarkdownFilter { struct MarkdownFilter {
#[parameters]
args: MarkdownArgs, args: MarkdownArgs,
host: Arc<dyn HostApi>,
}
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 { impl Filter for MarkdownFilter {
@@ -158,6 +199,7 @@ impl Filter for MarkdownFilter {
post_id: runtime post_id: runtime
.try_get(&[ScalarCow::new("post"), ScalarCow::new("id")]) .try_get(&[ScalarCow::new("post"), ScalarCow::new("id")])
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string())), .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(), &macro_context); let expanded = expand_builtin_macros(markdown.as_str(), &macro_context);
@@ -473,8 +515,31 @@ fn trim_html_suffix(value: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { 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::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<Value>,
) -> Result<Value, String> {
match (namespace, method) {
("posts", "get_all") => Ok(json!([{"id":"one"}, {"id":"two"}])),
_ => Err("unsupported test capability".into()),
}
}
}
struct TestRewriteContext { struct TestRewriteContext {
canonical_post_path_by_slug: HashMap<String, String>, canonical_post_path_by_slug: HashMap<String, String>,
@@ -520,4 +585,25 @@ mod tests {
assert_eq!(rendered, "ueber-die-bruecke"); 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, "<p>2</p>\n");
}
} }

View File

@@ -2,6 +2,7 @@ use std::collections::{BTreeMap, HashMap};
use std::error::Error; use std::error::Error;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use std::sync::Arc;
use crate::db::DbConnection as Connection; use crate::db::DbConnection as Connection;
use chrono::{Datelike, TimeZone, Utc}; use chrono::{Datelike, TimeZone, Utc};
@@ -16,8 +17,9 @@ use crate::model::{
}; };
use crate::render::{ use crate::render::{
RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path, 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::frontmatter::{read_script_file, read_template_file, read_translation_file};
use crate::util::slugify; use crate::util::slugify;
@@ -62,7 +64,7 @@ pub struct PreviewRenderResult {
pub html: String, pub html: String,
} }
#[derive(Debug, Clone)] #[derive(Clone)]
struct TemplateBundle { struct TemplateBundle {
post_templates: Vec<Template>, post_templates: Vec<Template>,
template_source_by_slug: HashMap<String, String>, template_source_by_slug: HashMap<String, String>,
@@ -71,6 +73,7 @@ struct TemplateBundle {
not_found_template: String, not_found_template: String,
partials: HashMap<String, String>, partials: HashMap<String, String>,
macro_scripts: HashMap<String, Value>, macro_scripts: HashMap<String, Value>,
host: Arc<dyn HostApi>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -268,6 +271,10 @@ fn load_template_bundle(
let mut default_list_template = STARTER_POST_LIST_TEMPLATE.to_string(); let mut default_list_template = STARTER_POST_LIST_TEMPLATE.to_string();
let mut not_found_template = STARTER_NOT_FOUND_TEMPLATE.to_string(); let mut not_found_template = STARTER_NOT_FOUND_TEMPLATE.to_string();
let mut macro_scripts = HashMap::new(); let mut macro_scripts = HashMap::new();
let host: Arc<dyn HostApi> = CoreHost::from_connection(conn, project_id, data_dir)
.map(|host| host.with_offline_mode(true))
.map(|host| Arc::new(host) as Arc<dyn HostApi>)
.unwrap_or_else(|_| Arc::new(UnavailableHost));
for script in queries::script::list_scripts_by_project(conn, project_id).unwrap_or_default() { for script in queries::script::list_scripts_by_project(conn, project_id).unwrap_or_default() {
if script.kind != ScriptKind::Macro if script.kind != ScriptKind::Macro
@@ -368,6 +375,7 @@ fn load_template_bundle(
not_found_template, not_found_template,
partials, partials,
macro_scripts, macro_scripts,
host,
}) })
} }
@@ -661,10 +669,11 @@ fn render_list_route(
"not_found_back_label": serde_json::Value::Null, "not_found_back_label": serde_json::Value::Null,
}); });
Ok(render_liquid_template( Ok(render_liquid_template_with_host(
list_template, list_template,
&bundle.partials, &bundle.partials,
&context, &context,
Arc::clone(&bundle.host),
)?) )?)
} }
@@ -776,10 +785,11 @@ fn render_post_route(
"not_found_back_label": serde_json::Value::Null, "not_found_back_label": serde_json::Value::Null,
}); });
Ok(render_liquid_template( Ok(render_liquid_template_with_host(
&template_source, &template_source,
&bundle.partials, &bundle.partials,
&context, &context,
Arc::clone(&bundle.host),
)?) )?)
} }
@@ -824,10 +834,11 @@ fn render_not_found_route(
"canonical_media_path_by_source_path": HashMap::<String, String>::new(), "canonical_media_path_by_source_path": HashMap::<String, String>::new(),
"post_data_json_by_id": HashMap::<String, Value>::new(), "post_data_json_by_id": HashMap::<String, Value>::new(),
}); });
Ok(render_liquid_template( Ok(render_liquid_template_with_host(
&bundle.not_found_template, &bundle.not_found_template,
&bundle.partials, &bundle.partials,
&context, &context,
Arc::clone(&bundle.host),
)?) )?)
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,430 @@
use std::collections::BTreeSet;
use std::sync::LazyLock;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct ApiManifest {
pub version: String,
pub root_methods: Vec<ApiMethod>,
pub methods: Vec<ApiMethod>,
pub types: Vec<ApiType>,
}
#[derive(Debug, Deserialize)]
pub struct ApiMethod {
#[serde(default = "bds2_compatibility")]
pub compatibility: String,
#[serde(default)]
pub namespace: String,
pub name: String,
pub params: Vec<ApiParameter>,
pub returns: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Deserialize)]
pub struct ApiParameter {
pub name: String,
#[serde(rename = "type")]
pub type_name: String,
pub required: bool,
}
#[derive(Debug, Deserialize)]
pub struct ApiType {
pub name: String,
pub description: String,
pub fields: serde_json::Map<String, serde_json::Value>,
}
impl ApiManifest {
pub fn namespaces(&self) -> Vec<&str> {
self.methods
.iter()
.map(|method| method.namespace.as_str())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
pub fn render_reference(&self) -> String {
let mut output = format!(
"# RuDS Lua API Reference\n\nContract version: `{}`\n\nThe `bds` global is the supported bridge from sandboxed Lua scripts to RuDS. The unmarked method signatures are identical to bDS2 so the same published script file can run in either application. Calls are synchronous, JSON-compatible values cross the bridge as Lua tables, and project-scoped methods always use the active project.\n\n## Usage\n\nA utility script exposes `main(input)` and calls the API through `bds`:\n\n```lua\nfunction main(input)\n local posts = bds.posts.get_all()\n bds.app.log(\"Found \" .. #posts .. \" posts\")\n return posts\nend\n```\n\nMacro scripts expose `render(input, context)` and transform scripts expose `main(input, context)`. Complete runnable files are in [`examples/`](examples/). Scripts cannot access the network, filesystem, processes, environment variables, or native Lua modules directly; use the documented host methods instead. Host failures return `nil` or `false` where the signature permits it.\n\n## Conventions\n\n- A parameter ending in `?` is optional.\n- `T | nil` means the call can return no value. Check for `nil` before using it.\n- `T[]` is a one-based Lua array.\n- Public records are documented in [Lua API Types](TYPES.md).\n- Dates and timestamps returned by RuDS records are ISO-8601 strings.\n- Methods marked **RuDS extension** are not available to scripts running under bDS2.\n\n## Contents\n",
self.version
);
if !self.root_methods.is_empty() {
output.push_str("\n- [Root helpers](#root-helpers)");
}
for namespace in self.namespaces() {
output.push_str(&format!("\n- [`bds.{namespace}`](#bds{namespace})"));
}
output.push_str("\n- [Public data types](TYPES.md)\n");
if !self.root_methods.is_empty() {
output.push_str("\n## Root helpers\n");
for method in &self.root_methods {
self.render_method(&mut output, method);
}
}
for namespace in self.namespaces() {
output.push_str(&format!("\n## `bds.{namespace}`\n"));
for method in self
.methods
.iter()
.filter(|method| method.namespace == namespace)
{
self.render_method(&mut output, method);
}
}
output
}
pub fn render_types(&self) -> String {
let mut output = format!(
"# Lua API Types\n\nContract version: `{}`\n\nThese are the public, JSON-compatible records returned by the Lua host API. They contain no database handles or private implementation fields.\n\n## Value conventions\n\n- `T | nil` marks an optional field.\n- `T[]` is a one-based Lua array.\n- `table` is a JSON-compatible Lua table whose shape depends on the operation.\n- ISO-8601 values are strings such as `2026-07-19T08:00:00Z`.\n\n## Contents\n",
self.version
);
for api_type in &self.types {
output.push_str(&format!(
"\n- [`{}`](#{})",
api_type.name,
api_type.name.to_lowercase()
));
}
output.push('\n');
for api_type in &self.types {
output.push_str(&format!(
"\n## `{}`\n\n{}\n\n**Lua shape**\n\n```lua\n{}\n```\n\n| Field | Type | Required | Meaning |\n| --- | --- | --- | --- |\n",
api_type.name,
api_type.description,
self.example_for_type(api_type)
));
for (field, type_name) in &api_type.fields {
let type_name = type_name.as_str().unwrap_or("any");
output.push_str(&format!(
"| `{field}` | `{}` | {} | {} |\n",
type_name.replace('|', "\\|"),
if type_name.contains("nil") {
"No"
} else {
"Yes"
},
field_description(field)
));
}
}
output
}
pub fn render_completions(&self) -> String {
let completions = self
.methods
.iter()
.map(|method| {
serde_json::json!({
"label": format!("bds.{}.{}", method.namespace, method.name),
"insert_text": self.example_call(method),
"detail": self.signature(method),
"description": method.description,
"parameters": method.params.iter().map(|param| serde_json::json!({
"name": param.name,
"type": param.type_name,
"required": param.required,
})).collect::<Vec<_>>(),
"returns": method.returns,
})
})
.chain(self.root_methods.iter().map(|method| {
serde_json::json!({
"label": format!("bds.{}", method.name),
"insert_text": self.example_call(method),
"detail": self.signature(method),
"description": method.description,
"parameters": method.params.iter().map(|param| serde_json::json!({
"name": param.name,
"type": param.type_name,
"required": param.required,
})).collect::<Vec<_>>(),
"returns": method.returns,
})
}))
.collect::<Vec<_>>();
serde_json::to_string_pretty(&completions).expect("completion data is serializable") + "\n"
}
fn render_method(&self, output: &mut String, method: &ApiMethod) {
let path = method_path(method);
output.push_str(&format!("\n### `{path}`\n\n{}\n\n", method.description));
if method.compatibility == "ruds" {
output.push_str(
"> **RuDS extension:** this helper is not part of the portable bDS2 API.\n\n",
);
}
output.push_str(&format!(
"**Signature**\n\n```text\n{}\n```\n\n**Parameters**\n\n",
self.signature(method)
));
if method.params.is_empty() {
output.push_str("None.\n");
} else {
output.push_str("| Name | Type | Required | Example |\n| --- | --- | --- | --- |\n");
for param in &method.params {
output.push_str(&format!(
"| `{}` | `{}` | {} | `{}` |\n",
param.name,
param.type_name.replace('|', "\\|"),
if param.required { "Yes" } else { "No" },
example_argument(param).replace('|', "\\|")
));
}
}
output.push_str(&format!(
"\n**Returns**\n\n{}",
self.render_return_type(&method.returns)
));
if method.returns.contains("nil") {
output.push_str(" `nil` means no value was available or the host operation failed.");
} else if method.returns == "boolean" {
output.push_str(" `false` means the operation was rejected or failed.");
}
output.push_str(&format!(
"\n\n**Example call**\n\n```lua\nlocal result = {}\n```\n\n**Example response**\n\n```lua\n{}\n```\n",
self.example_call(method),
self.example_response(&method.returns)
));
}
fn signature(&self, method: &ApiMethod) -> String {
let params = method
.params
.iter()
.map(|param| {
format!(
"{}{}: {}",
param.name,
if param.required { "" } else { "?" },
param.type_name
)
})
.collect::<Vec<_>>()
.join(", ");
format!("{}({params}) -> {}", method_path(method), method.returns)
}
fn example_call(&self, method: &ApiMethod) -> String {
let arguments = method
.params
.iter()
.map(example_argument)
.collect::<Vec<_>>()
.join(", ");
format!("{}({arguments})", method_path(method))
}
fn render_return_type(&self, returns: &str) -> String {
let references = self
.types
.iter()
.filter(|api_type| returns.contains(&api_type.name))
.map(|api_type| {
format!(
"[`{}`](TYPES.md#{})",
api_type.name,
api_type.name.to_lowercase()
)
})
.collect::<Vec<_>>();
if references.is_empty() {
format!("`{returns}`.")
} else {
format!("`{returns}`. See {}.", references.join(", "))
}
}
fn example_response(&self, returns: &str) -> String {
self.example_response_named("result", returns)
}
fn example_response_named(&self, name: &str, returns: &str) -> String {
let base = returns.split('|').next().unwrap_or(returns).trim();
if let Some(element) = base.strip_suffix("[]") {
if let Some(api_type) = self.types.iter().find(|item| item.name == element) {
return format!("{{\n{}\n}}", indent(&self.example_for_type(api_type), 2));
}
return match element {
"string" => "{ \"example\" }".to_owned(),
_ => "{ { key = \"value\" } }".to_owned(),
};
}
if let Some(api_type) = self.types.iter().find(|item| item.name == base) {
return self.example_for_type(api_type);
}
example_value(name, base)
}
fn example_for_type(&self, api_type: &ApiType) -> String {
let fields = api_type
.fields
.iter()
.map(|(field, type_name)| {
format!(
" {field} = {},",
self.example_response_named(field, type_name.as_str().unwrap_or("any"))
)
})
.collect::<Vec<_>>()
.join("\n");
format!("{{\n{fields}\n}}")
}
}
fn method_path(method: &ApiMethod) -> String {
if method.namespace.is_empty() {
format!("bds.{}", method.name)
} else {
format!("bds.{}.{}", method.namespace, method.name)
}
}
fn bds2_compatibility() -> String {
"bds2".to_owned()
}
fn example_argument(param: &ApiParameter) -> String {
match param.type_name.as_str() {
"table" => match param.name.as_str() {
"credentials" => "{ host = \"example.com\", username = \"author\" }".to_owned(),
"filters" => "{ status = \"draft\" }".to_owned(),
"options" => "{ language = \"en\" }".to_owned(),
"prefs" => "{ publish_drafts = false }".to_owned(),
"source_tag_ids" => "{ \"tag-1\", \"tag-2\" }".to_owned(),
"updates" => "{ description = \"Updated description\" }".to_owned(),
"payload" => "{ current = 1, total = 10, message = \"Working\" }".to_owned(),
_ => "{ title = \"Example\" }".to_owned(),
},
"number" => match param.name.as_str() {
"total" => "100".to_owned(),
_ => "1".to_owned(),
},
"boolean" => "true".to_owned(),
type_name if type_name.contains("string") => example_string(&param.name),
_ => "nil".to_owned(),
}
}
fn example_value(name: &str, type_name: &str) -> String {
let base = type_name.split('|').next().unwrap_or(type_name).trim();
if base.ends_with("[]") {
return if base == "string[]" {
"{ \"example\" }".to_owned()
} else {
"{ { key = \"value\" } }".to_owned()
};
}
match base {
"nil" => "nil".to_owned(),
"boolean" => "true".to_owned(),
"integer" => "1".to_owned(),
"number" => "0.5".to_owned(),
"table" => "{ key = \"value\" }".to_owned(),
value if value.contains("ISO-8601") => "\"2026-07-19T08:00:00Z\"".to_owned(),
"string" => example_string(name),
_ => "{ key = \"value\" }".to_owned(),
}
}
fn example_string(name: &str) -> String {
let value = match name {
"action" => "new-post",
"alt" => "A descriptive alternative",
"caption" => "Example caption",
"color" => "#336699",
"content" | "text" => "Example content",
"data_path" | "folder_path" | "item_path" | "source_path" | "file_path" => "/path/to/item",
"default_author" => "Ada Author",
"entrypoint" => "main",
"kind" => "utility",
"language" | "main_language" => "en",
"message" => "Working",
"mime_type" => "image/png",
"name" | "new_name" => "Example",
"original_name" => "image.png",
"public_url" => "https://example.com",
"query" => "rust",
"size" => "small",
"slug" | "post_template_slug" => "example-post",
"status" => "draft",
"title" => "Example post",
value if value == "id" || value.ends_with("_id") => "example-id",
_ => "example",
};
format!("\"{value}\"")
}
fn field_description(field: &str) -> &'static str {
match field {
"id" => "Stable record identifier.",
"project_id" => "Identifier of the owning project.",
"name" => "Human-readable name.",
"title" => "Human-readable title.",
"slug" => "URL-safe record identifier.",
"description" => "Human-readable description.",
"status" => "Current lifecycle state.",
"progress" => "Completion value reported by the task.",
"message" => "Latest user-facing task message.",
"created_at" => "Creation timestamp.",
"updated_at" => "Last-update timestamp.",
"language" | "main_language" => "BCP 47 language code.",
"blog_languages" => "Languages configured for the blog.",
"categories" => "Assigned category names.",
"tags" => "Assigned tag names.",
"active_count" => "Number of active tasks.",
"running_count" => "Number of currently running tasks.",
"pending_count" => "Number of queued tasks.",
"tasks" => "Tasks included in this status snapshot.",
"errors" => "Validation error messages.",
"valid" => "Whether validation succeeded.",
"is_active" => "Whether this is the active project.",
"enabled" => "Whether the record is enabled.",
"data_path" => "Filesystem path containing project data.",
"public_url" => "Published site base URL.",
"default_author" => "Default post author name.",
"publishing_preferences" => "Project publishing configuration.",
"backlinks" => "Links from other posts to this post.",
"links_to" => "Links from this post to other posts.",
"original_name" => "Original imported filename.",
"mime_type" => "Media MIME type.",
"file_path" => "Stored media file path.",
"alt" => "Alternative text for the media.",
"caption" => "Media caption.",
"kind" => "Script or template kind.",
"entrypoint" => "Lua function invoked by the runtime.",
"color" => "Optional display color.",
"post_template_slug" => "Template selected for tagged posts.",
_ => "Public value returned by the host API.",
}
}
fn indent(value: &str, spaces: usize) -> String {
let padding = " ".repeat(spaces);
value
.lines()
.map(|line| format!("{padding}{line}"))
.collect::<Vec<_>>()
.join("\n")
}
static API_MANIFEST: LazyLock<ApiManifest> = LazyLock::new(|| {
serde_json::from_str(include_str!("../../../../docs/scripting/api.json"))
.expect("docs/scripting/api.json must be valid")
});
pub fn api_manifest() -> &'static ApiManifest {
&API_MANIFEST
}

View File

@@ -7,6 +7,12 @@ use mlua::{
}; };
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
mod core_host;
mod manifest;
pub use core_host::{AppHostHandler, CoreHost};
pub use manifest::{ApiManifest, api_manifest};
const MACRO_TIMEOUT: Duration = Duration::from_secs(10); const MACRO_TIMEOUT: Duration = Duration::from_secs(10);
const MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024; const MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
const MAX_TOASTS_PER_SCRIPT: usize = 5; const MAX_TOASTS_PER_SCRIPT: usize = 5;
@@ -34,6 +40,29 @@ pub struct ScriptExecution {
pub toasts: Vec<String>, pub toasts: Vec<String>,
} }
/// Project/application operations explicitly made available to sandboxed Lua.
pub trait HostApi: Send + Sync {
fn call(
&self,
namespace: &str,
method: &str,
arguments: Vec<JsonValue>,
) -> Result<JsonValue, String>;
}
pub(crate) struct UnavailableHost;
impl HostApi for UnavailableHost {
fn call(
&self,
_namespace: &str,
_method: &str,
_arguments: Vec<JsonValue>,
) -> Result<JsonValue, String> {
Err("host capability is unavailable in this execution context".into())
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct ExecutionControl { pub struct ExecutionControl {
cancelled: Arc<AtomicBool>, cancelled: Arc<AtomicBool>,
@@ -48,9 +77,17 @@ impl Default for ExecutionControl {
} }
impl ExecutionControl { impl ExecutionControl {
pub fn from_cancelled(cancelled: Arc<AtomicBool>) -> Self {
Self { cancelled }
}
pub fn cancel(&self) { pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Release); self.cancelled.store(true, Ordering::Release);
} }
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Acquire)
}
} }
/// Parse Lua source with the same runtime used for execution. /// Parse Lua source with the same runtime used for execution.
@@ -71,12 +108,32 @@ pub fn execute(
kind: ExecutionKind, kind: ExecutionKind,
control: &ExecutionControl, control: &ExecutionControl,
) -> Result<ScriptExecution, String> { ) -> Result<ScriptExecution, String> {
execute_many( execute_many_with_host(
source, source,
entrypoint, entrypoint,
std::slice::from_ref(args), std::slice::from_ref(args),
kind, kind,
control, control,
Arc::new(UnavailableHost),
)
}
/// Execute with project-scoped host capabilities.
pub fn execute_with_host(
source: &str,
entrypoint: &str,
args: &JsonValue,
kind: ExecutionKind,
control: &ExecutionControl,
host: Arc<dyn HostApi>,
) -> Result<ScriptExecution, String> {
execute_many_with_host(
source,
entrypoint,
std::slice::from_ref(args),
kind,
control,
host,
) )
} }
@@ -87,6 +144,25 @@ pub fn execute_many(
args: &[JsonValue], args: &[JsonValue],
kind: ExecutionKind, kind: ExecutionKind,
control: &ExecutionControl, control: &ExecutionControl,
) -> Result<ScriptExecution, String> {
execute_many_with_host(
source,
entrypoint,
args,
kind,
control,
Arc::new(UnavailableHost),
)
}
/// Execute with positional arguments and project-scoped host capabilities.
pub fn execute_many_with_host(
source: &str,
entrypoint: &str,
args: &[JsonValue],
kind: ExecutionKind,
control: &ExecutionControl,
host: Arc<dyn HostApi>,
) -> Result<ScriptExecution, String> { ) -> Result<ScriptExecution, String> {
if entrypoint.trim().is_empty() { if entrypoint.trim().is_empty() {
return Err("script entrypoint is empty".into()); return Err("script entrypoint is empty".into());
@@ -96,7 +172,7 @@ pub fn execute_many(
let output = Arc::new(Mutex::new(Vec::<String>::new())); let output = Arc::new(Mutex::new(Vec::<String>::new()));
let progress = Arc::new(Mutex::new(Vec::<ScriptProgress>::new())); let progress = Arc::new(Mutex::new(Vec::<ScriptProgress>::new()));
let toasts = Arc::new(Mutex::new(Vec::<String>::new())); let toasts = Arc::new(Mutex::new(Vec::<String>::new()));
install_host_api(&lua, &output, &progress, &toasts)?; install_host_api(&lua, &output, &progress, &toasts, host)?;
let started = Instant::now(); let started = Instant::now();
let cancelled = Arc::clone(&control.cancelled); let cancelled = Arc::clone(&control.cancelled);
@@ -175,6 +251,7 @@ fn install_host_api(
output: &Arc<Mutex<Vec<String>>>, output: &Arc<Mutex<Vec<String>>>,
progress: &Arc<Mutex<Vec<ScriptProgress>>>, progress: &Arc<Mutex<Vec<ScriptProgress>>>,
toasts: &Arc<Mutex<Vec<String>>>, toasts: &Arc<Mutex<Vec<String>>>,
host: Arc<dyn HostApi>,
) -> Result<(), String> { ) -> Result<(), String> {
let globals = lua.globals(); let globals = lua.globals();
@@ -208,13 +285,14 @@ fn install_host_api(
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\t"); .join("\t");
log_output.lock().unwrap().push(line); log_output.lock().unwrap().push(line);
Ok(()) Ok(true)
}) })
.map_err(|error| error.to_string())?, .map_err(|error| error.to_string())?,
) )
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let progress_sink = Arc::clone(progress); let progress_sink = Arc::clone(progress);
let progress_host = Arc::clone(&host);
app.set( app.set(
"progress", "progress",
lua.create_function( lua.create_function(
@@ -222,9 +300,18 @@ fn install_host_api(
progress_sink.lock().unwrap().push(ScriptProgress { progress_sink.lock().unwrap().push(ScriptProgress {
current, current,
total, total,
message, message: message.clone(),
}); });
Ok(()) let _ = progress_host.call(
"bds",
"report_progress",
vec![serde_json::json!({
"current": current,
"total": total,
"message": message,
})],
);
Ok(true)
}, },
) )
.map_err(|error| error.to_string())?, .map_err(|error| error.to_string())?,
@@ -239,17 +326,97 @@ fn install_host_api(
if sink.len() < MAX_TOASTS_PER_SCRIPT { if sink.len() < MAX_TOASTS_PER_SCRIPT {
sink.push(message.chars().take(MAX_TOAST_LENGTH).collect()); sink.push(message.chars().take(MAX_TOAST_LENGTH).collect());
} }
Ok(()) Ok(true)
}) })
.map_err(|error| error.to_string())?, .map_err(|error| error.to_string())?,
) )
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
bds.set("app", app).map_err(|error| error.to_string())?; bds.set("app", app).map_err(|error| error.to_string())?;
for namespace in api_manifest().namespaces() {
let table = if namespace == "app" {
bds.get(namespace).map_err(|error| error.to_string())?
} else {
let table = lua.create_table().map_err(|error| error.to_string())?;
bds.set(namespace, table.clone())
.map_err(|error| error.to_string())?;
table
};
for method in api_manifest()
.methods
.iter()
.filter(|method| method.namespace == namespace)
{
if namespace == "app" && matches!(method.name.as_str(), "log" | "progress" | "toast") {
continue;
}
let host = Arc::clone(&host);
let namespace = method.namespace.clone();
let method_name = method.name.clone();
let returns = method.returns.clone();
table
.set(
method.name.as_str(),
lua.create_function(move |lua, values: MultiValue| {
let arguments = values
.into_iter()
.map(|value| lua.from_value(value))
.collect::<mlua::Result<Vec<JsonValue>>>()?;
let value = host
.call(&namespace, &method_name, arguments)
.unwrap_or_else(|_| failure_value(&returns));
lua.to_value(&value)
})
.map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
}
}
let report_sink = Arc::clone(progress);
let report_host = Arc::clone(&host);
bds.set(
"report_progress",
lua.create_function(move |lua, payload: Value| {
let payload: JsonValue = lua.from_value(payload)?;
let current = payload
.get("current")
.or_else(|| payload.get("progress"))
.and_then(JsonValue::as_f64)
.unwrap_or_default();
let total = payload.get("total").and_then(JsonValue::as_f64);
let message = payload
.get("message")
.and_then(JsonValue::as_str)
.map(str::to_owned);
report_sink.lock().unwrap().push(ScriptProgress {
current,
total,
message,
});
let _ = report_host.call("bds", "report_progress", vec![payload]);
Ok(true)
})
.map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
globals.set("bds", bds).map_err(|error| error.to_string())?; globals.set("bds", bds).map_err(|error| error.to_string())?;
Ok(()) Ok(())
} }
fn failure_value(returns: &str) -> JsonValue {
if returns == "boolean" {
JsonValue::Bool(false)
} else if returns.contains("[]") {
JsonValue::Array(Vec::new())
} else if returns == "table" {
JsonValue::Object(Default::default())
} else {
JsonValue::Null
}
}
fn lua_value_text(value: &Value) -> String { fn lua_value_text(value: &Value) -> String {
match value { match value {
Value::Nil => "nil".into(), Value::Nil => "nil".into(),
@@ -265,6 +432,292 @@ fn lua_value_text(value: &Value) -> String {
mod tests { mod tests {
use super::*; use super::*;
use serde_json::json; use serde_json::json;
use std::sync::Mutex;
struct RecordingHost(Mutex<Vec<String>>);
impl HostApi for RecordingHost {
fn call(
&self,
namespace: &str,
method: &str,
_arguments: Vec<JsonValue>,
) -> Result<JsonValue, String> {
self.0.lock().unwrap().push(format!("{namespace}.{method}"));
Ok(json!(format!("{namespace}.{method}")))
}
}
#[test]
fn manifest_and_runtime_expose_exact_core_namespaces() {
let manifest = api_manifest();
assert_eq!(
manifest.namespaces(),
[
"app",
"chat",
"media",
"meta",
"posts",
"projects",
"publish",
"scripts",
"tags",
"tasks",
"templates",
]
);
let execution = execute(
r#"
function main()
return {
sync = bds.sync,
embeddings = bds.embeddings,
report_progress = type(bds.report_progress),
post_search = type(bds.posts.search),
app_toast = type(bds.app.toast),
}
end
"#,
"main",
&JsonValue::Null,
ExecutionKind::Utility,
&ExecutionControl::default(),
)
.unwrap();
assert_eq!(
execution.value,
json!({
"report_progress": "function",
"post_search": "function",
"app_toast": "function",
})
);
}
#[test]
fn representative_calls_from_every_namespace_reach_one_host_dispatcher() {
let host = Arc::new(RecordingHost(Mutex::new(Vec::new())));
let execution = execute_with_host(
r#"
function main()
return {
bds.app.get_data_paths(),
bds.projects.get_active(),
bds.meta.get_project_metadata(),
bds.posts.get_all(),
bds.media.get_all(),
bds.scripts.get_all(),
bds.templates.get_all(),
bds.tags.get_all(),
bds.tasks.status_snapshot(),
bds.publish.upload_site({}),
bds.chat.detect_post_language("title", "body"),
}
end
"#,
"main",
&JsonValue::Null,
ExecutionKind::Utility,
&ExecutionControl::default(),
host.clone(),
)
.unwrap();
assert_eq!(execution.value.as_array().unwrap().len(), 11);
assert_eq!(host.0.lock().unwrap().len(), 11);
}
#[test]
fn spec_manifest_runtime_and_generated_docs_stay_in_sync() {
let manifest = api_manifest();
let spec = include_str!("../../../../specs/script.allium");
for namespace in manifest.namespaces() {
let marker = format!("bds.{namespace}:");
let start = spec
.find(&marker)
.unwrap_or_else(|| panic!("missing {marker} in spec"));
let list = &spec[start + marker.len()..];
let list = &list[..list.find('.').expect("method list ends with a period")];
let expected = list
.replace("--", "")
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_owned)
.collect::<std::collections::BTreeSet<_>>();
let actual = manifest
.methods
.iter()
.filter(|method| method.namespace == namespace && method.compatibility == "bds2")
.map(|method| method.name.clone())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
actual, expected,
"bds.{namespace} differs from the Allium contract"
);
}
let checks = manifest
.methods
.iter()
.map(|method| {
format!(
"assert(type(bds.{}.{}) == 'function')",
method.namespace, method.name
)
})
.chain(
manifest
.root_methods
.iter()
.map(|method| format!("assert(type(bds.{}) == 'function')", method.name)),
)
.collect::<Vec<_>>()
.join("\n");
validate(&checks).unwrap();
execute(
&format!("{checks}\nfunction main() return true end"),
"main",
&JsonValue::Null,
ExecutionKind::Utility,
&ExecutionControl::default(),
)
.unwrap();
assert_eq!(
manifest.render_reference(),
include_str!("../../../../docs/scripting/API_REFERENCE.md")
);
assert_eq!(
manifest.render_types(),
include_str!("../../../../docs/scripting/TYPES.md")
);
assert_eq!(
manifest.render_completions(),
include_str!("../../../../docs/scripting/completions.json")
);
}
#[test]
fn generated_reference_documents_every_manifest_entry() {
let manifest = api_manifest();
let undocumented = manifest
.methods
.iter()
.filter(|method| method.description.trim().is_empty())
.map(|method| format!("bds.{}.{}", method.namespace, method.name))
.collect::<Vec<_>>();
assert!(
undocumented.is_empty(),
"methods without documentation: {}",
undocumented.join(", ")
);
let undocumented_types = manifest
.types
.iter()
.filter(|api_type| api_type.description.trim().is_empty())
.map(|api_type| api_type.name.as_str())
.collect::<Vec<_>>();
assert!(
undocumented_types.is_empty(),
"types without documentation: {}",
undocumented_types.join(", ")
);
let reference = manifest.render_reference();
for section in [
"## Usage",
"**Parameters**",
"**Returns**",
"**Example call**",
"**Example response**",
] {
assert!(reference.contains(section), "missing {section}");
}
let types = manifest.render_types();
for section in ["## Value conventions", "**Lua shape**", "| Field | Type |"] {
assert!(types.contains(section), "missing {section}");
}
}
#[test]
fn portable_method_signatures_are_identical_to_bds2() {
let manifest = api_manifest();
let baseline: JsonValue = serde_json::from_str(include_str!(
"../../../../docs/scripting/bds2-core-signatures.json"
))
.unwrap();
assert_eq!(baseline["version"], manifest.version);
let expected = baseline["methods"]
.as_array()
.unwrap()
.iter()
.map(|method| {
let path = format!(
"{}.{}",
method["namespace"].as_str().unwrap(),
method["name"].as_str().unwrap()
);
(
path,
json!({"params": method["params"], "returns": method["returns"]}),
)
})
.collect::<std::collections::BTreeMap<_, _>>();
let actual = manifest
.methods
.iter()
.filter(|method| method.compatibility == "bds2")
.map(|method| {
let path = format!("{}.{}", method.namespace, method.name);
let params = method
.params
.iter()
.map(|param| {
json!({
"name": param.name,
"type": param.type_name,
"required": param.required,
})
})
.collect::<Vec<_>>();
(path, json!({"params": params, "returns": method.returns}))
})
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(actual, expected);
}
#[test]
fn bundled_examples_execute() {
execute_many(
include_str!("../../../../docs/scripting/examples/macro.lua"),
"render",
&[json!({"text":"Hello"}), json!({"mainLanguage":"en"})],
ExecutionKind::Macro,
&ExecutionControl::default(),
)
.unwrap();
execute_many(
include_str!("../../../../docs/scripting/examples/transform.lua"),
"main",
&[json!({}), json!({"source":"blogmark"})],
ExecutionKind::Transform,
&ExecutionControl::default(),
)
.unwrap();
execute_with_host(
include_str!("../../../../docs/scripting/examples/utility.lua"),
"main",
&JsonValue::Null,
ExecutionKind::Utility,
&ExecutionControl::default(),
Arc::new(RecordingHost(Mutex::new(Vec::new()))),
)
.unwrap();
}
#[test] #[test]
fn executes_entrypoint_and_routes_output_and_progress() { fn executes_entrypoint_and_routes_output_and_progress() {
@@ -290,6 +743,23 @@ mod tests {
assert_eq!(result.progress[0].message.as_deref(), Some("half")); assert_eq!(result.progress[0].message.as_deref(), Some("half"));
} }
#[test]
fn app_progress_reaches_the_live_host_once() {
let host = Arc::new(RecordingHost(Mutex::new(Vec::new())));
let result = execute_with_host(
"function main() bds.app.progress(1, 2, 'half') end",
"main",
&JsonValue::Null,
ExecutionKind::Utility,
&ExecutionControl::default(),
Arc::clone(&host) as Arc<dyn HostApi>,
)
.unwrap();
assert_eq!(result.progress.len(), 1);
assert_eq!(host.0.lock().unwrap().as_slice(), &["bds.report_progress"]);
}
#[test] #[test]
fn sandbox_hides_ambient_host_capabilities() { fn sandbox_hides_ambient_host_capabilities() {
let result = execute( let result = execute(

View File

@@ -1,6 +1,6 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::{Arc, Mutex};
use base64::Engine as _; use base64::Engine as _;
use bds_core::db::DbQueryError as SqlError; use bds_core::db::DbQueryError as SqlError;
@@ -119,7 +119,10 @@ pub enum Message {
// macOS lifecycle // macOS lifecycle
FileOpenRequested(PathBuf), FileOpenRequested(PathBuf),
UrlOpenRequested(String), UrlOpenRequested(String),
BlogmarkImported(Result<engine::blogmark::BlogmarkImportResult, String>), BlogmarkImported {
task_id: TaskId,
result: Result<engine::blogmark::BlogmarkImportResult, String>,
},
MainWindowLoaded(Option<window::Id>), MainWindowLoaded(Option<window::Id>),
EmbeddedPreviewReady(Result<(), String>), EmbeddedPreviewReady(Result<(), String>),
@@ -738,6 +741,7 @@ pub struct BdsApp {
// Tasks // Tasks
task_manager: Arc<TaskManager>, task_manager: Arc<TaskManager>,
script_menu_actions: Arc<Mutex<Vec<MenuAction>>>,
task_snapshots: Vec<TaskSnapshot>, task_snapshots: Vec<TaskSnapshot>,
collapsed_task_groups: HashSet<String>, collapsed_task_groups: HashSet<String>,
output_entries: Vec<OutputEntry>, output_entries: Vec<OutputEntry>,
@@ -891,6 +895,7 @@ impl BdsApp {
panel_visible: false, panel_visible: false,
panel_tab: PanelTab::Tasks, panel_tab: PanelTab::Tasks,
task_manager: Arc::new(TaskManager::default()), task_manager: Arc::new(TaskManager::default()),
script_menu_actions: Arc::new(Mutex::new(Vec::new())),
task_snapshots: Vec::new(), task_snapshots: Vec::new(),
collapsed_task_groups: HashSet::new(), collapsed_task_groups: HashSet::new(),
output_entries: Vec::new(), output_entries: Vec::new(),
@@ -956,6 +961,7 @@ impl BdsApp {
panel_visible: false, panel_visible: false,
panel_tab: PanelTab::Tasks, panel_tab: PanelTab::Tasks,
task_manager: Arc::new(TaskManager::default()), task_manager: Arc::new(TaskManager::default()),
script_menu_actions: Arc::new(Mutex::new(Vec::new())),
task_snapshots: Vec::new(), task_snapshots: Vec::new(),
collapsed_task_groups: HashSet::new(), collapsed_task_groups: HashSet::new(),
output_entries: Vec::new(), output_entries: Vec::new(),
@@ -1498,7 +1504,12 @@ impl BdsApp {
if !self.search_index_rebuild_running { if !self.search_index_rebuild_running {
self.auto_save_due_post_editors(); self.auto_save_due_post_editors();
} }
Task::none() let actions = std::mem::take(&mut *self.script_menu_actions.lock().unwrap());
Task::batch(
actions
.into_iter()
.map(|action| self.dispatch_menu_action(action)),
)
} }
Message::CancelTask(task_id) => { Message::CancelTask(task_id) => {
self.task_manager.cancel(task_id); self.task_manager.cancel(task_id);
@@ -1593,26 +1604,51 @@ impl BdsApp {
} }
let db_path = self.db_path.clone(); let db_path = self.db_path.clone();
let project_id = target.id; let project_id = target.id;
let task_manager = Arc::clone(&self.task_manager);
let label = t(self.ui_locale, "blogmark.importing");
let task_id = task_manager.submit(&label);
let offline_mode = self.offline_mode;
let app_handler = crate::platform::script_host::handler(
Arc::clone(&self.script_menu_actions),
t(self.ui_locale, "dialog.selectFolder"),
);
self.refresh_task_snapshots();
Task::perform( Task::perform(
async move { async move {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
if !task_manager.wait_until_runnable(task_id) {
return Err("cancelled".to_string());
}
let db = Database::open(&db_path).map_err(|error| error.to_string())?; let db = Database::open(&db_path).map_err(|error| error.to_string())?;
engine::blogmark::receive_deep_link( let host =
bds_core::scripting::CoreHost::new(db_path, &project_id, &data_dir)
.with_task(Arc::clone(&task_manager), task_id)
.with_offline_mode(offline_mode)
.with_app_handler(app_handler);
let control = task_manager
.cancellation_flag(task_id)
.map(bds_core::scripting::ExecutionControl::from_cancelled)
.unwrap_or_default();
engine::blogmark::receive_deep_link_with_host(
db.conn(), db.conn(),
&data_dir, &data_dir,
&project_id, &project_id,
&url, &url,
&control,
Arc::new(host),
) )
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
}) })
.await .await
.unwrap_or_else(|error| Err(format!("task panicked: {error}"))) .unwrap_or_else(|error| Err(format!("task panicked: {error}")))
}, },
Message::BlogmarkImported, move |result| Message::BlogmarkImported { task_id, result },
) )
} }
Message::BlogmarkImported(result) => match result { Message::BlogmarkImported { task_id, result } => match result {
Ok(result) => { Ok(result) => {
self.task_manager.complete(task_id);
self.refresh_task_snapshots();
for message in result.toasts { for message in result.toasts {
self.notify(ToastLevel::Info, &message); self.notify(ToastLevel::Info, &message);
} }
@@ -1635,6 +1671,10 @@ impl BdsApp {
self.refresh_counts() self.refresh_counts()
} }
Err(error) => { Err(error) => {
if self.task_manager.status(task_id) != Some(TaskStatus::Cancelled) {
self.task_manager.fail(task_id, error.clone());
}
self.refresh_task_snapshots();
self.notify( self.notify(
ToastLevel::Error, ToastLevel::Error,
&tw(self.ui_locale, "blogmark.failed", &[("error", &error)]), &tw(self.ui_locale, "blogmark.failed", &[("error", &error)]),

View File

@@ -708,9 +708,14 @@ impl BdsApp {
} }
} }
if let Some((source, entrypoint, kind)) = run_request { if let Some((source, entrypoint, kind)) = run_request {
let offline_mode = self.offline_mode;
let app_handler = crate::platform::script_host::handler(
Arc::clone(&self.script_menu_actions),
t(self.ui_locale, "dialog.selectFolder"),
);
return self.spawn_engine_task( return self.spawn_engine_task(
"engine.runScript", "engine.runScript",
move |_db_path, _project_id, _data_dir, task_manager, task_id| { move |db_path, project_id, data_dir, task_manager, task_id| {
let execution_kind = match kind { let execution_kind = match kind {
bds_core::model::ScriptKind::Macro => { bds_core::model::ScriptKind::Macro => {
bds_core::scripting::ExecutionKind::Macro bds_core::scripting::ExecutionKind::Macro
@@ -722,19 +727,22 @@ impl BdsApp {
bds_core::scripting::ExecutionKind::Transform bds_core::scripting::ExecutionKind::Transform
} }
}; };
let result = bds_core::scripting::execute( let host = bds_core::scripting::CoreHost::new(db_path, project_id, data_dir)
.with_task(Arc::clone(&task_manager), task_id)
.with_offline_mode(offline_mode)
.with_app_handler(Arc::clone(&app_handler));
let control = task_manager
.cancellation_flag(task_id)
.map(bds_core::scripting::ExecutionControl::from_cancelled)
.unwrap_or_default();
let result = bds_core::scripting::execute_with_host(
&source, &source,
&entrypoint, &entrypoint,
&serde_json::json!({}), &serde_json::json!({}),
execution_kind, execution_kind,
&bds_core::scripting::ExecutionControl::default(), &control,
Arc::new(host),
)?; )?;
for progress in &result.progress {
let percent = progress.total.and_then(|total| {
(total > 0.0).then_some((progress.current / total) as f32)
});
task_manager.report_progress(task_id, percent, progress.message.clone());
}
let mut lines = result.output; let mut lines = result.output;
lines.extend( lines.extend(
result result

View File

@@ -82,6 +82,40 @@ impl MenuAction {
MenuAction::ReportIssue, MenuAction::ReportIssue,
]; ];
pub fn from_script_name(name: &str) -> Option<Self> {
Some(match name {
"new_post" => Self::NewPost,
"import_media" => Self::ImportMedia,
"save" => Self::Save,
"open_in_browser" => Self::OpenInBrowser,
"open_data_folder" => Self::OpenDataFolder,
"find" => Self::Find,
"replace" => Self::Replace,
"edit_preferences" => Self::EditPreferences,
"view_posts" => Self::ViewPosts,
"view_media" => Self::ViewMedia,
"toggle_sidebar" => Self::ToggleSidebar,
"toggle_panel" => Self::TogglePanel,
"publish_selected" => Self::PublishSelected,
"preview_post" => Self::PreviewPost,
"edit_menu" => Self::EditMenu,
"rebuild_database" => Self::RebuildDatabase,
"reindex_text" => Self::ReindexText,
"metadata_diff" => Self::MetadataDiff,
"regenerate_calendar" => Self::RegenerateCalendar,
"validate_translations" => Self::ValidateTranslations,
"fill_missing_translations" => Self::FillMissingTranslations,
"generate_sitemap" | "force_render_site" => Self::GenerateSitemap,
"validate_site" => Self::ValidateSite,
"upload_site" => Self::UploadSite,
"about" => Self::About,
"open_documentation" => Self::OpenDocumentation,
"view_on_github" => Self::ViewOnGitHub,
"report_issue" => Self::ReportIssue,
_ => return None,
})
}
/// Return the i18n key for this action's menu label. /// Return the i18n key for this action's menu label.
pub fn i18n_key(self) -> &'static str { pub fn i18n_key(self) -> &'static str {
match self { match self {

View File

@@ -1,5 +1,6 @@
pub mod dialog; pub mod dialog;
pub mod menu; pub mod menu;
pub mod script_host;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub mod macos; pub mod macos;

View File

@@ -0,0 +1,130 @@
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex, OnceLock};
use bds_core::scripting::AppHostHandler;
use serde_json::{Value, json};
use super::menu::MenuAction;
pub fn handler(
menu_actions: Arc<Mutex<Vec<MenuAction>>>,
select_folder_title: String,
) -> Arc<AppHostHandler> {
Arc::new(move |method, args| match method {
"copy_to_clipboard" => Ok(copy_to_clipboard(string_arg(args, 0)?).into()),
"get_title_bar_metrics" => Ok(title_bar_metrics()),
"notify_renderer_ready" => Ok(Value::Bool(true)),
"open_folder" => Ok(open::that(string_arg(args, 0)?)
.map(|()| Value::String(String::new()))
.unwrap_or_else(|error| Value::String(error.to_string()))),
"select_folder" => Ok(rfd::FileDialog::new()
.set_title(optional_string_arg(args, 0).unwrap_or(select_folder_title.as_str()))
.pick_folder()
.map(|path| Value::String(path.to_string_lossy().into_owned()))
.unwrap_or(Value::Null)),
"set_preview_post_target" => {
*preview_post_target().lock().unwrap() =
optional_string_arg(args, 0).map(str::to_owned);
Ok(Value::Bool(true))
}
"show_item_in_folder" => {
let _ = show_item_in_folder(Path::new(string_arg(args, 0)?));
Ok(Value::Null)
}
"trigger_menu_action" => {
if let Some(action) = MenuAction::from_script_name(string_arg(args, 0)?) {
menu_actions.lock().unwrap().push(action);
}
Ok(Value::Null)
}
_ => Err(format!("unknown desktop shell capability: {method}")),
})
}
fn string_arg(args: &[Value], index: usize) -> Result<&str, String> {
args.get(index)
.and_then(Value::as_str)
.ok_or_else(|| format!("argument {} must be a string", index + 1))
}
fn optional_string_arg(args: &[Value], index: usize) -> Option<&str> {
args.get(index).and_then(Value::as_str)
}
fn preview_post_target() -> &'static Mutex<Option<String>> {
static TARGET: OnceLock<Mutex<Option<String>>> = OnceLock::new();
TARGET.get_or_init(|| Mutex::new(None))
}
#[cfg(target_os = "macos")]
fn title_bar_metrics() -> Value {
json!({"macos_left_inset": 72})
}
#[cfg(not(target_os = "macos"))]
fn title_bar_metrics() -> Value {
Value::Null
}
fn copy_to_clipboard(text: &str) -> bool {
#[cfg(target_os = "macos")]
let mut child = Command::new("pbcopy");
#[cfg(target_os = "windows")]
let mut child = {
let mut command = Command::new("cmd");
command.args(["/c", "clip"]);
command
};
#[cfg(all(unix, not(target_os = "macos")))]
let mut child = {
let mut command = Command::new("xclip");
command.args(["-selection", "clipboard"]);
command
};
child
.stdin(Stdio::piped())
.spawn()
.and_then(|mut child| {
child.stdin.take().unwrap().write_all(text.as_bytes())?;
child.wait().map(|status| status.success())
})
.unwrap_or(false)
}
#[cfg(target_os = "macos")]
fn show_item_in_folder(path: &Path) -> std::io::Result<()> {
Command::new("open")
.arg("-R")
.arg(path)
.status()
.map(|_| ())
}
#[cfg(target_os = "windows")]
fn show_item_in_folder(path: &Path) -> std::io::Result<()> {
Command::new("explorer")
.arg(format!("/select,{}", path.display()))
.status()
.map(|_| ())
}
#[cfg(all(unix, not(target_os = "macos")))]
fn show_item_in_folder(path: &Path) -> std::io::Result<()> {
open::that(path.parent().unwrap_or(path)).map_err(std::io::Error::other)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn desktop_handler_queues_supported_menu_actions() {
let queued = Arc::new(Mutex::new(Vec::new()));
handler(Arc::clone(&queued), String::new())("trigger_menu_action", &[json!("new_post")])
.unwrap();
assert_eq!(queued.lock().unwrap().as_slice(), &[MenuAction::NewPost]);
}
}

File diff suppressed because it is too large Load Diff

11
docs/scripting/README.md Normal file
View File

@@ -0,0 +1,11 @@
# Lua scripting
RuDS utilities, transforms, and macros run in a sandbox with explicit project-scoped capabilities under `bds`. Start with the complete [API reference](API_REFERENCE.md), [canonical types](TYPES.md), and [runnable examples](examples/). Portable method signatures match bDS2 contract `0.4.0`; RuDS-only helpers are marked as extensions.
`api.json` is the sole method/type manifest. Regenerate the reference and completion data with:
```sh
cargo run -p bds-core --example generate_scripting_docs
```
The core scripting tests verify the Allium capability names, runtime tables, generated files, sandbox, failure values, project scoping, examples, and the frozen bDS2 signature baseline in `bds2-core-signatures.json` together.

325
docs/scripting/TYPES.md Normal file
View File

@@ -0,0 +1,325 @@
# Lua API Types
Contract version: `0.4.0`
These are the public, JSON-compatible records returned by the Lua host API. They contain no database handles or private implementation fields.
## Value conventions
- `T | nil` marks an optional field.
- `T[]` is a one-based Lua array.
- `table` is a JSON-compatible Lua table whose shape depends on the operation.
- ISO-8601 values are strings such as `2026-07-19T08:00:00Z`.
## Contents
- [`ProjectData`](#projectdata)
- [`ProjectMetadata`](#projectmetadata)
- [`PostData`](#postdata)
- [`MediaData`](#mediadata)
- [`ScriptData`](#scriptdata)
- [`TemplateData`](#templatedata)
- [`TagData`](#tagdata)
- [`TaskData`](#taskdata)
- [`TaskStatus`](#taskstatus)
- [`ValidationResult`](#validationresult)
## `ProjectData`
Project record stored in the application database.
**Lua shape**
```lua
{
created_at = "2026-07-19T08:00:00Z",
data_path = "/path/to/item",
description = "example",
id = "example-id",
is_active = true,
name = "Example",
slug = "example-post",
updated_at = "2026-07-19T08:00:00Z",
}
```
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `created_at` | `ISO-8601 string` | Yes | Creation timestamp. |
| `data_path` | `string \| nil` | No | Filesystem path containing project data. |
| `description` | `string \| nil` | No | Human-readable description. |
| `id` | `string` | Yes | Stable record identifier. |
| `is_active` | `boolean` | Yes | Whether this is the active project. |
| `name` | `string` | Yes | Human-readable name. |
| `slug` | `string` | Yes | URL-safe record identifier. |
| `updated_at` | `ISO-8601 string` | Yes | Last-update timestamp. |
## `ProjectMetadata`
Current project metadata and publishing settings snapshot.
**Lua shape**
```lua
{
blog_languages = { "example" },
categories = { "example" },
default_author = "Ada Author",
description = "example",
main_language = "en",
name = "Example",
public_url = "https://example.com",
publishing_preferences = { key = "value" },
}
```
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `blog_languages` | `string[]` | Yes | Languages configured for the blog. |
| `categories` | `string[]` | Yes | Assigned category names. |
| `default_author` | `string \| nil` | No | Default post author name. |
| `description` | `string \| nil` | No | Human-readable description. |
| `main_language` | `string \| nil` | No | BCP 47 language code. |
| `name` | `string` | Yes | Human-readable name. |
| `public_url` | `string \| nil` | No | Published site base URL. |
| `publishing_preferences` | `table` | Yes | Project publishing configuration. |
## `PostData`
Post record with link graph data added for scripting.
**Lua shape**
```lua
{
backlinks = { { key = "value" } },
categories = { "example" },
created_at = "2026-07-19T08:00:00Z",
id = "example-id",
language = "en",
links_to = { { key = "value" } },
project_id = "example-id",
slug = "example-post",
status = "draft",
tags = { "example" },
title = "Example post",
updated_at = "2026-07-19T08:00:00Z",
}
```
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `backlinks` | `table[]` | Yes | Links from other posts to this post. |
| `categories` | `string[]` | Yes | Assigned category names. |
| `created_at` | `ISO-8601 string` | Yes | Creation timestamp. |
| `id` | `string` | Yes | Stable record identifier. |
| `language` | `string \| nil` | No | BCP 47 language code. |
| `links_to` | `table[]` | Yes | Links from this post to other posts. |
| `project_id` | `string` | Yes | Identifier of the owning project. |
| `slug` | `string` | Yes | URL-safe record identifier. |
| `status` | `string` | Yes | Current lifecycle state. |
| `tags` | `string[]` | Yes | Assigned tag names. |
| `title` | `string` | Yes | Human-readable title. |
| `updated_at` | `ISO-8601 string` | Yes | Last-update timestamp. |
## `MediaData`
Media record stored for a project.
**Lua shape**
```lua
{
alt = "A descriptive alternative",
caption = "Example caption",
created_at = "2026-07-19T08:00:00Z",
file_path = "/path/to/item",
id = "example-id",
mime_type = "image/png",
original_name = "image.png",
project_id = "example-id",
tags = { "example" },
title = "Example post",
updated_at = "2026-07-19T08:00:00Z",
}
```
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `alt` | `string \| nil` | No | Alternative text for the media. |
| `caption` | `string \| nil` | No | Media caption. |
| `created_at` | `ISO-8601 string` | Yes | Creation timestamp. |
| `file_path` | `string` | Yes | Stored media file path. |
| `id` | `string` | Yes | Stable record identifier. |
| `mime_type` | `string` | Yes | Media MIME type. |
| `original_name` | `string` | Yes | Original imported filename. |
| `project_id` | `string` | Yes | Identifier of the owning project. |
| `tags` | `string[]` | Yes | Assigned tag names. |
| `title` | `string \| nil` | No | Human-readable title. |
| `updated_at` | `ISO-8601 string` | Yes | Last-update timestamp. |
## `ScriptData`
Lua script record.
**Lua shape**
```lua
{
created_at = "2026-07-19T08:00:00Z",
enabled = true,
entrypoint = "main",
id = "example-id",
kind = "utility",
project_id = "example-id",
slug = "example-post",
status = "draft",
title = "Example post",
updated_at = "2026-07-19T08:00:00Z",
}
```
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `created_at` | `ISO-8601 string` | Yes | Creation timestamp. |
| `enabled` | `boolean` | Yes | Whether the record is enabled. |
| `entrypoint` | `string` | Yes | Lua function invoked by the runtime. |
| `id` | `string` | Yes | Stable record identifier. |
| `kind` | `string` | Yes | Script or template kind. |
| `project_id` | `string` | Yes | Identifier of the owning project. |
| `slug` | `string` | Yes | URL-safe record identifier. |
| `status` | `string` | Yes | Current lifecycle state. |
| `title` | `string` | Yes | Human-readable title. |
| `updated_at` | `ISO-8601 string` | Yes | Last-update timestamp. |
## `TemplateData`
Template record for site rendering.
**Lua shape**
```lua
{
created_at = "2026-07-19T08:00:00Z",
enabled = true,
id = "example-id",
kind = "utility",
project_id = "example-id",
slug = "example-post",
status = "draft",
title = "Example post",
updated_at = "2026-07-19T08:00:00Z",
}
```
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `created_at` | `ISO-8601 string` | Yes | Creation timestamp. |
| `enabled` | `boolean` | Yes | Whether the record is enabled. |
| `id` | `string` | Yes | Stable record identifier. |
| `kind` | `string` | Yes | Script or template kind. |
| `project_id` | `string` | Yes | Identifier of the owning project. |
| `slug` | `string` | Yes | URL-safe record identifier. |
| `status` | `string` | Yes | Current lifecycle state. |
| `title` | `string` | Yes | Human-readable title. |
| `updated_at` | `ISO-8601 string` | Yes | Last-update timestamp. |
## `TagData`
Tag record stored for a project.
**Lua shape**
```lua
{
color = "#336699",
created_at = "2026-07-19T08:00:00Z",
id = "example-id",
name = "Example",
post_template_slug = "example-post",
project_id = "example-id",
updated_at = "2026-07-19T08:00:00Z",
}
```
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `color` | `string \| nil` | No | Optional display color. |
| `created_at` | `ISO-8601 string` | Yes | Creation timestamp. |
| `id` | `string` | Yes | Stable record identifier. |
| `name` | `string` | Yes | Human-readable name. |
| `post_template_slug` | `string \| nil` | No | Template selected for tagged posts. |
| `project_id` | `string` | Yes | Identifier of the owning project. |
| `updated_at` | `ISO-8601 string` | Yes | Last-update timestamp. |
## `TaskData`
Public task snapshot returned by the task manager.
**Lua shape**
```lua
{
id = "example-id",
message = "Working",
name = "Example",
progress = 0.5,
status = "draft",
}
```
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `id` | `string` | Yes | Stable record identifier. |
| `message` | `string \| nil` | No | Latest user-facing task message. |
| `name` | `string` | Yes | Human-readable name. |
| `progress` | `number \| nil` | No | Completion value reported by the task. |
| `status` | `string` | Yes | Current lifecycle state. |
## `TaskStatus`
Aggregate task status snapshot.
**Lua shape**
```lua
{
active_count = 1,
pending_count = 1,
running_count = 1,
tasks = {
{
id = "example-id",
message = "Working",
name = "Example",
progress = 0.5,
status = "draft",
}
},
}
```
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `active_count` | `integer` | Yes | Number of active tasks. |
| `pending_count` | `integer` | Yes | Number of queued tasks. |
| `running_count` | `integer` | Yes | Number of currently running tasks. |
| `tasks` | `TaskData[]` | Yes | Tasks included in this status snapshot. |
## `ValidationResult`
Template validation result.
**Lua shape**
```lua
{
errors = { "example" },
valid = true,
}
```
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `errors` | `string[]` | Yes | Validation error messages. |
| `valid` | `boolean` | Yes | Whether validation succeeded. |

1702
docs/scripting/api.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
function render(params, env)
return "<strong>" .. (params.text or env.mainLanguage) .. "</strong>"
end

View File

@@ -0,0 +1,6 @@
function main(data, context)
bds.app.toast("Imported from " .. context.source)
data.tags = data.tags or {}
table.insert(data.tags, "imported")
return data
end

View File

@@ -0,0 +1,7 @@
function main()
local project = bds.projects.get_active()
if project == nil then return { processed = 0 } end
local posts = bds.posts.get_all()
bds.report_progress({ current = #posts, total = #posts, message = "Complete" })
return { processed = #posts }
end

View File

@@ -455,6 +455,7 @@ translationValidation-missingTranslation = Konfigurierte Übersetzung fehlt
blogmark-invalid = Ungültiges Blogmark: { $error } blogmark-invalid = Ungültiges Blogmark: { $error }
blogmark-unknownProject = Das Zielprojekt dieses Blogmarks ist hier nicht vorhanden. blogmark-unknownProject = Das Zielprojekt dieses Blogmarks ist hier nicht vorhanden.
blogmark-noProject = Öffnen Sie vor dem Import eines Blogmarks ein Projekt. blogmark-noProject = Öffnen Sie vor dem Import eines Blogmarks ein Projekt.
blogmark-importing = Blogmark wird importiert
blogmark-imported = Blogmark importiert blogmark-imported = Blogmark importiert
blogmark-failed = Blogmark-Import fehlgeschlagen: { $error } blogmark-failed = Blogmark-Import fehlgeschlagen: { $error }
engine-generatedPage = Erstellt { $url } ({ $current }/{ $total }) engine-generatedPage = Erstellt { $url } ({ $current }/{ $total })

View File

@@ -455,6 +455,7 @@ translationValidation-missingTranslation = Configured translation is missing
blogmark-invalid = Invalid blogmark: { $error } blogmark-invalid = Invalid blogmark: { $error }
blogmark-unknownProject = The project targeted by this blogmark does not exist here. blogmark-unknownProject = The project targeted by this blogmark does not exist here.
blogmark-noProject = Open a project before importing a blogmark. blogmark-noProject = Open a project before importing a blogmark.
blogmark-importing = Importing blogmark
blogmark-imported = Blogmark imported blogmark-imported = Blogmark imported
blogmark-failed = Blogmark import failed: { $error } blogmark-failed = Blogmark import failed: { $error }
engine-generatedPage = Generated { $url } ({ $current }/{ $total }) engine-generatedPage = Generated { $url } ({ $current }/{ $total })

View File

@@ -455,6 +455,7 @@ translationValidation-missingTranslation = Falta una traducción configurada
blogmark-invalid = Blogmark no válido: { $error } blogmark-invalid = Blogmark no válido: { $error }
blogmark-unknownProject = El proyecto de destino de este blogmark no existe aquí. blogmark-unknownProject = El proyecto de destino de este blogmark no existe aquí.
blogmark-noProject = Abre un proyecto antes de importar un blogmark. blogmark-noProject = Abre un proyecto antes de importar un blogmark.
blogmark-importing = Importando blogmark
blogmark-imported = Blogmark importado blogmark-imported = Blogmark importado
blogmark-failed = Error al importar el blogmark: { $error } blogmark-failed = Error al importar el blogmark: { $error }
engine-generatedPage = Generado { $url } ({ $current }/{ $total }) engine-generatedPage = Generado { $url } ({ $current }/{ $total })

View File

@@ -455,6 +455,7 @@ translationValidation-missingTranslation = Une traduction configurée est absent
blogmark-invalid = Blogmark invalide : { $error } blogmark-invalid = Blogmark invalide : { $error }
blogmark-unknownProject = Le projet ciblé par ce blogmark nexiste pas ici. blogmark-unknownProject = Le projet ciblé par ce blogmark nexiste pas ici.
blogmark-noProject = Ouvrez un projet avant dimporter un blogmark. blogmark-noProject = Ouvrez un projet avant dimporter un blogmark.
blogmark-importing = Importation du blogmark
blogmark-imported = Blogmark importé blogmark-imported = Blogmark importé
blogmark-failed = Échec de limport du blogmark : { $error } blogmark-failed = Échec de limport du blogmark : { $error }
engine-generatedPage = Généré { $url } ({ $current }/{ $total }) engine-generatedPage = Généré { $url } ({ $current }/{ $total })

View File

@@ -455,6 +455,7 @@ translationValidation-missingTranslation = Manca una traduzione configurata
blogmark-invalid = Blogmark non valido: { $error } blogmark-invalid = Blogmark non valido: { $error }
blogmark-unknownProject = Il progetto indicato dal blogmark non esiste qui. blogmark-unknownProject = Il progetto indicato dal blogmark non esiste qui.
blogmark-noProject = Apri un progetto prima di importare un blogmark. blogmark-noProject = Apri un progetto prima di importare un blogmark.
blogmark-importing = Importazione del blogmark
blogmark-imported = Blogmark importato blogmark-imported = Blogmark importato
blogmark-failed = Importazione del blogmark non riuscita: { $error } blogmark-failed = Importazione del blogmark non riuscita: { $error }
engine-generatedPage = Generato { $url } ({ $current }/{ $total }) engine-generatedPage = Generato { $url } ({ $current }/{ $total })