cueward-adapter-macos 0.5.0

macOS adapter for Cueward with Safari, Notes, Messages, Reminders, Calendar, Screenshot, Clipboard, and OCR integrations.
use plist::{Dictionary, Value};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use crate::MacosError;

pub const SAVED_STATE_FILENAME: &str = ".SavedStickiesState";
pub const TITLE_KEY: &str = "CuewardTitle";

#[derive(Debug, Clone)]
pub struct StickyStateEntry {
    pub id: String,
    pub raw: Dictionary,
}

pub fn stickies_root_dir() -> Result<PathBuf, MacosError> {
    let home = std::env::var("HOME")
        .map_err(|_| MacosError::Other("HOME environment variable must be set".into()))?;
    Ok(PathBuf::from(home).join("Library/Containers/com.apple.Stickies/Data/Library/Stickies"))
}

pub fn saved_state_path(root: &Path) -> PathBuf {
    root.join(SAVED_STATE_FILENAME)
}

pub fn sticky_dir(root: &Path, id: &str) -> PathBuf {
    root.join(format!("{id}.rtfd"))
}

pub fn sticky_rtf_path(root: &Path, id: &str) -> PathBuf {
    sticky_dir(root, id).join("TXT.rtf")
}

pub fn parse_saved_state_value(value: Value) -> Result<Vec<StickyStateEntry>, MacosError> {
    let entries = value
        .into_array()
        .ok_or_else(|| MacosError::Other("stickies state is not an array".into()))?;

    let mut parsed = Vec::new();
    for entry in entries {
        let raw = entry
            .into_dictionary()
            .ok_or_else(|| MacosError::Other("stickies state entry is not a dictionary".into()))?;
        let id = raw
            .get("UUID")
            .and_then(Value::as_string)
            .map(str::to_string)
            .ok_or_else(|| MacosError::Other("stickies state entry missing UUID".into()))?;
        parsed.push(StickyStateEntry { id, raw });
    }

    Ok(parsed)
}

pub fn parse_saved_state(path: &Path) -> Result<Vec<StickyStateEntry>, MacosError> {
    let value = Value::from_file(path)
        .map_err(|err| MacosError::Other(format!("failed to decode stickies state: {err}")))?;
    parse_saved_state_value(value)
}

pub fn write_saved_state(path: &Path, entries: &[StickyStateEntry]) -> Result<(), MacosError> {
    let value = Value::Array(
        entries
            .iter()
            .map(|entry| Value::Dictionary(entry.raw.clone()))
            .collect(),
    );
    value
        .to_file_xml(path)
        .map_err(|err| MacosError::Other(format!("failed to write stickies state: {err}")))
}

pub fn ensure_update_fields(title: Option<&str>, body: Option<&str>) -> Result<(), MacosError> {
    if title.is_none() && body.is_none() {
        return Err(MacosError::Other("no sticky updates specified".into()));
    }
    Ok(())
}

pub fn derive_sticky_title(stored_title: Option<&str>, body: &str, id: &str) -> String {
    stored_title
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::to_string)
        .or_else(|| {
            body.lines()
                .map(str::trim)
                .find(|line| !line.is_empty())
                .map(str::to_string)
        })
        .unwrap_or_else(|| format!("Sticky {}", &id[..id.len().min(8)]))
}

pub fn read_sticky_body(path: &Path) -> Result<String, MacosError> {
    let output = Command::new("textutil")
        .arg("-convert")
        .arg("txt")
        .arg("-stdout")
        .arg(path)
        .output()
        .map_err(|err| MacosError::Other(format!("textutil: {err}")))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(MacosError::Other(format!("textutil failed: {stderr}")));
    }

    Ok(String::from_utf8_lossy(&output.stdout).to_string())
}

pub fn write_sticky_body(path: &Path, body: &str) -> Result<(), MacosError> {
    let parent = path
        .parent()
        .ok_or_else(|| MacosError::Other("invalid sticky path".into()))?;
    fs::create_dir_all(parent)
        .map_err(|err| MacosError::Other(format!("failed to create sticky dir: {err}")))?;

    let mut child = Command::new("textutil")
        .arg("-convert")
        .arg("rtf")
        .arg("-stdin")
        .arg("-stdout")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|err| MacosError::Other(format!("textutil: {err}")))?;

    if let Some(mut stdin) = child.stdin.take() {
        use std::io::Write;
        stdin
            .write_all(body.as_bytes())
            .map_err(|err| MacosError::Other(format!("failed to write sticky body: {err}")))?;
    }

    let output = child
        .wait_with_output()
        .map_err(|err| MacosError::Other(format!("textutil: {err}")))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(MacosError::Other(format!("textutil failed: {stderr}")));
    }

    fs::write(path, output.stdout)
        .map_err(|err| MacosError::Other(format!("failed to write sticky rtf: {err}")))
}

pub fn max_z_order(entries: &[StickyStateEntry]) -> i64 {
    entries
        .iter()
        .filter_map(|entry| entry.raw.get("ZOrder"))
        .filter_map(|value| value.as_signed_integer())
        .max()
        .unwrap_or(0)
}

pub fn load_state_entries_for_mutation(root: &Path) -> Result<Vec<StickyStateEntry>, MacosError> {
    let state_path = saved_state_path(root);
    if !state_path.exists() {
        return Ok(Vec::new());
    }
    parse_saved_state(&state_path)
}

pub fn find_entry_index(entries: &[StickyStateEntry], id: &str) -> Result<usize, MacosError> {
    entries
        .iter()
        .position(|entry| entry.id == id)
        .ok_or_else(|| MacosError::Other(format!("sticky not found: {id}")))
}