cueward-adapter-macos 0.5.0

macOS adapter for Cueward with Safari, Notes, Messages, Reminders, Calendar, Screenshot, Clipboard, and OCR integrations.
use chrono::{DateTime, Utc};
use rusqlite::Connection;

use crate::MacosError;
use crate::notes::{DrawingAttachment, DrawingNote};

use super::{apple_to_unix_timestamp, normalize_media_title, open_notes_db, since_apple_epoch};

pub(crate) fn load_drawing_notes(since: DateTime<Utc>) -> Result<Vec<DrawingNote>, MacosError> {
    let conn = open_notes_db()?;
    load_drawing_notes_from_conn(&conn, since)
}

fn load_drawing_notes_from_conn(
    conn: &Connection,
    since: DateTime<Utc>,
) -> Result<Vec<DrawingNote>, MacosError> {
    let mut stmt = conn
        .prepare(
            r#"
        SELECT
            COALESCE(
                note.ZMODIFICATIONDATE,
                note.ZMODIFICATIONDATE1,
                attachment.ZMODIFICATIONDATE,
                attachment.ZMODIFICATIONDATE1
            ),
            COALESCE(note.ZTITLE, note.ZTITLE1),
            attachment.ZTITLE
        FROM ZICCLOUDSYNCINGOBJECT AS attachment
        JOIN ZICCLOUDSYNCINGOBJECT AS note
            ON attachment.ZNOTE = note.Z_PK
        WHERE attachment.ZTYPEUTI = 'com.apple.paper'
          AND COALESCE(
                note.ZMODIFICATIONDATE,
                note.ZMODIFICATIONDATE1,
                attachment.ZMODIFICATIONDATE,
                attachment.ZMODIFICATIONDATE1
              ) > ?
        "#,
        )
        .map_err(|err| MacosError::Other(format!("failed to prepare drawing query: {err}")))?;

    let mut rows = stmt
        .query([since_apple_epoch(since)])
        .map_err(|err| MacosError::Other(format!("failed to query drawing notes: {err}")))?;

    let mut grouped: std::collections::HashMap<(i64, Option<String>), Vec<DrawingAttachment>> =
        std::collections::HashMap::new();
    while let Some(row) = rows
        .next()
        .map_err(|err| MacosError::Other(format!("failed to read drawing row: {err}")))?
    {
        let modification_date: f64 = row
            .get(0)
            .map_err(|err| MacosError::Other(format!("failed to decode drawing modification date: {err}")))?;
        let note_title: Option<String> = row
            .get(1)
            .map_err(|err| MacosError::Other(format!("failed to decode drawing note title: {err}")))?;
        let attachment_title: Option<String> = row
            .get(2)
            .map_err(|err| MacosError::Other(format!("failed to decode drawing title: {err}")))?;

        let timestamp = apple_to_unix_timestamp(modification_date);
        grouped
            .entry((timestamp, normalize_media_title(note_title)))
            .or_default()
            .push(DrawingAttachment {
                title: normalize_media_title(attachment_title),
            });
    }

    Ok(grouped
        .into_iter()
        .map(|((timestamp, title), attachments)| DrawingNote {
            timestamp,
            title,
            attachments,
        })
        .collect())
}

#[cfg(test)]
mod tests {
    use chrono::{TimeZone, Utc};
    use rusqlite::Connection;

    use super::load_drawing_notes_from_conn;

    #[test]
    fn load_drawing_notes_maps_com_apple_paper_rows() {
        let conn = Connection::open_in_memory().expect("open sqlite");
        conn.execute_batch(
            r#"
            CREATE TABLE ZICCLOUDSYNCINGOBJECT (
                Z_PK INTEGER PRIMARY KEY,
                ZMODIFICATIONDATE REAL,
                ZMODIFICATIONDATE1 REAL,
                ZTITLE TEXT,
                ZTITLE1 TEXT,
                ZMEDIA INTEGER,
                ZNOTE INTEGER,
                ZTYPEUTI TEXT
            );

            INSERT INTO ZICCLOUDSYNCINGOBJECT (Z_PK, ZTITLE, ZMODIFICATIONDATE)
            VALUES (590, '新增備忘錄', 1000.0);

            INSERT INTO ZICCLOUDSYNCINGOBJECT (Z_PK, ZNOTE, ZTYPEUTI, ZMODIFICATIONDATE1)
            VALUES (594, 590, 'com.apple.paper', 1000.0);
            "#,
        )
        .expect("seed sqlite");

        let since = Utc
            .timestamp_opt(978_307_200 + 900, 0)
            .single()
            .expect("since");
        let notes = load_drawing_notes_from_conn(&conn, since).expect("load drawing notes");

        assert_eq!(notes.len(), 1);
        assert_eq!(notes[0].title.as_deref(), Some("新增備忘錄"));
        assert_eq!(notes[0].attachments.len(), 1);
        assert_eq!(notes[0].attachments[0].title, None);
    }

    #[test]
    fn load_drawing_notes_ignores_non_drawing_rows() {
        let conn = Connection::open_in_memory().expect("open sqlite");
        conn.execute_batch(
            r#"
            CREATE TABLE ZICCLOUDSYNCINGOBJECT (
                Z_PK INTEGER PRIMARY KEY,
                ZMODIFICATIONDATE REAL,
                ZMODIFICATIONDATE1 REAL,
                ZTITLE TEXT,
                ZTITLE1 TEXT,
                ZMEDIA INTEGER,
                ZNOTE INTEGER,
                ZTYPEUTI TEXT
            );

            INSERT INTO ZICCLOUDSYNCINGOBJECT (Z_PK, ZTITLE, ZMODIFICATIONDATE)
            VALUES (1, 'image note', 1000.0);

            INSERT INTO ZICCLOUDSYNCINGOBJECT (Z_PK, ZNOTE, ZTYPEUTI, ZMODIFICATIONDATE1)
            VALUES (2, 1, 'public.png', 1000.0);
            "#,
        )
        .expect("seed sqlite");

        let since = Utc
            .timestamp_opt(978_307_200 + 900, 0)
            .single()
            .expect("since");
        let notes = load_drawing_notes_from_conn(&conn, since).expect("load drawing notes");

        assert!(notes.is_empty());
    }
}