Skip to main content

agd_core/
capture_template.rs

1//! Capture templates: the `%^{prompt}` fields a template asks for
2//! ([`capture_prompts`]), and the entry text it renders for a draft, body and
3//! answers ([`render_capture_template`]), expanding org-capture's escapes
4//! (`%?`, `%i`, `%U`, `%u`, `%T`, `%t`, `%^g`, date prompts) and merging the
5//! draft's dates into one planning line.
6//!
7//! Title, body and answers are inserted literally and never expanded again,
8//! and a `%(sexp)` form is refused: a template never runs code. Where the
9//! entry goes in a file is the write engine's placement, not this module's.
10
11use std::collections::BTreeMap;
12use std::sync::LazyLock;
13
14use chrono::{NaiveDate, NaiveDateTime};
15use regex::Regex;
16
17use crate::date::format_inactive_timestamp;
18use crate::write::line_ending;
19use crate::{
20    AgdError, CaptureDraft, CaptureTemplate, ErrorCode, format_active_timestamp, parse_date_input,
21};
22
23static ESCAPE_RE: LazyLock<Regex> = LazyLock::new(|| {
24    // %^{name}t/T/u/U and a bare %^t/T/u/U prompt for a date, as Org does.
25    Regex::new(r"%(\?|i|U|u|T|t|%|\^g|\^\{([^}]+)\}([tTuU])?|\^([tTuU]))")
26        .expect("capture escape regex")
27});
28
29/// One `%^{...}` field a template asks for. A date prompt (`%^{name}t`,
30/// `%^t`, ...) takes the same input as `due:` and may be left empty.
31/// `planning` names the keyword (`DEADLINE`, `SCHEDULED`) when the prompt
32/// is that keyword's date on the template's planning line: `due:` or `on:`
33/// then answers it, so the prompt need not be asked.
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct CapturePrompt {
36    pub name: String,
37    pub date: bool,
38    pub planning: Option<String>,
39}
40
41impl CapturePrompt {
42    /// True when the capture line's own `due:`/`on:` already gives this
43    /// prompt's date.
44    pub fn answered_by(&self, draft: &CaptureDraft) -> bool {
45        match self.planning.as_deref() {
46            Some("DEADLINE") => draft.deadline.is_some(),
47            Some("SCHEDULED") => draft.scheduled.is_some(),
48            _ => false,
49        }
50    }
51}
52
53/// The `%^{prompt}` fields a template asks for, in order and without
54/// duplicates; `%^g` asks for "tags" and a bare `%^t` for "date".
55pub fn capture_prompts(template: &CaptureTemplate) -> Vec<CapturePrompt> {
56    let text = &template.template;
57    let mut prompts: Vec<CapturePrompt> = Vec::new();
58    for captures in ESCAPE_RE.captures_iter(text) {
59        let start = captures.get(0).expect("escape match").start();
60        let line_start = text[..start].rfind('\n').map_or(0, |index| index + 1);
61        let before = &text[line_start..start];
62        // The planning keyword closest before the prompt on its line.
63        let planning = ["DEADLINE:", "SCHEDULED:"]
64            .iter()
65            .filter_map(|keyword| before.rfind(keyword).map(|at| (at, *keyword)))
66            .max()
67            .map(|(_, keyword)| keyword.trim_end_matches(':').to_string());
68        let prompt = match (&captures[1], captures.get(2), captures.get(4)) {
69            ("^g", _, _) => CapturePrompt {
70                name: "tags".to_string(),
71                date: false,
72                planning: None,
73            },
74            (_, Some(name), _) => {
75                let date = captures.get(3).is_some();
76                CapturePrompt {
77                    name: name.as_str().to_string(),
78                    date,
79                    planning: if date { planning } else { None },
80                }
81            }
82            (_, None, Some(_)) => CapturePrompt {
83                name: "date".to_string(),
84                date: true,
85                planning,
86            },
87            _ => continue,
88        };
89        if !prompts.iter().any(|existing| existing.name == prompt.name) {
90            prompts.push(prompt);
91        }
92    }
93    prompts
94}
95
96pub fn render_capture_template(
97    template: &CaptureTemplate,
98    draft: &CaptureDraft,
99    body: &str,
100    prompts: &BTreeMap<String, String>,
101    now: NaiveDateTime,
102) -> Result<String, AgdError> {
103    if template.template.contains("%(") {
104        return Err(AgdError::new(
105            ErrorCode::InvalidTemplate,
106            "capture templates may not execute %(sexp) forms",
107        ));
108    }
109    let rendered = expand_capture_escapes(&template.template, draft, body, prompts, now)?;
110
111    let newline = line_ending(&rendered);
112    let mut lines: Vec<String> = rendered.split(newline).map(str::to_string).collect();
113    if let Some(first) = lines.first_mut() {
114        if let Some(priority) = &draft.priority {
115            insert_before_title(first, &format!("[#{}]", priority));
116        }
117        if !draft.tags.is_empty() {
118            first.push_str(&format!(" :{}:", draft.tags.join(":")));
119        }
120    }
121    // One planning line, as Org writes it: SCHEDULED then DEADLINE. A
122    // template's own planning line (often `DEADLINE: %^{deadline}t`) is
123    // merged with the due:/on: dates, which win; a keyword left without a
124    // date is dropped.
125    let template_planning = lines
126        .get(1)
127        .filter(|line| is_schedule_or_deadline_line(line))
128        .map(|line| planning_dates(line));
129    if template_planning.is_some() {
130        lines.remove(1);
131    }
132    let (mut scheduled, mut deadline) = template_planning.unwrap_or_default();
133    if let Some(timestamp) = &draft.scheduled {
134        scheduled = Some(format_active_timestamp(timestamp)?);
135    }
136    if let Some(timestamp) = &draft.deadline {
137        deadline = Some(format_active_timestamp(timestamp)?);
138    }
139    let mut planning = Vec::new();
140    if let Some(timestamp) = scheduled {
141        planning.push(format!("SCHEDULED: {timestamp}"));
142    }
143    if let Some(timestamp) = deadline {
144        planning.push(format!("DEADLINE: {timestamp}"));
145    }
146    if !planning.is_empty() {
147        lines.insert(1.min(lines.len()), planning.join(" "));
148    }
149    add_properties(
150        &mut lines,
151        &draft.properties,
152        usize::from(!planning.is_empty()) + 1,
153    )?;
154    Ok(lines.join(newline))
155}
156
157// Expands every escape in one pass over the template so that user-supplied
158// title, body and prompt text is inserted literally and never re-expanded.
159fn expand_capture_escapes(
160    template: &str,
161    draft: &CaptureDraft,
162    body: &str,
163    prompts: &BTreeMap<String, String>,
164    now: NaiveDateTime,
165) -> Result<String, AgdError> {
166    let mut rendered = String::with_capacity(template.len() + draft.title.len() + body.len());
167    let mut copied_to = 0;
168    for captures in ESCAPE_RE.captures_iter(template) {
169        let escape = captures.get(0).expect("escape match");
170        rendered.push_str(&template[copied_to..escape.start()]);
171        copied_to = escape.end();
172        match &captures[1] {
173            "?" => rendered.push_str(&draft.title),
174            "i" => rendered.push_str(body),
175            "U" => rendered.push_str(&format!("[{}]", now.format("%Y-%m-%d %a %H:%M"))),
176            "u" => rendered.push_str(&format!("[{}]", now.format("%Y-%m-%d %a"))),
177            "T" => rendered.push_str(&format!("<{}>", now.format("%Y-%m-%d %a %H:%M"))),
178            "t" => rendered.push_str(&format!("<{}>", now.format("%Y-%m-%d %a"))),
179            "%" => rendered.push('%'),
180            "^g" => rendered.push_str(prompts.get("tags").ok_or_else(|| {
181                AgdError::new(
182                    ErrorCode::InvalidPrompt,
183                    "capture tag prompt has no 'tags' value",
184                )
185            })?),
186            _ => {
187                let date_kind = captures
188                    .get(3)
189                    .or(captures.get(4))
190                    .map(|kind| kind.as_str());
191                let prompt = captures.get(2).map_or("date", |name| name.as_str());
192                match date_kind {
193                    Some(kind) => {
194                        let value = prompts.get(prompt).map_or("", String::as_str);
195                        let date = prompted_date(value, kind, now.date()).map_err(|error| {
196                            AgdError::new(ErrorCode::InvalidDate, format!("{prompt}: {error}"))
197                        })?;
198                        rendered.push_str(&date);
199                    }
200                    None => {
201                        let value = prompts.get(prompt).ok_or_else(|| {
202                            AgdError::new(
203                                ErrorCode::InvalidPrompt,
204                                format!("capture prompt '{prompt}' has no value"),
205                            )
206                        })?;
207                        rendered.push_str(value);
208                    }
209                }
210            }
211        }
212    }
213    rendered.push_str(&template[copied_to..]);
214    Ok(rendered)
215}
216
217/// A date prompt's answer as an Org timestamp: `t` active date, `T` active
218/// with time, `u`/`U` the inactive forms. Empty stays empty.
219fn prompted_date(value: &str, kind: &str, today: NaiveDate) -> Result<String, AgdError> {
220    if value.trim().is_empty() {
221        return Ok(String::new());
222    }
223    let mut timestamp = parse_date_input(value, today)?;
224    if kind == "t" || kind == "u" {
225        timestamp.time = None;
226    }
227    if kind == "u" || kind == "U" {
228        format_inactive_timestamp(&timestamp)
229    } else {
230        format_active_timestamp(&timestamp)
231    }
232}
233
234fn insert_before_title(line: &mut String, value: &str) {
235    let stars_end = line
236        .find(|character: char| !matches!(character, '*' | ' ' | '\t'))
237        .unwrap_or(line.len());
238    if stars_end >= line.len() {
239        line.push_str(value);
240        return;
241    }
242    let rest = &line[stars_end..];
243    let first_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
244    let insertion = if rest[..first_end]
245        .bytes()
246        .all(|byte| byte.is_ascii_uppercase() || matches!(byte, b'_' | b'-'))
247    {
248        stars_end + first_end
249    } else {
250        stars_end
251    };
252    line.insert_str(insertion, &format!(" {value}"));
253}
254
255/// A template's own planning line, the one a capture's dates merge into:
256/// it starts with SCHEDULED: or DEADLINE:, the dates a capture sets.
257fn is_schedule_or_deadline_line(line: &str) -> bool {
258    let trimmed = line.trim_start();
259    trimmed.starts_with("SCHEDULED:") || trimmed.starts_with("DEADLINE:")
260}
261
262/// The SCHEDULED and DEADLINE timestamps on a template's planning line;
263/// a keyword with no timestamp after it yields None.
264fn planning_dates(line: &str) -> (Option<String>, Option<String>) {
265    let date_after = |keyword: &str| {
266        let rest = line.split_once(keyword)?.1.trim_start();
267        let close = match rest.chars().next()? {
268            '<' => '>',
269            '[' => ']',
270            _ => return None,
271        };
272        let end = rest.find(close)?;
273        Some(rest[..=end].to_string())
274    };
275    (date_after("SCHEDULED:"), date_after("DEADLINE:"))
276}
277
278/// Adds properties to the template's own `:PROPERTIES:` drawer, or opens one
279/// right after the headline and planning line (`after`), keeping keys the
280/// template already sets.
281fn add_properties(
282    lines: &mut Vec<String>,
283    properties: &BTreeMap<String, String>,
284    after: usize,
285) -> Result<(), AgdError> {
286    if properties.is_empty() {
287        return Ok(());
288    }
289    let mut entries = Vec::new();
290    for (key, value) in properties {
291        if key.is_empty() || key.contains([' ', '\t', ':']) || value.contains(['\n', '\r']) {
292            return Err(AgdError::new(
293                ErrorCode::InvalidProperty,
294                format!("capture property '{key}' needs a plain key and a one-line value"),
295            ));
296        }
297        let present = lines.iter().any(|line| {
298            line.trim_start()
299                .to_ascii_uppercase()
300                .starts_with(&format!(":{}:", key.to_ascii_uppercase()))
301        });
302        if !present {
303            entries.push(format!(":{key}: {value}"));
304        }
305    }
306    let drawer = lines.iter().position(|line| line.trim() == ":PROPERTIES:");
307    let end = drawer.and_then(|start| {
308        lines[start..]
309            .iter()
310            .position(|line| line.trim() == ":END:")
311            .map(|offset| start + offset)
312    });
313    match end {
314        Some(end) => {
315            lines.splice(end..end, entries);
316        }
317        None => {
318            let at = after.min(lines.len());
319            let mut drawer = vec![":PROPERTIES:".to_string()];
320            drawer.extend(entries);
321            drawer.push(":END:".to_string());
322            lines.splice(at..at, drawer);
323        }
324    }
325    Ok(())
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::date::simple_date;
332
333    #[test]
334    fn user_text_is_inserted_literally() {
335        let template = CaptureTemplate {
336            template: "* TODO %?\n%i\n:CREATED: %U\n100%%".to_string(),
337            ..CaptureTemplate::default()
338        };
339        let draft = CaptureDraft {
340            title: "Literal %U and %i".to_string(),
341            scheduled: None,
342            deadline: None,
343            tags: Vec::new(),
344            priority: None,
345            properties: BTreeMap::new(),
346        };
347        let now = NaiveDate::from_ymd_opt(2026, 10, 13)
348            .unwrap()
349            .and_hms_opt(14, 22, 0)
350            .unwrap();
351        let rendered =
352            render_capture_template(&template, &draft, "body %? %t", &BTreeMap::new(), now)
353                .unwrap();
354        assert_eq!(
355            rendered,
356            "* TODO Literal %U and %i\nbody %? %t\n:CREATED: [2026-10-13 Tue 14:22]\n100%"
357        );
358    }
359
360    #[test]
361    fn capture_properties_join_the_template_drawer_or_open_one() {
362        let now = NaiveDate::from_ymd_opt(2026, 10, 13)
363            .unwrap()
364            .and_hms_opt(14, 22, 0)
365            .unwrap();
366        let draft = CaptureDraft {
367            title: "Fix flaky test".to_string(),
368            properties: BTreeMap::from([
369                ("REPO".to_string(), "agd".to_string()),
370                (
371                    "SOURCE".to_string(),
372                    "[[file:~/src/agd/write.rs::42]]".to_string(),
373                ),
374            ]),
375            ..CaptureDraft::default()
376        };
377        let with_drawer = render_capture_template(
378            &CaptureTemplate::default(),
379            &draft,
380            "",
381            &BTreeMap::new(),
382            now,
383        )
384        .unwrap();
385        assert_eq!(
386            with_drawer,
387            "* TODO Fix flaky test\n:PROPERTIES:\n:CREATED: [2026-10-13 Tue 14:22]\n:REPO: agd\n:SOURCE: [[file:~/src/agd/write.rs::42]]\n:END:"
388        );
389        let plain = CaptureTemplate {
390            template: "* TODO %?\nNotes".to_string(),
391            ..CaptureTemplate::default()
392        };
393        let opened = render_capture_template(&plain, &draft, "", &BTreeMap::new(), now).unwrap();
394        assert!(opened.starts_with("* TODO Fix flaky test\n:PROPERTIES:\n:REPO: agd\n"));
395        assert!(opened.ends_with(":END:\nNotes"));
396    }
397
398    #[test]
399    fn template_preview_is_exact_org_text() {
400        let template = CaptureTemplate::default();
401        let draft = CaptureDraft {
402            title: "Renew passport".to_string(),
403            scheduled: None,
404            deadline: Some(simple_date(NaiveDate::from_ymd_opt(2026, 10, 30).unwrap())),
405            tags: vec!["admin".to_string()],
406            priority: Some("A".to_string()),
407            properties: BTreeMap::new(),
408        };
409        let rendered = render_capture_template(
410            &template,
411            &draft,
412            "",
413            &BTreeMap::new(),
414            NaiveDate::from_ymd_opt(2026, 10, 13)
415                .unwrap()
416                .and_hms_opt(9, 30, 0)
417                .unwrap(),
418        )
419        .unwrap();
420        assert_eq!(
421            rendered,
422            "* TODO [#A] Renew passport :admin:\nDEADLINE: <2026-10-30 Fri>\n:PROPERTIES:\n:CREATED: [2026-10-13 Tue 09:30]\n:END:"
423        );
424    }
425
426    fn deadline_template() -> CaptureTemplate {
427        CaptureTemplate {
428            template: "* TODO %?\nDEADLINE: %^{deadline}t\nCREATED: %U".to_string(),
429            ..CaptureTemplate::default()
430        }
431    }
432
433    fn render(
434        template: &CaptureTemplate,
435        draft: &CaptureDraft,
436        deadline: &str,
437    ) -> Result<String, AgdError> {
438        let now = NaiveDate::from_ymd_opt(2026, 10, 13)
439            .unwrap()
440            .and_hms_opt(9, 30, 0)
441            .unwrap();
442        let prompts = BTreeMap::from([("deadline".to_string(), deadline.to_string())]);
443        render_capture_template(template, draft, "", &prompts, now)
444    }
445
446    #[test]
447    fn a_date_prompt_is_a_date_and_lists_as_one() {
448        assert_eq!(
449            capture_prompts(&deadline_template()),
450            vec![CapturePrompt {
451                name: "deadline".to_string(),
452                date: true,
453                planning: Some("DEADLINE".to_string()),
454            }]
455        );
456        let draft = CaptureDraft {
457            title: "Renew passport".to_string(),
458            ..CaptureDraft::default()
459        };
460        assert_eq!(
461            render(&deadline_template(), &draft, "fri").unwrap(),
462            "* TODO Renew passport\nDEADLINE: <2026-10-16 Fri>\nCREATED: [2026-10-13 Tue 09:30]"
463        );
464        let error = render(&deadline_template(), &draft, "someday").unwrap_err();
465        assert!(error.to_string().contains("deadline"), "{error}");
466    }
467
468    #[test]
469    fn token_dates_merge_into_the_template_planning_line() {
470        let draft = CaptureDraft {
471            title: "Renew passport".to_string(),
472            scheduled: Some(simple_date(NaiveDate::from_ymd_opt(2026, 10, 19).unwrap())),
473            deadline: Some(simple_date(NaiveDate::from_ymd_opt(2026, 10, 28).unwrap())),
474            ..CaptureDraft::default()
475        };
476        let expected = "* TODO Renew passport\nSCHEDULED: <2026-10-19 Mon> DEADLINE: <2026-10-28 Wed>\nCREATED: [2026-10-13 Tue 09:30]";
477        // The prompt left empty: the due: token fills the deadline.
478        assert_eq!(render(&deadline_template(), &draft, "").unwrap(), expected);
479        // Both given: the token wins, and there is still one planning line.
480        assert_eq!(
481            render(&deadline_template(), &draft, "fri").unwrap(),
482            expected
483        );
484        // Neither: the empty DEADLINE keyword is dropped.
485        let plain = CaptureDraft {
486            title: "Renew passport".to_string(),
487            ..CaptureDraft::default()
488        };
489        assert_eq!(
490            render(&deadline_template(), &plain, "").unwrap(),
491            "* TODO Renew passport\nCREATED: [2026-10-13 Tue 09:30]"
492        );
493    }
494
495    #[test]
496    fn timed_and_inactive_date_prompts_render_as_org_does() {
497        let template = CaptureTemplate {
498            template: "* Meeting %?\n%^{when}T\nLogged %^{deadline}u".to_string(),
499            ..CaptureTemplate::default()
500        };
501        let draft = CaptureDraft {
502            title: "Sync".to_string(),
503            ..CaptureDraft::default()
504        };
505        let now = NaiveDate::from_ymd_opt(2026, 10, 13)
506            .unwrap()
507            .and_hms_opt(9, 30, 0)
508            .unwrap();
509        let prompts = BTreeMap::from([
510            ("when".to_string(), "2026-10-30 14:00".to_string()),
511            ("deadline".to_string(), "2026-10-30 14:00".to_string()),
512        ]);
513        assert_eq!(
514            render_capture_template(&template, &draft, "", &prompts, now).unwrap(),
515            "* Meeting Sync\n<2026-10-30 Fri 14:00>\nLogged [2026-10-30 Fri]"
516        );
517    }
518}