1use std::cmp::Ordering;
2use std::collections::BTreeSet;
3use std::sync::LazyLock;
4
5use chrono::{Datelike, Duration, Months, NaiveDate};
6use regex::Regex;
7
8use serde::Serialize;
9
10use crate::{
11 AGENDA_ENTRY_SCHEMA, AgdError, AgendaEntry, HeadlineSpan, Item, Occurrence, OccurrenceType,
12 PlanningTimestamp, Settings, ViewBlock, ViewSettings, WorkspaceIndex,
13};
14
15pub fn validate_view_block(block: &ViewBlock) -> Result<(), AgdError> {
17 let Some(expression) = block.match_expression.as_deref() else {
18 return Ok(());
19 };
20 let expression = if block.kind == "todo" {
21 format!("/{expression}")
22 } else {
23 expression.to_string()
24 };
25 TagTodoMatcher::parse(&expression).map(|_| ())
26}
27
28#[derive(Clone, Debug, Serialize)]
31pub struct ViewSection {
32 pub header: String,
33 pub from: Option<NaiveDate>,
34 pub to: Option<NaiveDate>,
35 pub entries: Vec<AgendaEntry>,
36 pub items: Vec<Item>,
37}
38
39pub fn span_days(
42 span: &str,
43 start: NaiveDate,
44 week_start: &str,
45) -> Result<(NaiveDate, NaiveDate), AgdError> {
46 match span {
47 "day" => Ok(AgendaSpan::Day.bounds(start, week_start)),
48 "week" => Ok(AgendaSpan::Week.bounds(start, week_start)),
49 "month" => Ok(AgendaSpan::Month.bounds(start, week_start)),
50 days => {
51 let days = days
52 .parse::<i64>()
53 .ok()
54 .filter(|days| *days > 0)
55 .ok_or_else(|| {
56 AgdError::new(
57 "invalid_span",
58 format!("agenda span must be day, week, month or a day count: {span}"),
59 )
60 })?;
61 Ok((start, start + Duration::days(days - 1)))
62 }
63 }
64}
65
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub enum AgendaSpan {
68 Day,
69 Week,
70 Month,
71}
72
73impl AgendaSpan {
74 pub fn bounds(self, from: NaiveDate, week_start: &str) -> (NaiveDate, NaiveDate) {
75 match self {
76 Self::Day => (from, from),
77 Self::Week => {
78 let desired = if week_start.eq_ignore_ascii_case("sunday") {
79 6
80 } else {
81 0
82 };
83 let current = from.weekday().num_days_from_monday() as i64;
84 let start = from - Duration::days((current - desired).rem_euclid(7));
85 (start, start + Duration::days(6))
86 }
87 Self::Month => {
88 let start = NaiveDate::from_ymd_opt(from.year(), from.month(), 1)
89 .expect("valid first of month");
90 let next = start
91 .checked_add_months(Months::new(1))
92 .expect("next month");
93 (start, next - Duration::days(1))
94 }
95 }
96 }
97}
98
99#[derive(Clone, Debug)]
100pub struct AgendaQuery {
101 pub from: NaiveDate,
102 pub to: NaiveDate,
103 pub today: NaiveDate,
104 pub match_expression: Option<String>,
105 pub log_mode: bool,
107}
108
109#[derive(Clone, Debug)]
110pub struct AgendaEngine<'a> {
111 index: &'a WorkspaceIndex,
112}
113
114impl<'a> AgendaEngine<'a> {
115 pub fn new(index: &'a WorkspaceIndex) -> Self {
116 Self { index }
117 }
118
119 pub fn agenda(&self, query: &AgendaQuery) -> Result<Vec<AgendaEntry>, AgdError> {
120 if query.to < query.from {
121 return Err(AgdError::new(
122 "invalid_range",
123 "agenda end date is before its start date",
124 ));
125 }
126 let matcher = query
127 .match_expression
128 .as_deref()
129 .map(TagTodoMatcher::parse)
130 .transpose()?;
131 let mut entries = Vec::new();
136 for document in self.index.documents() {
137 let mut file_entries = Vec::new();
138 for headline in document.headlines() {
139 if matcher
140 .as_ref()
141 .is_some_and(|matcher| !matcher.matches(&headline.item))
142 {
143 continue;
144 }
145 let occurrences = self.occurrences(headline, query).map_err(|error| {
146 AgdError::new(
147 error.code().to_string(),
148 format!("{}:{}: {error}", headline.item.file, headline.item.line),
149 )
150 })?;
151 file_entries.extend(occurrences);
152 }
153 file_entries.sort_by_key(|entry| collection_rank(entry.occurrence.kind));
154 entries.extend(file_entries);
155 }
156 let settings = self.index.settings();
157 let mut keyed: Vec<(i64, AgendaEntry)> = entries
158 .into_iter()
159 .map(|entry| (urgency(&entry, query.today, settings), entry))
160 .collect();
161 let todo_order: Vec<String> = self
162 .index
163 .settings()
164 .todo_keywords
165 .iter()
166 .flatten()
167 .filter(|keyword| keyword.as_str() != "|")
168 .map(|keyword| keyword_name(keyword).to_string())
169 .collect();
170 keyed.sort_by(|left, right| {
171 left.1
172 .occurrence
173 .date
174 .cmp(&right.1.occurrence.date)
175 .then_with(|| compare_by_strategy(left, right, settings, &todo_order))
176 });
177 Ok(keyed.into_iter().map(|(_, entry)| entry).collect())
178 }
179
180 pub fn todo(&self, match_expression: Option<&str>) -> Result<Vec<Item>, AgdError> {
181 let matcher = match_expression.map(TagTodoMatcher::parse).transpose()?;
182 let mut items: Vec<Item> = self
183 .index
184 .items()
185 .filter(|item| item.todo.is_some() && !item.done)
186 .filter(|item| matcher.as_ref().is_none_or(|matcher| matcher.matches(item)))
187 .cloned()
188 .collect();
189 items.sort_by(compare_items);
190 Ok(items)
191 }
192
193 pub fn inbox(&self) -> Vec<Item> {
197 let mut items = Vec::new();
198 for document in self.index.documents() {
199 let in_inbox_file = document
200 .path()
201 .file_stem()
202 .is_some_and(|stem| stem.eq_ignore_ascii_case("inbox"));
203 for headline in document.headlines() {
204 let item = &headline.item;
205 let ancestors = &item.olpath[..item.olpath.len().saturating_sub(1)];
206 let under_inbox = ancestors
207 .iter()
208 .any(|heading| heading.eq_ignore_ascii_case("inbox"));
209 let leaf = headline.subtree_span.end == headline.section_span.end;
210 let container = in_inbox_file && headline.level == 1 && item.todo.is_none();
213 if !item.done
214 && !container
215 && (under_inbox || in_inbox_file || item.category.eq_ignore_ascii_case("inbox"))
216 && (item.todo.is_some() || leaf)
217 && !item.title.eq_ignore_ascii_case("inbox")
218 {
219 items.push(item.clone());
220 }
221 }
222 }
223 items
224 }
225
226 pub fn tags(&self, match_expression: &str) -> Result<Vec<Item>, AgdError> {
229 let matcher = TagTodoMatcher::parse(match_expression)?;
230 let mut items: Vec<Item> = self
231 .index
232 .items()
233 .filter(|item| matcher.matches(item))
234 .cloned()
235 .collect();
236 items.sort_by(compare_items);
237 Ok(items)
238 }
239
240 pub fn view(
243 &self,
244 view: &ViewSettings,
245 start: NaiveDate,
246 today: NaiveDate,
247 log_mode: bool,
248 ) -> Result<Vec<ViewSection>, AgdError> {
249 let settings = self.index.settings();
250 let mut sections = Vec::new();
251 for block in &view.blocks {
252 let match_expression = block.match_expression.clone();
253 let section = match block.kind.as_str() {
254 "agenda" => {
255 let span = block.span.as_deref().unwrap_or(&settings.agenda_span);
256 let (from, to) = span_days(span, start, &settings.week_start)?;
257 ViewSection {
258 header: block.header.clone().unwrap_or_default(),
259 from: Some(from),
260 to: Some(to),
261 entries: self.agenda(&AgendaQuery {
262 from,
263 to,
264 today,
265 match_expression,
266 log_mode,
267 })?,
268 items: Vec::new(),
269 }
270 }
271 kind => {
272 let keywords = match_expression.as_deref().unwrap_or("");
273 let (items, default_header) = match kind {
274 "alltodo" => (
275 self.todo(None)?,
276 "Global list of TODO items of type: ALL".to_string(),
277 ),
278 "todo" if !keywords.is_empty() => (
281 self.tags(&format!("/{keywords}"))?,
282 format!("Global list of TODO items of type: {keywords}"),
283 ),
284 "todo" => (
285 self.todo(None)?,
286 "Global list of TODO items of type: ALL".to_string(),
287 ),
288 "tags" => (
289 self.tags(keywords)?,
290 format!("Headlines with TAGS match: {keywords}"),
291 ),
292 _ => (
293 self.todo(Some(keywords))?,
294 format!("Headlines with TAGS match: {keywords}"),
295 ),
296 };
297 ViewSection {
298 header: block.header.clone().unwrap_or(default_header),
299 from: None,
300 to: None,
301 entries: Vec::new(),
302 items,
303 }
304 }
305 };
306 sections.push(section);
307 }
308 Ok(sections)
309 }
310
311 pub fn query(&self, expression: &str, today: NaiveDate) -> Result<Vec<Item>, AgdError> {
312 let expression = QlExpr::parse(expression)?;
313 let mut items: Vec<Item> = self
314 .index
315 .items()
316 .filter(|item| expression.matches(item, today))
317 .cloned()
318 .collect();
319 items.sort_by(compare_items);
320 Ok(items)
321 }
322
323 fn occurrences(
324 &self,
325 headline: &HeadlineSpan,
326 query: &AgendaQuery,
327 ) -> Result<Vec<AgendaEntry>, AgdError> {
328 let (from, to, today) = (query.from, query.to, query.today);
329 let item = &headline.item;
330 let settings = self.index.settings();
331 let mut entries = Vec::new();
332
333 if !(item.done && settings.skip_scheduled_if_done)
338 && let Some(scheduled) = &item.planning.scheduled
339 {
340 let base = parse_date(&scheduled.date)?;
341 for date in planning_dates(scheduled, from, to, today, item.done)? {
342 entries.push(entry(
343 item,
344 OccurrenceType::Scheduled,
345 date,
346 scheduled.time.clone(),
347 "Scheduled:".to_string(),
348 ));
349 }
350 if !item.done && base < today && today >= from && today <= to {
351 entries.push(entry(
352 item,
353 OccurrenceType::PastScheduled,
354 today,
355 None,
356 format!("Sched.{:>2}x:", (today - base).num_days()),
357 ));
358 }
359 }
360
361 if !(item.done && settings.skip_deadline_if_done)
362 && let Some(deadline) = &item.planning.deadline
363 {
364 let base = parse_date(&deadline.date)?;
365 for date in planning_dates(deadline, from, to, today, item.done)? {
366 entries.push(entry(
367 item,
368 OccurrenceType::Deadline,
369 date,
370 deadline.time.clone(),
371 "Deadline:".to_string(),
372 ));
373 }
374 let warning_days = deadline
375 .warning
376 .as_deref()
377 .and_then(parse_day_interval)
378 .unwrap_or(settings.deadline_warning_days)
379 .max(0);
380 if !item.done && today >= from && today <= to {
381 if base < today {
382 entries.push(entry(
383 item,
384 OccurrenceType::Deadline,
385 today,
386 None,
387 format!("{} d. ago:", (today - base).num_days()),
388 ));
389 } else if base > today && (base - today).num_days() <= warning_days {
390 entries.push(entry(
391 item,
392 OccurrenceType::UpcomingDeadline,
393 today,
394 None,
395 format!("In {:>3} d.:", (base - today).num_days()),
396 ));
397 }
398 }
399 }
400
401 if query.log_mode
402 && let Some(closed) = &item.planning.closed
403 {
404 let date = parse_date(&closed.date)?;
405 if date >= from && date <= to {
406 entries.push(entry(
407 item,
408 OccurrenceType::Closed,
409 date,
410 closed.time.clone(),
411 "Closed:".to_string(),
412 ));
413 }
414 }
415
416 if !(item.done && settings.skip_timestamp_if_done) {
417 let mut index = 0;
418 while index < headline.timestamps.len() {
419 let timestamp = &headline.timestamps[index];
420 if !timestamp.active {
421 index += 1;
422 continue;
423 }
424 if let Some(end) = headline.timestamps.get(index + 1)
425 && end.active
426 && headline.timestamps[index].span.end <= end.span.start
427 {
428 let document = self
429 .index
430 .document(std::path::Path::new(&item.file))
431 .expect("headline document");
432 if document.source()[timestamp.span.end..end.span.start].trim() == "--" {
433 let start_date = parse_date(×tamp.timestamp.date)?;
434 let end_date = parse_date(&end.timestamp.date)?;
435 let total = (end_date - start_date).num_days() + 1;
436 for day in date_range(start_date.max(from), end_date.min(to)) {
437 let ordinal = (day - start_date).num_days() + 1;
438 let time = if day == start_date {
439 timestamp.timestamp.time.clone()
440 } else if day == end_date {
441 end.timestamp.time.clone()
442 } else {
443 None
444 };
445 entries.push(entry(
446 item,
447 OccurrenceType::Block,
448 day,
449 time,
450 format!("({ordinal}/{total}):"),
451 ));
452 }
453 index += 2;
454 continue;
455 }
456 }
457 for date in repeated_dates(×tamp.timestamp, from, to, false)? {
458 entries.push(entry(
459 item,
460 OccurrenceType::Timestamp,
461 date,
462 timestamp.timestamp.time.clone(),
463 String::new(),
464 ));
465 }
466 index += 1;
467 }
468 }
469 Ok(entries)
470 }
471}
472
473#[derive(Clone, Debug)]
474struct TagTodoMatcher {
479 alternatives: Vec<TagTerms>,
480 todo_include: BTreeSet<String>,
481 todo_exclude: BTreeSet<String>,
482 todo_not_done: bool,
483}
484
485#[derive(Clone, Debug, Default)]
486struct TagTerms {
487 include: BTreeSet<String>,
488 exclude: BTreeSet<String>,
489}
490
491impl TagTodoMatcher {
492 fn parse(input: &str) -> Result<Self, AgdError> {
493 let (tags, todo) = input.split_once('/').unwrap_or((input, ""));
494 let mut alternatives = Vec::new();
495 if !tags.trim().is_empty() {
496 for alternative in tags.split('|') {
497 alternatives.push(parse_tag_terms(alternative, input)?);
498 }
499 }
500 let todo_not_done = todo.starts_with('!');
501 let mut todo_include = BTreeSet::new();
502 let mut todo_exclude = BTreeSet::new();
503 for term in todo.trim_start_matches('!').split('|') {
504 let term = term.trim();
505 if let Some(keyword) = term.strip_prefix('-') {
506 todo_exclude.insert(keyword.to_string());
507 } else if !term.is_empty() {
508 todo_include.insert(term.trim_start_matches('+').to_string());
509 }
510 }
511 Ok(Self {
512 alternatives,
513 todo_include,
514 todo_exclude,
515 todo_not_done,
516 })
517 }
518
519 fn matches(&self, item: &Item) -> bool {
520 let tags: BTreeSet<&str> = item
521 .tags
522 .iter()
523 .chain(&item.inherited_tags)
524 .map(String::as_str)
525 .collect();
526 let tags_match = self.alternatives.is_empty()
527 || self.alternatives.iter().any(|terms| {
528 terms.include.iter().all(|tag| tags.contains(tag.as_str()))
529 && terms.exclude.iter().all(|tag| !tags.contains(tag.as_str()))
530 });
531 let todo = item.todo.as_deref();
532 tags_match
533 && (!self.todo_not_done || (todo.is_some() && !item.done))
534 && (self.todo_include.is_empty()
535 || todo.is_some_and(|todo| self.todo_include.contains(todo)))
536 && todo.is_none_or(|todo| !self.todo_exclude.contains(todo))
537 }
538}
539
540fn parse_tag_terms(alternative: &str, input: &str) -> Result<TagTerms, AgdError> {
541 let mut terms = TagTerms::default();
542 let mut chars = alternative.trim().chars().peekable();
543 while let Some(&next) = chars.peek() {
544 let include = match next {
545 '+' | '&' => {
546 chars.next();
547 if next == '&' {
548 continue;
549 }
550 true
551 }
552 '-' => {
553 chars.next();
554 false
555 }
556 _ => true,
557 };
558 let mut tag = String::new();
559 while let Some(&character) = chars.peek() {
560 if character.is_alphanumeric() || matches!(character, '_' | '@' | '#' | '%') {
561 tag.push(character);
562 chars.next();
563 } else {
564 break;
565 }
566 }
567 if tag.is_empty() {
568 let found = chars.peek().map_or("end".to_string(), |c| format!("'{c}'"));
569 return Err(AgdError::new(
570 "invalid_match",
571 format!(
572 "expected a tag before {found} in '{input}'; property and regexp matches are not supported"
573 ),
574 ));
575 }
576 if include {
577 terms.include.insert(tag);
578 } else {
579 terms.exclude.insert(tag);
580 }
581 }
582 Ok(terms)
583}
584
585#[derive(Clone, Debug)]
586enum QlExpr {
587 And(Vec<QlExpr>),
588 Or(Vec<QlExpr>),
589 Not(Box<QlExpr>),
590 Todo(String),
591 Tags(String),
592 Priority(String),
593 Category(String),
594 Text(String),
595 Deadline(i64),
596 Scheduled(i64),
597 Done,
598}
599
600impl QlExpr {
601 fn parse(input: &str) -> Result<Self, AgdError> {
602 let tokens = tokenize(input)?;
603 let mut cursor = 0;
604 let expression = parse_ql_expression(&tokens, &mut cursor)?;
605 if cursor != tokens.len() {
606 return Err(AgdError::new(
607 "invalid_query",
608 "unexpected tokens after query",
609 ));
610 }
611 Ok(expression)
612 }
613
614 fn matches(&self, item: &Item, today: NaiveDate) -> bool {
615 match self {
616 Self::And(expressions) => expressions
617 .iter()
618 .all(|expression| expression.matches(item, today)),
619 Self::Or(expressions) => expressions
620 .iter()
621 .any(|expression| expression.matches(item, today)),
622 Self::Not(expression) => !expression.matches(item, today),
623 Self::Todo(todo) => item.todo.as_ref() == Some(todo),
624 Self::Tags(tag) => item
625 .tags
626 .iter()
627 .chain(&item.inherited_tags)
628 .any(|value| value == tag),
629 Self::Priority(priority) => item.priority.as_ref() == Some(priority),
630 Self::Category(category) => item.category == *category,
631 Self::Text(text) => item.title.to_lowercase().contains(&text.to_lowercase()),
632 Self::Deadline(days) => item
633 .planning
634 .deadline
635 .as_ref()
636 .and_then(|timestamp| parse_date(×tamp.date).ok())
637 .is_some_and(|date| date >= today && date <= today + Duration::days(*days)),
638 Self::Scheduled(days) => item
639 .planning
640 .scheduled
641 .as_ref()
642 .and_then(|timestamp| parse_date(×tamp.date).ok())
643 .is_some_and(|date| date >= today && date <= today + Duration::days(*days)),
644 Self::Done => item.done,
645 }
646 }
647}
648
649#[derive(Clone, Debug, Eq, PartialEq)]
650enum Token {
651 Open,
652 Close,
653 Word(String),
654 String(String),
655}
656
657fn tokenize(input: &str) -> Result<Vec<Token>, AgdError> {
658 let mut tokens = Vec::new();
659 let mut chars = input.char_indices().peekable();
660 while let Some((_, character)) = chars.next() {
661 match character {
662 '(' => tokens.push(Token::Open),
663 ')' => tokens.push(Token::Close),
664 '"' => {
665 let mut value = String::new();
666 let mut closed = false;
667 while let Some((_, character)) = chars.next() {
668 if character == '"' {
669 closed = true;
670 break;
671 }
672 if character == '\\' {
673 let Some((_, escaped)) = chars.next() else {
674 break;
675 };
676 value.push(escaped);
677 } else {
678 value.push(character);
679 }
680 }
681 if !closed {
682 return Err(AgdError::new("invalid_query", "unterminated string"));
683 }
684 tokens.push(Token::String(value));
685 }
686 character if character.is_whitespace() => {}
687 _ => {
688 let mut value = String::from(character);
689 while let Some((_, next)) = chars.peek() {
690 if next.is_whitespace() || matches!(next, '(' | ')') {
691 break;
692 }
693 value.push(*next);
694 chars.next();
695 }
696 tokens.push(Token::Word(value));
697 }
698 }
699 }
700 Ok(tokens)
701}
702
703fn parse_ql_expression(tokens: &[Token], cursor: &mut usize) -> Result<QlExpr, AgdError> {
704 expect(tokens, cursor, Token::Open)?;
705 let operator = word(tokens, cursor)?;
706 let expression = match operator.as_str() {
707 "and" | "or" => {
708 let mut children = Vec::new();
709 while tokens.get(*cursor) != Some(&Token::Close) {
710 children.push(parse_ql_expression(tokens, cursor)?);
711 }
712 if children.is_empty() {
713 return Err(AgdError::new(
714 "invalid_query",
715 format!("{operator} needs operands"),
716 ));
717 }
718 if operator == "and" {
719 QlExpr::And(children)
720 } else {
721 QlExpr::Or(children)
722 }
723 }
724 "not" => QlExpr::Not(Box::new(parse_ql_expression(tokens, cursor)?)),
725 "todo" => QlExpr::Todo(string_value(tokens, cursor)?),
726 "tags" => QlExpr::Tags(string_value(tokens, cursor)?),
727 "priority" => QlExpr::Priority(string_value(tokens, cursor)?),
728 "category" => QlExpr::Category(string_value(tokens, cursor)?),
729 "text" => QlExpr::Text(string_value(tokens, cursor)?),
730 "done" => QlExpr::Done,
731 "deadline" | "scheduled" => {
732 let keyword = word(tokens, cursor)?;
733 if keyword != ":to" {
734 return Err(AgdError::new(
735 "invalid_query",
736 format!("{operator} supports only :to"),
737 ));
738 }
739 let days = word(tokens, cursor)?.parse::<i64>().map_err(|_| {
740 AgdError::new("invalid_query", format!("{operator} :to needs an integer"))
741 })?;
742 if operator == "deadline" {
743 QlExpr::Deadline(days)
744 } else {
745 QlExpr::Scheduled(days)
746 }
747 }
748 _ => {
749 return Err(AgdError::new(
750 "invalid_query",
751 format!("unsupported selector: {operator}"),
752 ));
753 }
754 };
755 expect(tokens, cursor, Token::Close)?;
756 Ok(expression)
757}
758
759fn expect(tokens: &[Token], cursor: &mut usize, expected: Token) -> Result<(), AgdError> {
760 if tokens.get(*cursor) == Some(&expected) {
761 *cursor += 1;
762 Ok(())
763 } else {
764 Err(AgdError::new(
765 "invalid_query",
766 format!("expected {expected:?}"),
767 ))
768 }
769}
770
771fn word(tokens: &[Token], cursor: &mut usize) -> Result<String, AgdError> {
772 match tokens.get(*cursor) {
773 Some(Token::Word(value)) => {
774 *cursor += 1;
775 Ok(value.clone())
776 }
777 _ => Err(AgdError::new("invalid_query", "expected a word")),
778 }
779}
780
781fn string_value(tokens: &[Token], cursor: &mut usize) -> Result<String, AgdError> {
782 match tokens.get(*cursor) {
783 Some(Token::String(value)) => {
784 *cursor += 1;
785 Ok(value.clone())
786 }
787 _ => Err(AgdError::new("invalid_query", "expected a quoted string")),
788 }
789}
790
791static ACTIVE_TIMESTAMP_RE: LazyLock<Regex> = LazyLock::new(|| {
792 Regex::new(r"[ \t]*<\d{4}-\d{2}-\d{2}[^>\n]*>").expect("active timestamp regex")
793});
794
795pub fn agenda_title(item: &Item) -> String {
798 ACTIVE_TIMESTAMP_RE
799 .replace_all(&item.title, "")
800 .trim()
801 .to_string()
802}
803
804pub fn agenda_csv(entries: &[AgendaEntry]) -> String {
805 let mut output = String::new();
806 for entry in entries {
807 let mut seen_tags = BTreeSet::new();
808 let tags = entry
809 .item
810 .inherited_tags
811 .iter()
812 .chain(&entry.item.tags)
813 .filter(|tag| seen_tags.insert((*tag).clone()))
814 .cloned()
815 .collect::<Vec<_>>()
816 .join(":");
817 let fields = [
818 entry.item.category.clone(),
819 agenda_title(&entry.item),
820 occurrence_name(entry.occurrence.kind).to_string(),
821 entry.item.todo.clone().unwrap_or_default(),
822 tags,
823 entry.occurrence.date.clone(),
824 entry
826 .occurrence
827 .time
828 .as_deref()
829 .map(|time| match time.strip_prefix('0') {
830 Some(rest) if rest.starts_with(|c: char| c.is_ascii_digit()) => rest,
831 _ => time,
832 })
833 .unwrap_or_default()
834 .to_string(),
835 entry.occurrence.extra.clone(),
836 entry.item.priority.clone().unwrap_or_default(),
837 ];
838 output.push_str(&fields.map(|field| csv_field(&field)).join(","));
839 output.push('\n');
840 }
841 output
842}
843
844fn entry(
845 item: &Item,
846 kind: OccurrenceType,
847 date: NaiveDate,
848 time: Option<String>,
849 extra: String,
850) -> AgendaEntry {
851 AgendaEntry {
852 schema: AGENDA_ENTRY_SCHEMA.to_string(),
853 item: item.clone(),
854 occurrence: Occurrence {
855 kind,
856 date: date.format("%Y-%m-%d").to_string(),
857 time,
858 extra,
859 },
860 }
861}
862
863fn repeated_dates(
864 timestamp: &PlanningTimestamp,
865 from: NaiveDate,
866 to: NaiveDate,
867 done: bool,
868) -> Result<Vec<NaiveDate>, AgdError> {
869 let mut date = parse_date(×tamp.date)?;
870 if done {
871 return Ok((date >= from && date <= to)
872 .then_some(date)
873 .into_iter()
874 .collect());
875 }
876 let Some(repeater) = timestamp.repeater.as_deref() else {
877 return Ok((date >= from && date <= to)
878 .then_some(date)
879 .into_iter()
880 .collect());
881 };
882 let (_, amount, unit) = parse_repeater(repeater)?;
883 while date < from {
884 date = add_period(date, amount, unit)?;
885 }
886 let mut dates = Vec::new();
887 while date <= to {
888 dates.push(date);
889 date = add_period(date, amount, unit)?;
890 }
891 Ok(dates)
892}
893
894fn planning_dates(
897 timestamp: &PlanningTimestamp,
898 from: NaiveDate,
899 to: NaiveDate,
900 today: NaiveDate,
901 done: bool,
902) -> Result<Vec<NaiveDate>, AgdError> {
903 let base = parse_date(×tamp.date)?;
904 let mut dates = Vec::new();
905 if base >= from && base <= to {
906 dates.push(base);
907 }
908 if done {
909 return Ok(dates);
910 }
911 for date in repeated_dates(timestamp, from, to, false)? {
912 if date > today && date != base {
913 dates.push(date);
914 }
915 }
916 Ok(dates)
917}
918
919fn parse_repeater(input: &str) -> Result<(&str, u64, char), AgdError> {
920 let input = input.split('/').next().unwrap_or(input);
923 let (kind, value) = if let Some(value) = input.strip_prefix("++") {
924 ("++", value)
925 } else if let Some(value) = input.strip_prefix(".+") {
926 (".+", value)
927 } else if let Some(value) = input.strip_prefix('+') {
928 ("+", value)
929 } else {
930 return Err(AgdError::new(
931 "invalid_repeater",
932 format!("invalid repeater: {input}"),
933 ));
934 };
935 let unit = value
936 .chars()
937 .next_back()
938 .ok_or_else(|| AgdError::new("invalid_repeater", format!("invalid repeater: {input}")))?;
939 let amount = value[..value.len() - unit.len_utf8()]
940 .parse::<u64>()
941 .map_err(|_| AgdError::new("invalid_repeater", format!("invalid repeater: {input}")))?;
942 if amount == 0 {
943 return Err(AgdError::new(
944 "invalid_repeater",
945 "repeater interval must be positive",
946 ));
947 }
948 Ok((kind, amount, unit))
949}
950
951fn add_period(date: NaiveDate, amount: u64, unit: char) -> Result<NaiveDate, AgdError> {
952 let next = match unit {
953 'd' => date.checked_add_signed(Duration::days(amount as i64)),
954 'w' => date.checked_add_signed(Duration::weeks(amount as i64)),
955 'm' => date.checked_add_months(Months::new(amount as u32)),
956 'y' => date.checked_add_months(Months::new((amount * 12) as u32)),
957 _ => None,
958 };
959 next.ok_or_else(|| AgdError::new("invalid_repeater", "repeater date is out of range"))
960}
961
962fn parse_day_interval(input: &str) -> Option<i64> {
963 let value = input.strip_prefix('-')?;
964 let days = value.strip_suffix('d')?;
965 days.parse().ok()
966}
967
968fn parse_date(input: &str) -> Result<NaiveDate, AgdError> {
969 NaiveDate::parse_from_str(input, "%Y-%m-%d")
970 .map_err(|_| AgdError::new("invalid_date", format!("invalid Org date: {input}")))
971}
972
973fn date_range(start: NaiveDate, end: NaiveDate) -> impl Iterator<Item = NaiveDate> {
974 (0..=(end - start).num_days().max(-1)).map(move |days| start + Duration::days(days))
975}
976
977fn collection_rank(kind: OccurrenceType) -> u8 {
978 match kind {
979 OccurrenceType::Deadline | OccurrenceType::UpcomingDeadline => 0,
980 OccurrenceType::Scheduled | OccurrenceType::PastScheduled => 1,
981 OccurrenceType::Block => 2,
982 OccurrenceType::Timestamp => 3,
983 OccurrenceType::Closed | OccurrenceType::Todo => 4,
984 }
985}
986
987fn compare_by_strategy(
991 (left_urgency, left): &(i64, AgendaEntry),
992 (right_urgency, right): &(i64, AgendaEntry),
993 settings: &Settings,
994 todo_order: &[String],
995) -> Ordering {
996 let todo_rank = |entry: &AgendaEntry| {
997 entry.item.todo.as_deref().map_or(-1, |todo| {
998 todo_order
999 .iter()
1000 .position(|keyword| keyword == todo)
1001 .map_or(-1, |index| index as i64)
1002 })
1003 };
1004 for key in &settings.sorting_strategy {
1005 let (name, direction) = key.rsplit_once('-').unwrap_or((key, "up"));
1006 let ordering = match name {
1007 "time" => compare_time_of_day(&left.occurrence.time, &right.occurrence.time),
1008 "urgency" => left_urgency.cmp(right_urgency),
1009 "priority" => priority_value(left.item.priority.as_deref(), settings)
1010 .cmp(&priority_value(right.item.priority.as_deref(), settings)),
1011 "category" if direction != "keep" => left.item.category.cmp(&right.item.category),
1012 "alpha" => agenda_title(&left.item).cmp(&agenda_title(&right.item)),
1013 "todo-state" => todo_rank(left).cmp(&todo_rank(right)),
1014 _ => Ordering::Equal,
1015 };
1016 let ordering = if direction == "down" {
1017 ordering.reverse()
1018 } else {
1019 ordering
1020 };
1021 if ordering != Ordering::Equal {
1022 return ordering;
1023 }
1024 }
1025 Ordering::Equal
1026}
1027
1028fn keyword_name(keyword: &str) -> &str {
1029 keyword.split(['(', '!', '@']).next().unwrap_or(keyword)
1030}
1031
1032fn urgency(entry: &AgendaEntry, today: NaiveDate, settings: &Settings) -> i64 {
1036 let priority = priority_value(entry.item.priority.as_deref(), settings);
1037 let date = parse_date(&entry.occurrence.date).ok();
1038 match entry.occurrence.kind {
1039 OccurrenceType::Deadline | OccurrenceType::UpcomingDeadline => {
1040 let base = entry
1041 .item
1042 .planning
1043 .deadline
1044 .as_ref()
1045 .and_then(|deadline| parse_date(&deadline.date).ok());
1046 match (date, base) {
1047 (Some(date), Some(base)) if date == today => priority + (today - base).num_days(),
1048 _ => priority,
1049 }
1050 }
1051 OccurrenceType::Scheduled | OccurrenceType::PastScheduled => {
1052 let base = entry
1053 .item
1054 .planning
1055 .scheduled
1056 .as_ref()
1057 .and_then(|scheduled| parse_date(&scheduled.date).ok());
1058 match (date, base) {
1059 (Some(date), Some(base)) => priority + 99 + (date - base).num_days(),
1060 _ => priority + 99,
1061 }
1062 }
1063 _ => priority,
1064 }
1065}
1066
1067fn priority_value(priority: Option<&str>, settings: &Settings) -> i64 {
1068 let letter = |value: &str| value.chars().next().map_or(0, |c| c as i64);
1069 let lowest = letter(&settings.lowest_priority);
1070 let value = priority.map_or_else(|| letter(&settings.default_priority), letter);
1071 1000 * (lowest - value)
1072}
1073
1074fn compare_time_of_day(left: &Option<String>, right: &Option<String>) -> Ordering {
1076 match (left, right) {
1077 (Some(left), Some(right)) => time_minutes(left).cmp(&time_minutes(right)),
1078 (Some(_), None) => Ordering::Less,
1079 (None, Some(_)) => Ordering::Greater,
1080 (None, None) => Ordering::Equal,
1081 }
1082}
1083
1084fn time_minutes(time: &str) -> u32 {
1085 let (hours, minutes) = time.split_once(':').unwrap_or((time, "0"));
1086 hours.parse::<u32>().unwrap_or(0) * 60 + minutes.parse::<u32>().unwrap_or(0)
1087}
1088
1089fn compare_items(left: &Item, right: &Item) -> Ordering {
1092 compare_priority(&left.priority, &right.priority)
1093 .then_with(|| left.file.cmp(&right.file))
1094 .then_with(|| left.line.cmp(&right.line))
1095}
1096
1097fn compare_priority(left: &Option<String>, right: &Option<String>) -> Ordering {
1098 left.as_deref()
1099 .unwrap_or("B")
1100 .cmp(right.as_deref().unwrap_or("B"))
1101}
1102
1103fn occurrence_name(kind: OccurrenceType) -> &'static str {
1104 match kind {
1105 OccurrenceType::Scheduled => "scheduled",
1106 OccurrenceType::PastScheduled => "past-scheduled",
1107 OccurrenceType::Deadline => "deadline",
1108 OccurrenceType::UpcomingDeadline => "upcoming-deadline",
1109 OccurrenceType::Timestamp => "timestamp",
1110 OccurrenceType::Block => "block",
1111 OccurrenceType::Closed => "closed",
1112 OccurrenceType::Todo => "todo",
1113 }
1114}
1115
1116pub fn items_csv(items: &[Item]) -> String {
1118 let mut output = String::new();
1119 for item in items {
1120 let mut seen_tags = BTreeSet::new();
1121 let tags = item
1122 .inherited_tags
1123 .iter()
1124 .chain(&item.tags)
1125 .filter(|tag| seen_tags.insert((*tag).clone()))
1126 .cloned()
1127 .collect::<Vec<_>>()
1128 .join(":");
1129 let fields = [
1130 item.category.clone(),
1131 agenda_title(item),
1132 "todo".to_string(),
1133 item.todo.clone().unwrap_or_default(),
1134 tags,
1135 String::new(),
1136 String::new(),
1137 String::new(),
1138 item.priority.clone().unwrap_or_default(),
1139 ];
1140 output.push_str(&fields.map(|field| csv_field(&field)).join(","));
1141 output.push('\n');
1142 }
1143 output
1144}
1145
1146fn csv_field(value: &str) -> String {
1147 if value.contains([',', '"', '\n', '\r']) {
1148 format!("\"{}\"", value.replace('"', "\"\""))
1149 } else {
1150 value.to_string()
1151 }
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156 use super::*;
1157 use crate::Settings;
1158
1159 fn fixture(source: &str) -> (tempfile::TempDir, WorkspaceIndex) {
1160 let temp = tempfile::tempdir().unwrap();
1161 std::fs::write(temp.path().join("agenda.org"), source).unwrap();
1162 let settings = Settings {
1163 roots: vec![temp.path().to_string_lossy().into_owned()],
1164 agenda_files: vec!["**/*.org".to_string()],
1165 ..Settings::default()
1166 };
1167 let index = WorkspaceIndex::load(settings).unwrap();
1168 (temp, index)
1169 }
1170
1171 #[test]
1172 fn views_run_blocks_in_order_like_a_block_agenda() {
1173 let (_temp, index) = fixture(
1174 "#+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",
1175 );
1176 let view = ViewSettings {
1177 key: "m".to_string(),
1178 desc: "Mixed".to_string(),
1179 blocks: vec![
1180 ViewBlock {
1181 kind: "agenda".to_string(),
1182 match_expression: None,
1183 span: Some("day".to_string()),
1184 header: Some("Today".to_string()),
1185 },
1186 ViewBlock {
1187 kind: "todo".to_string(),
1188 match_expression: Some("NEXT|DONE".to_string()),
1189 span: None,
1190 header: None,
1191 },
1192 ViewBlock {
1193 kind: "tags".to_string(),
1194 match_expression: Some("work".to_string()),
1195 span: None,
1196 header: None,
1197 },
1198 ],
1199 };
1200 let today = NaiveDate::from_ymd_opt(2026, 10, 13).unwrap();
1201 let sections = AgendaEngine::new(&index)
1202 .view(&view, today, today, false)
1203 .unwrap();
1204 assert_eq!(sections[0].header, "Today");
1205 assert_eq!(sections[0].entries.len(), 1);
1206 let titles = |section: &ViewSection| -> Vec<String> {
1207 section
1208 .items
1209 .iter()
1210 .map(|item| item.title.clone())
1211 .collect()
1212 };
1213 assert_eq!(
1214 sections[1].header,
1215 "Global list of TODO items of type: NEXT|DONE"
1216 );
1217 assert_eq!(titles(§ions[1]), ["Later", "Old"]);
1218 assert_eq!(titles(§ions[2]), ["Today", "Old", "Note"]);
1219 }
1220
1221 #[test]
1222 fn tags_todo_match_follows_emacs_syntax() {
1223 let (_temp, index) = fixture(
1224 "#+TODO: TODO NEXT WAIT | DONE\n* TODO A :work:\n* NEXT B :work:urgent:\n* WAIT C :home:\n* DONE D :home:\n* TODO E :work:someday:\n",
1225 );
1226 let titles = |expression: &str| -> Vec<String> {
1227 index
1228 .items()
1229 .filter(|item| TagTodoMatcher::parse(expression).unwrap().matches(item))
1230 .map(|item| item.title.clone())
1231 .collect()
1232 };
1233 assert_eq!(titles("work"), ["A", "B", "E"]);
1234 assert_eq!(titles("work-someday"), ["A", "B"]);
1235 assert_eq!(titles("work&urgent|home"), ["B", "C", "D"]);
1236 assert_eq!(titles("+work/NEXT|TODO"), ["A", "B", "E"]);
1237 assert_eq!(titles("home/!"), ["C"]);
1238 assert_eq!(titles("/-WAIT"), ["A", "B", "D", "E"]);
1239 assert_eq!(
1240 TagTodoMatcher::parse("PRIORITY=\"A\"")
1241 .err()
1242 .unwrap()
1243 .code(),
1244 "invalid_match"
1245 );
1246 }
1247
1248 #[test]
1249 fn expands_warnings_repeaters_ranges_and_past_scheduled() {
1250 let (_temp, index) = fixture(
1251 "#+TODO: TODO | DONE\n#+FILETAGS: :work:\n* TODO Late\nSCHEDULED: <2026-10-01 Thu>\n* TODO Due\nDEADLINE: <2026-10-16 Fri -2d>\n* TODO Repeat\nSCHEDULED: <2026-10-12 Mon +2d>\n* TODO Block\n<2026-10-14 Wed>--<2026-10-15 Thu>\n",
1252 );
1253 let entries = AgendaEngine::new(&index)
1254 .agenda(&AgendaQuery {
1255 from: NaiveDate::from_ymd_opt(2026, 10, 13).unwrap(),
1256 to: NaiveDate::from_ymd_opt(2026, 10, 16).unwrap(),
1257 today: NaiveDate::from_ymd_opt(2026, 10, 14).unwrap(),
1258 match_expression: Some("+work/TODO".to_string()),
1259 log_mode: false,
1260 })
1261 .unwrap();
1262 assert!(
1263 entries
1264 .iter()
1265 .any(|entry| { entry.occurrence.kind == OccurrenceType::PastScheduled })
1266 );
1267 assert_eq!(
1268 entries
1269 .iter()
1270 .filter(|entry| entry.occurrence.kind == OccurrenceType::UpcomingDeadline)
1271 .count(),
1272 1
1273 );
1274 assert_eq!(
1275 entries
1276 .iter()
1277 .filter(|entry| entry.item.title == "Repeat")
1278 .count(),
1279 2
1280 );
1281 assert_eq!(
1282 entries
1283 .iter()
1284 .filter(|entry| entry.occurrence.kind == OccurrenceType::Block)
1285 .count(),
1286 2
1287 );
1288 }
1289
1290 #[test]
1291 fn evaluates_documented_org_ql_subset() {
1292 let (_temp, index) = fixture(
1293 "#+TODO: TODO NEXT | DONE\n* NEXT [#A] Report :work:\nDEADLINE: <2026-10-16 Fri>\n* TODO Other :personal:\n",
1294 );
1295 let items = AgendaEngine::new(&index)
1296 .query(
1297 "(and (todo \"NEXT\") (tags \"work\") (deadline :to 7))",
1298 NaiveDate::from_ymd_opt(2026, 10, 13).unwrap(),
1299 )
1300 .unwrap();
1301 assert_eq!(items.len(), 1);
1302 assert_eq!(items[0].title, "Report");
1303 }
1304
1305 #[test]
1306 fn csv_uses_emacs_occurrence_vocabulary() {
1307 let (_temp, index) = fixture("* TODO Call, bank\nSCHEDULED: <2026-10-13 Tue 10:00>\n");
1308 let entries = AgendaEngine::new(&index)
1309 .agenda(&AgendaQuery {
1310 from: NaiveDate::from_ymd_opt(2026, 10, 13).unwrap(),
1311 to: NaiveDate::from_ymd_opt(2026, 10, 13).unwrap(),
1312 today: NaiveDate::from_ymd_opt(2026, 10, 13).unwrap(),
1313 match_expression: None,
1314 log_mode: false,
1315 })
1316 .unwrap();
1317 assert!(agenda_csv(&entries).contains("\"Call, bank\",scheduled,TODO"));
1318 }
1319}