1use 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
26pub 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#[derive(Clone, Debug, Serialize)]
49pub struct ViewSection {
50 pub schema: String,
52 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 pub groups: Vec<ViewSectionGroup>,
61 pub color: String,
64}
65
66#[derive(Clone, Debug, Serialize)]
68pub struct ViewSectionGroup {
69 pub name: String,
70 pub entries: Vec<AgendaEntry>,
71 pub items: Vec<Item>,
72 pub color: String,
74}
75
76impl<'a> AgendaEngine<'a> {
77 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" 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(§ion.items)
164 .chain(grouped),
165 )
166 }
167 };
168 sections.push(section);
169 }
170 Ok(sections)
171 }
172
173 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 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
247fn 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
371pub(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
406fn 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(§ions[1]), ["Later", "Old"]);
483 assert_eq!(titles(§ions[2]), ["Today", "Old", "Note"]);
484 }
485
486 #[test]
487 fn durations_read_as_org_duration_to_minutes_does() {
488 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(§ions[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 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 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(§ions[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}