1use 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
22static 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
28pub 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
50pub 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 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
88pub 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
118fn 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
144fn 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
156fn 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
168fn 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
176fn 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
187fn 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
194const TAGS_COLUMN: usize = 79;
197
198pub 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
222pub 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
239pub 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
247fn 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
268fn 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
285fn 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
309fn 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
322fn 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}