1use super::access;
20use super::tools::{Answer, Args};
21use crate::{Session, one_line};
22use jiff::civil::Date;
23use jiff::tz::TimeZone;
24use jiff::{Timestamp, ToSpan, Zoned};
25use serde_json::{Value, json};
26use sioul_core::consent::Consent;
27use sioul_core::links::{self, Loaded};
28use sioul_core::{contracts, invoice, letters, papers, reminders, timelog, timereport};
29
30fn consent(s: &Session) -> (Consent, Loaded) {
32 let loaded = Loaded::read(&s.config);
33 (access::of(s, &loaded), loaded)
34}
35
36fn instant(seconds: i64) -> String {
38 Timestamp::from_second(seconds).map(|t| t.to_zoned(TimeZone::system()).strftime("%Y-%m-%dT%H:%M:%S%:z").to_string()).unwrap_or_default()
39}
40
41fn store(s: &Session) -> Result<std::path::PathBuf, String> {
43 s.config.notes_root_path().ok_or_else(|| s.tr.text("error-no-store", None))
44}
45
46fn rows(lines: &[String]) -> String {
48 lines
49 .iter()
50 .map(|line| {
51 let words = line.trim_start_matches(' ');
52 format!("{}{}", &line[..line.len() - words.len()], one_line(words))
53 })
54 .collect::<Vec<_>>()
55 .join("\n")
56}
57
58fn day(date: Option<Date>) -> String {
59 date.map(|d| d.to_string()).unwrap_or_default()
60}
61
62pub fn papers(s: &Session, _args: &Args) -> Result<Answer, String> {
65 let wallet = papers::Wallet::load(&store(s)?)?;
66 let (consent, _) = consent(s);
67 let today = Zoned::now().date();
68 let before = wallet.papers.len();
69 let shown: Vec<&papers::Paper> = wallet.papers.iter().filter(|p| consent.allows(&links::paper_uri(&p.id))).collect();
71 let closed = before - shown.len();
72 let mut lines = Vec::new();
73 let mut out = Vec::new();
74 for paper in shown {
75 let kind = s.tr.text(&format!("paper-kind-{}", paper.kind.id()), None);
76 let (standing, said) = match paper.standing(today) {
77 papers::Standing::Valid(until) => ("valid", format!("holds until {until}")),
78 papers::Standing::Renew(until) => ("renew", format!("holds until {until}; renewing starts now")),
79 papers::Standing::Ended(until) => ("ended", format!("ended on {until}")),
80 papers::Standing::Fresh(issued) => ("fresh", format!("issued on {issued}, recent enough to send")),
81 papers::Standing::Old(issued) => ("old", format!("issued on {issued}, older than what is usually asked")),
82 papers::Standing::Undated => ("undated", String::new()),
83 };
84 let holder = if paper.holder.is_empty() { String::new() } else { format!(" · {}", paper.holder) };
85 let tail = if said.is_empty() { String::new() } else { format!(" · {said}") };
86 lines.push(format!("{} ({kind}){holder}{tail} <{}>", paper.title, links::paper_uri(&paper.id)));
87 out.push(json!({
88 "uri": links::paper_uri(&paper.id), "kind": paper.kind.id(), "kind_said": kind, "title": paper.title, "holder": paper.holder,
89 "issued": day(paper.issued), "until": day(paper.until), "renew_from": day(paper.renew_from()), "standing": standing,
90 "renewal": paper.renewal,
91 }));
92 }
93 if out.is_empty() && closed == 0 {
94 lines.push("No paper is kept in the wallet yet.".into());
95 }
96 lines.extend(access::left_out(closed));
97 Ok(Answer { text: rows(&lines), data: json!({ "papers": out, "kept_from_agents": closed }) })
98}
99
100pub fn contracts(s: &Session, _args: &Args) -> Result<Answer, String> {
101 let list = contracts::Contracts::load(&store(s)?)?;
102 let (consent, _) = consent(s);
103 let today = Zoned::now().date();
104 let shown: Vec<&contracts::Contract> = if consent.outside { list.list.iter().collect() } else { Vec::new() };
106 let closed = list.list.len() - shown.len();
107 let mut lines = Vec::new();
108 let mut out = Vec::new();
109 for contract in shown {
110 let kind = s.tr.text(&format!("contract-kind-{}", contract.kind.id()), None);
111 let renews = contract.next_renewal(today);
112 let cancel_by = contract.cancel_by(today);
113 let mut said = Vec::new();
114 if !contract.party.is_empty() {
115 said.push(contract.party.clone());
116 }
117 if let Some(ended) = contract.ended {
118 said.push(format!("ended on {ended}"));
119 } else {
120 if let Some(renews) = renews {
121 said.push(format!("renews on {renews}"));
122 }
123 if contract.notice_days > 0 {
124 said.push(format!("{} days' notice", contract.notice_days));
125 }
126 if let Some(by) = cancel_by {
127 said.push(format!("to stop it, notice sent by {by}"));
128 }
129 }
130 lines.push(format!("{} ({kind}) · {}", contract.title, said.join(" · ")));
131 out.push(json!({
132 "kind": contract.kind.id(), "kind_said": kind, "title": contract.title, "party": contract.party, "started": day(contract.started),
133 "renews": day(renews), "every": contract.every, "notice_days": contract.notice_days, "cancel_by": day(cancel_by),
134 "ended": day(contract.ended), "covers": contract.covers, "open": contract.is_open(),
135 }));
136 }
137 if out.is_empty() && closed == 0 {
138 lines.push("No contract is kept yet.".into());
139 }
140 lines.extend(access::left_out(closed));
141 Ok(Answer { text: rows(&lines), data: json!({ "contracts": out, "kept_from_agents": closed }) })
142}
143
144pub fn letters(s: &Session, args: &Args) -> Result<Answer, String> {
145 let limit = args.number("limit", 30, 1, 500)? as usize;
146 let all = letters::Letters::load(&store(s)?)?;
147 let (consent, _) = consent(s);
148 let mut shown: Vec<&letters::Letter> = all.list.iter().filter(|l| consent.allows_projects([l.project.as_str()])).collect();
150 let closed = all.list.len() - shown.len();
151 shown.sort_by_key(|l| std::cmp::Reverse(l.received));
152 let mut lines = Vec::new();
153 let mut out = Vec::new();
154 for letter in shown.iter().take(limit) {
155 let r = &letter.reading;
156 let kind = s.tr.text(&format!("letter-kind-{}", r.kind.id()), None);
157 let mut said = vec![format!("{} · {kind}", if r.sender.is_empty() { "(sender not read)" } else { r.sender.as_str() })];
158 if let Some(received) = letter.received {
159 said.push(format!("received {received}"));
160 }
161 if let Some(amount) = r.amount {
162 said.push(s.tr.money(amount));
163 }
164 if let Some(deadline) = r.deadline {
165 said.push(format!("asked by {deadline}"));
166 }
167 if let Some((date, time)) = r.appointment {
168 said.push(match time {
169 Some((h, m)) => format!("appointment {date} {h:02}:{m:02}"),
170 None => format!("appointment {date}"),
171 });
172 }
173 lines.push(said.join(" · "));
174 if !r.why.is_empty() {
175 lines.push(format!(" {}", r.why));
176 }
177 out.push(json!({
178 "id": letter.id, "sender": r.sender, "kind": r.kind.id(), "kind_said": kind, "received": day(letter.received), "dated": day(r.dated),
179 "deadline": day(r.deadline), "why": r.why, "amount": r.amount.map(|a| s.tr.money(a)), "registered": r.registered,
180 "project": letter.project, "status": letter.status, "task": if letter.task.is_empty() { String::new() } else { links::task_uri(&letter.task) },
181 }));
182 }
183 if out.is_empty() && closed == 0 {
184 lines.push("No paper letter is kept yet.".into());
185 } else if shown.len() > limit {
186 lines.push(format!("… {} more", shown.len() - limit));
187 }
188 lines.extend(access::left_out(closed));
189 Ok(Answer { text: rows(&lines), data: json!({ "letters": out, "kept_from_agents": closed }) })
190}
191
192pub fn time(s: &Session, args: &Args) -> Result<Answer, String> {
195 let today = Zoned::now().date();
196 let to = args.date("to")?.unwrap_or(today);
197 let from = args.date("from")?.unwrap_or_else(|| to.checked_sub(30.days()).unwrap_or(to));
198 if to < from {
199 return Err("“to” comes before “from”.".into());
200 }
201 let project = args.text("project")?;
202 let (consent, loaded) = consent(s);
203 if let Some(id) = &project {
204 if !loaded.projects.iter().any(|c| &c.id == id) {
205 return Err(format!("No project “{}”: list_projects gives their ids.", one_line(id)));
206 }
207 if !consent.is_open(id) {
208 return Err(access::closed_project(id));
209 }
210 }
211 let entries = timereport::entries(&timelog::sessions(), &loaded.tasks, &loaded.projects, &TimeZone::system());
212 let in_range: Vec<&timereport::Entry> = entries.iter().filter(|e| e.day >= from && e.day <= to && project.as_ref().is_none_or(|p| &e.project == p)).collect();
213 let allowed = |e: &timereport::Entry| consent.allows_projects([e.project.as_str()]) && (e.task.is_empty() || consent.allows(&links::task_uri(&e.task)));
215 let shown: Vec<&timereport::Entry> = in_range.iter().copied().filter(|e| allowed(e)).collect();
216 let closed = in_range.len() - shown.len();
217 let title_of = |id: &str| loaded.projects.iter().find(|c| c.id == id).map_or_else(|| "(no project)".to_string(), |c| c.title.clone());
218 let mut by_project: std::collections::BTreeMap<&str, (u32, u32, u32)> = Default::default();
220 for e in &shown {
221 let sums = by_project.entry(e.project.as_str()).or_default();
222 sums.0 += e.minutes;
223 if e.billable {
224 sums.1 += e.minutes;
225 if e.invoice.is_empty() {
226 sums.2 += e.minutes;
227 }
228 }
229 }
230 let mut lines = vec![format!("Time noted from {from} to {to}:")];
231 let mut projects = Vec::new();
232 for (id, (all, billable, unbilled)) in &by_project {
233 let mut said = vec![timereport::duration(*all)];
234 if *billable > 0 {
235 said.push(format!("{} billable", timereport::duration(*billable)));
236 }
237 if *unbilled > 0 {
238 said.push(format!("{} not billed yet", timereport::duration(*unbilled)));
239 }
240 let address = if id.is_empty() { String::new() } else { format!(" <{}>", links::project_uri(id)) };
241 lines.push(format!(" {}: {}{address}", title_of(id), said.join(" · ")));
242 projects.push(json!({ "project": id, "title": title_of(id), "minutes": all, "billable_minutes": billable, "unbilled_minutes": unbilled }));
243 }
244 let sessions: Vec<Value> = shown
245 .iter()
246 .map(|e| {
247 json!({
248 "start": instant(e.start), "day": e.day.to_string(), "minutes": e.minutes, "project": e.project, "title": e.title,
249 "task": if e.task.is_empty() { String::new() } else { links::task_uri(&e.task) }, "note": e.note, "billable": e.billable, "invoice": e.invoice,
250 })
251 })
252 .collect();
253 if shown.is_empty() {
254 lines.push(" Nothing noted then.".into());
255 } else {
256 lines.push(String::new());
257 for e in shown.iter().rev().take(60) {
258 let billed = if !e.invoice.is_empty() { format!(" · billed ({})", e.invoice) } else if e.billable { " · billable".into() } else { String::new() };
259 lines.push(format!(" {} · {} · {}{billed}", e.day, timereport::duration(e.minutes), e.title));
260 }
261 if shown.len() > 60 {
262 lines.push(format!(" … {} more, in the data", shown.len() - 60));
263 }
264 }
265 lines.extend(access::left_out(closed));
266 Ok(Answer { text: rows(&lines), data: json!({ "from": from.to_string(), "to": to.to_string(), "projects": projects, "sessions": sessions, "kept_from_agents": closed }) })
267}
268
269pub fn invoices(s: &Session, _args: &Args) -> Result<Answer, String> {
270 let (consent, _) = consent(s);
271 let all = invoice::all_in(&invoice::folder());
272 let shown: Vec<&invoice::Invoice> = all.iter().filter(|i| consent.allows_projects([i.project.as_str()])).collect();
273 let closed = all.len() - shown.len();
274 let mut lines = Vec::new();
275 let mut out = Vec::new();
276 for i in shown.iter().rev() {
277 let paid = if i.paid { "paid" } else { "not paid yet" };
278 let client = i.client.lines().next().unwrap_or_default();
279 lines.push(format!("{} · {} · {client} · {} · {paid}{}", i.number, i.date, s.tr.money(i.total()), if i.due.is_empty() || i.paid { String::new() } else { format!(" (due {})", i.due) }));
280 out.push(json!({ "number": i.number, "date": i.date, "due": i.due, "project": i.project, "project_title": i.project_title, "client": client, "amount": s.tr.money(i.total()), "currency": i.currency, "paid": i.paid }));
281 }
282 if out.is_empty() && closed == 0 {
283 lines.push("No invoice issued yet.".into());
284 }
285 lines.extend(access::left_out(closed));
286 Ok(Answer { text: rows(&lines), data: json!({ "invoices": out, "kept_from_agents": closed }) })
287}
288
289pub fn reminders(s: &Session, args: &Args) -> Result<Answer, String> {
292 let days = args.number("days", 14, 1, 92)?;
293 let now = Zoned::now();
294 let stamp = now.timestamp().as_second();
295 let (all, _) = reminders::gather(&s.config, &s.tr, &now);
296 let (consent, loaded) = consent(s);
297 let allowed = |r: &reminders::Reminder| {
299 let event = loaded.events.iter().find(|e| e.key == r.target);
300 match event {
301 Some(event) => consent.allows(&links::event_uri(&event.uid)),
302 None if r.target.starts_with("sioul:") => consent.allows(&r.target),
303 None => consent.outside,
304 }
305 };
306 let coming: Vec<&reminders::Reminder> = all.iter().filter(|r| r.until > stamp && r.at < stamp + days * 86_400).collect();
307 let shown: Vec<&&reminders::Reminder> = coming.iter().filter(|r| allowed(r)).collect();
308 let closed = coming.len() - shown.len();
309 let told = reminders::told_dir();
310 let mut lines = Vec::new();
311 let mut out = Vec::new();
312 for r in shown {
313 let when = crate::date(s, Some(r.at));
314 let body = if r.body.is_empty() { String::new() } else { format!(" — {}", r.body) };
315 lines.push(format!("{when} · {}{body}", r.title));
316 let about = loaded.events.iter().find(|e| e.key == r.target).map_or_else(|| r.target.clone(), |e| links::event_uri(&e.uid));
317 out.push(json!({ "at": instant(r.at), "until": instant(r.until), "kind": r.kind, "title": r.title, "body": r.body, "about": about, "work": r.work, "told": reminders::told(&told, &r.key) }));
318 }
319 if out.is_empty() {
320 lines.push(s.tr.text("remind-nothing", None));
321 }
322 lines.extend(access::left_out(closed));
323 Ok(Answer { text: rows(&lines), data: json!({ "days": days, "reminders": out, "kept_from_agents": closed }) })
324}
325
326pub fn now(s: &Session, _args: &Args) -> Result<Answer, String> {
329 use sioul_core::attention::{self, Attention, Row};
330 use sioul_core::reach::Channel;
331 use sioul_core::everywhere::{self, Why};
332 use sioul_core::quiet::{self, Overrides, Reason};
333 let config = &s.config;
334 let now = Zoned::now();
335 let stamp = now.timestamp().as_second();
336 let overrides = Overrides::load(&Overrides::default_path());
337 let blocks = quiet::Blocks::read_now(&now);
338 let mode = quiet::mode(&config.week_hours(), &config.time_off, &overrides, &blocks, &now);
339 let until = mode.until.as_ref().map(|z| quiet::until_text(&s.tr, z, &now)).unwrap_or_default();
340 let mut args = sioul_core::i18n::args();
341 args.set("until", until.clone());
342 args.set("label", if mode.label.trim().is_empty() { "none".to_string() } else { mode.label.clone() });
343 let nothing = sioul_core::pause::nothing_now(&overrides, &config.free_time);
344 let line = match mode.reason {
346 Reason::FreeTime => s.tr.text(if nothing { "mode-free-time-nothing" } else { "mode-free-time" }, None),
347 Reason::Paused => s.tr.text("mode-paused", None),
348 Reason::NoHours => String::new(),
349 _ if until.is_empty() => String::new(),
350 Reason::Working | Reason::Extended => s.tr.text("home-card-work", Some(&args)),
351 Reason::TimeOff => s.tr.text("mode-time-off", Some(&args)),
352 Reason::WorkingLate => s.tr.text("mode-working-late", Some(&args)),
353 Reason::WorkNow => s.tr.text("mode-work-now-line", Some(&args)),
354 Reason::AdminTime => s.tr.text("mode-admin", Some(&args)),
355 Reason::DoneForTheDay => s.tr.text("mode-quiet", Some(&args)),
356 Reason::Evening | Reason::DayOff => s.tr.text("mode-leisure", Some(&args)),
357 Reason::Meal => s.tr.text("mode-meal", Some(&args)),
358 Reason::WindingDown => s.tr.text("mode-wind-down", Some(&args)),
359 Reason::Nap => s.tr.text("mode-nap", Some(&args)),
360 Reason::Sleep => s.tr.text("mode-sleep", Some(&args)),
361 };
362 let kind = match mode.reason {
363 Reason::Paused => "pause",
364 Reason::FreeTime => "free-time",
365 _ => mode.time.id(),
366 };
367 let sources = everywhere::sources_at(&overrides, &blocks, &now);
370 let dnd = everywhere::now(&config.dnd, &sources, everywhere::read_switch("").latest().as_ref(), stamp);
371 let dnd_until = dnd.until().map(instant).unwrap_or_default();
372 let matrix = Attention::of(config);
374 let mut moment = attention::Now::of(&mode).layers(attention::Slots::load(&attention::Slots::default_path()).at(&now), dnd.gates());
375 moment.nothing = mode.free() && nothing;
376 moment.reachable = dnd.reachable.is_some();
377 let mut reach = Vec::new();
378 let mut by_group: std::collections::BTreeMap<&str, Vec<String>> = Default::default();
379 for row in Row::ALL {
380 let level = matrix.level(row, &moment);
381 let label = attention::row_label(&s.tr, row);
382 let said = attention::level_label(&s.tr, row, level);
383 let group = match row {
384 Row::People(Channel::Mail, _) => "Mail",
385 Row::People(Channel::Calls, _) => "Calls",
386 Row::People(Channel::Messages, _) => "Messages from other apps",
387 Row::Own(_) => "Sioul's own",
388 };
389 by_group.entry(group).or_default().push(format!("{label}: {said}"));
390 reach.push(json!({ "row": row.id(), "label": label, "level": level.id(), "said": said }));
391 }
392 let mut lines = vec![format!("Now: {kind}{}", if line.is_empty() { String::new() } else { format!(". {line}") })];
393 lines.push(if dnd.on() {
394 let why = dnd.why().map(Why::id).unwrap_or_default();
395 format!("Do-not-disturb: on ({why}){}", if dnd_until.is_empty() { String::new() } else { format!(", until {dnd_until}") })
396 } else {
397 "Do-not-disturb: off".into()
398 });
399 lines.push("What reaches the person now:".into());
400 for group in ["Mail", "Calls", "Messages from other apps", "Sioul's own"] {
401 if let Some(rows) = by_group.get(group) {
402 lines.push(format!(" {group}: {}", rows.join("; ")));
403 }
404 }
405 lines.push("Respect this: outside work and admin hours, in a meal, asleep, in a pause or in Free time, do not press the person with admin; say what can wait, and when it comes back.".into());
406 let data = json!({
407 "time": mode.time.id(), "kind": kind, "reason": serde_json::to_value(&mode.reason).unwrap_or(Value::Null), "quiet": mode.quiet,
408 "until": mode.until.as_ref().map(|z| instant(z.timestamp().as_second())), "until_said": until, "line": line,
409 "back": mode.back.as_ref().map(|z| instant(z.timestamp().as_second())),
410 "dnd": { "on": dnd.on(), "why": dnd.why().map(Why::id), "until": dnd.until().map(instant) },
411 "reaches": reach,
412 });
413 Ok(Answer { text: rows(&lines), data })
414}