cueward-adapter-macos 0.5.0

macOS adapter for Cueward with Safari, Notes, Messages, Reminders, Calendar, Screenshot, Clipboard, and OCR integrations.
use std::io::Cursor;

use serde_json::{Map, Value};
use uuid::Uuid;

use cueward_core::{ShortcutAction, ShortcutReference, ShortcutSpec};

use crate::MacosError;

use super::super::actions::{build_action, resolve_reference, variable_wrapper};
use super::{dedupe_alias, default_output_name, inferred_default_output_alias};

pub fn compile_actions(spec: &ShortcutSpec) -> Result<Vec<u8>, MacosError> {
    let mut outputs = Map::<String, Value>::new();
    let actions = compile_action_sequence(&spec.actions, &mut outputs)?;

    let value = plist::to_value(&actions)
        .map_err(|error| MacosError::Other(format!("failed to convert shortcut actions to plist value: {error}")))?;

    let mut buffer = Cursor::new(Vec::new());
    plist::to_writer_binary(&mut buffer, &value)
        .map_err(|error| MacosError::Other(format!("failed to encode shortcut actions plist: {error}")))?;
    Ok(buffer.into_inner())
}

pub fn append_action(existing_payload: &[u8], action: &ShortcutAction) -> Result<Vec<u8>, MacosError> {
    let mut actions = plist::from_bytes::<Vec<Value>>(existing_payload)
        .map_err(|error| MacosError::Other(format!("failed to decode existing shortcut actions: {error}")))?;
    let mut outputs = collect_outputs(&actions);
    actions.extend(compile_action_sequence(std::slice::from_ref(action), &mut outputs)?);

    let value = plist::to_value(&actions)
        .map_err(|error| MacosError::Other(format!("failed to convert appended shortcut actions to plist value: {error}")))?;

    let mut buffer = Cursor::new(Vec::new());
    plist::to_writer_binary(&mut buffer, &value)
        .map_err(|error| MacosError::Other(format!("failed to encode appended shortcut actions plist: {error}")))?;
    Ok(buffer.into_inner())
}

fn compile_action_sequence(
    actions: &[ShortcutAction],
    outputs: &mut Map<String, Value>,
) -> Result<Vec<Value>, MacosError> {
    let mut compiled = Vec::new();

    for action in actions {
        match action {
            ShortcutAction::IfEqualsText {
                input,
                value,
                then_actions,
            } => {
                let grouping_id = new_grouping_id();
                compiled.push(compile_if_start(input, value, &grouping_id, outputs)?);
                compiled.extend(compile_action_sequence(then_actions, outputs)?);
                compiled.push(compile_if_end(&grouping_id));
            }
            ShortcutAction::RepeatEach { input, body } => {
                let grouping_id = new_grouping_id();
                compiled.push(compile_repeat_each_start(input, &grouping_id, outputs)?);
                compiled.extend(compile_action_sequence(body, outputs)?);
                compiled.push(compile_repeat_each_end(&grouping_id));
            }
            _ => compiled.push(build_action(action, outputs)?),
        }
    }

    Ok(compiled)
}

fn collect_outputs(actions: &[Value]) -> Map<String, Value> {
    let mut outputs = Map::new();
    let mut counts = Map::new();

    for action in actions {
        let Some(action_dict) = action.as_object() else {
            continue;
        };
        let Some(action_identifier) = action_dict
            .get("WFWorkflowActionIdentifier")
            .and_then(Value::as_str)
        else {
            continue;
        };
        let Some(params) = action_dict
            .get("WFWorkflowActionParameters")
            .and_then(Value::as_object)
        else {
            continue;
        };
        let Some(output_uuid) = params.get("UUID").and_then(Value::as_str) else {
            continue;
        };
        let output_alias = params
            .get("CustomOutputName")
            .and_then(Value::as_str)
            .map(ToOwned::to_owned)
            .or_else(|| inferred_default_output_alias(action_identifier));
        let output_name = params
            .get("CustomOutputName")
            .and_then(Value::as_str)
            .map(ToOwned::to_owned)
            .or_else(|| default_output_name(action_identifier).map(ToOwned::to_owned));

        if let (Some(output_alias), Some(output_name)) = (output_alias, output_name) {
            let output_alias = dedupe_alias(output_alias, &mut counts);
            outputs.insert(
                output_alias,
                serde_json::json!({
                    "OutputName": output_name,
                    "OutputUUID": output_uuid,
                }),
            );
        }
    }

    outputs
}

fn compile_if_start(
    input: &ShortcutReference,
    value: &str,
    grouping_id: &str,
    outputs: &mut Map<String, Value>,
) -> Result<Value, MacosError> {
    Ok(serde_json::json!({
        "WFWorkflowActionIdentifier": "is.workflow.actions.conditional",
        "WFWorkflowActionParameters": {
            "GroupingIdentifier": grouping_id,
            "WFControlFlowMode": 0,
            "WFCondition": 4,
            "WFInput": variable_wrapper(resolve_reference(outputs, input, false)?),
            "WFConditionalActionString": {
                "Value": {
                    "string": value
                },
                "WFSerializationType": "WFTextTokenString"
            }
        }
    }))
}

fn compile_repeat_each_start(
    input: &ShortcutReference,
    grouping_id: &str,
    outputs: &mut Map<String, Value>,
) -> Result<Value, MacosError> {
    Ok(serde_json::json!({
        "WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
        "WFWorkflowActionParameters": {
            "GroupingIdentifier": grouping_id,
            "WFControlFlowMode": 0,
            "WFInput": resolve_reference(outputs, input, false)?
        }
    }))
}

fn compile_if_end(grouping_id: &str) -> Value {
    compile_control_flow_end("is.workflow.actions.conditional", grouping_id)
}

fn compile_repeat_each_end(grouping_id: &str) -> Value {
    compile_control_flow_end("is.workflow.actions.repeat.each", grouping_id)
}

fn compile_control_flow_end(identifier: &str, grouping_id: &str) -> Value {
    serde_json::json!({
        "WFWorkflowActionIdentifier": identifier,
        "WFWorkflowActionParameters": {
            "GroupingIdentifier": grouping_id,
            "WFControlFlowMode": 2
        }
    })
}

fn new_grouping_id() -> String {
    Uuid::new_v4().to_string().to_uppercase()
}