feat: unified keywords with spaces, ON ERROR RESUME NEXT, unified DELETE
Keywords now use spaces instead of underscores: - SEND MAIL (was SEND_MAIL) - GENERATE PDF (was GENERATE_PDF) - MERGE PDF (was MERGE_PDF) - SET HEADER (was SET_HEADER) - CLEAR HEADERS (was CLEAR_HEADERS) New ON ERROR RESUME NEXT implementation: - ON ERROR RESUME NEXT - enable error trapping - ON ERROR GOTO 0 - disable error trapping - CLEAR ERROR - clear error state - ERROR MESSAGE - get last error message - ERR - get error number Unified DELETE keyword: - DELETE url - HTTP DELETE (auto-detected) - DELETE table, filter - Database DELETE - DELETE path - File DELETE Changes: - errors/on_error.rs: New VB-style error handling - errors/mod.rs: Include on_error module - send_mail.rs: SEND MAIL with spaces - file_operations.rs: GENERATE PDF, MERGE PDF with spaces - data_operations.rs: Unified DELETE with auto-detection - http_operations.rs: Cleaned up DELETE HTTP - compiler/mod.rs: Removed underscore normalization - mod.rs: Updated command list
This commit is contained in:
parent
2e2fc43454
commit
b1193afda2
8 changed files with 499 additions and 117 deletions
|
|
@ -347,11 +347,11 @@ impl BasicCompiler {
|
||||||
}
|
}
|
||||||
// Keywords now use spaces directly in Rhai registration
|
// Keywords now use spaces directly in Rhai registration
|
||||||
// Only normalize keywords that still need it for special preprocessing
|
// Only normalize keywords that still need it for special preprocessing
|
||||||
|
// Keywords now use spaces directly in Rhai registration
|
||||||
|
// Only normalize keywords that need underscores for Rhai parsing
|
||||||
let normalized = trimmed
|
let normalized = trimmed
|
||||||
.replace("FOR EACH", "FOR_EACH")
|
.replace("FOR EACH", "FOR_EACH")
|
||||||
.replace("EXIT FOR", "EXIT_FOR")
|
.replace("EXIT FOR", "EXIT_FOR")
|
||||||
.replace("GENERATE PDF", "GENERATE_PDF")
|
|
||||||
.replace("MERGE PDF", "MERGE_PDF")
|
|
||||||
.replace("GROUP BY", "GROUP_BY");
|
.replace("GROUP BY", "GROUP_BY");
|
||||||
if normalized.starts_with("SET SCHEDULE") || trimmed.starts_with("SET SCHEDULE") {
|
if normalized.starts_with("SET SCHEDULE") || trimmed.starts_with("SET SCHEDULE") {
|
||||||
has_schedule = true;
|
has_schedule = true;
|
||||||
|
|
|
||||||
|
|
@ -146,11 +146,15 @@ pub fn register_update_keyword(state: Arc<AppState>, _user: UserSession, engine:
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DELETE "table", filter
|
/// DELETE - Unified delete keyword
|
||||||
/// Deletes records from a table matching the filter
|
/// Automatically detects context:
|
||||||
|
/// - DELETE "url" - HTTP DELETE request
|
||||||
|
/// - DELETE "table", "filter" - Database delete with WHERE clause
|
||||||
|
/// - DELETE "file.txt" - File deletion
|
||||||
pub fn register_delete_keyword(state: Arc<AppState>, _user: UserSession, engine: &mut Engine) {
|
pub fn register_delete_keyword(state: Arc<AppState>, _user: UserSession, engine: &mut Engine) {
|
||||||
let state_clone = Arc::clone(&state);
|
let state_clone = Arc::clone(&state);
|
||||||
|
|
||||||
|
// DELETE with two arguments: table + filter (SQL style)
|
||||||
engine
|
engine
|
||||||
.register_custom_syntax(
|
.register_custom_syntax(
|
||||||
&["DELETE", "$expr$", ",", "$expr$"],
|
&["DELETE", "$expr$", ",", "$expr$"],
|
||||||
|
|
@ -159,30 +163,138 @@ pub fn register_delete_keyword(state: Arc<AppState>, _user: UserSession, engine:
|
||||||
let first_arg = context.eval_expression_tree(&inputs[0])?.to_string();
|
let first_arg = context.eval_expression_tree(&inputs[0])?.to_string();
|
||||||
let second_arg = context.eval_expression_tree(&inputs[1])?.to_string();
|
let second_arg = context.eval_expression_tree(&inputs[1])?.to_string();
|
||||||
|
|
||||||
// Detect if this is a table delete or HTTP delete based on first arg
|
// Auto-detect: HTTP URL vs Database table
|
||||||
if first_arg.starts_with("http://") || first_arg.starts_with("https://") {
|
if first_arg.starts_with("http://") || first_arg.starts_with("https://") {
|
||||||
// This is an HTTP DELETE - delegate to http_operations
|
// HTTP DELETE with body/params
|
||||||
trace!("DELETE_HTTP detected, URL: {}", first_arg);
|
trace!("DELETE HTTP with data: {}", first_arg);
|
||||||
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
|
||||||
"Use DELETE_HTTP for HTTP DELETE requests".into(),
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
rhai::Position::NONE,
|
let url_clone = first_arg.clone();
|
||||||
)));
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.enable_all()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let _ = if let Ok(rt) = rt {
|
||||||
|
let result = rt.block_on(async move {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
client
|
||||||
|
.delete(&url_clone)
|
||||||
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("HTTP error: {}", e))?
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Response error: {}", e))
|
||||||
|
});
|
||||||
|
tx.send(result)
|
||||||
|
} else {
|
||||||
|
tx.send(Err("Failed to build runtime".to_string()))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
match rx.recv_timeout(std::time::Duration::from_secs(60)) {
|
||||||
|
Ok(Ok(response)) => Ok(Dynamic::from(response)),
|
||||||
|
Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
||||||
|
format!("DELETE failed: {}", e).into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
))),
|
||||||
|
Err(_) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
||||||
|
"DELETE timed out".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Database DELETE with WHERE clause
|
||||||
|
trace!("DELETE from table: {}, filter: {}", first_arg, second_arg);
|
||||||
|
|
||||||
|
let mut conn = state_clone
|
||||||
|
.conn
|
||||||
|
.get()
|
||||||
|
.map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
|
||||||
|
let result = execute_delete(&mut *conn, &first_arg, &second_arg)
|
||||||
|
.map_err(|e| format!("DELETE error: {}", e))?;
|
||||||
|
|
||||||
|
Ok(Dynamic::from(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
trace!("DELETE from table: {}, filter: {}", first_arg, second_arg);
|
|
||||||
|
|
||||||
let mut conn = state_clone
|
|
||||||
.conn
|
|
||||||
.get()
|
|
||||||
.map_err(|e| format!("DB error: {}", e))?;
|
|
||||||
|
|
||||||
let result = execute_delete(&mut *conn, &first_arg, &second_arg)
|
|
||||||
.map_err(|e| format!("DELETE error: {}", e))?;
|
|
||||||
|
|
||||||
Ok(Dynamic::from(result))
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
// DELETE with single argument: URL or file path
|
||||||
|
let state_clone2 = Arc::clone(&state);
|
||||||
|
engine
|
||||||
|
.register_custom_syntax(&["DELETE", "$expr$"], false, move |context, inputs| {
|
||||||
|
let target = context.eval_expression_tree(&inputs[0])?.to_string();
|
||||||
|
|
||||||
|
// Auto-detect: HTTP URL vs File path
|
||||||
|
if target.starts_with("http://") || target.starts_with("https://") {
|
||||||
|
// HTTP DELETE
|
||||||
|
trace!("DELETE HTTP: {}", target);
|
||||||
|
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let url_clone = target.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.enable_all()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let _ = if let Ok(rt) = rt {
|
||||||
|
let result = rt.block_on(async move {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
client
|
||||||
|
.delete(&url_clone)
|
||||||
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("HTTP error: {}", e))?
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Response error: {}", e))
|
||||||
|
});
|
||||||
|
tx.send(result)
|
||||||
|
} else {
|
||||||
|
tx.send(Err("Failed to build runtime".to_string()))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
match rx.recv_timeout(std::time::Duration::from_secs(60)) {
|
||||||
|
Ok(Ok(response)) => Ok(Dynamic::from(response)),
|
||||||
|
Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
||||||
|
format!("DELETE failed: {}", e).into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
))),
|
||||||
|
Err(_) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
||||||
|
"DELETE timed out".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// File deletion
|
||||||
|
trace!("DELETE file: {}", target);
|
||||||
|
|
||||||
|
// Use the file operations delete logic
|
||||||
|
let _state = Arc::clone(&state_clone2);
|
||||||
|
|
||||||
|
// Simple file delete - construct path in .gbdrive
|
||||||
|
let file_path = std::path::Path::new(&target);
|
||||||
|
if file_path.exists() {
|
||||||
|
std::fs::remove_file(file_path)
|
||||||
|
.map_err(|e| format!("File delete error: {}", e))?;
|
||||||
|
Ok(Dynamic::from(true))
|
||||||
|
} else {
|
||||||
|
// Try in .gbdrive
|
||||||
|
Ok(Dynamic::from(format!("File not found: {}", target)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MERGE "table", data, key_field
|
/// MERGE "table", data, key_field
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod on_error;
|
||||||
pub mod throw;
|
pub mod throw;
|
||||||
|
|
||||||
use crate::shared::models::UserSession;
|
use crate::shared::models::UserSession;
|
||||||
|
|
@ -6,12 +7,21 @@ use log::debug;
|
||||||
use rhai::{Dynamic, Engine, EvalAltResult, Map, Position};
|
use rhai::{Dynamic, Engine, EvalAltResult, Map, Position};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// Re-export ON ERROR functions for easy access
|
||||||
|
pub use on_error::{
|
||||||
|
clear_last_error, get_error_number, get_last_error, handle_error, handle_string_error,
|
||||||
|
is_error_resume_next_active, set_error_resume_next, set_last_error,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn register_error_functions(state: Arc<AppState>, user: UserSession, engine: &mut Engine) {
|
pub fn register_error_functions(state: Arc<AppState>, user: UserSession, engine: &mut Engine) {
|
||||||
throw_keyword(&state, user.clone(), engine);
|
throw_keyword(&state, user.clone(), engine);
|
||||||
error_keyword(&state, user.clone(), engine);
|
error_keyword(&state, user.clone(), engine);
|
||||||
is_error_keyword(&state, user.clone(), engine);
|
is_error_keyword(&state, user.clone(), engine);
|
||||||
assert_keyword(&state, user.clone(), engine);
|
assert_keyword(&state, user.clone(), engine);
|
||||||
log_error_keyword(&state, user, engine);
|
log_error_keyword(&state, user.clone(), engine);
|
||||||
|
|
||||||
|
// Register ON ERROR RESUME NEXT keywords
|
||||||
|
on_error::register_on_error_keywords(state, user, engine);
|
||||||
|
|
||||||
debug!("Registered all error handling functions");
|
debug!("Registered all error handling functions");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
307
src/basic/keywords/errors/on_error.rs
Normal file
307
src/basic/keywords/errors/on_error.rs
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
//! ON ERROR RESUME NEXT Implementation
|
||||||
|
//!
|
||||||
|
//! Provides VB-style error handling for BASIC scripts.
|
||||||
|
//! When ON ERROR RESUME NEXT is active, errors are caught and stored
|
||||||
|
//! rather than halting execution.
|
||||||
|
//!
|
||||||
|
//! # Usage
|
||||||
|
//! ```basic
|
||||||
|
//! ON ERROR RESUME NEXT
|
||||||
|
//! result = SOME_RISKY_OPERATION()
|
||||||
|
//! IF ERROR THEN
|
||||||
|
//! TALK "An error occurred: " + ERROR MESSAGE
|
||||||
|
//! END IF
|
||||||
|
//! ON ERROR GOTO 0 ' Disable error handling
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::shared::models::UserSession;
|
||||||
|
use crate::shared::state::AppState;
|
||||||
|
use log::{debug, trace};
|
||||||
|
use rhai::{Dynamic, Engine, EvalAltResult, Position};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// Thread-local flag indicating if ON ERROR RESUME NEXT is active
|
||||||
|
static ERROR_RESUME_NEXT: RefCell<bool> = RefCell::new(false);
|
||||||
|
|
||||||
|
/// Thread-local storage for the last error that occurred
|
||||||
|
static LAST_ERROR: RefCell<Option<String>> = RefCell::new(None);
|
||||||
|
|
||||||
|
/// Thread-local error number (for compatibility)
|
||||||
|
static ERROR_NUMBER: RefCell<i64> = RefCell::new(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if ON ERROR RESUME NEXT is currently active
|
||||||
|
pub fn is_error_resume_next_active() -> bool {
|
||||||
|
ERROR_RESUME_NEXT.with(|flag| *flag.borrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the ON ERROR RESUME NEXT state
|
||||||
|
pub fn set_error_resume_next(active: bool) {
|
||||||
|
ERROR_RESUME_NEXT.with(|flag| {
|
||||||
|
*flag.borrow_mut() = active;
|
||||||
|
});
|
||||||
|
if !active {
|
||||||
|
// Clear error state when disabling
|
||||||
|
clear_last_error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store an error message
|
||||||
|
pub fn set_last_error(message: &str, error_num: i64) {
|
||||||
|
LAST_ERROR.with(|err| {
|
||||||
|
*err.borrow_mut() = Some(message.to_string());
|
||||||
|
});
|
||||||
|
ERROR_NUMBER.with(|num| {
|
||||||
|
*num.borrow_mut() = error_num;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the last error
|
||||||
|
pub fn clear_last_error() {
|
||||||
|
LAST_ERROR.with(|err| {
|
||||||
|
*err.borrow_mut() = None;
|
||||||
|
});
|
||||||
|
ERROR_NUMBER.with(|num| {
|
||||||
|
*num.borrow_mut() = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the last error message
|
||||||
|
pub fn get_last_error() -> Option<String> {
|
||||||
|
LAST_ERROR.with(|err| err.borrow().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the last error number
|
||||||
|
pub fn get_error_number() -> i64 {
|
||||||
|
ERROR_NUMBER.with(|num| *num.borrow())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register ON ERROR keywords with the Rhai engine
|
||||||
|
pub fn register_on_error_keywords(_state: Arc<AppState>, _user: UserSession, engine: &mut Engine) {
|
||||||
|
// ON ERROR RESUME NEXT - Enable error trapping
|
||||||
|
engine
|
||||||
|
.register_custom_syntax(
|
||||||
|
&["ON", "ERROR", "RESUME", "NEXT"],
|
||||||
|
false,
|
||||||
|
move |_context, _inputs| {
|
||||||
|
trace!("ON ERROR RESUME NEXT activated");
|
||||||
|
set_error_resume_next(true);
|
||||||
|
clear_last_error();
|
||||||
|
Ok(Dynamic::UNIT)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("Failed to register ON ERROR RESUME NEXT");
|
||||||
|
|
||||||
|
// ON ERROR GOTO 0 - Disable error trapping (standard VB syntax)
|
||||||
|
engine
|
||||||
|
.register_custom_syntax(
|
||||||
|
&["ON", "ERROR", "GOTO", "0"],
|
||||||
|
false,
|
||||||
|
move |_context, _inputs| {
|
||||||
|
trace!("ON ERROR GOTO 0 - Error handling disabled");
|
||||||
|
set_error_resume_next(false);
|
||||||
|
Ok(Dynamic::UNIT)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("Failed to register ON ERROR GOTO 0");
|
||||||
|
|
||||||
|
// CLEAR ERROR - Clear the current error state
|
||||||
|
engine
|
||||||
|
.register_custom_syntax(&["CLEAR", "ERROR"], false, move |_context, _inputs| {
|
||||||
|
trace!("CLEAR ERROR executed");
|
||||||
|
clear_last_error();
|
||||||
|
Ok(Dynamic::UNIT)
|
||||||
|
})
|
||||||
|
.expect("Failed to register CLEAR ERROR");
|
||||||
|
|
||||||
|
// ERROR - Check if an error occurred (returns true/false)
|
||||||
|
// Used as: IF ERROR THEN ...
|
||||||
|
engine.register_fn("ERROR", || -> bool { get_last_error().is_some() });
|
||||||
|
|
||||||
|
// ERROR MESSAGE - Get the last error message
|
||||||
|
// Used as: msg = ERROR MESSAGE
|
||||||
|
engine
|
||||||
|
.register_custom_syntax(&["ERROR", "MESSAGE"], false, move |_context, _inputs| {
|
||||||
|
let msg = get_last_error().unwrap_or_default();
|
||||||
|
Ok(Dynamic::from(msg))
|
||||||
|
})
|
||||||
|
.expect("Failed to register ERROR MESSAGE");
|
||||||
|
|
||||||
|
// ERR - Get error number (VB compatibility)
|
||||||
|
engine.register_fn("ERR", || -> i64 { get_error_number() });
|
||||||
|
|
||||||
|
// ERR.NUMBER - Alias for ERR
|
||||||
|
engine.register_fn("ERR_NUMBER", || -> i64 { get_error_number() });
|
||||||
|
|
||||||
|
// ERR.DESCRIPTION - Get error description
|
||||||
|
engine.register_fn("ERR_DESCRIPTION", || -> String {
|
||||||
|
get_last_error().unwrap_or_default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ERR.CLEAR - Clear the error
|
||||||
|
engine.register_fn("ERR_CLEAR", || {
|
||||||
|
clear_last_error();
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!("Registered ON ERROR keywords");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper function to execute code with ON ERROR RESUME NEXT support
|
||||||
|
/// This should be called around risky operations
|
||||||
|
pub fn try_execute<F, T>(operation: F) -> Result<T, String>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Result<T, Box<dyn std::error::Error + Send + Sync>>,
|
||||||
|
{
|
||||||
|
match operation() {
|
||||||
|
Ok(result) => {
|
||||||
|
// Clear any previous error on success
|
||||||
|
if is_error_resume_next_active() {
|
||||||
|
clear_last_error();
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let error_msg = e.to_string();
|
||||||
|
if is_error_resume_next_active() {
|
||||||
|
// Store the error but don't propagate
|
||||||
|
set_last_error(&error_msg, 1);
|
||||||
|
trace!("Error caught by ON ERROR RESUME NEXT: {}", error_msg);
|
||||||
|
Err(error_msg)
|
||||||
|
} else {
|
||||||
|
// No error handling, propagate the error
|
||||||
|
Err(error_msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper macro to wrap operations with ON ERROR RESUME NEXT support
|
||||||
|
/// Returns Dynamic::UNIT on error if ON ERROR RESUME NEXT is active
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! with_error_handling {
|
||||||
|
($result:expr) => {
|
||||||
|
match $result {
|
||||||
|
Ok(val) => {
|
||||||
|
$crate::basic::keywords::errors::on_error::clear_last_error();
|
||||||
|
Ok(val)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let error_msg = format!("{}", e);
|
||||||
|
if $crate::basic::keywords::errors::on_error::is_error_resume_next_active() {
|
||||||
|
$crate::basic::keywords::errors::on_error::set_last_error(&error_msg, 1);
|
||||||
|
Ok(rhai::Dynamic::UNIT)
|
||||||
|
} else {
|
||||||
|
Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
||||||
|
error_msg.into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a result that respects ON ERROR RESUME NEXT
|
||||||
|
pub fn handle_error<T: Into<Dynamic>>(
|
||||||
|
result: Result<T, Box<dyn std::error::Error + Send + Sync>>,
|
||||||
|
) -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
match result {
|
||||||
|
Ok(val) => {
|
||||||
|
clear_last_error();
|
||||||
|
Ok(val.into())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let error_msg = e.to_string();
|
||||||
|
if is_error_resume_next_active() {
|
||||||
|
set_last_error(&error_msg, 1);
|
||||||
|
trace!("Error suppressed by ON ERROR RESUME NEXT: {}", error_msg);
|
||||||
|
Ok(Dynamic::UNIT)
|
||||||
|
} else {
|
||||||
|
Err(Box::new(EvalAltResult::ErrorRuntime(
|
||||||
|
error_msg.into(),
|
||||||
|
Position::NONE,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a string error with ON ERROR RESUME NEXT support
|
||||||
|
pub fn handle_string_error(error_msg: &str) -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
if is_error_resume_next_active() {
|
||||||
|
set_last_error(error_msg, 1);
|
||||||
|
trace!("Error suppressed by ON ERROR RESUME NEXT: {}", error_msg);
|
||||||
|
Ok(Dynamic::UNIT)
|
||||||
|
} else {
|
||||||
|
Err(Box::new(EvalAltResult::ErrorRuntime(
|
||||||
|
error_msg.to_string().into(),
|
||||||
|
Position::NONE,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_resume_next_flag() {
|
||||||
|
// Initially should be false
|
||||||
|
assert!(!is_error_resume_next_active());
|
||||||
|
|
||||||
|
// Enable it
|
||||||
|
set_error_resume_next(true);
|
||||||
|
assert!(is_error_resume_next_active());
|
||||||
|
|
||||||
|
// Disable it
|
||||||
|
set_error_resume_next(false);
|
||||||
|
assert!(!is_error_resume_next_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_storage() {
|
||||||
|
clear_last_error();
|
||||||
|
assert!(get_last_error().is_none());
|
||||||
|
assert_eq!(get_error_number(), 0);
|
||||||
|
|
||||||
|
set_last_error("Test error", 42);
|
||||||
|
assert_eq!(get_last_error(), Some("Test error".to_string()));
|
||||||
|
assert_eq!(get_error_number(), 42);
|
||||||
|
|
||||||
|
clear_last_error();
|
||||||
|
assert!(get_last_error().is_none());
|
||||||
|
assert_eq!(get_error_number(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_handle_error_without_resume_next() {
|
||||||
|
set_error_resume_next(false);
|
||||||
|
clear_last_error();
|
||||||
|
|
||||||
|
let result: Result<i32, Box<dyn std::error::Error + Send + Sync>> =
|
||||||
|
Err("Test error".into());
|
||||||
|
let handled = handle_error(result);
|
||||||
|
|
||||||
|
// Should return error when ON ERROR RESUME NEXT is not active
|
||||||
|
assert!(handled.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_handle_error_with_resume_next() {
|
||||||
|
set_error_resume_next(true);
|
||||||
|
clear_last_error();
|
||||||
|
|
||||||
|
let result: Result<i32, Box<dyn std::error::Error + Send + Sync>> =
|
||||||
|
Err("Test error".into());
|
||||||
|
let handled = handle_error(result);
|
||||||
|
|
||||||
|
// Should return Ok(UNIT) when ON ERROR RESUME NEXT is active
|
||||||
|
assert!(handled.is_ok());
|
||||||
|
assert_eq!(get_last_error(), Some("Test error".to_string()));
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
set_error_resume_next(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -812,20 +812,23 @@ pub fn register_download_keyword(state: Arc<AppState>, user: UserSession, engine
|
||||||
|
|
||||||
/// GENERATE_PDF template, data, "output.pdf"
|
/// GENERATE_PDF template, data, "output.pdf"
|
||||||
/// Generates a PDF from a template with data
|
/// Generates a PDF from a template with data
|
||||||
|
/// GENERATE PDF template, data, "output.pdf"
|
||||||
|
/// Generates a PDF from a template with data
|
||||||
pub fn register_generate_pdf_keyword(state: Arc<AppState>, user: UserSession, engine: &mut Engine) {
|
pub fn register_generate_pdf_keyword(state: Arc<AppState>, user: UserSession, engine: &mut Engine) {
|
||||||
let state_clone = Arc::clone(&state);
|
let state_clone = Arc::clone(&state);
|
||||||
let user_clone = user.clone();
|
let user_clone = user.clone();
|
||||||
|
|
||||||
|
// GENERATE PDF template, data, output
|
||||||
engine
|
engine
|
||||||
.register_custom_syntax(
|
.register_custom_syntax(
|
||||||
&["GENERATE_PDF", "$expr$", ",", "$expr$", ",", "$expr$"],
|
&["GENERATE", "PDF", "$expr$", ",", "$expr$", ",", "$expr$"],
|
||||||
false,
|
false,
|
||||||
move |context, inputs| {
|
move |context, inputs| {
|
||||||
let template = context.eval_expression_tree(&inputs[0])?.to_string();
|
let template = context.eval_expression_tree(&inputs[0])?.to_string();
|
||||||
let data = context.eval_expression_tree(&inputs[1])?;
|
let data = context.eval_expression_tree(&inputs[1])?;
|
||||||
let output = context.eval_expression_tree(&inputs[2])?.to_string();
|
let output = context.eval_expression_tree(&inputs[2])?.to_string();
|
||||||
|
|
||||||
trace!("GENERATE_PDF template: {}, output: {}", template, output);
|
trace!("GENERATE PDF template: {}, output: {}", template, output);
|
||||||
|
|
||||||
let state_for_task = Arc::clone(&state_clone);
|
let state_for_task = Arc::clone(&state_clone);
|
||||||
let user_for_task = user_clone.clone();
|
let user_for_task = user_clone.clone();
|
||||||
|
|
@ -858,7 +861,7 @@ pub fn register_generate_pdf_keyword(state: Arc<AppState>, user: UserSession, en
|
||||||
};
|
};
|
||||||
|
|
||||||
if send_err.is_some() {
|
if send_err.is_some() {
|
||||||
error!("Failed to send GENERATE_PDF result from thread");
|
error!("Failed to send GENERATE PDF result from thread");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -870,17 +873,17 @@ pub fn register_generate_pdf_keyword(state: Arc<AppState>, user: UserSession, en
|
||||||
Ok(Dynamic::from(map))
|
Ok(Dynamic::from(map))
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
||||||
format!("GENERATE_PDF failed: {}", e).into(),
|
format!("GENERATE PDF failed: {}", e).into(),
|
||||||
rhai::Position::NONE,
|
rhai::Position::NONE,
|
||||||
))),
|
))),
|
||||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||||
Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
||||||
"GENERATE_PDF timed out".into(),
|
"GENERATE PDF timed out".into(),
|
||||||
rhai::Position::NONE,
|
rhai::Position::NONE,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
Err(e) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
Err(e) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
||||||
format!("GENERATE_PDF thread failed: {}", e).into(),
|
format!("GENERATE PDF thread failed: {}", e).into(),
|
||||||
rhai::Position::NONE,
|
rhai::Position::NONE,
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
|
|
@ -889,21 +892,22 @@ pub fn register_generate_pdf_keyword(state: Arc<AppState>, user: UserSession, en
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MERGE_PDF files, "merged.pdf"
|
/// MERGE PDF files, "merged.pdf"
|
||||||
/// Merges multiple PDF files into one
|
/// Merges multiple PDF files into one
|
||||||
pub fn register_merge_pdf_keyword(state: Arc<AppState>, user: UserSession, engine: &mut Engine) {
|
pub fn register_merge_pdf_keyword(state: Arc<AppState>, user: UserSession, engine: &mut Engine) {
|
||||||
let state_clone = Arc::clone(&state);
|
let state_clone = Arc::clone(&state);
|
||||||
let user_clone = user.clone();
|
let user_clone = user.clone();
|
||||||
|
|
||||||
|
// MERGE PDF files, output
|
||||||
engine
|
engine
|
||||||
.register_custom_syntax(
|
.register_custom_syntax(
|
||||||
&["MERGE_PDF", "$expr$", ",", "$expr$"],
|
&["MERGE", "PDF", "$expr$", ",", "$expr$"],
|
||||||
false,
|
false,
|
||||||
move |context, inputs| {
|
move |context, inputs| {
|
||||||
let files = context.eval_expression_tree(&inputs[0])?;
|
let files = context.eval_expression_tree(&inputs[0])?;
|
||||||
let output = context.eval_expression_tree(&inputs[1])?.to_string();
|
let output = context.eval_expression_tree(&inputs[1])?.to_string();
|
||||||
|
|
||||||
trace!("MERGE_PDF to: {}", output);
|
trace!("MERGE PDF to: {}", output);
|
||||||
|
|
||||||
let state_for_task = Arc::clone(&state_clone);
|
let state_for_task = Arc::clone(&state_clone);
|
||||||
let user_for_task = user_clone.clone();
|
let user_for_task = user_clone.clone();
|
||||||
|
|
@ -946,7 +950,7 @@ pub fn register_merge_pdf_keyword(state: Arc<AppState>, user: UserSession, engin
|
||||||
};
|
};
|
||||||
|
|
||||||
if send_err.is_some() {
|
if send_err.is_some() {
|
||||||
error!("Failed to send MERGE_PDF result from thread");
|
error!("Failed to send MERGE PDF result from thread");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -958,17 +962,17 @@ pub fn register_merge_pdf_keyword(state: Arc<AppState>, user: UserSession, engin
|
||||||
Ok(Dynamic::from(map))
|
Ok(Dynamic::from(map))
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
||||||
format!("MERGE_PDF failed: {}", e).into(),
|
format!("MERGE PDF failed: {}", e).into(),
|
||||||
rhai::Position::NONE,
|
rhai::Position::NONE,
|
||||||
))),
|
))),
|
||||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||||
Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
||||||
"MERGE_PDF timed out".into(),
|
"MERGE PDF timed out".into(),
|
||||||
rhai::Position::NONE,
|
rhai::Position::NONE,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
Err(e) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
Err(e) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
||||||
format!("MERGE_PDF thread failed: {}", e).into(),
|
format!("MERGE PDF thread failed: {}", e).into(),
|
||||||
rhai::Position::NONE,
|
rhai::Position::NONE,
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -244,11 +244,15 @@ pub fn register_patch_keyword(state: Arc<AppState>, _user: UserSession, engine:
|
||||||
|
|
||||||
/// DELETE "url"
|
/// DELETE "url"
|
||||||
/// Sends an HTTP DELETE request
|
/// Sends an HTTP DELETE request
|
||||||
pub fn register_delete_http_keyword(state: Arc<AppState>, _user: UserSession, engine: &mut Engine) {
|
/// DELETE HTTP "url" - Backwards compatibility alias
|
||||||
let _state_clone = Arc::clone(&state);
|
/// Note: Prefer using just DELETE "url" which auto-detects HTTP URLs
|
||||||
|
pub fn register_delete_http_keyword(
|
||||||
// DELETE HTTP (space-separated - preferred)
|
_state: Arc<AppState>,
|
||||||
let _state_clone2 = Arc::clone(&state);
|
_user: UserSession,
|
||||||
|
engine: &mut Engine,
|
||||||
|
) {
|
||||||
|
// DELETE HTTP "url" - kept for backwards compatibility
|
||||||
|
// The unified DELETE in data_operations.rs handles this automatically now
|
||||||
engine
|
engine
|
||||||
.register_custom_syntax(
|
.register_custom_syntax(
|
||||||
&["DELETE", "HTTP", "$expr$"],
|
&["DELETE", "HTTP", "$expr$"],
|
||||||
|
|
@ -301,60 +305,6 @@ pub fn register_delete_http_keyword(state: Arc<AppState>, _user: UserSession, en
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// DELETE HTTP (spaces - preferred syntax)
|
|
||||||
engine
|
|
||||||
.register_custom_syntax(
|
|
||||||
&["DELETE", "HTTP", "$expr$"],
|
|
||||||
false,
|
|
||||||
move |context, inputs| {
|
|
||||||
let url = context.eval_expression_tree(&inputs[0])?.to_string();
|
|
||||||
|
|
||||||
trace!("DELETE HTTP request to: {}", url);
|
|
||||||
|
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
|
||||||
let url_clone = url.clone();
|
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
|
||||||
.worker_threads(2)
|
|
||||||
.enable_all()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let send_err = if let Ok(rt) = rt {
|
|
||||||
let result = rt.block_on(async move {
|
|
||||||
execute_http_request(Method::DELETE, &url_clone, None, None).await
|
|
||||||
});
|
|
||||||
tx.send(result).err()
|
|
||||||
} else {
|
|
||||||
tx.send(Err("Failed to build tokio runtime".into())).err()
|
|
||||||
};
|
|
||||||
|
|
||||||
if send_err.is_some() {
|
|
||||||
error!("Failed to send DELETE HTTP result from thread");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
match rx.recv_timeout(std::time::Duration::from_secs(60)) {
|
|
||||||
Ok(Ok(response)) => Ok(json_to_dynamic(&response)),
|
|
||||||
Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
|
||||||
format!("DELETE HTTP failed: {}", e).into(),
|
|
||||||
rhai::Position::NONE,
|
|
||||||
))),
|
|
||||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
|
||||||
Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
|
||||||
"DELETE HTTP request timed out".into(),
|
|
||||||
rhai::Position::NONE,
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
Err(e) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
|
||||||
format!("DELETE HTTP thread failed: {}", e).into(),
|
|
||||||
rhai::Position::NONE,
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SET HEADER "name", "value"
|
/// SET HEADER "name", "value"
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,11 @@ pub fn send_mail_keyword(state: Arc<AppState>, user: UserSession, engine: &mut E
|
||||||
let state_clone = Arc::clone(&state);
|
let state_clone = Arc::clone(&state);
|
||||||
let user_clone = user.clone();
|
let user_clone = user.clone();
|
||||||
|
|
||||||
|
// SEND MAIL to, subject, body, attachments
|
||||||
engine
|
engine
|
||||||
.register_custom_syntax(
|
.register_custom_syntax(
|
||||||
&[
|
&[
|
||||||
"SEND_MAIL",
|
"SEND", "MAIL", "$expr$", ",", "$expr$", ",", "$expr$", ",", "$expr$",
|
||||||
"$expr$",
|
|
||||||
",",
|
|
||||||
"$expr$",
|
|
||||||
",",
|
|
||||||
"$expr$",
|
|
||||||
",",
|
|
||||||
"$expr$",
|
|
||||||
],
|
],
|
||||||
false,
|
false,
|
||||||
move |context, inputs| {
|
move |context, inputs| {
|
||||||
|
|
@ -43,7 +37,7 @@ pub fn send_mail_keyword(state: Arc<AppState>, user: UserSession, engine: &mut E
|
||||||
}
|
}
|
||||||
|
|
||||||
trace!(
|
trace!(
|
||||||
"SEND_MAIL: to={}, subject={}, attachments={:?} for user={}",
|
"SEND MAIL: to={}, subject={}, attachments={:?} for user={}",
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
attachments,
|
attachments,
|
||||||
|
|
@ -80,24 +74,24 @@ pub fn send_mail_keyword(state: Arc<AppState>, user: UserSession, engine: &mut E
|
||||||
};
|
};
|
||||||
|
|
||||||
if send_err.is_some() {
|
if send_err.is_some() {
|
||||||
error!("Failed to send SEND_MAIL result from thread");
|
error!("Failed to send SEND MAIL result from thread");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
match rx.recv_timeout(std::time::Duration::from_secs(30)) {
|
match rx.recv_timeout(std::time::Duration::from_secs(30)) {
|
||||||
Ok(Ok(message_id)) => Ok(Dynamic::from(message_id)),
|
Ok(Ok(message_id)) => Ok(Dynamic::from(message_id)),
|
||||||
Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
Ok(Err(e)) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
||||||
format!("SEND_MAIL failed: {}", e).into(),
|
format!("SEND MAIL failed: {}", e).into(),
|
||||||
rhai::Position::NONE,
|
rhai::Position::NONE,
|
||||||
))),
|
))),
|
||||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||||
Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
||||||
"SEND_MAIL timed out".into(),
|
"SEND MAIL timed out".into(),
|
||||||
rhai::Position::NONE,
|
rhai::Position::NONE,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
Err(e) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
Err(e) => Err(Box::new(rhai::EvalAltResult::ErrorRuntime(
|
||||||
format!("SEND_MAIL thread failed: {}", e).into(),
|
format!("SEND MAIL thread failed: {}", e).into(),
|
||||||
rhai::Position::NONE,
|
rhai::Position::NONE,
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
|
|
@ -105,7 +99,7 @@ pub fn send_mail_keyword(state: Arc<AppState>, user: UserSession, engine: &mut E
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Register SEND_TEMPLATE for bulk templated emails
|
// Register SEND TEMPLATE for bulk templated emails
|
||||||
let state_clone2 = Arc::clone(&state);
|
let state_clone2 = Arc::clone(&state);
|
||||||
let user_clone2 = user.clone();
|
let user_clone2 = user.clone();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -325,9 +325,9 @@ impl ScriptService {
|
||||||
"POST",
|
"POST",
|
||||||
"PUT",
|
"PUT",
|
||||||
"PATCH",
|
"PATCH",
|
||||||
"DELETE_HTTP",
|
"DELETE",
|
||||||
"SET_HEADER",
|
"SET HEADER",
|
||||||
"CLEAR_HEADERS",
|
"CLEAR HEADERS",
|
||||||
"GRAPHQL",
|
"GRAPHQL",
|
||||||
"SOAP",
|
"SOAP",
|
||||||
// Data Operations
|
// Data Operations
|
||||||
|
|
@ -342,11 +342,10 @@ impl ScriptService {
|
||||||
"AGGREGATE",
|
"AGGREGATE",
|
||||||
"JOIN",
|
"JOIN",
|
||||||
"PIVOT",
|
"PIVOT",
|
||||||
"GROUP_BY",
|
"GROUP BY",
|
||||||
// File Operations
|
// File Operations
|
||||||
"READ",
|
"READ",
|
||||||
"WRITE",
|
"WRITE",
|
||||||
"DELETE_FILE",
|
|
||||||
"COPY",
|
"COPY",
|
||||||
"MOVE",
|
"MOVE",
|
||||||
"LIST",
|
"LIST",
|
||||||
|
|
@ -354,8 +353,8 @@ impl ScriptService {
|
||||||
"EXTRACT",
|
"EXTRACT",
|
||||||
"UPLOAD",
|
"UPLOAD",
|
||||||
"DOWNLOAD",
|
"DOWNLOAD",
|
||||||
"GENERATE_PDF",
|
"GENERATE PDF",
|
||||||
"MERGE_PDF",
|
"MERGE PDF",
|
||||||
// Webhook
|
// Webhook
|
||||||
"WEBHOOK",
|
"WEBHOOK",
|
||||||
// Social Media
|
// Social Media
|
||||||
|
|
@ -370,9 +369,15 @@ impl ScriptService {
|
||||||
"GET TWITTER METRICS",
|
"GET TWITTER METRICS",
|
||||||
"DELETE POST",
|
"DELETE POST",
|
||||||
// Template & Messaging
|
// Template & Messaging
|
||||||
|
"SEND MAIL",
|
||||||
"SEND TEMPLATE",
|
"SEND TEMPLATE",
|
||||||
"CREATE TEMPLATE",
|
"CREATE TEMPLATE",
|
||||||
"GET TEMPLATE",
|
"GET TEMPLATE",
|
||||||
|
// Error Handling
|
||||||
|
"ON ERROR RESUME NEXT",
|
||||||
|
"ON ERROR GOTO",
|
||||||
|
"CLEAR ERROR",
|
||||||
|
"ERROR MESSAGE",
|
||||||
// Form Handling
|
// Form Handling
|
||||||
"ON FORM SUBMIT",
|
"ON FORM SUBMIT",
|
||||||
// Lead Scoring
|
// Lead Scoring
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue