lazybar_core/panels/
github.rs

1use std::{
2    fs::File,
3    io::Read,
4    pin::Pin,
5    rc::Rc,
6    sync::{Arc, Mutex},
7    task::Poll,
8    time::Duration,
9};
10
11use anyhow::Result;
12use async_trait::async_trait;
13use derive_builder::Builder;
14use futures::{task::AtomicWaker, FutureExt, StreamExt};
15use lazy_static::lazy_static;
16use lazybar_types::EventResponse;
17use regex::Regex;
18use reqwest::{
19    header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT},
20    Client,
21};
22use serde::Deserialize;
23use tokio::{
24    task::{self, JoinHandle},
25    time::{interval, Interval},
26};
27use tokio_stream::Stream;
28
29use crate::{
30    attrs::Attrs,
31    bar::{Event, PanelDrawInfo},
32    common::{PanelCommon, ShowHide},
33    ipc::ChannelEndpoint,
34    remove_array_from_config, remove_bool_from_config,
35    remove_string_from_config, remove_uint_from_config, Highlight, PanelConfig,
36    PanelStream,
37};
38
39lazy_static! {
40    static ref REGEX: Regex =
41        Regex::new(r#"<(?<url>\S*)>; rel="next""#).unwrap();
42}
43
44/// Displays the number of github notifications you have.
45#[derive(Debug, Clone, Builder)]
46#[builder_struct_attr(allow(missing_docs))]
47#[builder_impl_attr(allow(missing_docs))]
48pub struct Github {
49    name: &'static str,
50    #[builder(default = "Duration::from_secs(60)")]
51    interval: Duration,
52    #[builder(default)]
53    waker: Arc<AtomicWaker>,
54    token: String,
55    #[builder(default = "Vec::new()")]
56    filter: Vec<String>,
57    #[builder(default)]
58    include: bool,
59    #[builder(default = "true")]
60    show_zero: bool,
61    format: &'static str,
62    attrs: Attrs,
63    #[builder(default, setter(strip_option))]
64    highlight: Option<Highlight>,
65    common: PanelCommon,
66}
67
68impl Github {
69    fn draw(
70        &self,
71        cr: &Rc<cairo::Context>,
72        height: i32,
73        count: usize,
74        paused: Arc<Mutex<bool>>,
75    ) -> Result<PanelDrawInfo> {
76        let mut text = if !self.show_zero && count == 0 {
77            String::new()
78        } else {
79            self.format.replace("%count%", count.to_string().as_str())
80        };
81
82        if count == 50 {
83            text.push('+');
84        }
85
86        self.common.draw(
87            cr,
88            text.as_str(),
89            &self.attrs,
90            self.common.dependence,
91            self.highlight.clone(),
92            self.common.images.clone(),
93            height,
94            ShowHide::Default(paused, self.waker.clone()),
95            format!("{self:?}"),
96        )
97    }
98}
99
100#[async_trait(?Send)]
101impl PanelConfig for Github {
102    /// Parses an instance of the panel from the global [`Config`]
103    ///
104    /// Configuration options:
105    /// - `interval`: how long to wait between requests. The panel will never
106    ///   poll more often than this, but it may poll less often according to the
107    ///   `X-Poll-Interval` header of the reponse. See
108    ///   <https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#about-github-notifications>
109    ///   for more information.
110    /// - `token`: A file path containing your GitHub token. Visit <https://github.com/settings/tokens/new>
111    ///   to generate a token. The `notifications` scope is required.
112    /// - `filter`: An array of strings corresponding to notification reasons.
113    ///   See <https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#about-notification-reasons>
114    ///   for details.
115    /// - `include`: Whether to include or exclude the reasons in `filter`. If
116    ///   `include` is true, only notifications with one of the reasons in
117    ///   `filter` will be counted. Otherwise, only notifications with reasons
118    ///   not in `filter` will be counted.
119    /// - `show_zero`: Whether or not the panel is shown when you have zero
120    ///   notifications.
121    /// - `format`: The formatting option. The only formatting option is
122    ///   `%count%`.
123    /// - `attrs`: A string specifying the attrs for the panel. See
124    ///   [`Attrs::parse`] for details.
125    /// - `highlight`: A string specifying the highlight for the panel. See
126    ///   [`Highlight::parse`] for details.
127    /// - See [`PanelCommon::parse_common`].
128    fn parse(
129        name: &'static str,
130        table: &mut std::collections::HashMap<String, config::Value>,
131        _global: &config::Config,
132    ) -> anyhow::Result<Self> {
133        let mut builder = GithubBuilder::default();
134
135        builder.name(name);
136
137        if let Some(interval) = remove_uint_from_config("interval", table) {
138            builder.interval(Duration::from_secs(interval.max(1) * 60));
139        }
140
141        if let Some(path) = remove_string_from_config("token", table) {
142            let mut token = String::new();
143            File::open(path)?.read_to_string(&mut token)?;
144
145            builder.token(token);
146        }
147
148        if let Some(filter) = remove_array_from_config("filter", table) {
149            builder.filter(
150                filter
151                    .iter()
152                    .filter_map(|v| v.clone().into_string().ok())
153                    .collect(),
154            );
155        }
156
157        if let Some(include) = remove_bool_from_config("include", table) {
158            builder.include(include);
159        }
160
161        if let Some(show_zero) = remove_bool_from_config("show_zero", table) {
162            builder.show_zero(show_zero);
163        }
164
165        let common = PanelCommon::parse_common(table)?;
166        let format = PanelCommon::parse_format(table, "", "%count%");
167        let attrs = PanelCommon::parse_attr(table, "");
168        let highlight = PanelCommon::parse_highlight(table, "");
169
170        builder.common(common);
171        builder.format(format.leak());
172        builder.attrs(attrs);
173        builder.highlight(highlight);
174
175        Ok(builder.build()?)
176    }
177
178    fn props(&self) -> (&'static str, bool) {
179        (self.name, self.common.visible)
180    }
181
182    async fn run(
183        mut self: Box<Self>,
184        cr: Rc<cairo::Context>,
185        global_attrs: Attrs,
186        height: i32,
187    ) -> Result<(PanelStream, Option<ChannelEndpoint<Event, EventResponse>>)>
188    {
189        self.attrs.apply_to(&global_attrs);
190
191        let paused = Arc::new(Mutex::new(false));
192
193        let stream = GithubStream::new(
194            self.token.as_str(),
195            self.interval,
196            paused.clone(),
197            self.filter.clone(),
198            self.include,
199        )?
200        .map(move |r| self.draw(&cr, height, r?, paused.clone()));
201
202        Ok((Box::pin(stream), None))
203    }
204}
205
206struct GithubStream {
207    handle: Option<JoinHandle<Result<usize>>>,
208    interval: Arc<futures::lock::Mutex<Interval>>,
209    waker: Arc<AtomicWaker>,
210    paused: Arc<Mutex<bool>>,
211    filter: Vec<String>,
212    include: bool,
213    client: Client,
214}
215
216impl GithubStream {
217    pub fn new(
218        token: &str,
219        duration: Duration,
220        paused: Arc<Mutex<bool>>,
221        filter: Vec<String>,
222        include: bool,
223    ) -> Result<Self> {
224        let mut headers = HeaderMap::new();
225        headers.insert(
226            ACCEPT,
227            HeaderValue::from_static("application/vnd.github+json"),
228        );
229        headers.insert(
230            "X-Github-Api-Version",
231            HeaderValue::from_static("2022-11-28"),
232        );
233        headers.insert(USER_AGENT, HeaderValue::from_static("lazybar"));
234        let mut secret =
235            HeaderValue::from_str(format!("Bearer {}", token.trim()).as_str())?;
236        secret.set_sensitive(true);
237        headers.insert(AUTHORIZATION, secret);
238        let client = Client::builder().default_headers(headers).build()?;
239        let interval = Arc::new(futures::lock::Mutex::new(interval(duration)));
240        let waker = Arc::new(AtomicWaker::new());
241        Ok(Self {
242            handle: None,
243            interval,
244            waker,
245            paused,
246            filter,
247            include,
248            client,
249        })
250    }
251}
252
253impl Stream for GithubStream {
254    type Item = Result<usize>;
255
256    fn poll_next(
257        mut self: Pin<&mut Self>,
258        cx: &mut std::task::Context<'_>,
259    ) -> Poll<Option<Self::Item>> {
260        self.waker.register(cx.waker());
261        if *self.paused.lock().unwrap() {
262            Poll::Pending
263        } else if let Some(handle) = &mut self.handle {
264            let val = handle.poll_unpin(cx).map(Result::ok);
265
266            if val.is_ready() {
267                self.handle = None;
268            }
269
270            val
271        } else {
272            let interval = self.interval.clone();
273            let filter = self.filter.clone();
274            let include = self.include;
275            let client = self.client.clone();
276            self.handle = Some(task::spawn(get_notifications(
277                interval, filter, include, client,
278            )));
279
280            Poll::Pending
281        }
282    }
283}
284
285async fn get_notifications(
286    interval: Arc<futures::lock::Mutex<Interval>>,
287    filter: Vec<String>,
288    include: bool,
289    client: Client,
290) -> Result<usize> {
291    interval.lock().await.tick().await;
292
293    let request = client.get("https://api.github.com/notifications").build()?;
294
295    let response = client.execute(request).await?;
296
297    let headers = response.headers().clone();
298    let wait = headers
299        .get("X-Poll-Interval")
300        .and_then(|v| v.to_str().ok())
301        .and_then(|s| s.parse().ok())
302        .unwrap_or(60);
303
304    interval.lock().await.reset_after(Duration::from_secs(wait));
305
306    let body = response.json::<Vec<Thread>>().await?;
307
308    let count = body
309        .into_iter()
310        .filter(|t| !(include ^ filter.contains(&t.reason)))
311        .count();
312
313    Ok(count)
314}
315
316#[derive(Deserialize, Debug)]
317#[non_exhaustive]
318struct Thread {
319    reason: String,
320}