1use crate::backend::{QtThread, load_config, tr};
13use cxx_qt_lib::QString;
14use sioul_core::reminders::{self, Kind};
15use std::path::PathBuf;
16use std::sync::Mutex;
17
18static BUSY: Mutex<()> = Mutex::new(());
19
20pub(crate) fn tick(qt: &QtThread) {
22 let qt = qt.clone();
23 std::thread::spawn(move || {
24 let Some(_busy) = crate::backend::one_at_a_time(&BUSY) else { return };
25 let now = jiff::Zoned::now();
26 let (mut all, holds) = reminders::gather(&load_config(), tr(), &now);
29 crate::homecard::reminders_seen(&all);
31 let holds = crate::hours::with_layers(holds);
32 let phone = cfg!(target_os = "android");
33 if phone {
34 all.retain(|r| matches!(r.kind, Kind::Event | Kind::Before | Kind::Alarm));
35 }
36 let events = if phone { all.clone() } else { Vec::new() };
37 for reminder in reminders::to_tell(all, &reminders::told_dir(), now.timestamp().as_second(), &holds) {
38 if phone {
39 crate::eventalarms::show(&reminder);
40 continue;
41 }
42 let (kind, uri, key) = match reminder.kind {
43 Kind::Asked | Kind::Wait => ("task", reminder.target.clone(), reminder.target.trim_start_matches("sioul:task/").to_string()),
44 Kind::Event | Kind::Before | Kind::Alarm => ("event", String::new(), reminder.target.clone()),
45 Kind::Payment => ("budget", reminder.target.clone(), String::new()),
46 Kind::Paper => ("paper", reminder.target.clone(), reminder.target.trim_start_matches("sioul:paper/").to_string()),
47 Kind::Contract => ("contract", reminder.target.clone(), reminder.target.trim_start_matches("sioul:contract/").to_string()),
48 Kind::Money => ("budget", reminder.target.clone(), String::new()),
49 };
50 let qt = qt.clone();
51 let open: Box<dyn FnOnce() + Send> = Box::new(move || {
52 let _ = qt.queue(move |mut sioul| sioul.as_mut().reminder_opened(QString::from(kind), QString::from(&uri), QString::from(&key)));
53 });
54 let _ = sioul_sync::notify::remind(&reminder.title, &reminder.body, Some((tr().text("reminder-open", None), open)));
55 }
56 if phone {
59 crate::eventalarms::schedule_from(&events, &holds, &now);
60 }
61 });
62}
63
64fn remind_word(value: &str) -> String {
67 match reminders::Remind::read(value) {
68 reminders::Remind::Usual => String::new(),
69 reminders::Remind::Never => "none".to_string(),
70 reminders::Remind::Minutes(minutes) => minutes.to_string(),
71 }
72}
73
74pub(crate) fn choices(current: &str) -> String {
79 choices_with(tr(), load_config().reminders.before_event, current).to_string()
80}
81
82fn choices_with(tr: &sioul_core::i18n::Translator, usual: u32, current: &str) -> serde_json::Value {
84 let lead = |id: &str, minutes: u32| {
85 let mut args = sioul_core::i18n::args();
86 args.set("lead", reminders::lead_text(tr, minutes));
87 tr.text(id, Some(&args))
88 };
89 let mut minutes: Vec<u32> = reminders::LEADS.iter().copied().filter(|m| *m > 0).collect();
90 if let reminders::Remind::Minutes(own) = reminders::Remind::read(current)
91 && !minutes.contains(&own)
92 {
93 minutes.push(own);
94 minutes.sort_unstable();
95 }
96 let mut out = vec![
97 serde_json::json!({ "value": "", "label": lead("event-remind-usual", usual) }),
98 serde_json::json!({ "value": "none", "label": tr.text("event-remind-none", None) }),
99 ];
100 out.extend(minutes.into_iter().map(|m| serde_json::json!({ "value": m.to_string(), "label": lead("event-remind-before", m) })));
101 serde_json::Value::Array(out)
102}
103
104pub(crate) fn of_event(key: &str, start: i64) -> String {
109 let Some(path) = crate::pim::ours(key) else { return String::new() };
110 let Some(calendar) = crate::pim::collection_of(&path) else { return String::new() };
111 let zone = jiff::tz::TimeZone::system();
112 let found = sioul_core::agenda::file_occurrences(&path, &calendar, start, start + 1, &zone);
113 let Some(event) = found.iter().find(|o| o.start == start && !o.all_day) else { return String::new() };
115 let config = load_config();
116 let line = match reminders::before_at(event, config.reminders.before_event).and_then(|at| jiff::Timestamp::from_second(at).ok()) {
117 Some(at) => {
118 let at = at.to_zoned(zone.clone());
119 let day = jiff::Timestamp::from_second(event.start).map(|s| s.to_zoned(zone.clone()).date()).ok();
120 let when = if day == Some(at.date()) { at.strftime("%H:%M").to_string() } else { tr().date(&at, true) };
121 crate::backend::say("event-reminds", &[("when", when)])
122 }
123 None => tr().text("event-reminds-none", None),
124 };
125 serde_json::json!({ "line": line, "remind": remind_word(&event.remind), "writable": !calendar.read_only }).to_string()
126}
127
128pub(crate) fn set_event(qt: &QtThread, shared: &std::sync::Arc<crate::backend::Shared>, key: &str, value: &str) -> Result<(), String> {
132 let path = crate::pim::ours(key).ok_or_else(|| tr().text("agenda-gone", None))?;
133 let mut edit = sioul_core::agenda::edit_of(&path).ok_or_else(|| tr().text("agenda-gone", None))?;
134 edit.remind = remind_word(value);
135 let json = serde_json::to_string(&edit).map_err(|e| e.to_string())?;
136 crate::pim::save_event(qt, shared, key, &json, "")
137}
138
139pub(crate) fn command() -> Option<PathBuf> {
141 let name = format!("sioul{}", std::env::consts::EXE_SUFFIX);
142 let beside = std::env::current_exe().ok().and_then(|exe| exe.parent().map(|dir| dir.join(&name))).filter(|p| p.is_file());
143 beside.or_else(|| std::env::var_os("PATH").and_then(|paths| std::env::split_paths(&paths).map(|dir| dir.join(&name)).find(|p| p.is_file())))
144}
145
146fn entry() -> Option<PathBuf> {
149 if cfg!(target_os = "macos") {
150 return Some(sioul_core::config::expand_home("~/Library/LaunchAgents/org.sioul.reminders.plist"));
151 }
152 if cfg!(windows) {
153 return None;
154 }
155 sioul_core::config::config_dir().parent().map(|dir| dir.join("autostart").join("sioul-reminders.desktop"))
156}
157
158pub(crate) fn background() -> bool {
160 entry().is_some_and(|e| e.is_file())
161}
162
163pub(crate) fn set_background(on: bool) -> Result<(), String> {
165 let entry = entry().ok_or_else(|| tr().text("reminders-closed-unavailable", None))?;
166 if !on {
167 let _ = std::fs::remove_file(&entry);
168 stop();
169 return Ok(());
170 }
171 let sioul = command().ok_or_else(|| tr().text("reminders-closed-no-command", None))?;
172 let text = if cfg!(target_os = "macos") {
173 format!(
174 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n <key>Label</key><string>org.sioul.reminders</string>\n <key>ProgramArguments</key>\n <array><string>{}</string><string>remind</string><string>--watch</string></array>\n <key>RunAtLoad</key><true/>\n</dict>\n</plist>\n",
175 sioul.display()
176 )
177 } else {
178 let line = |id: &str| tr().text(id, None).replace(['\n', '\r'], " ");
180 format!(
181 "[Desktop Entry]\nType=Application\nName={}\nComment={}\nExec=\"{}\" remind --watch\nIcon=appointment-soon\nNoDisplay=true\nX-GNOME-Autostart-enabled=true\n",
182 line("reminders-entry-name"),
183 line("reminders-entry-comment"),
184 sioul.display()
185 )
186 };
187 if let Some(parent) = entry.parent() {
188 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
189 }
190 std::fs::write(&entry, text).map_err(|e| format!("{}: {e}", entry.display()))?;
191 std::process::Command::new(&sioul)
193 .args(["remind", "--watch"])
194 .stdin(std::process::Stdio::null())
195 .stdout(std::process::Stdio::null())
196 .stderr(std::process::Stdio::null())
197 .spawn()
198 .map(drop)
199 .map_err(|e| format!("{}: {e}", sioul.display()))
200}
201
202fn stop() {
206 let state = sioul_core::config::state_dir();
207 let path = state.join("remind.pid");
208 let running = std::fs::OpenOptions::new().write(true).open(state.join("remind.lock")).is_ok_and(|lock| matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock)));
209 let pid = std::fs::read_to_string(&path).ok().and_then(|t| t.trim().parse::<u32>().ok()).filter(|_| running);
210 if let Some(pid) = pid {
211 #[cfg(unix)]
212 let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
213 #[cfg(windows)]
214 let _ = std::process::Command::new("taskkill").args(["/PID", &pid.to_string()]).status();
215 }
216 let _ = std::fs::remove_file(path);
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn an_events_reminder_choices() {
225 let en = sioul_core::i18n::Translator::new("en");
226 let offered = choices_with(&en, 15, "");
227 let values: Vec<&str> = offered.as_array().unwrap().iter().map(|c| c["value"].as_str().unwrap()).collect();
228 assert_eq!(values, ["", "none", "5", "10", "15", "30", "60", "120"]);
229 let labels: Vec<&str> = offered.as_array().unwrap().iter().map(|c| c["label"].as_str().unwrap()).collect();
230 assert_eq!(labels, ["As usual (15 minutes)", "Not this one", "5 minutes before", "10 minutes before", "15 minutes before", "30 minutes before", "1 hour before", "2 hours before"]);
231 let other = choices_with(&sioul_core::i18n::Translator::new("fr"), 0, "20");
233 let values: Vec<&str> = other.as_array().unwrap().iter().map(|c| c["value"].as_str().unwrap()).collect();
234 assert_eq!(values, ["", "none", "5", "10", "15", "20", "30", "60", "120"]);
235 assert_eq!(other[0]["label"], "Comme d’habitude (aucun)");
236 assert_eq!(other[5]["label"], "20 minutes avant");
237 assert_eq!((remind_word("NONE"), remind_word("15"), remind_word("")), ("none".to_string(), "15".to_string(), String::new()));
238 }
239}