things_mcp/core/applescript/
script.rs1pub fn escape_applescript_string(s: &str) -> String {
10 s.replace('\\', "\\\\").replace('"', "\\\"")
11}
12
13pub fn render_create_tag(name: &str, parent: Option<&str>) -> String {
14 let name_q = escape_applescript_string(name);
15 match parent {
16 Some(p) => {
17 let p_q = escape_applescript_string(p);
18 format!(
19 r#"tell application "Things3"
20 set newTag to make new tag with properties {{name:"{name_q}"}}
21 set parent tag of newTag to tag "{p_q}"
22end tell"#,
23 )
24 }
25 None => format!(
26 r#"tell application "Things3"
27 make new tag with properties {{name:"{name_q}"}}
28end tell"#,
29 ),
30 }
31}
32
33pub fn render_rename_tag(old: &str, new: &str) -> String {
34 let old_q = escape_applescript_string(old);
35 let new_q = escape_applescript_string(new);
36 format!(
37 r#"tell application "Things3"
38 set name of tag "{old_q}" to "{new_q}"
39end tell"#,
40 )
41}
42
43pub fn render_merge_tags(source: &str, target: &str) -> String {
48 let s_q = escape_applescript_string(source);
49 let t_q = escape_applescript_string(target);
50 format!(
51 r#"tell application "Things3"
52 set sourceTag to tag "{s_q}"
53 set targetTag to tag "{t_q}"
54 repeat with t in (to dos of sourceTag)
55 set tag names of t to (tag names of t) & "{t_q}"
56 end repeat
57 delete sourceTag
58end tell"#,
59 )
60}
61
62pub fn render_delete_tag(name: &str) -> String {
63 let name_q = escape_applescript_string(name);
64 format!(
65 r#"tell application "Things3"
66 delete tag "{name_q}"
67end tell"#,
68 )
69}
70
71pub fn render_move_tag(name: &str, new_parent: Option<&str>) -> String {
72 let name_q = escape_applescript_string(name);
73 match new_parent {
74 Some(p) => {
75 let p_q = escape_applescript_string(p);
76 format!(
77 r#"tell application "Things3"
78 set parent tag of tag "{name_q}" to tag "{p_q}"
79end tell"#,
80 )
81 }
82 None => format!(
88 r#"tell application "Things3"
89 delete parent tag of tag "{name_q}"
90end tell"#,
91 ),
92 }
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 #[test]
100 fn escape_handles_quotes_and_backslashes() {
101 assert_eq!(escape_applescript_string("plain"), "plain");
102 assert_eq!(escape_applescript_string("he said \"hi\""), "he said \\\"hi\\\"");
103 assert_eq!(escape_applescript_string("path\\to"), "path\\\\to");
104 }
105
106 #[test]
107 fn create_tag_no_parent_renders_make_tag() {
108 let s = render_create_tag("Work", None);
109 assert!(s.contains("tell application \"Things3\""));
110 assert!(s.contains("make new tag with properties {name:\"Work\"}"));
111 assert!(!s.contains("parent tag"));
112 }
113
114 #[test]
115 fn create_tag_with_parent_renders_parent_link() {
116 let s = render_create_tag("Urgent", Some("Work"));
117 assert!(s.contains("make new tag with properties {name:\"Urgent\"}"));
118 assert!(s.contains("set parent tag of newTag to tag \"Work\""));
119 }
120
121 #[test]
122 fn create_tag_escapes_quotes_in_name() {
123 let s = render_create_tag("She said \"yes\"", None);
124 assert!(s.contains("name:\"She said \\\"yes\\\"\""));
125 }
126
127 #[test]
128 fn rename_tag_renders_set_name() {
129 let s = render_rename_tag("Old", "New");
130 assert!(s.contains("set name of tag \"Old\" to \"New\""));
131 }
132
133 #[test]
134 fn rename_tag_escapes_quotes() {
135 let s = render_rename_tag("a\"b", "c\"d");
136 assert!(s.contains("set name of tag \"a\\\"b\" to \"c\\\"d\""));
137 }
138
139 #[test]
140 fn merge_tags_renders_loop_and_delete() {
141 let s = render_merge_tags("Source", "Target");
142 assert!(s.contains("set sourceTag to tag \"Source\""));
143 assert!(s.contains("set targetTag to tag \"Target\""));
144 assert!(s.contains("repeat with t in (to dos of sourceTag)"));
145 assert!(s.contains("delete sourceTag"));
146 }
147
148 #[test]
149 fn merge_tags_escapes_quotes_in_target_inside_loop_body() {
150 let s = render_merge_tags("A", "B \"quoted\"");
151 assert!(s.contains("set targetTag to tag \"B \\\"quoted\\\"\""));
154 assert!(s.contains("& \"B \\\"quoted\\\"\""));
155 }
156
157 #[test]
158 fn delete_tag_renders_delete() {
159 let s = render_delete_tag("Stale");
160 assert!(s.contains("delete tag \"Stale\""));
161 }
162
163 #[test]
164 fn delete_tag_escapes_quotes() {
165 let s = render_delete_tag("Bad\"name");
166 assert!(s.contains("delete tag \"Bad\\\"name\""));
167 }
168
169 #[test]
170 fn move_tag_under_parent_renders_set_parent() {
171 let s = render_move_tag("Urgent", Some("Work"));
172 assert!(s.contains("set parent tag of tag \"Urgent\" to tag \"Work\""));
173 }
174
175 #[test]
176 fn move_tag_to_root_deletes_parent_property() {
177 let s = render_move_tag("Urgent", None);
182 assert!(s.contains("delete parent tag of tag \"Urgent\""));
183 assert!(!s.contains("missing value"));
184 }
185
186 #[test]
187 fn move_tag_escapes_quotes_in_both_names() {
188 let s = render_move_tag("a\"b", Some("c\"d"));
189 assert!(s.contains("set parent tag of tag \"a\\\"b\" to tag \"c\\\"d\""));
190 }
191}