1use crate::backend::{QtThread, Shared, load_config, tr};
16use jiff::Timestamp;
17use jiff::tz::TimeZone;
18use serde::Serialize;
19use sioul_core::projects::{Project, ProjectStore};
20use sioul_core::i18n::Translator;
21use sioul_core::stopped::Stopped;
22use sioul_core::tasks::{self, Task};
23use sioul_core::timelog::{self, Running};
24use std::ffi::c_char;
25use std::path::Path;
26use std::sync::{Arc, Mutex, PoisonError};
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30struct Note {
31 title: String,
33 text: String,
35 paused: bool,
36 since: i64,
39 pause: String,
41 stop: String,
42 channel: String,
44}
45
46fn note_of(running: Option<&Running>, title: &str, now: i64, tr: &Translator, zone: &TimeZone) -> Option<Note> {
49 let running = running?;
50 let paused = running.paused_at.is_some();
51 let mut args = sioul_core::i18n::args();
52 let text = if paused {
53 args.set("minutes", running.elapsed(now) / 60);
54 tr.text("focus-notification-paused", Some(&args))
55 } else {
56 args.set("time", Timestamp::from_second(running.start).map(|t| t.to_zoned(zone.clone()).strftime("%H:%M").to_string()).unwrap_or_default());
57 if running.planned == 0 {
58 tr.text("focus-notification-open", Some(&args))
59 } else {
60 args.set("minutes", running.planned);
61 tr.text("focus-notification-planned", Some(&args))
62 }
63 };
64 Some(Note {
65 title: if title.trim().is_empty() { tr.text("focus-title", None) } else { title.to_string() },
66 text,
67 paused,
68 since: if paused { 0 } else { (running.start + running.paused) * 1000 },
69 pause: tr.text(if paused { "focus-resume" } else { "focus-pause" }, None),
70 stop: tr.text("focus-stop", None),
71 channel: tr.text("focus-notification-channel", None),
72 })
73}
74
75fn title_of(uid: &str, tasks: &[Task], projects: &[Project]) -> String {
78 let Some(task) = tasks.iter().find(|t| t.uid == uid) else { return String::new() };
79 if !task.title.trim().is_empty() {
80 return task.title.clone();
81 }
82 task.projects.iter().find_map(|id| projects.iter().find(|c| c.id == *id)).map(|c| c.title.clone()).unwrap_or_default()
83}
84
85struct Shown {
87 note: Option<Option<Note>>,
90 named: (String, String),
92 over: bool,
94 #[cfg(not(target_os = "android"))]
96 ongoing: Option<sioul_sync::notify::Ongoing>,
97}
98
99static SHOWN: Mutex<Shown> = Mutex::new(Shown {
100 note: None,
101 named: (String::new(), String::new()),
102 over: false,
103 #[cfg(not(target_os = "android"))]
104 ongoing: None,
105});
106
107static WINDOW: Mutex<Option<(QtThread, Arc<Shared>)>> = Mutex::new(None);
110
111static ACTING: Mutex<()> = Mutex::new(());
113
114pub(crate) fn follow(qt: &QtThread, shared: &Arc<Shared>) {
118 if let Ok(mut window) = WINDOW.lock()
119 && window.is_none()
120 {
121 *window = Some((qt.clone(), Arc::clone(shared)));
122 }
123 post(Some((qt, shared)));
124}
125
126fn post(window: Option<(&QtThread, &Arc<Shared>)>) -> Option<Note> {
129 let mut shown = SHOWN.lock().unwrap_or_else(PoisonError::into_inner);
130 if shown.over {
131 return None;
132 }
133 let running = timelog::running();
134 let title = running.as_ref().map(|r| title_now(&mut shown.named, &r.task, window.map(|(_, shared)| shared))).unwrap_or_default();
135 let note = note_of(running.as_ref(), &title, Timestamp::now().as_second(), tr(), &TimeZone::system());
136 let note = note.filter(|_| crate::hours::comes(sioul_core::attention::Kind::Time));
139 if shown.note.as_ref() != Some(¬e) {
140 put(&mut shown, note.as_ref(), window);
141 shown.note = Some(note.clone());
142 std::thread::spawn(crate::everywhere::apply);
144 }
145 note
146}
147
148fn title_now(named: &mut (String, String), uid: &str, shared: Option<&Arc<Shared>>) -> String {
152 let read = shared.and_then(|s| s.loaded.lock().ok().and_then(|l| l.clone()));
153 let title = match read {
154 Some(loaded) => title_of(uid, &loaded.tasks, &loaded.projects),
155 None if named.0 == uid => return named.1.clone(),
156 None => {
157 let projects = load_config().notes_root_path().and_then(|root| ProjectStore::load(&root).ok()).map(|store| store.projects).unwrap_or_default();
158 title_of(uid, &tasks::all(&TimeZone::system()), &projects)
159 }
160 };
161 *named = (uid.to_string(), title.clone());
162 title
163}
164
165#[cfg(not(target_os = "android"))]
168fn put(shown: &mut Shown, note: Option<&Note>, window: Option<(&QtThread, &Arc<Shared>)>) {
169 let Some(note) = note else {
170 shown.ongoing = None;
172 return;
173 };
174 let buttons = vec![
175 (if note.paused { "resume" } else { "pause" }.to_string(), note.pause.clone()),
176 ("stop".to_string(), note.stop.clone()),
177 ("default".to_string(), tr().text("reminder-open", None)),
178 ];
179 if let Some(ongoing) = &shown.ongoing {
180 ongoing.update(¬e.title, ¬e.text, buttons);
181 } else if let Some((qt, shared)) = window {
182 let (qt, shared) = (qt.clone(), Arc::clone(shared));
183 shown.ongoing = Some(sioul_sync::notify::ongoing(¬e.title, ¬e.text, buttons, Box::new(move |key: &str| pressed(&qt, &shared, key))));
184 }
185}
186
187#[cfg(target_os = "android")]
188unsafe extern "C" {
190 fn sioul_android_time_note(json: *const c_char);
193}
194
195#[cfg(target_os = "android")]
197fn put(_shown: &mut Shown, note: Option<&Note>, _window: Option<(&QtThread, &Arc<Shared>)>) {
198 let Ok(text) = std::ffi::CString::new(note.map_or_else(String::new, |n| crate::backend::json(n))) else { return };
199 unsafe { sioul_android_time_note(text.as_ptr()) };
201}
202
203#[cfg(not(target_os = "android"))]
206fn pressed(qt: &QtThread, shared: &Arc<Shared>, key: &str) {
207 if key != "default" {
208 act(key, Some((qt, shared)));
209 post(Some((qt, shared)));
210 return;
211 }
212 let Some(uid) = timelog::running().map(|r| r.task).filter(|t| !t.is_empty()) else { return };
213 let uri = sioul_core::links::task_uri(&uid);
214 let _ = qt.queue(move |mut sioul| sioul.as_mut().reminder_opened(cxx_qt_lib::QString::from("task"), cxx_qt_lib::QString::from(&uri), cxx_qt_lib::QString::from(&uid)));
215}
216
217fn act(action: &str, window: Option<(&QtThread, &Arc<Shared>)>) -> bool {
223 let _acting = ACTING.lock().unwrap_or_else(PoisonError::into_inner);
224 let Some((qt, shared)) = window else {
225 return act_in(&timelog::folder(), &Stopped::default_path(), action, Timestamp::now().as_second()).unwrap_or_else(|e| {
226 eprintln!("Sioul: the time running: {e}");
227 false
228 });
229 };
230 let Some(running) = timelog::running() else { return false };
231 match action {
232 "pause" | "resume" if running.paused_at.is_some() == (action == "resume") => crate::work::focus_pause(qt, shared),
233 "stop" => drop(crate::work::focus_stop(qt, shared, false, "")),
234 _ => return false,
235 }
236 true
237}
238
239fn act_in(dir: &Path, stopped: &Path, action: &str, now: i64) -> Result<bool, String> {
243 let Some(running) = timelog::running_in(dir) else { return Ok(false) };
244 match action {
245 "pause" | "resume" if running.paused_at.is_some() == (action == "resume") => timelog::change_running_in(dir, |running| {
247 if running.paused_at.is_some() == (action == "resume") {
248 running.toggle_pause(now);
249 }
250 }),
251 "stop" => {
252 let left = Stopped::load(stopped).filter(|s| s.task == running.task && s.at >= running.start).map(|s| s.text).unwrap_or_default();
253 timelog::finish_noted_in(dir, now, false, &left).map(|session| session.is_some())
254 }
255 _ => Ok(false),
256 }
257}
258
259#[unsafe(no_mangle)]
272pub unsafe extern "C" fn sioul_time_action(action: *const c_char) -> *mut c_char {
273 let action = unsafe { crate::alarms::key_of(action) };
275 let answer = std::panic::catch_unwind(|| {
276 let window = WINDOW.lock().ok().and_then(|w| w.clone());
277 let window = window.as_ref().map(|(qt, shared)| (qt, shared));
278 act(&action, window);
279 let _ = crate::share::exchange_here(false);
280 post(window).map(|note| crate::backend::json(¬e)).unwrap_or_default()
281 })
282 .unwrap_or_else(|_| {
283 eprintln!("Sioul: the time running: \"{action}\" broke");
284 String::new()
285 });
286 crate::alarms::handed(answer)
287}
288
289pub(crate) fn closing() {
293 #[cfg(not(target_os = "android"))]
294 {
295 let ongoing = {
296 let mut shown = SHOWN.lock().unwrap_or_else(PoisonError::into_inner);
297 shown.over = true;
298 shown.ongoing.take()
299 };
300 if let Some(ongoing) = ongoing {
301 ongoing.close(std::time::Duration::from_secs(1));
302 }
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 fn at(text: &str) -> i64 {
311 format!("{text}[Europe/Paris]").parse::<jiff::Zoned>().unwrap().timestamp().as_second()
312 }
313
314 #[test]
315 fn the_note_says_the_task_since_when_and_its_buttons() {
316 let (zone, tr) = (TimeZone::get("Europe/Paris").unwrap(), Translator::new("en"));
317 let start = at("2026-10-05T14:02");
318 let mut running = Running { task: "t1".into(), start, planned: 25, ..Running::default() };
319 let note = note_of(Some(&running), "Write to the bank", start + 60, &tr, &zone).unwrap();
320 assert_eq!((note.title.as_str(), note.text.as_str(), note.paused, note.since), ("Write to the bank", "Since 14:02, 25 min chosen", false, start * 1000));
321 assert_eq!((note.pause.as_str(), note.stop.as_str(), note.channel.as_str()), ("Pause", "Stop", "Focus timer"));
322 running.toggle_pause(start + 12 * 60);
324 let note = note_of(Some(&running), "Write to the bank", start + 20 * 60, &tr, &zone).unwrap();
325 assert_eq!((note.text.as_str(), note.paused, note.since, note.pause.as_str()), ("Paused, 12 min so far", true, 0, "Go on"));
326 running.toggle_pause(start + 20 * 60);
328 assert_eq!(note_of(Some(&running), "Write to the bank", start + 21 * 60, &tr, &zone).unwrap().since, (start + 8 * 60) * 1000);
329 running.planned = 0;
331 let note = note_of(Some(&running), " ", start + 21 * 60, &Translator::new("fr"), &zone).unwrap();
332 assert_eq!((note.title.as_str(), note.text.as_str(), note.pause.as_str(), note.stop.as_str()), ("Concentration", "Depuis 14:02, sans fin fixée", "Pause", "Arrêter"));
333 assert_eq!(note_of(None, "Write to the bank", start, &tr, &zone), None);
335 }
336
337 #[test]
338 fn titles_from_the_task_else_its_project() {
339 let task = |uid: &str, title: &str, projects: &[&str]| Task { uid: uid.into(), title: title.into(), projects: projects.iter().map(|c| c.to_string()).collect(), ..Task::default() };
340 let tasks = vec![task("t1", "Write to the bank", &["lumen"]), task("t2", "", &["gone", "lumen"])];
341 let projects = vec![Project { id: "lumen".into(), title: "Lumen, the website".into(), ..Project::default() }];
342 assert_eq!(title_of("t1", &tasks, &projects), "Write to the bank");
343 assert_eq!(title_of("t2", &tasks, &projects), "Lumen, the website");
344 assert_eq!(title_of("t3", &tasks, &projects), "", "a task not here yet");
345 }
346
347 #[test]
348 fn its_buttons_pause_go_on_and_stop_once() {
349 let dir = std::env::temp_dir().join(format!("sioul-timenote-{}", std::process::id()));
350 let _ = std::fs::remove_dir_all(&dir);
351 let stopped = dir.join("stopped.toml");
352 let (zone, tr) = (TimeZone::get("Europe/Paris").unwrap(), Translator::new("en"));
353 let note = |now: i64| note_of(timelog::running_in(&dir).as_ref(), "Write to the bank", now, &tr, &zone);
354 let start = at("2026-10-05T09:00");
355 timelog::keep_running_in(&dir, Some(&Running { task: "t1".into(), start, planned: 25, ..Running::default() })).unwrap();
357 assert_eq!(note(start).map(|n| (n.title, n.paused)), Some(("Write to the bank".to_string(), false)));
358 assert!(!act_in(&dir, &stopped, "resume", start + 60).unwrap());
360 assert!(act_in(&dir, &stopped, "pause", start + 600).unwrap());
361 assert_eq!(note(start + 700).map(|n| (n.text, n.paused)), Some(("Paused, 10 min so far".to_string(), true)));
362 assert!(!act_in(&dir, &stopped, "pause", start + 700).unwrap(), "a button a moment behind changes nothing");
363 assert_eq!(timelog::running_in(&dir).unwrap().paused_at, Some(start + 600));
364 assert!(act_in(&dir, &stopped, "resume", start + 900).unwrap());
365 assert!(note(start + 900).is_some_and(|n| !n.paused && n.since == (start + 300) * 1000));
366 Stopped::keep(&stopped, "the second paragraph", "t1", start + 610).unwrap();
368 assert!(act_in(&dir, &stopped, "stop", start + 1500).unwrap());
369 assert_eq!(note(start + 1500), None);
370 let kept = timelog::sessions_in(&dir);
371 assert_eq!((kept.len(), kept[0].minutes, kept[0].note.as_str(), kept[0].done), (1, 20, "the second paragraph", false));
372 assert!(!act_in(&dir, &stopped, "stop", start + 1600).unwrap(), "nothing left to stop");
373 assert!(!act_in(&dir, &stopped, "jump", start + 1600).unwrap());
374 std::fs::remove_dir_all(&dir).unwrap();
375 }
376}