agd_core/document/
timestamp.rs1use std::sync::LazyLock;
9
10use regex::Regex;
11
12use crate::PlanningTimestamp;
13
14use super::source::ByteSpan;
15
16static LINK_TARGET_RE: LazyLock<Regex> =
17 LazyLock::new(|| Regex::new(r"\[\[[^\]\n]*\]").expect("link target regex"));
18static TIMESTAMP_RE: LazyLock<Regex> = LazyLock::new(|| {
19 Regex::new(
20 r"[<\[](\d{4}-\d{2}-\d{2})(?:[ \t]+[^\s\d+\->\]]+)?(?:[ \t]+(\d{1,2}:\d{2})(?:-(\d{1,2}:\d{2}))?)?([^>\]]*)[>\]]",
21 )
22 .expect("timestamp regex")
23});
24
25#[derive(Clone, Debug)]
26pub struct TimestampSpan {
27 pub timestamp: PlanningTimestamp,
28 pub span: ByteSpan,
29 pub active: bool,
30}
31
32pub fn parse_timestamp(input: &str) -> Option<PlanningTimestamp> {
33 let captures = TIMESTAMP_RE.captures(input)?;
34 let suffix = captures.get(4).map_or("", |matched| matched.as_str());
35 let repeater = suffix
36 .split_whitespace()
37 .find(|token| token.starts_with('+') || token.starts_with(".+"))
38 .map(str::to_string);
39 let warning = suffix
40 .split_whitespace()
41 .find(|token| token.starts_with('-'))
42 .map(str::to_string);
43 Some(PlanningTimestamp {
44 date: captures.get(1)?.as_str().to_string(),
45 time: captures.get(2).map(|matched| clock(matched.as_str())),
46 end_time: captures.get(3).map(|matched| clock(matched.as_str())),
47 repeater,
48 warning,
49 raw: captures.get(0).map(|matched| matched.as_str().to_string()),
50 })
51}
52
53fn clock(time: &str) -> String {
56 format!("{time:0>5}")
57}
58
59pub(super) fn scan_timestamps(source: &str, start: usize, end: usize) -> Vec<TimestampSpan> {
62 if start >= end || end > source.len() {
63 return Vec::new();
64 }
65 let mut found = Vec::new();
66 let mut in_verbatim_block = false;
67 let mut line_start = start;
68 for line in source[start..end].split_inclusive('\n') {
69 let offset = line_start;
70 line_start += line.len();
71 let trimmed = line.trim();
72 let lower = trimmed.to_ascii_lowercase();
73 if in_verbatim_block {
74 in_verbatim_block = !lower.starts_with("#+end_");
75 continue;
76 }
77 if [
78 "#+begin_src",
79 "#+begin_example",
80 "#+begin_export",
81 "#+begin_comment",
82 ]
83 .iter()
84 .any(|opening| lower.starts_with(opening))
85 {
86 in_verbatim_block = true;
87 continue;
88 }
89 if trimmed == ":" || trimmed.starts_with(": ") {
90 continue;
91 }
92 let masked = LINK_TARGET_RE.replace_all(line, |captures: ®ex::Captures| {
93 " ".repeat(captures[0].len())
94 });
95 for captures in TIMESTAMP_RE.captures_iter(&masked) {
96 let Some(matched) = captures.get(0) else {
97 continue;
98 };
99 let Some(timestamp) = parse_timestamp(matched.as_str()) else {
100 continue;
101 };
102 found.push(TimestampSpan {
103 timestamp,
104 span: ByteSpan {
105 start: offset + matched.start(),
106 end: offset + matched.end(),
107 },
108 active: matched.as_str().starts_with('<'),
109 });
110 }
111 }
112 found
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
120 fn times_read_as_hh_mm_and_raw_keeps_the_text() {
121 let timestamp = parse_timestamp("<2026-10-13 Tue 9:30-10:15 +1w>").unwrap();
122 assert_eq!(timestamp.time.as_deref(), Some("09:30"));
123 assert_eq!(timestamp.end_time.as_deref(), Some("10:15"));
124 assert_eq!(
125 timestamp.raw.as_deref(),
126 Some("<2026-10-13 Tue 9:30-10:15 +1w>")
127 );
128 }
129}