feat: implement the bDS2-compatible core Lua API
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -797,6 +797,7 @@ name = "bds-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
"deunicode",
|
||||
"diesel",
|
||||
|
||||
@@ -9,7 +9,7 @@ The project is under active development. Core blogging workflows are broadly ava
|
||||
- Native Iced desktop workspace with localized menus, tabs, sidebars, dialogs, tasks, and embedded Wry previews.
|
||||
- Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, and media.
|
||||
- Media import, thumbnails, metadata translations, filters, validation, and post assignment.
|
||||
- Template and Lua script management using a custom Ropey/Syntect/Cosmic Text editor.
|
||||
- Template and Lua script management using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms.
|
||||
- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search.
|
||||
- Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation.
|
||||
- Local preview in the app or system browser.
|
||||
|
||||
@@ -6,7 +6,7 @@ RuDS is the native Rust replacement for bDS2. Core covers the complete everyday
|
||||
|
||||
The behavioural contract is `specs/*.allium`. When a spec is ambiguous, `../bDS2` is the reference implementation. [RUST_PLAN_EXTENSION.md](RUST_PLAN_EXTENSION.md) contains optional and advanced surfaces.
|
||||
|
||||
Status in this document describes the current source code as of 2026-07-18. It deliberately does not track build runs, test runs, release gates, or implementation history.
|
||||
Status in this document describes the current source code as of 2026-07-19. It deliberately does not track build runs, test runs, release gates, or implementation history.
|
||||
|
||||
## Non-Negotiable Constraints
|
||||
|
||||
@@ -58,6 +58,7 @@ Available:
|
||||
- Native file/folder dialogs and recent-project handling.
|
||||
- macOS open-file and URL lifecycle plumbing.
|
||||
- Localized UI separate from project content language.
|
||||
- Desktop-backed Lua application capabilities for clipboard, folders, preview targeting, title-bar metrics, renderer readiness, and supported menu actions.
|
||||
|
||||
Open:
|
||||
|
||||
@@ -126,27 +127,26 @@ Available:
|
||||
- One managed publish job processes the HTML, thumbnail, and media targets in
|
||||
sequence, matching bDS2. Parallel target uploads are not a parity requirement.
|
||||
|
||||
### Lua scripting — Partly done
|
||||
### Lua scripting — Done
|
||||
|
||||
Available:
|
||||
|
||||
- Sandboxed vendored Lua 5.4 runtime with cancellation and execution limits.
|
||||
- Application log, progress, and toast functions.
|
||||
- User macro and transform execution, including Blogmark transforms.
|
||||
- Project-scoped bDS2-compatible core `bds.*` APIs with matching Lua signatures and failure values.
|
||||
- User macro and transform execution, including project capabilities during rendered macros and Blogmark transforms.
|
||||
- Managed Blogmark transform cancellation and live, non-duplicated progress reporting.
|
||||
- Project-scoped post and media reindexing from Lua without disturbing other projects.
|
||||
- Lua script persistence, rebuild, metadata diff, validation, and editor support.
|
||||
- Fixed `.lua` script file contract.
|
||||
|
||||
Open:
|
||||
|
||||
- Expose the bDS2-compatible core `bds.*` API for post, media, tag, project, and other script-visible data.
|
||||
- Generate and bundle the Lua API reference, canonical type reference, and macro/transform/utility examples under `docs/scripting/`.
|
||||
- Add a documentation-sync check tied to the exposed API.
|
||||
- Generated and bundled API/type references plus executable macro, transform, and utility examples under `docs/scripting/`.
|
||||
- Manifest-driven runtime, documentation, and completion data with drift checks.
|
||||
- `bds.sync` and `bds.embeddings` remain extension APIs and are not exposed by core.
|
||||
|
||||
## Remaining Core Blocks
|
||||
|
||||
1. Complete the Lua host API and scripting documentation.
|
||||
2. Finish the linked-image authoring workflow.
|
||||
3. Add generation section-task grouping.
|
||||
4. Return normalized token accounting from one-shot AI calls.
|
||||
1. Finish the linked-image authoring workflow.
|
||||
2. Add generation section-task grouping.
|
||||
3. Return normalized token accounting from one-shot AI calls.
|
||||
|
||||
Core is feature-complete when these blocks are closed and the implementation continues to satisfy the Allium and bDS2 compatibility contracts.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Extensions add migration, collaboration, automation, alternate clients, and advanced discovery on top of the core blogging workflow. They must reuse core engines and formats, remain reachable through real UI or automation surfaces, and preserve airplane-mode and localization rules.
|
||||
|
||||
Status describes the current source code as of 2026-07-18. Core one-shot AI, publishing, rendering, and integrity work is tracked in [RUST_PLAN_CORE.md](RUST_PLAN_CORE.md).
|
||||
Status describes the current source code as of 2026-07-19. Core one-shot AI, publishing, rendering, and integrity work is tracked in [RUST_PLAN_CORE.md](RUST_PLAN_CORE.md).
|
||||
|
||||
## Current Extension Status
|
||||
|
||||
@@ -17,6 +17,7 @@ Done:
|
||||
Open:
|
||||
|
||||
- `GitEngine`, repository status, history, diff view, commit, fetch, pull, push, and LFS command integration.
|
||||
- Expose `bds.sync` after the shared Git/synchronization workflow exists.
|
||||
- Replace the Git sidebar and Git log placeholders with working flows.
|
||||
|
||||
### WordPress import — Open
|
||||
@@ -46,6 +47,7 @@ Open:
|
||||
|
||||
- Embedding generation and persistence.
|
||||
- Vector index and semantic search.
|
||||
- Expose `bds.embeddings` after those engines exist.
|
||||
- Duplicate detection, dismissed-pair handling, metadata integrity, and UI.
|
||||
- Replace the Find Duplicates placeholder.
|
||||
|
||||
@@ -61,7 +63,7 @@ Open:
|
||||
- Browsable Lua API documentation and examples.
|
||||
- Replace Documentation and API Documentation placeholders.
|
||||
|
||||
The generated Lua documentation itself is a core requirement.
|
||||
The generated Lua documentation and examples are complete core functionality; this section tracks only their in-app browsing experience.
|
||||
|
||||
### Menu editor and deep links — Partly done
|
||||
|
||||
@@ -85,11 +87,12 @@ Open:
|
||||
- Domain event bus from `events.allium` for desktop, CLI, TUI, and future remote clients.
|
||||
- Replace the MCP settings placeholder.
|
||||
|
||||
### Blogmark and transform pipeline — Mostly done
|
||||
### Blogmark and transform pipeline — Done
|
||||
|
||||
Done:
|
||||
|
||||
- Blogmark bookmarklet copy, `ruds://new-post` parsing, content capture, post import, transform selection, and Lua transform execution.
|
||||
- Project-scoped `bds.*` capabilities, managed task progress, and operator cancellation during transform execution.
|
||||
- bDS2-compatible delivery behavior without adding unsupported deep-link actions.
|
||||
|
||||
### Headless server — Open
|
||||
|
||||
@@ -34,6 +34,7 @@ diesel_migrations = { workspace = true }
|
||||
libsqlite3-sys = { workspace = true }
|
||||
mlua = { workspace = true }
|
||||
url = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
12
crates/bds-core/examples/generate_scripting_docs.rs
Normal file
12
crates/bds-core/examples/generate_scripting_docs.rs
Normal 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(())
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use std::path::Path;
|
||||
|
||||
use diesel::connection::SimpleConnection;
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types::Text;
|
||||
|
||||
use crate::db::migrations;
|
||||
|
||||
@@ -17,6 +18,12 @@ pub enum DatabaseError {
|
||||
/// Shared synchronous Diesel connection used by the engine query API.
|
||||
pub struct DbConnection(RefCell<SqliteConnection>);
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
struct DatabasePathRow {
|
||||
#[diesel(sql_type = Text)]
|
||||
file: String,
|
||||
}
|
||||
|
||||
impl DbConnection {
|
||||
pub fn with<T>(
|
||||
&self,
|
||||
@@ -45,6 +52,20 @@ impl DbConnection {
|
||||
.borrow_mut()
|
||||
.batch_execute("ROLLBACK TO bds_operation; RELEASE bds_operation")
|
||||
}
|
||||
|
||||
pub(crate) fn database_path(&self) -> diesel::QueryResult<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.
|
||||
@@ -101,4 +122,16 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(result, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_the_disk_database_path() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("project.sqlite3");
|
||||
let db = Database::open(&path).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
db.conn().database_path().unwrap().canonicalize().unwrap(),
|
||||
path.canonicalize().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -9,7 +10,7 @@ use crate::db::DbConnection as Connection;
|
||||
use crate::db::queries::script as script_queries;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Post, Script, ScriptKind};
|
||||
use crate::scripting::{self, ExecutionControl, ExecutionKind};
|
||||
use crate::scripting::{self, CoreHost, ExecutionControl, ExecutionKind, HostApi, UnavailableHost};
|
||||
|
||||
const MAX_TITLE_LENGTH: usize = 200;
|
||||
const MAX_URL_LENGTH: usize = 2_048;
|
||||
@@ -80,6 +81,27 @@ pub fn receive_deep_link(
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
raw: &str,
|
||||
) -> EngineResult<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> {
|
||||
let mut candidate = parse_deep_link(raw)?;
|
||||
if let Some(target) = &candidate.project_id
|
||||
@@ -98,7 +120,10 @@ pub fn receive_deep_link(
|
||||
));
|
||||
}
|
||||
let (mut candidate, toasts, transform_errors) =
|
||||
run_transforms(conn, data_dir, project_id, candidate)?;
|
||||
run_transforms(conn, data_dir, project_id, candidate, control, host)?;
|
||||
if control.is_cancelled() {
|
||||
return Err(EngineError::Validation("script cancelled".into()));
|
||||
}
|
||||
if candidate.categories.is_empty() {
|
||||
let metadata = crate::engine::meta::read_project_json(data_dir)?;
|
||||
if let Some(category) = metadata
|
||||
@@ -133,6 +158,8 @@ fn run_transforms(
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
candidate: BlogmarkCandidate,
|
||||
control: &ExecutionControl,
|
||||
host: Arc<dyn HostApi>,
|
||||
) -> EngineResult<(BlogmarkCandidate, Vec<String>, Vec<String>)> {
|
||||
let mut transforms = script_queries::list_scripts_by_project(conn, project_id)?
|
||||
.into_iter()
|
||||
@@ -149,6 +176,9 @@ fn run_transforms(
|
||||
let mut toasts = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
for script in transforms {
|
||||
if control.is_cancelled() {
|
||||
return Err(EngineError::Validation("script cancelled".into()));
|
||||
}
|
||||
if script.entrypoint.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -157,12 +187,13 @@ fn run_transforms(
|
||||
"source": "blogmark",
|
||||
"url": current.get("url").cloned().unwrap_or(Value::Null),
|
||||
});
|
||||
match scripting::execute_many(
|
||||
match scripting::execute_many_with_host(
|
||||
&source,
|
||||
&script.entrypoint,
|
||||
&[current.clone(), context],
|
||||
ExecutionKind::Transform,
|
||||
&ExecutionControl::default(),
|
||||
control,
|
||||
Arc::clone(&host),
|
||||
) {
|
||||
Ok(execution) => {
|
||||
let (next, returned_toasts) = split_transform_result(execution.value, ¤t);
|
||||
@@ -172,6 +203,9 @@ fn run_transforms(
|
||||
execution.toasts.into_iter().chain(returned_toasts),
|
||||
);
|
||||
}
|
||||
Err(_error) if control.is_cancelled() => {
|
||||
return Err(EngineError::Validation("script cancelled".into()));
|
||||
}
|
||||
Err(error) => errors.push(format!("{}: {error}", script.slug)),
|
||||
}
|
||||
}
|
||||
@@ -287,6 +321,22 @@ mod tests {
|
||||
use crate::model::ScriptKind;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct PostsHost;
|
||||
|
||||
impl HostApi for PostsHost {
|
||||
fn call(
|
||||
&self,
|
||||
namespace: &str,
|
||||
method: &str,
|
||||
_arguments: Vec<Value>,
|
||||
) -> Result<Value, String> {
|
||||
match (namespace, method) {
|
||||
("posts", "get_all") => Ok(json!([{"id":"one"}, {"id":"two"}])),
|
||||
_ => Err("unsupported test capability".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_and_hardens_blogmark_links() {
|
||||
let candidate = parse_deep_link(
|
||||
@@ -358,4 +408,80 @@ mod tests {
|
||||
assert_eq!(result.toasts, vec!["done"]);
|
||||
assert!(result.transform_errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_use_the_supplied_project_host() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
crate::db::fts::ensure_fts_tables(db.conn()).unwrap();
|
||||
let directory = TempDir::new().unwrap();
|
||||
let project = crate::engine::project::create_project(
|
||||
db.conn(),
|
||||
"Blog",
|
||||
Some(directory.path().to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
crate::engine::script::create_script(
|
||||
db.conn(),
|
||||
&project.id,
|
||||
"Count posts",
|
||||
ScriptKind::Transform,
|
||||
"function main(data) data.title = tostring(#bds.posts.get_all()) .. ' posts'; return data end",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = receive_deep_link_with_host(
|
||||
db.conn(),
|
||||
directory.path(),
|
||||
&project.id,
|
||||
"ruds://new-post?title=Example",
|
||||
&ExecutionControl::default(),
|
||||
Arc::new(PostsHost),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.post.title, "2 posts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_stops_transform_import_before_post_creation() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
crate::db::fts::ensure_fts_tables(db.conn()).unwrap();
|
||||
let directory = TempDir::new().unwrap();
|
||||
let project = crate::engine::project::create_project(
|
||||
db.conn(),
|
||||
"Blog",
|
||||
Some(directory.path().to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
crate::engine::script::create_script(
|
||||
db.conn(),
|
||||
&project.id,
|
||||
"Cancelled",
|
||||
ScriptKind::Transform,
|
||||
"function main(data) return data end",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let control = ExecutionControl::default();
|
||||
control.cancel();
|
||||
|
||||
assert!(
|
||||
receive_deep_link_with_host(
|
||||
db.conn(),
|
||||
directory.path(),
|
||||
&project.id,
|
||||
"ruds://new-post?title=Example",
|
||||
&control,
|
||||
Arc::new(PostsHost),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
crate::db::queries::post::count_posts_by_project(db.conn(), &project.id).unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
conn: &Connection,
|
||||
on_item: Option<&ItemProgressFn>,
|
||||
@@ -112,71 +132,94 @@ fn index_all_projects(
|
||||
};
|
||||
|
||||
for project in projects {
|
||||
let main_language = project
|
||||
.data_path
|
||||
.as_deref()
|
||||
.and_then(|path| crate::engine::meta::read_project_json(Path::new(path)).ok())
|
||||
.and_then(|metadata| metadata.main_language)
|
||||
.unwrap_or_else(|| "en".to_string());
|
||||
let indexed = index_project(
|
||||
conn,
|
||||
&project.id,
|
||||
project.data_path.as_deref(),
|
||||
total,
|
||||
&mut current,
|
||||
on_item,
|
||||
)?;
|
||||
report.posts_indexed += indexed.posts_indexed;
|
||||
report.media_indexed += indexed.media_indexed;
|
||||
}
|
||||
|
||||
for post in post_q::list_posts_by_project(conn, &project.id)? {
|
||||
current += 1;
|
||||
if let Some(callback) = on_item {
|
||||
callback(current, total, &post.title);
|
||||
}
|
||||
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
|
||||
let translation_data = translations
|
||||
.iter()
|
||||
.map(|translation| fts::PostTranslationFts {
|
||||
title: translation.title.clone(),
|
||||
excerpt: translation.excerpt.clone(),
|
||||
content: translation.content.clone(),
|
||||
language: translation.language.clone(),
|
||||
})
|
||||
.collect::<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;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
for media in media_q::list_media_by_project(conn, &project.id)? {
|
||||
current += 1;
|
||||
if let Some(callback) = on_item {
|
||||
callback(current, total, &media.original_name);
|
||||
}
|
||||
let translations =
|
||||
media_translation::list_media_translations_by_media(conn, &media.id)?;
|
||||
let translation_data = translations
|
||||
.iter()
|
||||
.map(|translation| fts::MediaTranslationFts {
|
||||
title: translation.title.clone(),
|
||||
alt: translation.alt.clone(),
|
||||
caption: translation.caption.clone(),
|
||||
language: translation.language.clone(),
|
||||
})
|
||||
.collect::<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;
|
||||
fn index_project(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
data_path: Option<&str>,
|
||||
total: usize,
|
||||
current: &mut usize,
|
||||
on_item: Option<&ItemProgressFn>,
|
||||
) -> EngineResult<ReindexReport> {
|
||||
let main_language = data_path
|
||||
.and_then(|path| crate::engine::meta::read_project_json(Path::new(path)).ok())
|
||||
.and_then(|metadata| metadata.main_language)
|
||||
.unwrap_or_else(|| "en".to_string());
|
||||
let mut report = ReindexReport {
|
||||
posts_indexed: 0,
|
||||
media_indexed: 0,
|
||||
};
|
||||
|
||||
for post in post_q::list_posts_by_project(conn, project_id)? {
|
||||
*current += 1;
|
||||
if let Some(callback) = on_item {
|
||||
callback(*current, total, &post.title);
|
||||
}
|
||||
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
|
||||
let translation_data = translations
|
||||
.iter()
|
||||
.map(|translation| fts::PostTranslationFts {
|
||||
title: translation.title.clone(),
|
||||
excerpt: translation.excerpt.clone(),
|
||||
content: translation.content.clone(),
|
||||
language: translation.language.clone(),
|
||||
})
|
||||
.collect::<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)
|
||||
@@ -286,4 +329,40 @@ mod tests {
|
||||
assert_eq!(report.posts_indexed, 2);
|
||||
assert_eq!(results.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_reindex_leaves_other_project_rows_intact() {
|
||||
let (db, first_project_id) = setup();
|
||||
let first_dir = tempfile::tempdir().unwrap();
|
||||
let second_dir = tempfile::tempdir().unwrap();
|
||||
let second = engine::project::create_project(
|
||||
db.conn(),
|
||||
"Second Project",
|
||||
Some(second_dir.path().to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
for (project_id, data_dir, title) in [
|
||||
(&first_project_id, first_dir.path(), "First Searchable"),
|
||||
(&second.id, second_dir.path(), "Second Searchable"),
|
||||
] {
|
||||
engine::post::create_post(
|
||||
db.conn(),
|
||||
data_dir,
|
||||
project_id,
|
||||
title,
|
||||
Some("body"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
Some("en"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
reindex_project(db.conn(), &first_project_id, None).unwrap();
|
||||
let results = crate::db::fts::search_posts(db.conn(), "searchable", "en").unwrap();
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,16 @@ impl TaskManager {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Shared cancellation flag for a worker owned by this task.
|
||||
pub fn cancellation_flag(&self, task_id: TaskId) -> Option<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.
|
||||
pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
@@ -216,6 +226,14 @@ impl TaskManager {
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove every finished task while preserving running and queued work.
|
||||
pub fn clear_completed(&self) {
|
||||
self.tasks
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running));
|
||||
}
|
||||
|
||||
/// Update progress for a running task. Throttled to at most once per 250ms.
|
||||
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
@@ -403,6 +421,17 @@ mod tests {
|
||||
assert_eq!(mgr.snapshots().len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_completed_preserves_active_tasks() {
|
||||
let mgr = TaskManager::new(2);
|
||||
let done = mgr.submit("done");
|
||||
let running = mgr.submit("running");
|
||||
mgr.complete(done);
|
||||
mgr.clear_completed();
|
||||
assert_eq!(mgr.status(done), None);
|
||||
assert_eq!(mgr.status(running), Some(TaskStatus::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completing_task_starts_next_queued() {
|
||||
let mgr = TaskManager::new(1);
|
||||
|
||||
@@ -135,6 +135,17 @@ pub struct PublishingPreferences {
|
||||
pub ssh_mode: SshMode,
|
||||
}
|
||||
|
||||
impl Default for PublishingPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ssh_host: None,
|
||||
ssh_user: None,
|
||||
ssh_remote_path: None,
|
||||
ssh_mode: default_ssh_mode(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_ssh_mode() -> SshMode {
|
||||
SshMode::Scp
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Map, Value as JsonValue};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct MacroRenderContext {
|
||||
pub roots: Map<String, JsonValue>,
|
||||
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 {
|
||||
@@ -77,12 +89,13 @@ fn render_script_macro(
|
||||
"translations": context.roots.get("translations").cloned().unwrap_or(JsonValue::Array(Vec::new())),
|
||||
});
|
||||
let params = serde_json::to_value(args).ok()?;
|
||||
match crate::scripting::execute_many(
|
||||
match crate::scripting::execute_many_with_host(
|
||||
source,
|
||||
entrypoint,
|
||||
&[params, env],
|
||||
crate::scripting::ExecutionKind::Macro,
|
||||
&crate::scripting::ExecutionControl::default(),
|
||||
Arc::clone(&context.host),
|
||||
) {
|
||||
Ok(result) => Some(match result.value {
|
||||
JsonValue::Null => String::new(),
|
||||
@@ -519,6 +532,7 @@ mod tests {
|
||||
&MacroRenderContext {
|
||||
roots,
|
||||
post_id: Some("post-1".to_string()),
|
||||
..MacroRenderContext::default()
|
||||
},
|
||||
);
|
||||
|
||||
@@ -555,6 +569,7 @@ mod tests {
|
||||
&MacroRenderContext {
|
||||
roots,
|
||||
post_id: Some("post-1".into()),
|
||||
..MacroRenderContext::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(rendered, "<aside>Hallo:de</aside>");
|
||||
|
||||
@@ -11,6 +11,7 @@ pub use generation::{
|
||||
write_generated_bytes, write_generated_file,
|
||||
};
|
||||
pub use markdown::render_markdown_to_html;
|
||||
pub(crate) use page_renderer::render_liquid_template_with_host;
|
||||
pub use page_renderer::{RenderError, render_liquid_template};
|
||||
pub use routes::{
|
||||
RenderedPage, build_canonical_post_path, render_starter_list_page,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use liquid::ParserBuilder;
|
||||
use liquid::partials::{EagerCompiler, InMemorySource};
|
||||
use liquid_core::model::ScalarCow;
|
||||
use liquid_core::parser::FilterArguments;
|
||||
use liquid_core::{
|
||||
Display_filter, Expression, Filter, FilterParameters, FilterReflection, FromFilterParameters,
|
||||
ParseFilter, Runtime, Value, ValueView,
|
||||
@@ -14,6 +17,7 @@ use thiserror::Error;
|
||||
use crate::i18n::translate_render;
|
||||
use crate::render::macros::{MacroRenderContext, expand_builtin_macros};
|
||||
use crate::render::render_markdown_to_html;
|
||||
use crate::scripting::{HostApi, UnavailableHost};
|
||||
use crate::util::slugify;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -32,6 +36,20 @@ pub fn render_liquid_template<T: Serialize>(
|
||||
template_source: &str,
|
||||
partials: &HashMap<String, String>,
|
||||
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> {
|
||||
let mut compiled_partials: EagerCompiler<InMemorySource> = EagerCompiler::empty();
|
||||
for (name, content) in partials {
|
||||
@@ -40,7 +58,7 @@ pub fn render_liquid_template<T: Serialize>(
|
||||
|
||||
let parser = ParserBuilder::with_stdlib()
|
||||
.filter(I18n)
|
||||
.filter(Markdown)
|
||||
.filter(Markdown { host })
|
||||
.filter(Slugify)
|
||||
.partials(compiled_partials)
|
||||
.build()?;
|
||||
@@ -121,20 +139,43 @@ struct MarkdownArgs {
|
||||
language_prefix: Option<Expression>,
|
||||
}
|
||||
|
||||
#[derive(Clone, ParseFilter, FilterReflection)]
|
||||
#[derive(Clone, FilterReflection)]
|
||||
#[filter(
|
||||
name = "markdown",
|
||||
description = "Render markdown to HTML and rewrite preview URLs.",
|
||||
parameters(MarkdownArgs),
|
||||
parsed(MarkdownFilter)
|
||||
)]
|
||||
struct Markdown;
|
||||
struct Markdown {
|
||||
host: Arc<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"]
|
||||
struct MarkdownFilter {
|
||||
#[parameters]
|
||||
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 {
|
||||
@@ -158,6 +199,7 @@ impl Filter for MarkdownFilter {
|
||||
post_id: runtime
|
||||
.try_get(&[ScalarCow::new("post"), ScalarCow::new("id")])
|
||||
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string())),
|
||||
host: Arc::clone(&self.host),
|
||||
};
|
||||
|
||||
let expanded = expand_builtin_macros(markdown.as_str(), ¯o_context);
|
||||
@@ -473,8 +515,31 @@ fn trim_html_suffix(value: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{RewriteContextView, render_liquid_template, rewrite_rendered_html_urls};
|
||||
use super::{
|
||||
RewriteContextView, render_liquid_template, render_liquid_template_with_host,
|
||||
rewrite_rendered_html_urls,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::scripting::HostApi;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct PostsHost;
|
||||
|
||||
impl HostApi for PostsHost {
|
||||
fn call(
|
||||
&self,
|
||||
namespace: &str,
|
||||
method: &str,
|
||||
_arguments: Vec<Value>,
|
||||
) -> Result<Value, String> {
|
||||
match (namespace, method) {
|
||||
("posts", "get_all") => Ok(json!([{"id":"one"}, {"id":"two"}])),
|
||||
_ => Err("unsupported test capability".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TestRewriteContext {
|
||||
canonical_post_path_by_slug: HashMap<String, String>,
|
||||
@@ -520,4 +585,25 @@ mod tests {
|
||||
|
||||
assert_eq!(rendered, "ueber-die-bruecke");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_macros_use_the_supplied_project_host() {
|
||||
let rendered = render_liquid_template_with_host(
|
||||
"{{ post.content | markdown }}",
|
||||
&HashMap::new(),
|
||||
&json!({
|
||||
"post": {"id": "post-1", "content": "[[count]]"},
|
||||
"macro_scripts": {
|
||||
"count": {
|
||||
"source": "function render() return tostring(#bds.posts.get_all()) end",
|
||||
"entrypoint": "render"
|
||||
}
|
||||
}
|
||||
}),
|
||||
Arc::new(PostsHost),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rendered, "<p>2</p>\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::collections::{BTreeMap, HashMap};
|
||||
use std::error::Error;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::db::DbConnection as Connection;
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
@@ -16,8 +17,9 @@ use crate::model::{
|
||||
};
|
||||
use crate::render::{
|
||||
RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path,
|
||||
render_liquid_template, resolve_post_template,
|
||||
render_liquid_template_with_host, resolve_post_template,
|
||||
};
|
||||
use crate::scripting::{CoreHost, HostApi, UnavailableHost};
|
||||
use crate::util::frontmatter::{read_script_file, read_template_file, read_translation_file};
|
||||
use crate::util::slugify;
|
||||
|
||||
@@ -62,7 +64,7 @@ pub struct PreviewRenderResult {
|
||||
pub html: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
struct TemplateBundle {
|
||||
post_templates: Vec<Template>,
|
||||
template_source_by_slug: HashMap<String, String>,
|
||||
@@ -71,6 +73,7 @@ struct TemplateBundle {
|
||||
not_found_template: String,
|
||||
partials: HashMap<String, String>,
|
||||
macro_scripts: HashMap<String, Value>,
|
||||
host: Arc<dyn HostApi>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -268,6 +271,10 @@ fn load_template_bundle(
|
||||
let mut default_list_template = STARTER_POST_LIST_TEMPLATE.to_string();
|
||||
let mut not_found_template = STARTER_NOT_FOUND_TEMPLATE.to_string();
|
||||
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() {
|
||||
if script.kind != ScriptKind::Macro
|
||||
@@ -368,6 +375,7 @@ fn load_template_bundle(
|
||||
not_found_template,
|
||||
partials,
|
||||
macro_scripts,
|
||||
host,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -661,10 +669,11 @@ fn render_list_route(
|
||||
"not_found_back_label": serde_json::Value::Null,
|
||||
});
|
||||
|
||||
Ok(render_liquid_template(
|
||||
Ok(render_liquid_template_with_host(
|
||||
list_template,
|
||||
&bundle.partials,
|
||||
&context,
|
||||
Arc::clone(&bundle.host),
|
||||
)?)
|
||||
}
|
||||
|
||||
@@ -776,10 +785,11 @@ fn render_post_route(
|
||||
"not_found_back_label": serde_json::Value::Null,
|
||||
});
|
||||
|
||||
Ok(render_liquid_template(
|
||||
Ok(render_liquid_template_with_host(
|
||||
&template_source,
|
||||
&bundle.partials,
|
||||
&context,
|
||||
Arc::clone(&bundle.host),
|
||||
)?)
|
||||
}
|
||||
|
||||
@@ -824,10 +834,11 @@ fn render_not_found_route(
|
||||
"canonical_media_path_by_source_path": HashMap::<String, String>::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.partials,
|
||||
&context,
|
||||
Arc::clone(&bundle.host),
|
||||
)?)
|
||||
}
|
||||
|
||||
|
||||
1845
crates/bds-core/src/scripting/core_host.rs
Normal file
1845
crates/bds-core/src/scripting/core_host.rs
Normal file
File diff suppressed because it is too large
Load Diff
430
crates/bds-core/src/scripting/manifest.rs
Normal file
430
crates/bds-core/src/scripting/manifest.rs
Normal 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(¶m.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
|
||||
}
|
||||
@@ -7,6 +7,12 @@ use mlua::{
|
||||
};
|
||||
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 MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
|
||||
const MAX_TOASTS_PER_SCRIPT: usize = 5;
|
||||
@@ -34,6 +40,29 @@ pub struct ScriptExecution {
|
||||
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)]
|
||||
pub struct ExecutionControl {
|
||||
cancelled: Arc<AtomicBool>,
|
||||
@@ -48,9 +77,17 @@ impl Default for ExecutionControl {
|
||||
}
|
||||
|
||||
impl ExecutionControl {
|
||||
pub fn from_cancelled(cancelled: Arc<AtomicBool>) -> Self {
|
||||
Self { cancelled }
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
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.
|
||||
@@ -71,12 +108,32 @@ pub fn execute(
|
||||
kind: ExecutionKind,
|
||||
control: &ExecutionControl,
|
||||
) -> Result<ScriptExecution, String> {
|
||||
execute_many(
|
||||
execute_many_with_host(
|
||||
source,
|
||||
entrypoint,
|
||||
std::slice::from_ref(args),
|
||||
kind,
|
||||
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],
|
||||
kind: ExecutionKind,
|
||||
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> {
|
||||
if entrypoint.trim().is_empty() {
|
||||
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 progress = Arc::new(Mutex::new(Vec::<ScriptProgress>::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 cancelled = Arc::clone(&control.cancelled);
|
||||
@@ -175,6 +251,7 @@ fn install_host_api(
|
||||
output: &Arc<Mutex<Vec<String>>>,
|
||||
progress: &Arc<Mutex<Vec<ScriptProgress>>>,
|
||||
toasts: &Arc<Mutex<Vec<String>>>,
|
||||
host: Arc<dyn HostApi>,
|
||||
) -> Result<(), String> {
|
||||
let globals = lua.globals();
|
||||
|
||||
@@ -208,13 +285,14 @@ fn install_host_api(
|
||||
.collect::<Vec<_>>()
|
||||
.join("\t");
|
||||
log_output.lock().unwrap().push(line);
|
||||
Ok(())
|
||||
Ok(true)
|
||||
})
|
||||
.map_err(|error| error.to_string())?,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let progress_sink = Arc::clone(progress);
|
||||
let progress_host = Arc::clone(&host);
|
||||
app.set(
|
||||
"progress",
|
||||
lua.create_function(
|
||||
@@ -222,9 +300,18 @@ fn install_host_api(
|
||||
progress_sink.lock().unwrap().push(ScriptProgress {
|
||||
current,
|
||||
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())?,
|
||||
@@ -239,17 +326,97 @@ fn install_host_api(
|
||||
if sink.len() < MAX_TOASTS_PER_SCRIPT {
|
||||
sink.push(message.chars().take(MAX_TOAST_LENGTH).collect());
|
||||
}
|
||||
Ok(())
|
||||
Ok(true)
|
||||
})
|
||||
.map_err(|error| error.to_string())?,
|
||||
)
|
||||
.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())?;
|
||||
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 {
|
||||
match value {
|
||||
Value::Nil => "nil".into(),
|
||||
@@ -265,6 +432,292 @@ fn lua_value_text(value: &Value) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
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]
|
||||
fn executes_entrypoint_and_routes_output_and_progress() {
|
||||
@@ -290,6 +743,23 @@ mod tests {
|
||||
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]
|
||||
fn sandbox_hides_ambient_host_capabilities() {
|
||||
let result = execute(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use base64::Engine as _;
|
||||
use bds_core::db::DbQueryError as SqlError;
|
||||
@@ -119,7 +119,10 @@ pub enum Message {
|
||||
// macOS lifecycle
|
||||
FileOpenRequested(PathBuf),
|
||||
UrlOpenRequested(String),
|
||||
BlogmarkImported(Result<engine::blogmark::BlogmarkImportResult, String>),
|
||||
BlogmarkImported {
|
||||
task_id: TaskId,
|
||||
result: Result<engine::blogmark::BlogmarkImportResult, String>,
|
||||
},
|
||||
MainWindowLoaded(Option<window::Id>),
|
||||
EmbeddedPreviewReady(Result<(), String>),
|
||||
|
||||
@@ -738,6 +741,7 @@ pub struct BdsApp {
|
||||
|
||||
// Tasks
|
||||
task_manager: Arc<TaskManager>,
|
||||
script_menu_actions: Arc<Mutex<Vec<MenuAction>>>,
|
||||
task_snapshots: Vec<TaskSnapshot>,
|
||||
collapsed_task_groups: HashSet<String>,
|
||||
output_entries: Vec<OutputEntry>,
|
||||
@@ -891,6 +895,7 @@ impl BdsApp {
|
||||
panel_visible: false,
|
||||
panel_tab: PanelTab::Tasks,
|
||||
task_manager: Arc::new(TaskManager::default()),
|
||||
script_menu_actions: Arc::new(Mutex::new(Vec::new())),
|
||||
task_snapshots: Vec::new(),
|
||||
collapsed_task_groups: HashSet::new(),
|
||||
output_entries: Vec::new(),
|
||||
@@ -956,6 +961,7 @@ impl BdsApp {
|
||||
panel_visible: false,
|
||||
panel_tab: PanelTab::Tasks,
|
||||
task_manager: Arc::new(TaskManager::default()),
|
||||
script_menu_actions: Arc::new(Mutex::new(Vec::new())),
|
||||
task_snapshots: Vec::new(),
|
||||
collapsed_task_groups: HashSet::new(),
|
||||
output_entries: Vec::new(),
|
||||
@@ -1498,7 +1504,12 @@ impl BdsApp {
|
||||
if !self.search_index_rebuild_running {
|
||||
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) => {
|
||||
self.task_manager.cancel(task_id);
|
||||
@@ -1593,26 +1604,51 @@ impl BdsApp {
|
||||
}
|
||||
let db_path = self.db_path.clone();
|
||||
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(
|
||||
async 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())?;
|
||||
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(),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
&url,
|
||||
&control,
|
||||
Arc::new(host),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.await
|
||||
.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) => {
|
||||
self.task_manager.complete(task_id);
|
||||
self.refresh_task_snapshots();
|
||||
for message in result.toasts {
|
||||
self.notify(ToastLevel::Info, &message);
|
||||
}
|
||||
@@ -1635,6 +1671,10 @@ impl BdsApp {
|
||||
self.refresh_counts()
|
||||
}
|
||||
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(
|
||||
ToastLevel::Error,
|
||||
&tw(self.ui_locale, "blogmark.failed", &[("error", &error)]),
|
||||
|
||||
@@ -708,9 +708,14 @@ impl BdsApp {
|
||||
}
|
||||
}
|
||||
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(
|
||||
"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 {
|
||||
bds_core::model::ScriptKind::Macro => {
|
||||
bds_core::scripting::ExecutionKind::Macro
|
||||
@@ -722,19 +727,22 @@ impl BdsApp {
|
||||
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,
|
||||
&entrypoint,
|
||||
&serde_json::json!({}),
|
||||
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;
|
||||
lines.extend(
|
||||
result
|
||||
|
||||
@@ -82,6 +82,40 @@ impl MenuAction {
|
||||
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.
|
||||
pub fn i18n_key(self) -> &'static str {
|
||||
match self {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod dialog;
|
||||
pub mod menu;
|
||||
pub mod script_host;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
|
||||
130
crates/bds-ui/src/platform/script_host.rs
Normal file
130
crates/bds-ui/src/platform/script_host.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
4761
docs/scripting/API_REFERENCE.md
Normal file
4761
docs/scripting/API_REFERENCE.md
Normal file
File diff suppressed because it is too large
Load Diff
11
docs/scripting/README.md
Normal file
11
docs/scripting/README.md
Normal 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
325
docs/scripting/TYPES.md
Normal 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
1702
docs/scripting/api.json
Normal file
File diff suppressed because it is too large
Load Diff
1
docs/scripting/bds2-core-signatures.json
Normal file
1
docs/scripting/bds2-core-signatures.json
Normal file
File diff suppressed because one or more lines are too long
1686
docs/scripting/completions.json
Normal file
1686
docs/scripting/completions.json
Normal file
File diff suppressed because it is too large
Load Diff
3
docs/scripting/examples/macro.lua
Normal file
3
docs/scripting/examples/macro.lua
Normal file
@@ -0,0 +1,3 @@
|
||||
function render(params, env)
|
||||
return "<strong>" .. (params.text or env.mainLanguage) .. "</strong>"
|
||||
end
|
||||
6
docs/scripting/examples/transform.lua
Normal file
6
docs/scripting/examples/transform.lua
Normal 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
|
||||
7
docs/scripting/examples/utility.lua
Normal file
7
docs/scripting/examples/utility.lua
Normal 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
|
||||
@@ -455,6 +455,7 @@ translationValidation-missingTranslation = Konfigurierte Übersetzung fehlt
|
||||
blogmark-invalid = Ungültiges Blogmark: { $error }
|
||||
blogmark-unknownProject = Das Zielprojekt dieses Blogmarks ist hier nicht vorhanden.
|
||||
blogmark-noProject = Öffnen Sie vor dem Import eines Blogmarks ein Projekt.
|
||||
blogmark-importing = Blogmark wird importiert
|
||||
blogmark-imported = Blogmark importiert
|
||||
blogmark-failed = Blogmark-Import fehlgeschlagen: { $error }
|
||||
engine-generatedPage = Erstellt { $url } ({ $current }/{ $total })
|
||||
|
||||
@@ -455,6 +455,7 @@ translationValidation-missingTranslation = Configured translation is missing
|
||||
blogmark-invalid = Invalid blogmark: { $error }
|
||||
blogmark-unknownProject = The project targeted by this blogmark does not exist here.
|
||||
blogmark-noProject = Open a project before importing a blogmark.
|
||||
blogmark-importing = Importing blogmark
|
||||
blogmark-imported = Blogmark imported
|
||||
blogmark-failed = Blogmark import failed: { $error }
|
||||
engine-generatedPage = Generated { $url } ({ $current }/{ $total })
|
||||
|
||||
@@ -455,6 +455,7 @@ translationValidation-missingTranslation = Falta una traducción configurada
|
||||
blogmark-invalid = Blogmark no válido: { $error }
|
||||
blogmark-unknownProject = El proyecto de destino de este blogmark no existe aquí.
|
||||
blogmark-noProject = Abre un proyecto antes de importar un blogmark.
|
||||
blogmark-importing = Importando blogmark
|
||||
blogmark-imported = Blogmark importado
|
||||
blogmark-failed = Error al importar el blogmark: { $error }
|
||||
engine-generatedPage = Generado { $url } ({ $current }/{ $total })
|
||||
|
||||
@@ -455,6 +455,7 @@ translationValidation-missingTranslation = Une traduction configurée est absent
|
||||
blogmark-invalid = Blogmark invalide : { $error }
|
||||
blogmark-unknownProject = Le projet ciblé par ce blogmark n’existe pas ici.
|
||||
blogmark-noProject = Ouvrez un projet avant d’importer un blogmark.
|
||||
blogmark-importing = Importation du blogmark
|
||||
blogmark-imported = Blogmark importé
|
||||
blogmark-failed = Échec de l’import du blogmark : { $error }
|
||||
engine-generatedPage = Généré { $url } ({ $current }/{ $total })
|
||||
|
||||
@@ -455,6 +455,7 @@ translationValidation-missingTranslation = Manca una traduzione configurata
|
||||
blogmark-invalid = Blogmark non valido: { $error }
|
||||
blogmark-unknownProject = Il progetto indicato dal blogmark non esiste qui.
|
||||
blogmark-noProject = Apri un progetto prima di importare un blogmark.
|
||||
blogmark-importing = Importazione del blogmark
|
||||
blogmark-imported = Blogmark importato
|
||||
blogmark-failed = Importazione del blogmark non riuscita: { $error }
|
||||
engine-generatedPage = Generato { $url } ({ $current }/{ $total })
|
||||
|
||||
Reference in New Issue
Block a user