bnto-spreadsheet 0.1.3

Spreadsheet processing nodes for Bnto engine — clean, convert, merge, rename
Documentation
// WASM bridge for spreadsheet processing — `#[wasm_bindgen]` exports that convert
// between JS types (JsValue, Uint8Array) and Rust types for the Web Worker.

use wasm_bindgen::prelude::*;

use bnto_core::NoopContext;
use bnto_core::errors::BntoError;
use bnto_core::processor::{FileData, NodeInput, NodeOutput, NodeProcessor};
use bnto_core::progress::ProgressReporter;

use crate::clean::CleanSpreadsheet;
use crate::rename::RenameColumns;

// Helper: convert BntoError to JsValue at the WASM boundary.
// Each node crate has its own copy because Rust's orphan rule prevents
// implementing From<BntoError> for JsValue in bnto-core.
fn bnto_err_to_js(error: BntoError) -> JsValue {
    JsError::new(&error.to_string()).into()
}

// =============================================================================
// Combined Functions — Single-Call Metadata + Bytes
// =============================================================================
//
// Each combined function calls process() ONCE and returns a JavaScript object
// containing BOTH the metadata JSON string AND the raw output bytes. This
// eliminates the old dual-function pattern (metadata + bytes as separate calls)
// which processed every file TWICE — doubling CPU time and causing progress
// to go 0% -> 100% -> 0% -> 100%.
//
// HOW THE RESULT OBJECT LOOKS IN JAVASCRIPT:
// ```js
// const result = clean_spreadsheet_combined(data, filename, params, progressCb);
// // result = {
// //   metadata: '{"originalRows":100,"cleanedRows":85,...}',
// //   data: Uint8Array([...]),       // the raw cleaned CSV bytes
// //   filename: "data-cleaned.csv",
// //   mimeType: "text/csv"
// // }
// ```

/// Build a JavaScript object containing both metadata JSON and raw bytes
/// from a single `NodeOutput`.
///
/// This is the same helper pattern used in bnto-image. Each node crate
/// has its own copy because:
///   1. It avoids adding js_sys as a dependency to bnto-core (which must
///      stay target-agnostic — no WASM-specific code)
///   2. The function is small enough that duplication is clearer than a
///      shared utility that would need its own crate
fn build_combined_result(output: NodeOutput) -> Result<JsValue, JsValue> {
    // --- Step 1: Extract the first output file ---
    //
    // All current node types produce exactly one output file.
    // `.into_iter().next()` moves the first file out of the Vec,
    // consuming the Vec in the process (no copying).
    let file = output
        .files
        .into_iter()
        .next()
        .ok_or_else(|| JsValue::from_str("No output file produced"))?;

    // --- Step 2: Serialize metadata to a JSON string ---
    let metadata_json = serde_json::to_string(&output.metadata)
        .map_err(|e| JsValue::from_str(&format!("Failed to serialize metadata: {e}")))?;

    // --- Step 3: Create a new JavaScript object ---
    let result = js_sys::Object::new();

    // --- Step 4: Set the "metadata" property (JSON string) ---
    js_sys::Reflect::set(&result, &"metadata".into(), &metadata_json.into())
        .map_err(|_| JsValue::from_str("Failed to set metadata on result object"))?;

    // --- Step 5: Set the "data" property (Uint8Array of raw bytes) ---
    //
    // Extract bytes from FileData (always Bytes variant in WASM context),
    // then create a JS Uint8Array that copies from WASM linear memory
    // into the JS heap.
    let bytes = file
        .data
        .into_bytes()
        .map_err(|e| JsValue::from_str(&format!("Failed to read output file data: {e}")))?;
    let uint8 = js_sys::Uint8Array::from(bytes.as_slice());
    js_sys::Reflect::set(&result, &"data".into(), &uint8)
        .map_err(|_| JsValue::from_str("Failed to set data on result object"))?;

    // --- Step 6: Set the "filename" property ---
    js_sys::Reflect::set(&result, &"filename".into(), &file.filename.into())
        .map_err(|_| JsValue::from_str("Failed to set filename on result object"))?;

    // --- Step 7: Set the "mimeType" property ---
    js_sys::Reflect::set(&result, &"mimeType".into(), &file.mime_type.into())
        .map_err(|_| JsValue::from_str("Failed to set mimeType on result object"))?;

    // --- Step 8: Return the object as a JsValue ---
    Ok(result.into())
}

// =============================================================================
// Clean Spreadsheet — Combined (Single Process Call)
// =============================================================================
//
// NOTE: setup() and version() are provided by the bnto-wasm entry point crate.
// Each node crate only exports its specific processing functions here.
// The entry point handles one-time initialization (panic hook, versioning).

/// Clean a CSV file and return BOTH metadata and bytes in one call.
///
/// The CSV is processed exactly ONCE, and the result contains everything
/// the Web Worker needs — no double processing.
///
/// ARGUMENTS (from JavaScript):
///   - `data` (Uint8Array): The raw CSV file bytes
///   - `filename` (string): The original filename (e.g., "data.csv")
///   - `params_json` (string): JSON string with cleaning config
///     (e.g., '{"removeDuplicates": true}'). Pass '{}' for defaults.
///   - `progress_callback` (Function): Called with (percent: number, message: string)
///     to report progress. The Web Worker forwards this to the main thread.
///
/// RETURNS:
///   A JavaScript object with four properties:
///   ```js
///   {
///     metadata: '{"originalRows":100,"cleanedRows":85,...}',  // JSON string
///     data: Uint8Array([...]),                                // raw cleaned CSV bytes
///     filename: "data-cleaned.csv",                           // output filename
///     mimeType: "text/csv"                                    // MIME type
///   }
///   ```
#[wasm_bindgen]
pub fn clean_spreadsheet_combined(
    data: &[u8],
    filename: &str,
    params_json: &str,
    progress_callback: js_sys::Function,
) -> Result<JsValue, JsValue> {
    // --- Step 1: Parse the config parameters from JSON ---
    //
    // The Web Worker sends config as a JSON string. We parse it into
    // a serde_json::Map (a Rust HashMap that mirrors a JSON object).
    let params: serde_json::Map<String, serde_json::Value> =
        serde_json::from_str(params_json).unwrap_or_default();

    // --- Step 2: Build the NodeInput ---
    //
    // Convert from the JS-friendly arguments to our internal Rust types.
    //
    // `.to_vec()` copies the bytes from the WASM linear memory into a
    // new Vec<u8>. This is necessary because the input `&[u8]` is a
    // borrowed reference to the JS Uint8Array's backing buffer — we
    // can't hold onto it while processing (the JS side might move it).
    let input = NodeInput {
        data: FileData::Bytes(data.to_vec()),
        filename: filename.to_string(),
        mime_type: None,
        params,
    };

    // --- Step 3: Create the processor and progress reporter ---
    let processor = CleanSpreadsheet::new();
    // Wrap the JS callback in a Rust closure so ProgressReporter stays
    // target-agnostic (no js_sys dependency in bnto-core).
    //
    // `move` transfers ownership of `progress_callback` into the closure.
    // `let _` ignores the Result from call2() — progress is best-effort.
    let progress = ProgressReporter::new(move |percent, message| {
        let _ = progress_callback.call2(
            &JsValue::NULL,
            &JsValue::from(percent),
            &JsValue::from(message),
        );
    });

    // --- Step 4: Run the CSV cleaning (ONCE!) ---
    //
    // `process()` returns `Result<NodeOutput, BntoError>`.
    // We need to convert the error to a JsValue for the WASM boundary.
    let output = processor
        .process(input, &progress, &NoopContext)
        .map_err(bnto_err_to_js)?;

    // --- Step 5: Return combined result with both metadata and bytes ---
    //
    // This packs the NodeOutput into a single JS object containing
    // metadata JSON string + raw Uint8Array bytes + filename + mimeType.
    build_combined_result(output)
}

// =============================================================================
// Rename Spreadsheet Columns — Combined (Single Process Call)
// =============================================================================

/// Rename columns in a CSV file and return BOTH metadata and bytes in one call.
///
/// The CSV is processed exactly ONCE, and the result contains everything
/// the Web Worker needs — no double processing.
///
/// ARGUMENTS (from JavaScript):
///   - `data` (Uint8Array): The raw CSV file bytes
///   - `filename` (string): The original filename (e.g., "data.csv")
///   - `params_json` (string): JSON string with rename config
///     (e.g., '{"columns": {"First Name": "first_name"}}').
///     Pass '{}' for no renames (passthrough).
///   - `progress_callback` (Function): Called with (percent: number, message: string)
///     to report progress. The Web Worker forwards this to the main thread.
///
/// RETURNS:
///   A JavaScript object with four properties:
///   ```js
///   {
///     metadata: '{"columnsRenamed":2,"totalColumns":5,...}',  // JSON string
///     data: Uint8Array([...]),                                // raw modified CSV bytes
///     filename: "data-renamed.csv",                           // output filename
///     mimeType: "text/csv"                                    // MIME type
///   }
///   ```
#[wasm_bindgen]
pub fn rename_spreadsheet_columns_combined(
    data: &[u8],
    filename: &str,
    params_json: &str,
    progress_callback: js_sys::Function,
) -> Result<JsValue, JsValue> {
    // --- Step 1: Parse the config parameters from JSON ---
    let params: serde_json::Map<String, serde_json::Value> =
        serde_json::from_str(params_json).unwrap_or_default();

    // --- Step 2: Build the NodeInput ---
    //
    // NOTE: Unlike clean_spreadsheet, rename_spreadsheet_columns sets mime_type to
    // "text/csv" explicitly. This ensures the output metadata correctly
    // reflects the CSV content type.
    let input = NodeInput {
        data: FileData::Bytes(data.to_vec()),
        filename: filename.to_string(),
        mime_type: Some("text/csv".to_string()),
        params,
    };

    // --- Step 3: Create the processor and progress reporter ---
    let processor = RenameColumns::new();
    // Wrap the JS callback in a Rust closure so ProgressReporter stays
    // target-agnostic (no js_sys dependency in bnto-core).
    let progress = ProgressReporter::new(move |percent, message| {
        let _ = progress_callback.call2(
            &JsValue::NULL,
            &JsValue::from(percent),
            &JsValue::from(message),
        );
    });

    // --- Step 4: Run the rename operation (ONCE!) ---
    let output = processor
        .process(input, &progress, &NoopContext)
        .map_err(bnto_err_to_js)?;

    // --- Step 5: Return combined result with both metadata and bytes ---
    build_combined_result(output)
}