Skip to main content

agd_core/agenda/
view.rs

1//! Saved views: `AgendaEngine::view` runs a view's blocks in order like a
2//! block agenda and groups a block's rows as org-super-agenda does; this
3//! module also checks a block's match (`validate_view_block`) and reads Org
4//! effort durations (`duration_minutes`).
5//!
6//! The view settings themselves are config types. A row goes to the first
7//! group that takes it, and rows no group takes come last as "Other items".
8
9use std::cmp::Ordering;
10use std::collections::{BTreeMap, HashMap};
11use std::sync::LazyLock;
12
13use chrono::{NaiveDate, NaiveDateTime};
14use regex::Regex;
15use serde::Serialize;
16
17use crate::{
18    AgdError, AgendaEntry, ErrorCode, HeadlineSpan, Item, Settings, VIEW_SECTION_SCHEMA, ViewBlock,
19    ViewGroup, ViewSettings,
20};
21
22use super::color::keyword_color;
23use super::engine::{AgendaEngine, AgendaQuery};
24use super::matcher::{TagTodoMatcher, matcher_now};
25
26/// Checks a view block's match the way `AgendaEngine::view` will use it.
27pub fn validate_view_block(block: &ViewBlock) -> Result<(), AgdError> {
28    let Some(expression) = block.match_expression.as_deref() else {
29        return Ok(());
30    };
31    let expression = if block.kind == "todo" {
32        format!("/{expression}")
33    } else {
34        expression.to_string()
35    };
36    TagTodoMatcher::parse(&expression)?;
37    for group in &block.groups {
38        if let Some(expression) = &group.match_expression {
39            TagTodoMatcher::parse(expression)?;
40        }
41    }
42    Ok(())
43}
44
45/// One block of a saved view: agenda rows for an agenda block, otherwise a
46/// list of items. `from`/`to` are set for agenda blocks only. A block with
47/// groups has its rows in `groups` and none in `entries` or `items`.
48#[derive(Clone, Debug, Serialize)]
49pub struct ViewSection {
50    /// VIEW_SECTION_SCHEMA.
51    pub schema: String,
52    /// The block's kind: agenda, alltodo, todo, tags or tags-todo.
53    pub kind: String,
54    pub header: String,
55    pub from: Option<NaiveDate>,
56    pub to: Option<NaiveDate>,
57    pub entries: Vec<AgendaEntry>,
58    pub items: Vec<Item>,
59    /// Empty unless the block has groups.
60    pub groups: Vec<ViewSectionGroup>,
61    /// The header's color name (COLOR_NAMES): the block's configured color,
62    /// or the one its rows' keywords share (see `keyword_color`).
63    pub color: String,
64}
65
66/// A grouped block's rows under one group name, in display order.
67#[derive(Clone, Debug, Serialize)]
68pub struct ViewSectionGroup {
69    pub name: String,
70    pub entries: Vec<AgendaEntry>,
71    pub items: Vec<Item>,
72    /// As for a section: the group's configured color, or its rows' shared one.
73    pub color: String,
74}
75
76impl<'a> AgendaEngine<'a> {
77    /// Runs a saved view block by block. `start` is the first agenda day
78    /// before span alignment, as for `agd agenda --from`.
79    pub fn view(
80        &self,
81        view: &ViewSettings,
82        start: NaiveDate,
83        today: NaiveDate,
84        log_mode: bool,
85    ) -> Result<Vec<ViewSection>, AgdError> {
86        let settings = self.index.settings();
87        let mut sections = Vec::new();
88        for block in &view.blocks {
89            let match_expression = block.match_expression.clone();
90            let section = match block.kind.as_str() {
91                "agenda" => {
92                    let span = block.span.unwrap_or(settings.agenda_span);
93                    let (from, to) = span.bounds(start, settings.week_start);
94                    ViewSection {
95                        schema: VIEW_SECTION_SCHEMA.to_string(),
96                        kind: block.kind.clone(),
97                        header: block.header.clone().unwrap_or_default(),
98                        from: Some(from),
99                        to: Some(to),
100                        entries: self.agenda(&AgendaQuery {
101                            from,
102                            to,
103                            today,
104                            match_expression,
105                            log_mode,
106                        })?,
107                        items: Vec::new(),
108                        groups: Vec::new(),
109                        color: String::new(),
110                    }
111                }
112                kind => {
113                    let keywords = match_expression.as_deref().unwrap_or("");
114                    let (items, default_header) = match kind {
115                        // `(todo "DELEGATED")` lists that keyword even when it is
116                        // a done state; only alltodo and tags-todo skip done.
117                        "todo" if !keywords.is_empty() => (
118                            self.tags(&format!("/{keywords}"), today)?,
119                            format!("Global list of TODO items of type: {keywords}"),
120                        ),
121                        "alltodo" | "todo" => (
122                            self.todo(None, today)?,
123                            "Global list of TODO items of type: ALL".to_string(),
124                        ),
125                        "tags" => (
126                            self.tags(keywords, today)?,
127                            format!("Headlines with TAGS match: {keywords}"),
128                        ),
129                        _ => (
130                            self.todo(Some(keywords), today)?,
131                            format!("Headlines with TAGS match: {keywords}"),
132                        ),
133                    };
134                    ViewSection {
135                        schema: VIEW_SECTION_SCHEMA.to_string(),
136                        kind: block.kind.clone(),
137                        header: block.header.clone().unwrap_or(default_header),
138                        from: None,
139                        to: None,
140                        entries: Vec::new(),
141                        items,
142                        groups: Vec::new(),
143                        color: String::new(),
144                    }
145                }
146            };
147            let mut section = self.group_section(section, &block.groups, today)?;
148            section.color = match &block.color {
149                Some(color) => color.clone(),
150                None => {
151                    let grouped = section.groups.iter().flat_map(|group| {
152                        group
153                            .entries
154                            .iter()
155                            .map(|entry| &entry.item)
156                            .chain(&group.items)
157                    });
158                    self.shared_color(
159                        section
160                            .entries
161                            .iter()
162                            .map(|entry| &entry.item)
163                            .chain(&section.items)
164                            .chain(grouped),
165                    )
166                }
167            };
168            sections.push(section);
169        }
170        Ok(sections)
171    }
172
173    /// The color the items' keywords share, or blue when they differ or
174    /// there are none.
175    fn shared_color<'b>(&self, items: impl Iterator<Item = &'b Item>) -> String {
176        let mut colors = items.map(|item| keyword_color(self.index, item));
177        match colors.next() {
178            Some(first) if colors.all(|color| color == first) => first,
179            _ => "blue".to_string(),
180        }
181    }
182
183    /// Moves a block's rows into its groups, if it has any.
184    fn group_section(
185        &self,
186        mut section: ViewSection,
187        groups: &[ViewGroup],
188        today: NaiveDate,
189    ) -> Result<ViewSection, AgdError> {
190        if groups.is_empty() {
191            return Ok(section);
192        }
193        let headlines: HashMap<(&str, usize), &HeadlineSpan> = self
194            .headlines()
195            .map(|headline| ((headline.item.file.as_str(), headline.item.line), headline))
196            .collect();
197        let context = GroupContext {
198            headlines: &headlines,
199            now: matcher_now(today),
200            settings: self.index.settings(),
201        };
202        let entries = group_rows(
203            std::mem::take(&mut section.entries),
204            |entry| &entry.item,
205            groups,
206            &context,
207        )?;
208        let items = group_rows(
209            std::mem::take(&mut section.items),
210            |item| item,
211            groups,
212            &context,
213        )?;
214        section.groups = entries
215            .into_iter()
216            .map(|group| ViewSectionGroup {
217                color: group.color.unwrap_or_else(|| {
218                    self.shared_color(group.rows.iter().map(|entry| &entry.item))
219                }),
220                name: group.name,
221                entries: group.rows,
222                items: Vec::new(),
223            })
224            .chain(items.into_iter().map(|group| {
225                ViewSectionGroup {
226                    color: group
227                        .color
228                        .unwrap_or_else(|| self.shared_color(group.rows.iter())),
229                    name: group.name,
230                    entries: Vec::new(),
231                    items: group.rows,
232                }
233            }))
234            .collect();
235        Ok(section)
236    }
237}
238
239struct GroupContext<'a> {
240    headlines: &'a HashMap<(&'a str, usize), &'a HeadlineSpan>,
241    now: NaiveDateTime,
242    settings: &'a Settings,
243}
244
245const OTHER_ITEMS: &str = "Other items";
246
247/// org-super-agenda's grouping: each group takes the remaining rows it
248/// matches, in list order; groups show by order (equal non-zero orders by
249/// name), the rest last as "Other items" (order 99), empty groups not at
250/// all. Rows keep their order inside a group.
251fn group_rows<T>(
252    rows: Vec<T>,
253    item_of: impl Fn(&T) -> &Item,
254    groups: &[ViewGroup],
255    context: &GroupContext<'_>,
256) -> Result<Vec<RowGroup<T>>, AgdError> {
257    let mut remaining = rows;
258    let mut sections: Vec<RowGroup<T>> = Vec::new();
259    for group in groups {
260        if let Some(by) = &group.by {
261            let mut keyed: BTreeMap<String, Vec<T>> = BTreeMap::new();
262            let mut rest = Vec::new();
263            for row in remaining {
264                match group_key(by, item_of(&row)) {
265                    Some(key) => keyed.entry(key).or_default().push(row),
266                    None => rest.push(row),
267                }
268            }
269            sections.extend(keyed.into_iter().map(|(name, rows)| RowGroup {
270                name,
271                order: group.order,
272                color: group.color.clone(),
273                rows,
274            }));
275            remaining = rest;
276            continue;
277        }
278        let matcher = group
279            .match_expression
280            .as_deref()
281            .map(TagTodoMatcher::parse)
282            .transpose()?;
283        let effort_bound = |bound: &Option<String>| -> Result<Option<f64>, AgdError> {
284            bound
285                .as_deref()
286                .map(|text| {
287                    duration_minutes(text).ok_or_else(|| {
288                        AgdError::new(
289                            ErrorCode::InvalidView,
290                            format!("effort '{text}' is not a duration"),
291                        )
292                    })
293                })
294                .transpose()
295        };
296        let effort_max = effort_bound(&group.effort_max)?;
297        let effort_min = effort_bound(&group.effort_min)?;
298        let (matching, rest): (Vec<T>, Vec<T>) = remaining.into_iter().partition(|row| {
299            let item = item_of(row);
300            let Some(headline) = context.headlines.get(&(item.file.as_str(), item.line)) else {
301                return matcher.is_none() && effort_max.is_none() && effort_min.is_none();
302            };
303            let effort = || {
304                headline
305                    .properties
306                    .iter()
307                    .find(|property| property.key.eq_ignore_ascii_case("Effort"))
308                    .and_then(|property| duration_minutes(&property.value))
309            };
310            matcher
311                .as_ref()
312                .is_none_or(|matcher| matcher.matches(headline, context.now, context.settings))
313                && effort_max.is_none_or(|max| effort().is_some_and(|effort| effort <= max))
314                && effort_min.is_none_or(|min| effort().is_some_and(|effort| effort >= min))
315        });
316        remaining = rest;
317        if !group.discard {
318            let name = group
319                .name
320                .clone()
321                .or_else(|| group.match_expression.clone())
322                .unwrap_or_else(|| OTHER_ITEMS.to_string());
323            sections.push(RowGroup {
324                name,
325                order: group.order,
326                color: group.color.clone(),
327                rows: matching,
328            });
329        }
330    }
331    sections.insert(
332        0,
333        RowGroup {
334            name: OTHER_ITEMS.to_string(),
335            order: 99,
336            color: None,
337            rows: remaining,
338        },
339    );
340    sections.sort_by(|left, right| {
341        left.order.cmp(&right.order).then_with(|| {
342            if left.order == 0 {
343                Ordering::Equal
344            } else {
345                left.name.cmp(&right.name)
346            }
347        })
348    });
349    sections.retain(|group| !group.rows.is_empty());
350    Ok(sections)
351}
352
353struct RowGroup<T> {
354    name: String,
355    order: i64,
356    color: Option<String>,
357    rows: Vec<T>,
358}
359
360static DURATION_NUMBER: LazyLock<Regex> =
361    LazyLock::new(|| Regex::new(r"^[0-9]+(?:\.[0-9]*)?$").unwrap());
362static DURATION: LazyLock<Regex> = LazyLock::new(|| {
363    Regex::new(
364        r"^((?:[0-9]+(?:\.[0-9]*)?[ \t]*(?:min|h|d|w|m|y)[ \t]*)*)(?:([0-9]+):([0-9]{2})(?::([0-9]{2}))?)?$",
365    )
366    .unwrap()
367});
368static DURATION_UNIT: LazyLock<Regex> =
369    LazyLock::new(|| Regex::new(r"([0-9]+(?:\.[0-9]*)?)[ \t]*(min|h|d|w|m|y)").unwrap());
370
371/// Minutes in an Org duration, as org-duration-to-minutes reads it: a
372/// number of minutes, `H:MM` or `H:MM:SS`, unit sums such as `1h 30min` or
373/// `2d` (`m` is a 30-day month, `y` 365.25 days), or units then `H:MM`.
374pub(crate) fn duration_minutes(text: &str) -> Option<f64> {
375    let text = text.trim();
376    if DURATION_NUMBER.is_match(text) {
377        return text.parse().ok();
378    }
379    let captures = DURATION.captures(text)?;
380    let units = captures.get(1).map_or("", |units| units.as_str());
381    if units.is_empty() && captures.get(2).is_none() {
382        return None;
383    }
384    let mut minutes = 0.0;
385    for unit in DURATION_UNIT.captures_iter(units) {
386        let count: f64 = unit[1].parse().ok()?;
387        minutes += count
388            * match &unit[2] {
389                "min" => 1.0,
390                "h" => 60.0,
391                "d" => 1_440.0,
392                "w" => 10_080.0,
393                "m" => 43_200.0,
394                _ => 525_960.0,
395            };
396    }
397    let clock = |index: usize| -> f64 {
398        captures
399            .get(index)
400            .and_then(|part| part.as_str().parse::<f64>().ok())
401            .unwrap_or(0.0)
402    };
403    Some(minutes + clock(2) * 60.0 + clock(3) + clock(4) / 60.0)
404}
405
406/// The group header for a `by` group, as org-super-agenda's auto groups
407/// name them; None leaves the row for later groups.
408fn group_key(by: &str, item: &Item) -> Option<String> {
409    match by {
410        "category" => Some(format!("Category: {}", item.category)),
411        "todo" => item.todo.as_ref().map(|todo| format!("To-do: {todo}")),
412        "priority" => item
413            .priority
414            .as_ref()
415            .map(|priority| format!("Priority: {priority}")),
416        _ => item
417            .olpath
418            .len()
419            .checked_sub(2)
420            .map(|parent| item.olpath[parent].clone()),
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use crate::AgendaSpan;
428    use crate::agenda::test_support::fixture;
429
430    #[test]
431    fn views_run_blocks_in_order_like_a_block_agenda() {
432        let (_temp, index) = fixture(
433            "#+TODO: TODO NEXT | DONE\n* TODO Today :work:\nSCHEDULED: <2026-10-13 Tue>\n* NEXT Later :home:\n* DONE Old :work:\n* Note :work:\n",
434        );
435        let view = ViewSettings {
436            key: "m".to_string(),
437            desc: "Mixed".to_string(),
438            blocks: vec![
439                ViewBlock {
440                    kind: "agenda".to_string(),
441                    match_expression: None,
442                    span: Some(AgendaSpan::Day),
443                    header: Some("Today".to_string()),
444                    color: None,
445                    groups: Vec::new(),
446                },
447                ViewBlock {
448                    kind: "todo".to_string(),
449                    match_expression: Some("NEXT|DONE".to_string()),
450                    span: None,
451                    header: None,
452                    color: None,
453                    groups: Vec::new(),
454                },
455                ViewBlock {
456                    kind: "tags".to_string(),
457                    match_expression: Some("work".to_string()),
458                    span: None,
459                    header: None,
460                    color: None,
461                    groups: Vec::new(),
462                },
463            ],
464        };
465        let today = NaiveDate::from_ymd_opt(2026, 10, 13).unwrap();
466        let sections = AgendaEngine::new(&index)
467            .view(&view, today, today, false)
468            .unwrap();
469        assert_eq!(sections[0].header, "Today");
470        assert_eq!(sections[0].entries.len(), 1);
471        let titles = |section: &ViewSection| -> Vec<String> {
472            section
473                .items
474                .iter()
475                .map(|item| item.title.clone())
476                .collect()
477        };
478        assert_eq!(
479            sections[1].header,
480            "Global list of TODO items of type: NEXT|DONE"
481        );
482        assert_eq!(titles(&sections[1]), ["Later", "Old"]);
483        assert_eq!(titles(&sections[2]), ["Today", "Old", "Note"]);
484    }
485
486    #[test]
487    fn durations_read_as_org_duration_to_minutes_does() {
488        // Expected values printed by org-duration-to-minutes in Emacs 30.2.
489        for (text, minutes) in [
490            ("0:10", 10.0),
491            ("1:30", 90.0),
492            ("10", 10.0),
493            ("90", 90.0),
494            ("1h", 60.0),
495            ("1h 30min", 90.0),
496            ("2d", 2880.0),
497            ("1:05:30", 65.5),
498            (" 0:10 ", 10.0),
499            ("0.5", 0.5),
500            ("1.5h", 90.0),
501        ] {
502            assert_eq!(duration_minutes(text), Some(minutes), "{text}");
503        }
504        assert_eq!(duration_minutes("abc"), None);
505        assert_eq!(duration_minutes(""), None);
506    }
507
508    #[test]
509    fn effort_groups_compare_durations_inclusively() {
510        let (_temp, index) = fixture(
511            "* TODO Ten as a clock\n:PROPERTIES:\n:Effort:   0:10\n:END:\n* TODO Fifteen as minutes\n:PROPERTIES:\n:Effort:   15\n:END:\n* TODO Ninety as a clock\n:PROPERTIES:\n:Effort:   1:30\n:END:\n* TODO No estimate\n",
512        );
513        let view = ViewSettings {
514            key: "e".to_string(),
515            desc: "Effort".to_string(),
516            blocks: vec![ViewBlock {
517                kind: "alltodo".to_string(),
518                match_expression: None,
519                span: None,
520                header: None,
521                color: None,
522                groups: vec![
523                    ViewGroup {
524                        name: Some("Quick".to_string()),
525                        effort_max: Some("0:15".to_string()),
526                        ..ViewGroup::default()
527                    },
528                    ViewGroup {
529                        name: Some("Long".to_string()),
530                        effort_min: Some("1h".to_string()),
531                        ..ViewGroup::default()
532                    },
533                ],
534            }],
535        };
536        let today = NaiveDate::from_ymd_opt(2026, 10, 13).unwrap();
537        let sections = AgendaEngine::new(&index)
538            .view(&view, today, today, false)
539            .unwrap();
540        assert_eq!(
541            group_titles(&sections[0]),
542            [
543                ("Quick", vec!["Ten as a clock", "Fifteen as minutes"]),
544                ("Long", vec!["Ninety as a clock"]),
545                ("Other items", vec!["No estimate"]),
546            ]
547        );
548    }
549
550    /// Each group's name and its items' titles, in order.
551    fn group_titles(section: &ViewSection) -> Vec<(&str, Vec<&str>)> {
552        section
553            .groups
554            .iter()
555            .map(|group| {
556                let titles = group.items.iter().map(|item| item.title.as_str()).collect();
557                (group.name.as_str(), titles)
558            })
559            .collect()
560    }
561
562    #[test]
563    fn view_groups_take_rows_first_match_first_and_show_by_order() {
564        let (_temp, index) = fixture(
565            "#+TODO: TODO NEXT WAIT HOLD | DONE\n* TODO Alone :work:\n* NEXT Decided :home:\n* HOLD Parked\n* WAIT Waiting\n* Project\n** TODO Child\n** TODO Hot child :urgent:\n",
566        );
567        let group = |name: &str, expression: &str, order: i64| ViewGroup {
568            name: Some(name.to_string()),
569            match_expression: Some(expression.to_string()),
570            order,
571            ..ViewGroup::default()
572        };
573        let view = ViewSettings {
574            key: "g".to_string(),
575            desc: "Grouped".to_string(),
576            blocks: vec![ViewBlock {
577                kind: "alltodo".to_string(),
578                match_expression: None,
579                span: None,
580                header: Some("Backlog".to_string()),
581                color: None,
582                groups: vec![
583                    ViewGroup {
584                        match_expression: Some("/HOLD".to_string()),
585                        discard: true,
586                        ..ViewGroup::default()
587                    },
588                    // Matched before the others, shown after them.
589                    group("Waiting", "/WAIT", 9),
590                    group("Next", "/NEXT", -1),
591                    group("Urgent", "urgent", 0),
592                    group("Nothing", "absent", 0),
593                    ViewGroup {
594                        by: Some("parent".to_string()),
595                        ..ViewGroup::default()
596                    },
597                ],
598            }],
599        };
600        let today = NaiveDate::from_ymd_opt(2026, 10, 13).unwrap();
601        let sections = AgendaEngine::new(&index)
602            .view(&view, today, today, false)
603            .unwrap();
604        assert_eq!(
605            group_titles(&sections[0]),
606            [
607                ("Next", vec!["Decided"]),
608                ("Urgent", vec!["Hot child"]),
609                ("Project", vec!["Child"]),
610                ("Waiting", vec!["Waiting"]),
611                ("Other items", vec!["Alone"]),
612            ]
613        );
614        assert!(sections[0].items.is_empty());
615    }
616}