markdown-org-extract 0.18.0

Library and CLI for extracting tasks from markdown files with Emacs Org-mode support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! Exceptions to a repeating entry: an occurrence that is gone, and one that
//! moved.
//!
//! A repeating timestamp describes an endless series and has nowhere to say
//! that one of its occurrences is different. ADR-0031 answers that in the
//! shape iCalendar settled on, written with the `org-properties` keys of
//! ADR-0020:
//!
//! - `EXDATE` on the series lists occurrences the series does not have;
//! - a separate entry carrying `SERIES_ID` and `RECURRENCE_ID` replaces the
//!   one occurrence it names, and needs no `EXDATE` beside it — that is RFC
//!   5545's split between an occurrence that is gone and one that moved.
//!
//! The two reasons are kept apart all the way to the agenda, because they part
//! ways over a debt: nothing is owed for an occurrence that never was, and
//! what is owed for one that moved is owed by the entry it moved to.
//!
//! Matching is at day granularity, because the agenda draws at most one
//! occurrence of a series per day; the clock time a `RECURRENCE_ID` may carry
//! is kept for the reader and for export, and is not matched on.

use std::collections::{HashMap, HashSet};

use chrono::NaiveDate;

use crate::types::Task;

/// Property key listing the occurrences a series does not have.
pub const EXDATE_KEY: &str = "EXDATE";
/// Property key naming the occurrence an entry replaces.
pub const RECURRENCE_ID_KEY: &str = "RECURRENCE_ID";
/// Property key naming the series an entry replaces an occurrence of.
pub const SERIES_ID_KEY: &str = "SERIES_ID";
/// Property key holding a task's own stable identifier (ADR-0020).
pub const ID_KEY: &str = "ID";

/// The dates listed in an `EXDATE` value, normalised to `YYYY-MM-DD`.
///
/// Separators are commas and whitespace, in any mix — a list is written for a
/// person to read, and both are what people write. The result keeps the order
/// the value was written in and holds one entry per date, whichever way the
/// value spelled it.
///
/// A field that does not parse as a date is handed to `on_rejected` as it is
/// met rather than collected: the value is only as short as the file makes it,
/// and one written entirely of rubbish would otherwise be held twice over —
/// once in the file, once in a vector — for a caller that reports the first
/// few and drops the rest.
pub fn parse_excluded_dates(raw: &str, mut on_rejected: impl FnMut(&str)) -> Vec<String> {
    let mut dates = Vec::new();
    // A set of what has been seen, rather than a scan of what has been kept:
    // the scan is linear per date and so quadratic over the value, which on a
    // long `EXDATE` is the difference between a pass and a stall.
    let mut seen = HashSet::new();
    for field in raw.split([',', ' ', '\t']).filter(|f| !f.is_empty()) {
        match NaiveDate::parse_from_str(field, "%Y-%m-%d") {
            Ok(date) => {
                if seen.insert(date) {
                    dates.push(date.format("%Y-%m-%d").to_string());
                }
            }
            Err(_) => on_rejected(field),
        }
    }
    dates
}

/// The occurrence a `RECURRENCE_ID` value names: a date, optionally followed
/// by a clock time.
///
/// Returns the value normalised (`YYYY-MM-DD` or `YYYY-MM-DD HH:MM`), or
/// `None` when the date does not parse. A trailing field that is not a time
/// is dropped and the date kept: the date is what the resolver matches on,
/// and losing the exception over a stray word would be the worse failure.
pub fn parse_recurrence_id(raw: &str) -> Option<String> {
    let mut fields = raw.split_whitespace();
    let date = NaiveDate::parse_from_str(fields.next()?, "%Y-%m-%d").ok()?;
    let time = fields
        .next()
        .and_then(|t| chrono::NaiveTime::parse_from_str(t, "%H:%M").ok());
    Some(match time {
        Some(t) => format!("{} {}", date.format("%Y-%m-%d"), t.format("%H:%M")),
        None => date.format("%Y-%m-%d").to_string(),
    })
}

/// The date half of a `RECURRENCE_ID`, which is what occurrences match on.
pub fn recurrence_id_date(value: &str) -> Option<NaiveDate> {
    NaiveDate::parse_from_str(value.split_whitespace().next()?, "%Y-%m-%d").ok()
}

/// Which occurrences of which series are not there, for one run.
///
/// Built from the whole task list because a replacement lives in an entry of
/// its own — possibly in another file of the same scan. An exception
/// therefore reaches only as far as the scan does, which ADR-0031 states as a
/// consequence.
#[derive(Debug, Default, Clone)]
pub struct OccurrenceExceptions {
    replaced: HashMap<String, HashSet<NaiveDate>>,
}

impl OccurrenceExceptions {
    /// Collect every `(SERIES_ID, RECURRENCE_ID)` pair in the run.
    pub fn from_tasks(tasks: &[Task]) -> Self {
        let mut replaced: HashMap<String, HashSet<NaiveDate>> = HashMap::new();
        for task in tasks {
            let (Some(series), Some(recurrence)) =
                (task.series_id.as_deref(), task.recurrence_id.as_deref())
            else {
                continue;
            };
            if let Some(date) = recurrence_id_date(recurrence) {
                replaced.entry(series.to_string()).or_default().insert(date);
            }
        }
        Self { replaced }
    }

    /// Every occurrence `task` does not have: what it cancelled itself, and
    /// what other entries of the run replace.
    ///
    /// The one place that answers the question, and it answers it once per
    /// task: the day-by-day walk of a week or a month reads a set instead of
    /// re-reading properties on every cell.
    pub fn dates_for(&self, task: &Task) -> ExcludedOccurrences {
        let cancelled = task
            .excluded_dates
            .as_deref()
            .unwrap_or_default()
            .iter()
            // A date nothing can read is dropped here as it was dropped at
            // the parser: a `Task` can also be built by a library caller,
            // and one bad string must not take the whole list with it.
            .filter_map(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
            .collect();
        let replaced = self
            .task_id(task)
            .and_then(|id| self.replaced.get(id))
            .cloned()
            .unwrap_or_default();
        ExcludedOccurrences {
            cancelled,
            replaced,
        }
    }

    fn task_id<'a>(&self, task: &'a Task) -> Option<&'a str> {
        task.properties.as_ref()?.get(ID_KEY).map(String::as_str)
    }
}

/// The occurrences one entry does not have, kept apart by reason.
///
/// Both reasons take the occurrence out of the day it would have fallen on.
/// They part ways over the arrears: a cancelled occurrence never was, so the
/// debt is whichever earlier one still stands, while a replaced occurrence did
/// take place — elsewhere — and its debt travels with the entry that replaced
/// it (ADR-0031).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ExcludedOccurrences {
    cancelled: HashSet<NaiveDate>,
    replaced: HashSet<NaiveDate>,
}

impl ExcludedOccurrences {
    /// Whether the series skips `date`, for either reason.
    pub fn contains(&self, date: &NaiveDate) -> bool {
        self.cancelled.contains(date) || self.replaced.contains(date)
    }

    /// Whether another entry of the run stands in for the occurrence on
    /// `date`.
    ///
    /// Asked where the two reasons differ, which is the arrears bucket. A
    /// date named by both is treated as replaced: the occurrence is somewhere,
    /// and an `EXDATE` beside a replacement is redundant rather than
    /// contradictory.
    pub fn is_replaced(&self, date: &NaiveDate) -> bool {
        self.replaced.contains(date)
    }

    /// Whether this entry misses no occurrence at all — the fast path for the
    /// overwhelmingly common case of an entry without an exception.
    pub fn is_empty(&self) -> bool {
        self.cancelled.is_empty() && self.replaced.is_empty()
    }

    /// How many occurrences are missing, counting a date named by both
    /// reasons twice. An upper bound is all the walks over a series need, and
    /// an exact count would cost a pass over the smaller set.
    pub fn len(&self) -> usize {
        self.cancelled.len() + self.replaced.len()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::BTreeMap;

    fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
        NaiveDate::from_ymd_opt(y, m, d).unwrap()
    }

    /// The dates of an `EXDATE` value, for a test that expects all of them to
    /// read.
    fn dates_of(raw: &str) -> Vec<String> {
        parse_excluded_dates(raw, |field| panic!("unexpected reject: {field:?}"))
    }

    fn series(id: &str) -> Task {
        let mut props = BTreeMap::new();
        props.insert(ID_KEY.to_string(), id.to_string());
        Task {
            properties: Some(props),
            ..Task::default()
        }
    }

    fn cancelling(dates: &[&str]) -> Task {
        Task {
            excluded_dates: Some(dates.iter().map(|d| (*d).to_string()).collect()),
            ..Task::default()
        }
    }

    fn replacement(series_id: &str, recurrence: &str) -> Task {
        Task {
            series_id: Some(series_id.to_string()),
            recurrence_id: Some(recurrence.to_string()),
            ..Task::default()
        }
    }

    #[test]
    fn excluded_dates_take_commas_and_spaces_alike() {
        assert_eq!(
            dates_of("2026-08-20, 2026-08-27 2026-09-03"),
            ["2026-08-20", "2026-08-27", "2026-09-03"]
        );
    }

    #[test]
    fn excluded_dates_drop_what_is_not_a_date_and_say_so() {
        let mut rejected = Vec::new();
        let dates = parse_excluded_dates("2026-08-20, next thursday", |field| {
            rejected.push(field.to_string());
        });

        assert_eq!(dates, ["2026-08-20"]);
        assert_eq!(
            rejected,
            ["next", "thursday"],
            "each field is reported as it is met"
        );
    }

    #[test]
    fn excluded_dates_keep_one_copy_of_a_repeated_date() {
        assert_eq!(dates_of("2026-08-20 2026-08-20"), ["2026-08-20"]);
    }

    #[test]
    fn excluded_dates_keep_one_copy_however_the_date_was_spelled() {
        assert_eq!(dates_of("2026-8-20, 2026-08-20"), ["2026-08-20"]);
    }

    #[test]
    fn a_long_exdate_costs_one_pass_and_not_one_per_date_already_seen() {
        // A value is only as short as the file makes it, and a linear scan of
        // what is already collected turns that length into its square: 20 000
        // dates are 2*10^8 string comparisons, seconds of a test run, and on a
        // file of the size the scanner accepts, an entry nothing finishes
        // reading.
        const DATES: i64 = 20_000;
        let first = ymd(2000, 1, 1);
        let raw = (0..DATES)
            .map(|i| {
                (first + chrono::Duration::days(i))
                    .format("%Y-%m-%d")
                    .to_string()
            })
            .collect::<Vec<_>>()
            .join(", ");

        let dates = dates_of(&raw);

        assert_eq!(dates.len(), DATES as usize, "every date is kept, once");
        assert_eq!(dates[0], "2000-01-01", "in the order it was written");
    }

    #[test]
    fn a_recurrence_id_keeps_the_time_when_it_carries_one() {
        assert_eq!(
            parse_recurrence_id("2026-08-20 15:00").as_deref(),
            Some("2026-08-20 15:00")
        );
        assert_eq!(
            parse_recurrence_id("2026-08-20").as_deref(),
            Some("2026-08-20")
        );
    }

    #[test]
    fn a_recurrence_id_without_a_date_is_no_recurrence_id() {
        assert_eq!(parse_recurrence_id("thursday 15:00"), None);
    }

    #[test]
    fn a_trailing_field_that_is_not_a_time_leaves_the_date_standing() {
        assert_eq!(
            parse_recurrence_id("2026-08-20 afternoon").as_deref(),
            Some("2026-08-20")
        );
    }

    #[test]
    fn an_entry_skips_the_date_it_lists_itself() {
        let task = cancelling(&["2026-08-20"]);
        let missing =
            OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);

        assert!(missing.contains(&ymd(2026, 8, 20)));
        assert!(!missing.contains(&ymd(2026, 8, 27)));
        assert!(
            !missing.is_replaced(&ymd(2026, 8, 20)),
            "an EXDATE cancels an occurrence, it does not move it"
        );
    }

    #[test]
    fn a_date_in_an_exdate_that_cannot_be_read_is_dropped_and_the_rest_kept() {
        // Reachable through the library, where a `Task` is built by hand and
        // not by the parser that normalises what it writes.
        let task = cancelling(&["last thursday", "2026-08-27"]);
        let missing =
            OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);

        assert!(missing.contains(&ymd(2026, 8, 27)));
        assert_eq!(missing.len(), 1);
    }

    #[test]
    fn a_replacement_suppresses_the_occurrence_it_names() {
        let english = series("series-1");
        let moved = replacement("series-1", "2026-08-20 15:00");
        let missing =
            OccurrenceExceptions::from_tasks(&[english.clone(), moved]).dates_for(&english);

        assert!(missing.contains(&ymd(2026, 8, 20)));
        assert!(!missing.contains(&ymd(2026, 8, 27)));
        assert!(
            missing.is_replaced(&ymd(2026, 8, 20)),
            "the occurrence moved: its debt is the replacement's"
        );
    }

    #[test]
    fn both_reasons_meet_in_one_answer_and_stay_apart_in_it() {
        let mut english = series("series-1");
        english.excluded_dates = Some(vec!["2026-08-13".to_string()]);
        let moved = replacement("series-1", "2026-08-20 15:00");
        let missing =
            OccurrenceExceptions::from_tasks(&[english.clone(), moved]).dates_for(&english);

        assert_eq!(missing.len(), 2);
        assert!(missing.contains(&ymd(2026, 8, 13)) && missing.contains(&ymd(2026, 8, 20)));
        assert!(!missing.is_replaced(&ymd(2026, 8, 13)), "the 13th is gone");
        assert!(missing.is_replaced(&ymd(2026, 8, 20)), "the 20th moved");
    }

    #[test]
    fn a_replacement_of_another_series_leaves_this_one_alone() {
        let english = series("series-1");
        let moved = replacement("series-2", "2026-08-20");
        let missing =
            OccurrenceExceptions::from_tasks(&[english.clone(), moved]).dates_for(&english);

        assert!(missing.is_empty());
    }

    #[test]
    fn a_series_without_an_id_cannot_be_replaced() {
        let anonymous = Task::default();
        let moved = replacement("series-1", "2026-08-20");
        let missing =
            OccurrenceExceptions::from_tasks(&[anonymous.clone(), moved]).dates_for(&anonymous);

        assert!(missing.is_empty());
    }

    #[test]
    fn an_entry_whose_only_exception_is_an_exdate_is_not_an_entry_without_any() {
        let task = cancelling(&["2026-08-20"]);
        let missing =
            OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);

        assert!(
            !missing.is_empty(),
            "an EXDATE is an exception: an entry holding one is not an entry without any"
        );
    }

    #[test]
    fn one_definition_answers_whatever_the_date_is_written_like() {
        // `2026-8-20` is what a person writes and what chrono reads. One
        // definition of "is this occurrence missing" means one answer,
        // whichever way the value spelled the day.
        let task = cancelling(&["2026-8-20"]);
        let missing =
            OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);

        assert!(missing.contains(&ymd(2026, 8, 20)));
    }
}