cueward-adapter-macos 0.5.0

macOS adapter for Cueward with Safari, Notes, Messages, Reminders, Calendar, Screenshot, Clipboard, and OCR integrations.
use crate::applescript::{escape, escape_body};

use super::types::SafariTab;
use super::{
    FIELD_SEPARATOR, JS_APPLE_EVENT_TIMEOUT_MARKER, JS_APPLE_EVENT_TIMEOUT_SECONDS, TAB_SEPARATOR,
};

pub(super) fn js_apple_event_command(target: &str, timeout_identity: Option<&str>) -> String {
    let timeout_message = match timeout_identity {
        Some(identity) => format!("\"{JS_APPLE_EVENT_TIMEOUT_MARKER}|\" & {identity}"),
        None => format!("\"{JS_APPLE_EVENT_TIMEOUT_MARKER}\""),
    };
    format!(
        r#"try
                with timeout of {JS_APPLE_EVENT_TIMEOUT_SECONDS} seconds
                    set rawResult to do JavaScript jsCode in {target}
                end timeout
            on error errMsg number errNum
                if errNum is -1712 then error {timeout_message} number -1712
                error errMsg number errNum
            end try"#
    )
}

pub(super) fn decode_field(value: &str) -> String {
    let mut decoded = String::with_capacity(value.len());
    let mut chars = value.chars();
    while let Some(ch) = chars.next() {
        if ch != '\\' {
            decoded.push(ch);
            continue;
        }

        match chars.next() {
            Some('n') => decoded.push('\n'),
            Some('r') => decoded.push('\r'),
            Some('t') => decoded.push('\t'),
            Some('s') => decoded.push_str(TAB_SEPARATOR),
            Some('\\') => decoded.push('\\'),
            Some(other) => {
                decoded.push('\\');
                decoded.push(other);
            }
            None => decoded.push('\\'),
        }
    }
    decoded
}

pub(super) fn escape_js_string(value: &str) -> String {
    value
        .replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('\n', "\\n")
        .replace('\r', "\\r")
        .replace('\t', "\\t")
}

pub(super) fn extract_profile(window_name: &str, active_tab_title: &str) -> Option<String> {
    let expected_suffix = format!(" — {active_tab_title}");
    window_name
        .strip_suffix(&expected_suffix)
        .map(str::trim)
        .filter(|profile| !profile.is_empty())
        .map(ToOwned::to_owned)
}

pub(super) fn target_window_block(profile_filter: Option<&str>) -> String {
    match profile_filter {
        Some(profile) => {
            let profile = escape(profile);
            format!(
                r#"set w to missing value
            repeat with candidate in every window
                if (name of candidate contains "{profile}") then
                    set w to candidate
                    exit repeat
                end if
            end repeat
            if w is missing value then
                return ""
            end if"#,
            )
        }
        None => "set w to front window".to_string(),
    }
}

pub(super) fn parse_tab_line(line: &str) -> Option<SafariTab> {
    let parts: Vec<&str> = line.split(FIELD_SEPARATOR).collect();
    if parts.len() != 6 {
        return None;
    }

    let window_id = parts[0].trim().parse().ok()?;
    let window_name = decode_field(parts[1]);
    let index = parts[2].trim().parse().ok()?;
    let title = decode_field(parts[3]);
    let url = decode_field(parts[4]);
    let active = parts[5].trim() == "true";

    Some(SafariTab {
        window_id,
        window_name,
        profile: None,
        index,
        title,
        url,
        active,
    })
}

pub(super) fn parse_tabs_output(stdout: &str) -> Vec<SafariTab> {
    let mut tabs: Vec<SafariTab> = stdout
        .split(TAB_SEPARATOR)
        .filter(|line| !line.trim().is_empty())
        .filter_map(parse_tab_line)
        .collect();

    let mut profiles_by_window = std::collections::HashMap::new();
    for tab in &tabs {
        if tab.active {
            if let Some(profile) = extract_profile(&tab.window_name, &tab.title) {
                profiles_by_window.insert(tab.window_id, profile);
            }
        }
    }

    for tab in &mut tabs {
        tab.profile = profiles_by_window.get(&tab.window_id).cloned();
    }

    tabs
}

pub(super) fn safari_script_prelude() -> String {
    format!(
        r#"
        on replace_text(find_text, replace_text, source_text)
            set previous_delimiters to AppleScript's text item delimiters
            set AppleScript's text item delimiters to find_text
            set chunks to every text item of source_text
            set AppleScript's text item delimiters to replace_text
            set replaced_text to chunks as text
            set AppleScript's text item delimiters to previous_delimiters
            return replaced_text
        end replace_text

        on encode_field(source_text)
            if source_text is missing value then
                return ""
            end if
            set escaped_text to my replace_text("\\", "\\\\", source_text)
            set escaped_text to my replace_text(tab, "\\t", escaped_text)
            set escaped_text to my replace_text(return, "\\r", escaped_text)
            set escaped_text to my replace_text(linefeed, "\\n", escaped_text)
            set escaped_text to my replace_text("{separator}", "\\s", escaped_text)
            return escaped_text
        end encode_field
    "#,
        separator = TAB_SEPARATOR,
    )
}

pub(super) fn build_tab_return_block(tab_ref: &str, active_flag: &str) -> String {
    format!(
        r#"set winId to id of w
            set winName to my encode_field(name of w)
            set tabIndex to (index of {tab_ref}) - 1
            set tabTitle to my encode_field(name of {tab_ref})
            set tabURL to my encode_field(URL of {tab_ref})
            return (winId as text) & "{field_separator}" & winName & "{field_separator}" & (tabIndex as text) & "{field_separator}" & tabTitle & "{field_separator}" & tabURL & "{field_separator}" & "{active_flag}""#,
        field_separator = FIELD_SEPARATOR,
    )
}

pub(super) fn build_tabs_script() -> String {
    format!(
        r#"
        {prelude}
        tell application "Safari"
            set output to ""
            repeat with w in every window
                set winId to id of w
                set winName to my encode_field(name of w)
                set activeTabIndex to index of current tab of w
                repeat with t in tabs of w
                    set tabIndex to (index of t) - 1
                    set tabTitle to my encode_field(name of t)
                    set tabURL to my encode_field(URL of t)
                    if (index of t) is activeTabIndex then
                        set isActive to "true"
                    else
                        set isActive to "false"
                    end if
                    set output to output & (winId as text) & "{field_separator}" & winName & "{field_separator}" & (tabIndex as text) & "{field_separator}" & tabTitle & "{field_separator}" & tabURL & "{field_separator}" & isActive & "{separator}"
                end repeat
            end repeat
            return output
        end tell
    "#,
        prelude = safari_script_prelude(),
        separator = TAB_SEPARATOR,
        field_separator = FIELD_SEPARATOR,
    )
}

pub(super) fn build_active_tab_script(profile_filter: Option<&str>) -> String {
    let tab_return = build_tab_return_block("t", "true");
    let target_window = target_window_block(profile_filter);
    format!(
        r#"
        {prelude}
        tell application "Safari"
            if (count of windows) is 0 then
                return ""
            end if
            {target_window}
            set t to current tab of w
            {tab_return}
        end tell
    "#,
        prelude = safari_script_prelude(),
        target_window = target_window,
        tab_return = tab_return,
    )
}

pub(super) fn build_exec_script_for_profile(js_code: &str, profile_filter: Option<&str>) -> String {
    let js_expr = escape_body(js_code);
    let js_command =
        js_apple_event_command("targetTab", Some("targetWindowId & \"|\" & targetTabIndex"));
    let target_window = target_window_block(profile_filter);
    format!(
        r#"
        {prelude}
        tell application "Safari"
            if (count of windows) is 0 then
                return ""
            end if
            {target_window}
            set targetTab to current tab of w
            set targetWindowId to (id of w) as text
            set targetTabIndex to ((index of targetTab) - 1) as text
            set jsCode to {js_expr}
            set rawResult to missing value
            {js_command}
            if rawResult is missing value then
                return ""
            end if
            set rawResult to rawResult as string
            return my encode_field(rawResult)
        end tell
    "#,
        prelude = safari_script_prelude(),
        target_window = target_window,
        js_expr = js_expr,
        js_command = js_command,
    )
}

pub(super) fn build_open_script(url: &str, profile_filter: Option<&str>) -> String {
    let escaped_url = escape(url);
    let tab_return = build_tab_return_block("t", "true");
    let target_window = target_window_block(profile_filter);
    format!(
        r#"
        {prelude}
        tell application "Safari"
            if (count of windows) is 0 then
                make new document with properties {{URL:"{escaped_url}"}}
                set w to front window
            else
                {target_window}
                set t to make new tab at end of tabs of w with properties {{URL:"{escaped_url}"}}
                set current tab of w to t
            end if
            delay 0.1
            set t to current tab of w
            {tab_return}
        end tell
    "#,
        prelude = safari_script_prelude(),
        escaped_url = escaped_url,
        target_window = target_window,
        tab_return = tab_return,
    )
}

pub(super) fn build_close_script(index: Option<usize>) -> String {
    let target_block = match index {
        Some(index) => {
            let one_based = index + 1;
            format!(
                r#"if {one_based} > (count of tabs of w) then
                    error "tab index out of range"
                end if
                set t to tab {one_based} of w"#
            )
        }
        None => "set t to current tab of w".to_string(),
    };

    format!(
        r#"
        tell application "Safari"
            if (count of windows) is 0 then
                return "false"
            end if
            set w to front window
            {target_block}
            close t
            return "true"
        end tell
    "#,
        target_block = target_block,
    )
}

#[allow(dead_code)]
pub(super) fn build_exec_script(js_code: &str) -> String {
    let js_expr = escape_body(js_code);
    let js_command = js_apple_event_command("current tab of front window", None);
    format!(
        r#"
        {prelude}
        tell application "Safari"
            if (count of windows) is 0 then
                return ""
            end if
            set jsCode to {js_expr}
            set rawResult to missing value
            {js_command}
            if rawResult is missing value then
                return ""
            end if
            set rawResult to rawResult as string
            return my encode_field(rawResult)
        end tell
    "#,
        prelude = safari_script_prelude(),
        js_expr = js_expr,
        js_command = js_command,
    )
}

pub(super) fn selector_text_js(selector: &str) -> String {
    let selector = escape_js_string(selector);
    format!(
        r#"(() => {{
            const el = document.querySelector("{selector}");
            if (!el) throw new Error("selector not found");
            return (el.innerText ?? el.textContent ?? "").trim();
        }})()"#
    )
}