Skip to main content

sioul_app/
remind.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Reminders before dates, for the window (docs/reminders.md): each minute,
5//! what is due becomes one quiet notification with "Open"; with the window
6//! closed, `sioul remind --watch` tells them, started with the session when
7//! asked on the Parameters page. Each is marked when told: told once, whichever runs.
8//! On a phone, the events' reminders only, through Android's notifications
9//! (`eventalarms`, whose alarms tell them while Sioul is away); the others
10//! wait for a computer.
11
12use 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
20/// What is due, told; off the window's thread.
21pub(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        // What holds now is the notification matrix's to say (Settings ▸ Reminders and
27        // notifications): asleep, as usual, nothing but what you chose to happen then.
28        let (mut all, holds) = reminders::gather(&load_config(), tr(), &now);
29        // The phone's card says the dates, waits, payments and papers due (homecard.rs).
30        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        // On a phone, the coming ones (those just told left out) given to
57        // Android's alarm clock when they changed: they come with Sioul away too.
58        if phone {
59            crate::eventalarms::schedule_from(&events, &holds, &now);
60        }
61    });
62}
63
64/// An event's own reminder as the form and the details say it: "" as usual,
65/// "none", or minutes ("15").
66fn 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
74/// The choices of an event's reminder before it, as its form and its
75/// details offer them, [{value, label}]: "As usual (15 minutes)", "Not this
76/// one", "5 minutes before"… "2 hours before"; `current` among them when an
77/// event says another time (set elsewhere).
78pub(crate) fn choices(current: &str) -> String {
79    choices_with(tr(), load_config().reminders.before_event, current).to_string()
80}
81
82/// `choices`, said by `tr`, the usual time `usual` minutes.
83fn 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
104/// An event's reminder before it, as its details say it (AgendaPage.qml),
105/// the occurrence starting at `start`: {line, remind, writable}: "Reminder:
106/// 13:15" (with its day when another), or "No reminder before it"; its own
107/// choice; whether it can be changed here. "" when the event is not found.
108pub(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    // A whole day is reminded the working day before only: nothing to say or choose here.
114    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
128/// An event's own reminder changed from its details, for every time it
129/// comes (`value`: "" as usual, "none", "15"): written into its file and
130/// sent, as its form saves it.
131pub(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
139/// `sioul`, next to this program, else on the PATH: the reminders' watcher, the spam filter's training by itself.
140pub(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
146/// Where the session is told to start `sioul remind --watch`: the desktop's
147/// autostart folder, or macOS's launch agents. None on Windows, not done yet.
148fn 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
158/// Whether reminders come with the window closed.
159pub(crate) fn background() -> bool {
160    entry().is_some_and(|e| e.is_file())
161}
162
163/// Reminders with the window closed, or not: the entry written or taken out, the watcher started or stopped now.
164pub(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        // Its name and comment show in the desktop's list of what starts with the session.
179        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    // Now too, not only from the next session; a second one leaves at once.
192    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
202/// Stops the watcher started earlier, by its number: only while a watcher
203/// holds its lock (`sioul remind --watch`), since a number left by one gone
204/// may be another program's by now.
205fn 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        // Another time, set elsewhere, kept among them; the usual time none.
232        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}