task_picker/
tasks.rs

1use std::{
2    cmp::Ordering,
3    sync::{Arc, Mutex},
4};
5
6use anyhow::Result;
7use chrono::{DateTime, Utc};
8use eframe::epaint::ahash::HashMap;
9use keyring::Entry;
10#[cfg(test)]
11use mockall::mock;
12#[cfg(test)]
13use serde::Deserializer;
14use serde::{Deserialize, Serialize};
15
16use crate::sources::TaskSource;
17
18#[derive(Serialize, Deserialize, Clone, Debug)]
19pub struct Task {
20    pub project: String,
21    pub title: String,
22    pub description: String,
23    pub due: Option<DateTime<Utc>>,
24    pub created: Option<DateTime<Utc>>,
25    pub id: Option<String>,
26}
27
28impl Task {
29    pub fn get_id(&self) -> String {
30        // Use provided ID or fall back to an auto-generated one
31        self.id
32            .as_ref()
33            .cloned()
34            .unwrap_or_else(|| format!("{}/{}", self.project, self.title))
35    }
36}
37
38#[derive(Serialize, Deserialize, Default)]
39#[serde(default)]
40pub struct TaskManager {
41    tasks: Arc<Mutex<Vec<Task>>>,
42    sources: Vec<(TaskSource, bool)>,
43    #[serde(skip)]
44    error_by_source: Arc<Mutex<HashMap<String, anyhow::Error>>>,
45}
46
47fn compare_optional<T: Ord>(a: &Option<T>, b: &Option<T>) -> Ordering {
48    if let (Some(a), Some(b)) = (a, b) {
49        a.cmp(b)
50    } else if a.is_none() {
51        if b.is_none() {
52            Ordering::Equal
53        } else {
54            Ordering::Greater
55        }
56    } else {
57        Ordering::Less
58    }
59}
60
61#[cfg(test)]
62mock! {
63    pub TaskManager {
64        pub fn tasks(&self) -> Vec<Task>;
65
66        pub fn add_or_replace_source(&mut self, source: TaskSource, secret: &str);
67        pub fn remove_source(&mut self, idx: usize) -> (TaskSource, bool);
68        pub fn refresh<F>(&mut self, finish_callback: F)
69        where
70            F: FnOnce() + Send + 'static;
71        pub fn sources(&self) -> &Vec<(TaskSource, bool)>;
72        pub fn source_ref_mut(&mut self, idx: usize) -> &mut (TaskSource, bool);
73        pub fn get_and_clear_last_err(&self, source: &str) -> Option<anyhow::Error>;
74
75        fn private_deserialize(deserializable: Result<TaskManager, ()>) -> Self;
76        fn private_serialize(&self) -> TaskManager;
77
78    }
79}
80
81#[cfg(test)]
82impl serde::Serialize for MockTaskManager {
83    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
84        self.private_serialize().serialize(s)
85    }
86}
87
88#[cfg(test)]
89impl<'de> Deserialize<'de> for MockTaskManager {
90    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
91    where
92        D: Deserializer<'de>,
93    {
94        let serializable = TaskManager::deserialize(deserializer).map_err(|_| ());
95        Ok(MockTaskManager::private_deserialize(serializable))
96    }
97}
98
99/// Store a secret for a source in the keyring
100fn save_password(source_name: &str, secret: &str) -> Result<()> {
101    let keyring_entry = Entry::new("task-picker", source_name)?;
102    keyring_entry.set_password(secret)?;
103    Ok(())
104}
105
106impl TaskManager {
107    /// Refresh task list in the background
108    pub fn refresh<F>(&mut self, finish_callback: F)
109    where
110        F: FnOnce() + Send + 'static,
111    {
112        let sources = self.sources.clone();
113        let error_by_source = self.error_by_source.clone();
114        let tasks = self.tasks.clone();
115
116        rayon::spawn(move || {
117            // Query tasks for each source and collect errors when they occur, but
118            // proceed with the next source.
119            let mut new_tasks = Vec::default();
120            let mut new_errors = HashMap::default();
121            for (source, active) in &sources {
122                if *active {
123                    let secret = source.secret();
124                    let tasks_for_source = match source {
125                        TaskSource::CalDav(s) => s.query_tasks(secret),
126                        TaskSource::GitHub(s) => s.query_tasks(secret),
127                        TaskSource::GitLab(s) => s.query_tasks(secret),
128                        TaskSource::OpenProject(s) => s.query_tasks(secret),
129                    };
130                    match tasks_for_source {
131                        Ok(tasks_for_source) => new_tasks.extend(tasks_for_source),
132                        Err(e) => {
133                            new_errors.insert(source.name().to_string(), e);
134                        }
135                    }
136                }
137            }
138
139            // Show the tasks that are due next first. Tasks without due date are sorted
140            // by their creation date (oldest first).
141            new_tasks.sort_by(|a, b| {
142                let by_due_date = compare_optional(&a.due, &b.due);
143
144                if by_due_date == Ordering::Equal {
145                    compare_optional(&a.created, &b.created)
146                } else {
147                    by_due_date
148                }
149            });
150
151            {
152                let mut tasks = tasks.lock().expect("Lock poisoning");
153                *tasks = new_tasks;
154            }
155            {
156                let mut error_by_source = error_by_source.lock().expect("Lock poisoning");
157                *error_by_source = new_errors;
158            }
159
160            finish_callback();
161        });
162    }
163
164    pub fn tasks(&self) -> Vec<Task> {
165        let tasks = self.tasks.lock().expect("Lock poisoning");
166        tasks.clone()
167    }
168
169    pub fn sources(&self) -> &Vec<(TaskSource, bool)> {
170        &self.sources
171    }
172
173    pub fn source_ref_mut(&mut self, idx: usize) -> &mut (TaskSource, bool) {
174        &mut self.sources[idx]
175    }
176
177    /// Adds a new resource or replaces an existing one if a source with the
178    /// same name already exists.
179    pub fn add_or_replace_source(&mut self, source: TaskSource, secret: &str) {
180        let source_name = source.name().to_string();
181        let existing = self
182            .sources
183            .binary_search_by(|(probe, _)| probe.name().cmp(source.name()));
184        match existing {
185            Ok(i) => self.sources[i].0 = source,
186            Err(i) => self.sources.insert(i, (source, true)),
187        };
188
189        if let Err(e) = save_password(&source_name, secret) {
190            let mut error_by_source = self.error_by_source.lock().expect("Lock poisoning");
191            error_by_source.insert(source_name, e);
192        }
193    }
194
195    pub fn remove_source(&mut self, idx: usize) -> (TaskSource, bool) {
196        self.sources.remove(idx)
197    }
198
199    pub fn get_and_clear_last_err(&self, source: &str) -> Option<anyhow::Error> {
200        let mut error_by_source = self.error_by_source.lock().expect("Lock poisoning");
201        error_by_source.remove(source)
202    }
203}