cueward-adapter-macos 0.5.0

macOS adapter for Cueward with Safari, Notes, Messages, Reminders, Calendar, Screenshot, Clipboard, and OCR integrations.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
use chrono::{DateTime, Local};
use serde::Serialize;

use crate::MacosError;
use crate::applescript::{applescript_date_block, escape, escape_body, run, run_capture};

#[path = "calendar_eventkit.rs"]
mod eventkit;

const EVENT_SEPARATOR: &str = "---EVENT_SEP---";

#[derive(Serialize)]
pub struct CalendarEvent {
    pub title: String,
    pub start: String,
    pub end: String,
    pub calendar: String,
    pub location: String,
    pub notes: String,
    pub all_day: bool,
}

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('\\') => decoded.push('\\'),
            Some(other) => {
                decoded.push('\\');
                decoded.push(other);
            }
            None => decoded.push('\\'),
        }
    }
    decoded
}

/// Parse a tab-separated event line from the AppleScript output.
/// Fields: title \t start \t end \t calendar \t location \t notes \t all_day
pub fn parse_event_line(line: &str) -> Option<CalendarEvent> {
    let parts: Vec<&str> = line.split('\t').collect();
    if parts.len() != 7 {
        return None;
    }
    let title = decode_field(parts[0]);
    if title.is_empty() {
        return None;
    }
    Some(CalendarEvent {
        title,
        start: parts[1].to_string(),
        end: parts[2].to_string(),
        calendar: decode_field(parts[3]),
        location: decode_field(parts[4]),
        notes: decode_field(parts[5]),
        all_day: parts[6].trim() == "true",
    })
}

fn parse_events_output(stdout: &str) -> Vec<CalendarEvent> {
    stdout
        .split(EVENT_SEPARATOR)
        .filter(|line| !line.trim().is_empty())
        .filter_map(parse_event_line)
        .collect()
}

fn calendar_date_format_prelude() -> &'static str {
    r#"
        on pad2(value_num)
            set value_text to value_num as string
            if value_num < 10 then
                return "0" & value_text
            end if
            return value_text
        end pad2

        on format_calendar_date(calendar_date)
            set y to year of calendar_date as integer
            set m to month of calendar_date as integer
            set d to day of calendar_date as integer
            set hh to hours of calendar_date as integer
            set mm to minutes of calendar_date as integer
            set ss to seconds of calendar_date as integer
            return (y as string) & "-" & my pad2(m) & "-" & my pad2(d) & "T" & my pad2(hh) & ":" & my pad2(mm) & ":" & my pad2(ss)
        end format_calendar_date
    "#
}

fn calendar_date_assignment(output_var: &str, date_expression: &str) -> String {
    format!(
        "set {output_var}Value to {date_expression}\n                    set {output_var} to my format_calendar_date({output_var}Value)"
    )
}

/// List calendar events in the given time range, optionally filtered by calendar name.
pub fn list_events(
    from: DateTime<Local>,
    to: DateTime<Local>,
    calendar_filter: Option<&str>,
) -> Result<Vec<CalendarEvent>, MacosError> {
    if let Some(events) = eventkit::list_events(from, to, calendar_filter)? {
        return Ok(events);
    }

    let from_block = applescript_date_block("fromDate", &from);
    let to_block = applescript_date_block("toDate", &to);

    let cal_filter_block = match calendar_filter {
        Some(name) => {
            let escaped = escape(name);
            format!(
                r#"set targetCals to (calendars whose name is "{escaped}")
            if targetCals is {{}} then
                return ""
            end if"#
            )
        }
        None => "set targetCals to calendars".to_string(),
    };
    let evt_start_assignment = calendar_date_assignment("evtStart", "start date of evt");
    let evt_end_assignment = calendar_date_assignment("evtEnd", "end date of evt");

    let script = format!(
        r#"
        {date_format_prelude}
        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)
            return escaped_text
        end encode_field

        {from_block}
        {to_block}

        tell application "Calendar"
            set output to ""
            {cal_filter_block}
            repeat with aCal in targetCals
                set calName to my encode_field(name of aCal)
                set evts to (events of aCal whose (start date < toDate) and (end date > fromDate))
                repeat with evt in evts
                    set evtTitle to my encode_field(summary of evt)
                    {evt_start_assignment}
                    {evt_end_assignment}
                    if location of evt is missing value then
                        set evtLoc to ""
                    else
                        set evtLoc to my encode_field(location of evt)
                    end if
                    if description of evt is missing value then
                        set evtNotes to ""
                    else
                        set evtNotes to my encode_field(description of evt)
                    end if
                    set evtAllDay to allday event of evt
                    set output to output & evtTitle & tab & evtStart & tab & evtEnd & tab & calName & tab & evtLoc & tab & evtNotes & tab & evtAllDay & "{EVENT_SEPARATOR}"
                end repeat
            end repeat
            return output
        end tell
        "#,
        date_format_prelude = calendar_date_format_prelude(),
    );

    let stdout = run_capture(&script, "list_events")?;
    let events = parse_events_output(&stdout);

    Ok(events)
}

/// Create a calendar event.
pub fn create_event(
    title: &str,
    start: DateTime<Local>,
    end: DateTime<Local>,
    calendar_name: Option<&str>,
    notes: Option<&str>,
    location: Option<&str>,
) -> Result<(), MacosError> {
    let escaped_title = escape(title);
    let start_block = applescript_date_block("startDate", &start);
    let end_block = applescript_date_block("endDate", &end);

    let notes_prop = notes
        .map(|n| format!(r#", description:{}"#, escape_body(n)))
        .unwrap_or_default();
    let location_prop = location
        .map(|l| format!(r#", location:{}"#, escape_body(l)))
        .unwrap_or_default();

    let target_cal_block = match calendar_name {
        Some(name) => {
            let escaped = escape(name);
            format!(r#"set targetCal to calendar "{escaped}""#)
        }
        None => "set targetCal to first calendar".to_string(),
    };

    let script = format!(
        r#"
        {start_block}
        {end_block}
        tell application "Calendar"
            {target_cal_block}
            make new event at end of events of targetCal with properties {{summary:"{escaped_title}", start date:startDate, end date:endDate{notes_prop}{location_prop}}}
        end tell
        "#
    );

    run(&script, "failed to create calendar event")
}

/// Delete a calendar event matched by title and start date.
pub fn delete_event(
    title: &str,
    start: DateTime<Local>,
    calendar_name: &str,
) -> Result<(), MacosError> {
    let escaped_title = escape(title);
    let escaped_cal = escape(calendar_name);
    let start_block = applescript_date_block("startDate", &start);

    let script = format!(
        r#"
        {start_block}
        tell application "Calendar"
            set targetCal to calendar "{escaped_cal}"
            set matchingEvts to (events of targetCal whose summary is "{escaped_title}" and start date is startDate)
            if matchingEvts is {{}} then
                error "event not found: {escaped_title}"
            end if
            repeat with evt in matchingEvts
                delete evt
            end repeat
        end tell
        "#
    );

    run(&script, "failed to delete calendar event")
}

fn build_update_script(
    title: &str,
    calendar_name: Option<&str>,
    new_title: Option<&str>,
    new_start: Option<&DateTime<Local>>,
    new_end: Option<&DateTime<Local>>,
    notes: Option<&str>,
    location: Option<&str>,
) -> String {
    let escaped_title = escape(title);
    let target_cal_block = match calendar_name {
        Some(name) => {
            let escaped = escape(name);
            format!(
                r#"set targetCals to (calendars whose name is "{escaped}")
            if targetCals is {{}} then
                error "calendar not found: {escaped}"
            end if"#
            )
        }
        None => "set targetCals to calendars".to_string(),
    };
    let start_block = new_start
        .map(|dt| applescript_date_block("newStartDate", dt))
        .unwrap_or_default();
    let end_block = new_end
        .map(|dt| applescript_date_block("newEndDate", dt))
        .unwrap_or_default();

    let mut actions = Vec::new();
    if let Some(new_title) = new_title {
        actions.push(format!(
            r#"set summary of targetEvt to "{}""#,
            escape(new_title)
        ));
    }
    match (new_start.is_some(), new_end.is_some()) {
        (true, true) => {
            actions.push("set start date of targetEvt to newStartDate".to_string());
            actions.push("set end date of targetEvt to newEndDate".to_string());
        }
        (true, false) => {
            actions.push(
                "set originalDuration to (end date of targetEvt) - (start date of targetEvt)"
                    .to_string(),
            );
            actions.push("set start date of targetEvt to newStartDate".to_string());
            actions
                .push("set end date of targetEvt to newStartDate + originalDuration".to_string());
        }
        (false, true) => {
            actions.push("set end date of targetEvt to newEndDate".to_string());
        }
        (false, false) => {}
    }
    if let Some(notes) = notes {
        actions.push(format!(
            "set description of targetEvt to {}",
            escape_body(notes)
        ));
    }
    if let Some(location) = location {
        actions.push(format!(
            "set location of targetEvt to {}",
            escape_body(location)
        ));
    }

    format!(
        r#"
        {start_block}
        {end_block}
        tell application "Calendar"
            {target_cal_block}
            set matchingEvts to {{}}
            repeat with aCal in targetCals
                repeat with evt in (events of aCal whose summary is "{escaped_title}")
                    copy evt to end of matchingEvts
                end repeat
            end repeat
            if matchingEvts is {{}} then
                error "event not found: {escaped_title}"
            end if
            if (count of matchingEvts) > 1 then
                error "event title is ambiguous: {escaped_title}"
            end if
            set targetEvt to item 1 of matchingEvts
            {actions}
        end tell
        "#,
        actions = actions.join("\n            "),
    )
}

/// Update a calendar event matched by title and optional calendar.
pub fn update_event(
    title: &str,
    calendar_name: Option<&str>,
    new_title: Option<&str>,
    new_start: Option<DateTime<Local>>,
    new_end: Option<DateTime<Local>>,
    notes: Option<&str>,
    location: Option<&str>,
) -> Result<(), MacosError> {
    if new_title.is_none()
        && new_start.is_none()
        && new_end.is_none()
        && notes.is_none()
        && location.is_none()
    {
        return Err(MacosError::Other("no calendar updates specified".into()));
    }

    let script = build_update_script(
        title,
        calendar_name,
        new_title,
        new_start.as_ref(),
        new_end.as_ref(),
        notes,
        location,
    );

    run(&script, "failed to update calendar event")
}

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

    use super::{
        build_update_script, calendar_date_assignment, calendar_date_format_prelude,
        parse_event_line, parse_events_output,
    };

    #[test]
    fn calendar_fallback_formats_dates_with_osascript() {
        let formatter_script = format!(
            r#"
            {prelude}
            script CalendarDateFixture
                property «class year» : 2026
                property «class mnth» : 8
                property «class day » : 9
                property «class hour» : 7
                property «class min » : 5
                property «class scnd» : 3
            end script
            {assignment}
            return formattedDate
            "#,
            prelude = calendar_date_format_prelude(),
            assignment = calendar_date_assignment("formattedDate", "CalendarDateFixture"),
        );
        let formatted =
            crate::applescript::run_capture(&formatter_script, "calendar_date_formatter_probe")
                .expect("execute calendar date formatter");
        assert_eq!(formatted.trim(), "2026-08-09T07:05:03");
    }

    #[test]
    fn parse_event_line_unescapes_sanitized_fields() {
        let line = "Team\\tSync\t2026-04-11T09:00:00Z\t2026-04-11T10:00:00Z\tWork\\nCal\tRoom\\rA\tLine 1\\nLine 2\ttrue";

        let event = parse_event_line(line).expect("event");

        assert_eq!(event.title, "Team\tSync");
        assert_eq!(event.calendar, "Work\nCal");
        assert_eq!(event.location, "Room\rA");
        assert_eq!(event.notes, "Line 1\nLine 2");
        assert!(event.all_day);
    }

    #[test]
    fn parse_events_output_keeps_multiline_notes() {
        let raw = concat!(
            "Title\t2026-04-11T09:00:00Z\t2026-04-11T10:00:00Z\tWork\tDesk\tLine 1\\nLine 2\tfalse",
            "---EVENT_SEP---"
        );

        let events = parse_events_output(raw);

        assert_eq!(events.len(), 1);
        assert_eq!(events[0].notes, "Line 1\nLine 2");
    }

    #[test]
    fn create_event_script_uses_escape_body_for_multiline_fields() {
        let escaped = crate::applescript::escape_body("Line 1\nLine 2");

        assert_eq!(escaped, "\"Line 1\" & linefeed & \"Line 2\"");
    }

    #[test]
    fn update_event_script_preserves_duration_when_only_start_changes() {
        let new_start = Local
            .with_ymd_and_hms(2026, 4, 16, 14, 0, 0)
            .single()
            .expect("new start");

        let script = build_update_script(
            "顧問會議",
            Some("Work"),
            Some("顧問會議(改期)"),
            Some(&new_start),
            None,
            None,
            None,
        );

        assert!(script.contains(r#"set targetCals to (calendars whose name is "Work")"#));
        assert!(
            script.contains(r#"repeat with evt in (events of aCal whose summary is "顧問會議")"#)
        );
        assert!(script.contains(
            "set originalDuration to (end date of targetEvt) - (start date of targetEvt)"
        ));
        assert!(script.contains("set start date of targetEvt to newStartDate"));
        assert!(script.contains("set end date of targetEvt to newStartDate + originalDuration"));
        assert!(script.contains(r#"set summary of targetEvt to "顧問會議(改期)""#));
    }
}