kasl/libs/
inbox_filter.rs1use crate::db::jira_inbox::JiraInboxItem;
10use anyhow::{Result, bail};
11use chrono::{Duration, NaiveDateTime};
12
13pub fn parse_window(text: &str) -> Result<Duration> {
18 let text = text.trim();
19 let (digits, unit) = match text.char_indices().find(|(_, c)| !c.is_ascii_digit()) {
20 Some((at, _)) => text.split_at(at),
21 None => (text, "d"),
22 };
23 let amount: i64 = match digits.parse() {
24 Ok(n) if n > 0 => n,
25 _ => bail!("'{text}' is not a window; try 1d, 7d, 12h or 2w"),
26 };
27 match unit {
28 "h" => Ok(Duration::hours(amount)),
29 "d" => Ok(Duration::days(amount)),
30 "w" => Ok(Duration::weeks(amount)),
31 _ => bail!("'{text}' is not a window; the unit is h, d or w"),
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum PriorityCut {
38 Exactly(i32),
40 AtLeast(i32),
42}
43
44impl PriorityCut {
45 fn keeps(self, rank: i32) -> bool {
46 match self {
47 PriorityCut::Exactly(wanted) => rank == wanted,
48 PriorityCut::AtLeast(wanted) => rank <= wanted,
49 }
50 }
51}
52
53#[derive(Debug, Default, Clone, PartialEq)]
55pub struct InboxFilter {
56 pub since: Option<Duration>,
58 pub changed: Option<Duration>,
60 pub min_score: Option<f64>,
62 pub priority: Option<PriorityCut>,
64 pub status: Option<String>,
66}
67
68impl InboxFilter {
69 pub fn is_empty(&self) -> bool {
71 *self == Self::default()
72 }
73
74 pub fn keeps(&self, item: &JiraInboxItem, now: NaiveDateTime) -> bool {
76 let within = |at: Option<NaiveDateTime>, window: Duration| at.is_some_and(|at| now.signed_duration_since(at) <= window);
77 if let Some(window) = self.since
78 && !within(Some(item.first_seen), window)
79 {
80 return false;
81 }
82 if let Some(window) = self.changed
83 && !within(item.changed_at, window)
84 {
85 return false;
86 }
87 if let Some(floor) = self.min_score
88 && !item.sort_value.is_some_and(|score| score >= floor)
89 {
90 return false;
91 }
92 if let Some(cut) = self.priority
93 && !cut.keeps(item.priority_rank)
94 {
95 return false;
96 }
97 if let Some(status) = &self.status
98 && !item.status_name.eq_ignore_ascii_case(status)
99 && !item.status_id.as_deref().is_some_and(|id| id.eq_ignore_ascii_case(status))
100 {
101 return false;
102 }
103 true
104 }
105
106 pub fn apply(&self, items: Vec<JiraInboxItem>, now: NaiveDateTime) -> Vec<JiraInboxItem> {
108 items.into_iter().filter(|item| self.keeps(item, now)).collect()
109 }
110}
111
112pub fn priority_cut_named(items: &[JiraInboxItem], wanted: &str) -> Result<PriorityCut> {
119 let (name, and_above) = match wanted.strip_suffix('+') {
120 Some(name) => (name.trim(), true),
121 None => (wanted.trim(), false),
122 };
123 let rank = items
124 .iter()
125 .filter(|item| item.priority.as_deref().is_some_and(|p| p.eq_ignore_ascii_case(name)))
126 .map(|item| item.priority_rank)
127 .min();
128 let Some(rank) = rank else {
129 let mut known: Vec<&str> = items.iter().filter_map(|item| item.priority.as_deref()).collect();
130 known.sort_unstable();
131 known.dedup();
132 if known.is_empty() {
133 bail!("no priority named '{name}' in the inbox, and no issue carries a priority yet");
134 }
135 bail!("no priority named '{name}' in the inbox; known: {}", known.join(", "));
136 };
137 Ok(if and_above { PriorityCut::AtLeast(rank) } else { PriorityCut::Exactly(rank) })
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
142pub enum InboxSort {
143 #[default]
145 Score,
146 Priority,
148 New,
150 Changed,
152}
153
154pub fn sort(items: &mut [JiraInboxItem], by: InboxSort) {
156 items.sort_by(|a, b| {
157 let frame = a.gone_at.is_some().cmp(&b.gone_at.is_some()).then_with(|| b.pinned.cmp(&a.pinned));
158 frame.then_with(|| match by {
159 InboxSort::Score => std::cmp::Ordering::Equal,
160 InboxSort::Priority => a.priority_rank.cmp(&b.priority_rank),
161 InboxSort::New => b.first_seen.cmp(&a.first_seen),
162 InboxSort::Changed => b.changed_at.cmp(&a.changed_at),
163 })
164 });
165}