Skip to main content

agd_core/agenda/
export.rs

1//! The agenda as Emacs prints it: `org-batch-agenda` text rows,
2//! `org-batch-agenda-csv` columns, and the headline text a row shows.
3//!
4//! The layouts match pinned Emacs byte for byte (checked in
5//! `conformance/agenda`), so change one only together with its fixture. This
6//! module decides nothing about which rows exist or their order.
7
8use std::collections::BTreeSet;
9use std::sync::LazyLock;
10
11use chrono::{Datelike, NaiveDate};
12use regex::Regex;
13use unicode_width::UnicodeWidthStr;
14
15use crate::{AgendaEntry, Item, Occurrence, OccurrenceType, WeekStart, WorkspaceIndex};
16
17use super::engine::date_range;
18use super::sort::priority_value;
19use super::span::AgendaSpan;
20use crate::date::parse_org_date;
21
22/// An active timestamp in a headline, with the blanks before it in group
23/// 1: a title leaves both out, a timestamp row only the timestamp.
24static HEADLINE_TIMESTAMP_RE: LazyLock<Regex> = LazyLock::new(|| {
25    Regex::new(r"([ \t]*)<\d{4}-\d{2}-\d{2}[^>\n]*>").expect("headline timestamp regex")
26});
27
28/// Headline text as org-agenda prints it: active timestamps in the title,
29/// such as `Birthday <1993-10-16 Sat +1y>`, are left out.
30pub fn agenda_title(item: &Item) -> String {
31    HEADLINE_TIMESTAMP_RE
32        .replace_all(&item.title, "")
33        .trim()
34        .to_string()
35}
36
37fn occurrence_name(kind: OccurrenceType) -> &'static str {
38    match kind {
39        OccurrenceType::Scheduled => "scheduled",
40        OccurrenceType::PastScheduled => "past-scheduled",
41        OccurrenceType::Deadline => "deadline",
42        OccurrenceType::UpcomingDeadline => "upcoming-deadline",
43        OccurrenceType::Timestamp => "timestamp",
44        OccurrenceType::Block => "block",
45        OccurrenceType::Closed => "closed",
46        OccurrenceType::Todo => "todo",
47    }
48}
49
50// Emacs's export layouts, checked against pinned Emacs in conformance/agenda:
51// the rows of `org-batch-agenda-csv` and the text `org-batch-agenda` prints.
52
53/// Agenda rows in Emacs's `org-batch-agenda-csv` layout: all eleven columns
54/// (category, head, type, todo, tags, date, time, extra, priority-l,
55/// priority-n, agenda-day) with Emacs's values. As in Emacs, a field's
56/// commas become semicolons and nothing is quoted. `today` decides which
57/// scheduled rows Emacs calls past-scheduled.
58pub fn agenda_csv(entries: &[AgendaEntry], today: NaiveDate, index: &WorkspaceIndex) -> String {
59    let mut output = String::new();
60    for entry in entries {
61        let item = &entry.item;
62        let (kind, date) = csv_type_and_date(entry, today);
63        // Org gives every closed row the same priority-n.
64        let priority = match entry.occurrence.kind {
65            OccurrenceType::Closed => 100_000,
66            _ => priority_value(item, index),
67        };
68        push_csv_row(
69            &mut output,
70            [
71                &item.category,
72                &row_head(entry),
73                kind,
74                item.todo.as_deref().unwrap_or_default(),
75                &all_tags(item).join(":"),
76                &date,
77                &agenda_time(&entry.occurrence),
78                &entry.occurrence.extra,
79                item.priority.as_deref().unwrap_or_default(),
80                &priority.to_string(),
81                &entry.occurrence.date,
82            ],
83        );
84    }
85    output
86}
87
88/// A TODO-list or tags block's rows in the same layout: type `tagsmatch`
89/// for a `tags` or `tags-todo` block and `todo` for the others, with no
90/// date, time, extra or agenda day.
91pub fn items_csv(items: &[Item], block_kind: &str, index: &WorkspaceIndex) -> String {
92    let kind = match block_kind {
93        "tags" | "tags-todo" => "tagsmatch",
94        _ => "todo",
95    };
96    let mut output = String::new();
97    for item in items {
98        push_csv_row(
99            &mut output,
100            [
101                &item.category,
102                &item.title,
103                kind,
104                item.todo.as_deref().unwrap_or_default(),
105                &all_tags(item).join(":"),
106                "",
107                "",
108                "",
109                item.priority.as_deref().unwrap_or_default(),
110                &priority_value(item, index).to_string(),
111                "",
112            ],
113        );
114    }
115    output
116}
117
118/// Emacs's type and date columns for a row. Org calls every row of an entry
119/// scheduled before today `past-scheduled` and dates it on its schedule; a
120/// deadline row is dated on its deadline; other rows on their agenda day.
121fn csv_type_and_date(entry: &AgendaEntry, today: NaiveDate) -> (&'static str, String) {
122    let planning = &entry.item.planning;
123    let day = entry.occurrence.date.clone();
124    match entry.occurrence.kind {
125        OccurrenceType::Scheduled | OccurrenceType::PastScheduled => {
126            match planning.scheduled.as_ref().map(|scheduled| &scheduled.date) {
127                Some(base) if parse_org_date(base).is_ok_and(|base| base < today) => {
128                    ("past-scheduled", base.clone())
129                }
130                _ => ("scheduled", day),
131            }
132        }
133        OccurrenceType::Deadline => (
134            "deadline",
135            planning
136                .deadline
137                .as_ref()
138                .map_or(day, |deadline| deadline.date.clone()),
139        ),
140        kind => (occurrence_name(kind), day),
141    }
142}
143
144/// The headline text Emacs shows on a row: a timestamp row leaves out the
145/// headline's active timestamps (`Birthday <1993-10-16 Sat +1y>`) and keeps
146/// the text around them; every other row shows the headline as written.
147fn row_head(entry: &AgendaEntry) -> String {
148    match entry.occurrence.kind {
149        OccurrenceType::Timestamp => HEADLINE_TIMESTAMP_RE
150            .replace_all(&entry.item.title, "${1}")
151            .into_owned(),
152        _ => entry.item.title.clone(),
153    }
154}
155
156/// A row's time as org-agenda prints it with org-agenda-time-leading-zero
157/// nil: ` 9:00......`, or ` 9:00-10:30` for a range; empty without a time.
158fn agenda_time(occurrence: &Occurrence) -> String {
159    let Some(start) = occurrence.time.as_deref() else {
160        return String::new();
161    };
162    match occurrence.end_time.as_deref() {
163        Some(end) => format!("{:>5}-{}", clock(start), clock(end)),
164        None => format!("{:>5}......", clock(start)),
165    }
166}
167
168/// `09:30` as Org writes it in the agenda: `9:30`.
169fn clock(time: &str) -> &str {
170    match time.strip_prefix('0') {
171        Some(rest) if rest.starts_with(|c: char| c.is_ascii_digit()) => rest,
172        _ => time,
173    }
174}
175
176/// Inherited tags, then the headline's own, each once.
177fn all_tags(item: &Item) -> Vec<&str> {
178    let mut seen = BTreeSet::new();
179    item.inherited_tags
180        .iter()
181        .chain(&item.tags)
182        .filter(|tag| seen.insert(tag.as_str()))
183        .map(String::as_str)
184        .collect()
185}
186
187/// org-agenda-export-csv-mapper: commas become semicolons, ends trimmed.
188fn push_csv_row(output: &mut String, fields: [&str; 11]) {
189    let fields = fields.map(|field| field.replace(',', ";").trim().to_string());
190    output.push_str(&fields.join(","));
191    output.push('\n');
192}
193
194/// The column tags end at: org-agenda-tags-column `auto` right-aligns them
195/// to the window, and `org-batch-agenda`'s window is 79 columns wide.
196const TAGS_COLUMN: usize = 79;
197
198/// The agenda from `from` to `to` as `org-batch-agenda` prints it with the
199/// time grid off: `header`, or Org's span line such as `Week-agenda (W42):`,
200/// then every day with its rows (org-agenda-show-all-dates).
201pub fn agenda_text(
202    entries: &[AgendaEntry],
203    from: NaiveDate,
204    to: NaiveDate,
205    header: Option<&str>,
206) -> String {
207    let mut output = match header {
208        Some(header) => format!("{header}\n"),
209        None => span_header(from, to),
210    };
211    for day in date_range(from, to) {
212        output.push_str(&day_line(day));
213        let date = day.format("%Y-%m-%d").to_string();
214        for entry in entries.iter().filter(|entry| entry.occurrence.date == date) {
215            output.push_str(&agenda_row(entry));
216            output.push('\n');
217        }
218    }
219    output
220}
221
222/// One agenda row as org-agenda prints it: `  category:`, the time, the
223/// leader (`Scheduled:`, `In   3 d.:`), TODO, priority, headline and tags.
224pub fn agenda_row(entry: &AgendaEntry) -> String {
225    let mut line = format!("  {:<12}", format!("{}:", entry.item.category));
226    let time = agenda_time(&entry.occurrence);
227    if !time.is_empty() {
228        line.push_str(&format!("{time:<12}"));
229    }
230    let leader = agenda_leader(&entry.occurrence.extra);
231    if !leader.is_empty() {
232        line.push_str(&leader);
233        line.push(' ');
234    }
235    push_headline(&mut line, &entry.item, &row_head(entry));
236    with_tags(line, &entry.item)
237}
238
239/// One row of a TODO list or tags match: `  category:`, TODO, priority,
240/// headline and tags.
241pub fn item_row(item: &Item) -> String {
242    let mut line = format!("  {:<12}", format!("{}:", item.category));
243    push_headline(&mut line, item, &item.title);
244    with_tags(line, item)
245}
246
247/// `Day-agenda (W42):`, `Week-agenda (W42):`, `Month-agenda (W40-W44):` or
248/// `12 days-agenda (W41-W43):`: Org names spans of 1, 7 and 14 days and the
249/// month span, and counts the days of any other.
250fn span_header(from: NaiveDate, to: NaiveDate) -> String {
251    let days = (to - from).num_days() + 1;
252    let name = match days {
253        1 => "Day".to_string(),
254        7 => "Week".to_string(),
255        14 => "Fortnight".to_string(),
256        _ if AgendaSpan::Month.bounds(from, WeekStart::Monday) == (from, to) => "Month".to_string(),
257        _ => format!("{days} days"),
258    };
259    let (first, last) = (from.iso_week().week(), to.iso_week().week());
260    let weeks = if first == last {
261        format!("W{first:02}")
262    } else {
263        format!("W{first:02}-W{last:02}")
264    };
265    format!("{name}-agenda ({weeks}):\n")
266}
267
268/// org-agenda-format-date-aligned: `Monday      5 October 2026 W41`, the
269/// ISO week on Mondays.
270fn day_line(day: NaiveDate) -> String {
271    let week = if day.weekday() == chrono::Weekday::Mon {
272        format!(" W{:02}", day.iso_week().week())
273    } else {
274        String::new()
275    };
276    format!(
277        "{:<10} {:>2} {} {}{week}\n",
278        day.format("%A"),
279        day.day(),
280        day.format("%B"),
281        day.year()
282    )
283}
284
285/// Org's default leader for a row's `extra` (org-agenda-scheduled-leaders,
286/// org-agenda-deadline-leaders and the log mode's `Closed:`), padded as
287/// Emacs prints it; `extra` itself is trimmed, as in Emacs's CSV.
288fn agenda_leader(extra: &str) -> String {
289    match extra {
290        "" => String::new(),
291        "Deadline:" => "Deadline:  ".to_string(),
292        "Closed:" => "Closed:    ".to_string(),
293        _ if extra.ends_with(" d. ago:") => format!("{extra:>10} "),
294        _ => format!("{extra} "),
295    }
296}
297
298fn push_headline(line: &mut String, item: &Item, head: &str) {
299    if let Some(todo) = &item.todo {
300        line.push_str(todo);
301        line.push(' ');
302    }
303    if let Some(priority) = &item.priority {
304        line.push_str(&format!("[#{priority}] "));
305    }
306    line.push_str(head);
307}
308
309/// Appends the row's tags ending at TAGS_COLUMN, at least one space after
310/// the text, as org-agenda-align-tags does; display width counts wide
311/// characters twice.
312fn with_tags(line: String, item: &Item) -> String {
313    let Some(tags) = agenda_tags(item) else {
314        return line;
315    };
316    let text = line.trim_end();
317    let used = text.width() + tags.width();
318    let padding = TAGS_COLUMN.saturating_sub(used).max(1);
319    format!("{text}{}{tags}", " ".repeat(padding))
320}
321
322/// Tags as org-agenda shows them, inherited ones first: `:own:`,
323/// `:file:parent::own:`, or `:file::` when every tag is inherited.
324fn agenda_tags(item: &Item) -> Option<String> {
325    let inherited = item.inherited_tags.join(":");
326    let own = item.tags.join(":");
327    match (inherited.is_empty(), own.is_empty()) {
328        (true, true) => None,
329        (true, false) => Some(format!(":{own}:")),
330        (false, true) => Some(format!(":{inherited}::")),
331        (false, false) => Some(format!(":{inherited}::{own}:")),
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::agenda::test_support::fixture;
339    use crate::{AgendaEngine, AgendaQuery};
340
341    #[test]
342    fn csv_writes_emacs_columns_and_values() {
343        let (_temp, index) = fixture(
344            "* TODO Call, bank\nSCHEDULED: <2026-10-13 Tue 09:00-10:30>\n* TODO Old\nSCHEDULED: <2026-10-10 Sat>\n",
345        );
346        let today = NaiveDate::from_ymd_opt(2026, 10, 13).unwrap();
347        let entries = AgendaEngine::new(&index)
348            .agenda(&AgendaQuery {
349                from: today,
350                to: today,
351                today,
352                match_expression: None,
353                log_mode: false,
354            })
355            .unwrap();
356        let rows = agenda_csv(&entries, today, &index);
357        let category = &entries[0].item.category;
358        assert_eq!(
359            rows,
360            format!(
361                "{category},Call; bank,scheduled,TODO,,2026-10-13,9:00-10:30,Scheduled:,,1000,2026-10-13\n\
362                 {category},Old,past-scheduled,TODO,,2026-10-10,,Sched. 3x:,,1000,2026-10-13\n"
363            )
364        );
365    }
366}