cueward-adapter-macos 0.5.0

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

use crate::MacosError;

use super::super::core::execute_js_for_profile;
use super::super::script::escape_js_string;
use super::{GeminiMode, SafariDeepResearchResult};

mod conversations;
mod deep_research;
mod media;
mod prompt;

pub use conversations::{gemini_list_conversations, gemini_read_conversation};
pub use deep_research::{
    poll_gemini_deep_research, prepare_gemini_mode, start_gemini_deep_research,
};
pub use media::{gemini_save_images, gemini_save_media};
pub use prompt::{ensure_gemini_home, send_gemini_prompt};

pub(super) fn gemini_mode_placeholders(mode: GeminiMode) -> &'static [&'static str] {
    match mode {
        GeminiMode::Image => &[
            "請輸入圖片說明",
            "Describe the image",
            "Describe the image you want to create",
        ],
        GeminiMode::DeepResearch => &["你想研究什麼?", "What do you want to research?"],
        GeminiMode::Video => &["描述影片", "Describe the video", "Describe your video"],
        GeminiMode::Music => &[
            "描述音樂",
            "描述要創作的音樂",
            "Describe the music",
            "Describe the music you",
        ],
    }
}

pub(super) fn gemini_mode_slug(mode: GeminiMode) -> &'static str {
    match mode {
        GeminiMode::Image => "image",
        GeminiMode::DeepResearch => "deep-research",
        GeminiMode::Video => "video",
        GeminiMode::Music => "music",
    }
}

pub(super) fn gemini_mode_url(mode: GeminiMode) -> &'static str {
    match mode {
        GeminiMode::Image => "https://gemini.google.com/image",
        GeminiMode::DeepResearch => "https://gemini.google.com/deepresearch",
        GeminiMode::Video => "https://gemini.google.com/veo",
        GeminiMode::Music => "https://gemini.google.com/music",
    }
}

pub(super) fn build_gemini_go_home_js() -> String {
    r#"(function() {
        window.location.href = "https://gemini.google.com/app";
        return "true";
    })()"#
        .to_string()
}

pub(super) fn build_gemini_placeholder_read_js() -> String {
    r#"(function() {
        var input = document.querySelector(".ql-editor, rich-textarea .ProseMirror, div[role='textbox'][contenteditable='true'], div[contenteditable='true']");
        if (!input) return "";
        return String(
          input.getAttribute("data-placeholder") ||
          input.getAttribute("placeholder") ||
          input.getAttribute("aria-label") ||
          ""
        );
    })()"#
        .to_string()
}

pub(super) fn gemini_response_extract_js() -> String {
    r#"(() => {
        const selectors = [
          ".model-response-text",
          ".message-content",
          ".markdown",
          "div[data-test-id='message-content']"
        ];
        for (const selector of selectors) {
          const elements = document.querySelectorAll(selector);
          if (!elements.length) continue;
          const last = elements[elements.length - 1];
          const text = (last.innerText || last.textContent || "").trim();
          if (text) return text;
        }
        return "";
    })()"#
        .to_string()
}

pub(super) fn build_gemini_fill_input_js(prompt: &str) -> String {
    let prompt = escape_js_string(prompt);
    format!(
        r#"(() => {{
            const input = document.querySelector(
              ".ql-editor, rich-textarea .ProseMirror, div[role='textbox'][contenteditable='true'], div[contenteditable='true']"
            );
            if (!input) throw new Error("gemini input not found");
            input.focus();
            input.textContent = "";
            document.execCommand("insertText", false, "{prompt}");
            return "true";
        }})()"#
    )
}

pub(super) fn build_gemini_click_send_js() -> String {
    r#"(() => {
        const sendLabels = ["傳送訊息", "Send message"];
        const buttons = [...document.querySelectorAll('button,[role="button"]')];
        for (const button of buttons) {
          const label = [
            button.getAttribute("aria-label"),
            button.getAttribute("title"),
            button.innerText,
            button.textContent
          ].filter(Boolean).join(" ");
          if (!sendLabels.some((v) => label.includes(v))) continue;
          if (button.disabled) return "disabled";
          button.click();
          return "true";
        }
        return "false";
    })()"#
        .to_string()
}

pub(super) fn gemini_deep_research_poll_js() -> String {
    r#"(() => {
        const text = document.body.innerText || "";
        const selectors = [
          ".model-response-text",
          ".message-content",
          ".markdown",
          "div[data-test-id='message-content']"
        ];
        const extractLastResponse = () => {
          for (const selector of selectors) {
            const elements = document.querySelectorAll(selector);
            if (!elements.length) continue;
            const last = elements[elements.length - 1];
            const content = (last.innerText || last.textContent || "").trim();
            if (content) return content;
          }
          return "";
        };

        if (text.includes("開始研究") || text.includes("Start research") || text.includes("編輯計畫") || text.includes("Edit plan")) {
          return JSON.stringify({
            status: "plan_ready",
            plan: extractLastResponse(),
            actions: ["confirm", "edit"]
          });
        }
        if (text.includes("研究中") || text.includes("Researching") || text.includes("Generating report")) {
          return JSON.stringify({ status: "running" });
        }

        const content = extractLastResponse();
        if (content) {
          return JSON.stringify({ status: "complete", response: content });
        }

        return JSON.stringify({ status: "running" });
    })()"#
        .to_string()
}

pub(super) fn parse_deep_research_payload(
    payload: &str,
) -> Result<SafariDeepResearchResult, MacosError> {
    let value: Value = serde_json::from_str(payload).map_err(|error| {
        MacosError::Other(format!("invalid deep research payload: {error}: {payload}"))
    })?;
    let status = value
        .get("status")
        .and_then(Value::as_str)
        .ok_or_else(|| MacosError::Other("deep research payload missing status".to_string()))?;
    let actions = value
        .get("actions")
        .and_then(Value::as_array)
        .map(|items| {
            items
                .iter()
                .filter_map(Value::as_str)
                .map(ToOwned::to_owned)
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();

    Ok(SafariDeepResearchResult {
        provider: "gemini".to_string(),
        mode: "deep-research".to_string(),
        status: status.to_string(),
        conversation_url: None,
        plan: value
            .get("plan")
            .and_then(Value::as_str)
            .map(ToOwned::to_owned),
        response: value
            .get("response")
            .and_then(Value::as_str)
            .map(ToOwned::to_owned),
        actions,
    })
}

pub(super) fn get_gemini_conversation_url(
    profile_filter: Option<&str>,
) -> Result<String, MacosError> {
    let js = r#"(() => {
        const url = window.location.href || "";
        if (url.match(/gemini\.google\.com\/app\/[a-f0-9]{10,}/)) return url;
        return "";
    })()"#;
    let url = execute_js_for_profile(js, profile_filter, "safari_gemini_conversation_url")?;
    let url = url.trim();
    if url.is_empty() {
        return Err(MacosError::Other("no conversation URL found".to_string()));
    }
    Ok(url.to_string())
}

#[cfg(test)]
mod tests {
    use super::{build_gemini_fill_input_js, build_gemini_go_home_js, gemini_response_extract_js};

    #[test]
    fn gemini_fill_input_script_targets_editor() {
        let script = build_gemini_fill_input_js("hello world");

        assert!(script.contains(".ql-editor"));
        assert!(script.contains("hello world"));
        assert!(script.contains("execCommand"));
    }

    #[test]
    fn gemini_go_home_script_targets_root_app() {
        let script = build_gemini_go_home_js();

        assert!(script.contains("gemini.google.com/app"));
    }

    #[test]
    fn gemini_response_extract_script_targets_latest_response() {
        let script = gemini_response_extract_js();

        assert!(script.contains(".model-response-text"));
        assert!(script.contains("querySelectorAll"));
        assert!(script.contains("elements[elements.length - 1]"));
    }
}