1use crate::backend::{QtThread, load_config, tr};
23use jiff::Zoned;
24use sioul_core::areas::Area;
25use sioul_core::attention::{Attention, Level, Now, Output};
26use sioul_core::config::Config;
27use sioul_core::everywhere::People;
28use sioul_core::mailnote::{self, Fate, Ledger, Letter};
29use sioul_core::porch::{self, Triaged};
30use std::path::PathBuf;
31use std::sync::Mutex;
32use std::time::{Duration, Instant};
33
34const GATHERED: Duration = Duration::from_secs(10);
36const AT_MOST: Duration = Duration::from_secs(60);
38
39#[derive(Clone)]
42pub(crate) struct Window {
43 #[cfg_attr(target_os = "android", allow(dead_code))]
45 pub(crate) qt: QtThread,
46 pub(crate) active: i64,
48}
49
50struct Batch {
52 letters: Vec<Letter>,
53 first: Option<Instant>,
54 last: Option<Instant>,
55 window: Option<Window>,
56 opens: bool,
58 waiting: bool,
60}
61
62static BATCH: Mutex<Batch> = Mutex::new(Batch { letters: Vec::new(), first: None, last: None, window: None, opens: false, waiting: false });
63
64fn add_to_batch(letters: Vec<Letter>, window: Option<Window>, opens: bool) {
67 let start = {
68 let Ok(mut batch) = BATCH.lock() else { return };
69 batch.letters.extend(letters);
70 batch.first.get_or_insert_with(Instant::now);
71 batch.last = Some(Instant::now());
72 batch.opens |= opens;
73 if window.is_some() {
74 batch.window = window;
75 }
76 !std::mem::replace(&mut batch.waiting, true)
77 };
78 if start {
79 std::thread::spawn(close_batch);
80 }
81}
82
83static MOMENT: Mutex<Option<String>> = Mutex::new(None);
85
86struct Seen {
91 config: Config,
92 senders: std::cell::OnceCell<porch::Senders>,
95 attention: Attention,
96 now: Now,
97 always: People,
99 may: bool,
101}
102
103impl Seen {
104 fn now(config: Config, now: &Zoned) -> Seen {
105 let mode = crate::hours::mode_at(now);
106 let moment = crate::hours::attention_at(&mode, now);
107 let may = config.reminders.mail && !moment.holds.porch_rests;
108 Seen { attention: Attention::of(&config), always: People::load(&People::default_path()), config, senders: std::cell::OnceCell::new(), now: moment, may }
109 }
110
111 fn senders(&self) -> &porch::Senders {
112 self.senders.get_or_init(|| porch::Senders::load(&self.config))
113 }
114
115 fn decided(&self, t: &Triaged) -> Output {
119 let area = t.card.account.as_deref().and_then(|id| self.config.account(id)).and_then(|a| a.area.as_deref()).and_then(Area::parse).unwrap_or(Area::WORK);
120 self.attention.mail(t, self.senders(), &self.always, area, &self.now)
121 }
122
123 fn fate(&self, t: &Triaged) -> Fate {
125 let out = self.decided(t);
126 match out {
127 _ if !self.may => Fate::Later,
128 out if out.told && out.pierce => Fate::Through,
129 out if out.told => Fate::Now,
130 out if out.level == Level::Never => Fate::Never,
131 _ => Fate::Later,
132 }
133 }
134
135 fn tells(&self, t: &Triaged) -> bool {
137 self.may && self.decided(t).told
138 }
139
140 fn moment(&self) -> String {
141 mailnote::moment(&self.now, self.may)
142 }
143
144 fn judge(&self, files: &[PathBuf], stamp: i64) -> Vec<Triaged> {
147 let (store, known) = self.world();
148 porch::judge(files, &self.config.mail_sources(), store.as_ref(), &known, self.senders(), stamp)
149 }
150
151 fn porch(&self, stamp: i64) -> Vec<Triaged> {
153 let (store, known) = self.world();
154 let state = sioul_core::state::PorchState::load(&sioul_core::state::PorchState::default_path());
155 porch::gather(&self.config.mail_sources(), store.as_ref(), &known, self.senders(), &state, stamp)
156 }
157
158 fn world(&self) -> (Option<sioul_core::projects::ProjectStore>, porch::SenderList) {
159 let ties = sioul_core::links::LocalLinks::load(&sioul_core::links::LocalLinks::default_path());
160 let store = self.config.notes_root_path().and_then(|root| sioul_core::projects::ProjectStore::load(&root).ok()).map(|s| s.with_ties(&ties));
161 (store, porch::KnownSenders::load(&self.config.known_senders_path()))
162 }
163}
164
165#[cfg_attr(not(target_os = "android"), allow(dead_code))]
169pub(crate) fn arrived(account: &str, new: &[PathBuf], first: bool) {
170 arrived_from(account, new, first, None);
171}
172
173pub(crate) fn arrived_in_window(window: Window, account: &str, new: &[PathBuf], first: bool) {
175 arrived_from(account, new, first, Some(window));
176}
177
178#[cfg_attr(not(target_os = "android"), allow(dead_code))]
183pub(crate) fn unfiltered(account: &str, files: &[PathBuf]) {
184 told(account, files, None, false);
185}
186
187pub(crate) fn unfiltered_in_window(window: Window, account: &str, files: &[PathBuf]) {
189 told(account, files, Some(window), false);
190}
191
192fn arrived_from(account: &str, new: &[PathBuf], first: bool, window: Option<Window>) {
193 if !first {
194 told(account, new, window, true);
195 }
196}
197
198fn told(_account: &str, files: &[PathBuf], window: Option<Window>, hold: bool) {
202 if files.is_empty() {
203 return;
204 }
205 let config = load_config();
206 if !config.reminders.mail {
207 return;
208 }
209 let now = Zoned::now();
210 let stamp = now.timestamp().as_second();
211 let newsletters = config.reminders.mail_newsletters;
212 let seen = Seen::now(config, &now);
213 let judged = seen.judge(files, stamp);
214 let arrivals = if hold { crate::filters::untold(&seen.config, judged, seen.senders()) } else { judged };
216 let path = Ledger::default_path();
217 let letters = sioul_core::filelock::with_lock(&path, || {
222 let mut ledger = Ledger::load(&path);
223 let sorted = mailnote::sort(&arrivals, &ledger, newsletters, |t| seen.fate(t));
224 for key in &sorted.never {
225 ledger.tell(key, stamp);
226 }
227 for key in sorted.later.iter().chain(sorted.now.iter().map(|l| &l.key)) {
228 ledger.wait(key, stamp);
229 }
230 ledger.forget_old(stamp);
231 if let Err(e) = ledger.save(&path) {
232 eprintln!("{e}");
233 }
234 sorted.now
235 });
236 if !letters.is_empty() {
237 add_to_batch(letters, window, false);
238 }
239}
240
241#[cfg_attr(not(target_os = "android"), allow(dead_code))]
246pub(crate) fn close_now() {
247 let (letters, window, opens) = {
248 let Ok(mut batch) = BATCH.lock() else { return };
249 batch.first = None;
250 batch.last = None;
251 (std::mem::take(&mut batch.letters), batch.window.take(), std::mem::take(&mut batch.opens))
252 };
253 if !letters.is_empty() {
254 tell_batch(letters, window, opens);
255 }
256}
257
258fn close_batch() {
261 loop {
262 std::thread::sleep(Duration::from_secs(1));
263 let ready = {
264 let Ok(mut batch) = BATCH.lock() else { return };
265 let done = batch.last.is_none_or(|last| last.elapsed() >= GATHERED) || batch.first.is_some_and(|first| first.elapsed() >= AT_MOST);
266 done.then(|| {
267 batch.waiting = false;
268 batch.first = None;
269 batch.last = None;
270 (std::mem::take(&mut batch.letters), batch.window.take(), std::mem::take(&mut batch.opens))
271 })
272 };
273 if let Some((letters, window, opens)) = ready {
274 if !letters.is_empty() {
276 tell_batch(letters, window, opens);
277 }
278 return;
279 }
280 }
281}
282
283fn tell_batch(letters: Vec<Letter>, window: Option<Window>, opens: bool) {
288 let now = Zoned::now();
289 let stamp = now.timestamp().as_second();
290 if !Seen::now(load_config(), &now).may {
291 return;
292 }
293 let mine = claim(letters, &[], stamp);
294 if mine.is_empty() || !ours(window.as_ref()) {
295 return;
296 }
297 let (title, body) = if opens { mailnote::opens(tr(), &mine) } else { mailnote::batch(tr(), &mine) };
298 show(&title, &body, mine.iter().any(|l| l.through), window.as_ref());
299}
300
301#[cfg_attr(not(target_os = "android"), allow(dead_code))]
305pub(crate) fn tick() {
306 tick_from(None);
307}
308
309pub(crate) fn tick_in_window(window: Window) {
311 std::thread::spawn(move || tick_from(Some(window)));
312}
313
314fn tick_from(window: Option<Window>) {
315 static BUSY: Mutex<()> = Mutex::new(());
316 let Some(_busy) = crate::backend::one_at_a_time(&BUSY) else { return };
317 let config = load_config();
318 if !config.reminders.mail {
319 return;
320 }
321 let now = Zoned::now();
322 let stamp = now.timestamp().as_second();
323 let newsletters = config.reminders.mail_newsletters;
324 let seen = Seen::now(config, &now);
325 let moment = seen.moment();
326 let changed = MOMENT.lock().is_ok_and(|mut last| last.replace(moment.clone()).as_ref() != Some(&moment));
327 if !changed || !seen.may {
328 return;
329 }
330 let path = Ledger::default_path();
331 let ledger = Ledger::load(&path);
332 if ledger.waiting.is_empty() {
333 return;
334 }
335 let items = seen.porch(stamp);
336 let letters = mailnote::waited(&items, &ledger, newsletters, |t| seen.tells(t));
337 let gone = mailnote::gone_by(&items, &ledger, newsletters);
339 if window.is_some() {
342 claim(Vec::new(), &gone, stamp);
343 if !letters.is_empty() {
344 add_to_batch(letters, window, true);
345 }
346 return;
347 }
348 let mine = claim(letters, &gone, stamp);
349 if mine.is_empty() || !ours(window.as_ref()) {
350 return;
351 }
352 let (title, body) = mailnote::opens(tr(), &mine);
353 show(&title, &body, mine.iter().any(|l| l.through), window.as_ref());
354}
355
356fn claim(letters: Vec<Letter>, gone: &[String], stamp: i64) -> Vec<Letter> {
359 let path = Ledger::default_path();
360 sioul_core::filelock::with_lock(&path, || {
361 let mut ledger = Ledger::load(&path);
362 let mut seen = std::collections::BTreeSet::new();
363 let mine: Vec<Letter> = letters.into_iter().filter(|l| ledger.waiting.contains_key(&l.key) && seen.insert(l.key.clone())).collect();
364 for key in mine.iter().map(|l| &l.key).chain(gone) {
365 ledger.tell(key, stamp);
366 }
367 if mine.is_empty() && gone.is_empty() {
368 return mine;
369 }
370 match ledger.save(&path) {
371 Ok(()) => mine,
372 Err(e) => {
374 eprintln!("{e}");
375 Vec::new()
376 }
377 }
378 })
379}
380
381fn ours(window: Option<&Window>) -> bool {
384 match window {
385 Some(w) => crate::share::keeper("notices", sioul_sync::lease::Rule::FollowsYou, w.active, false).0.mine,
386 None => crate::share::looked("notices", sioul_sync::lease::Rule::FollowsYou).mine,
387 }
388}
389
390fn show(title: &str, body: &str, through: bool, window: Option<&Window>) {
395 #[cfg(target_os = "android")]
396 {
397 let _ = window;
398 crate::eventalarms::mail_note(title, body, through);
399 }
400 #[cfg(not(target_os = "android"))]
401 {
402 use cxx_qt_lib::QString;
403 let open = window.map(|w| {
404 let qt = w.qt.clone();
405 let action: Box<dyn FnOnce() + Send> = Box::new(move || {
406 let _ = qt.queue(|mut sioul| sioul.as_mut().reminder_opened(QString::from("porch"), QString::default(), QString::default()));
407 });
408 (tr().text("reminder-open", None), action)
409 });
410 if let Err(e) = sioul_sync::notify::mail(title, body, through, open) {
411 eprintln!("{e}");
412 }
413 }
414}