1use docgen_bases::{properties_from_yaml, value_from_yaml, BaseLink, Corpus, Note, Value};
10
11use crate::pipeline::PreparedDoc;
12
13#[derive(Debug, Clone, Copy, Default)]
17pub struct FileFacts {
18 pub size: u64,
19 pub ctime_ms: Option<i64>,
20 pub mtime_ms: Option<i64>,
21}
22
23pub fn note_from_doc(p: &PreparedDoc, facts: FileFacts) -> Note {
25 let name = p
26 .rel_path
27 .rsplit('/')
28 .next()
29 .unwrap_or(&p.rel_path)
30 .to_string();
31 let basename = name
32 .rsplit_once('.')
33 .map(|(b, _)| b.to_string())
34 .unwrap_or_else(|| name.clone());
35 let ext = name
36 .rsplit_once('.')
37 .map(|(_, e)| e.to_string())
38 .unwrap_or_default();
39 let folder = p
40 .rel_path
41 .rsplit_once('/')
42 .map(|(d, _)| d.to_string())
43 .unwrap_or_default();
44
45 let mut properties = properties_from_yaml(&p.frontmatter);
46 properties
49 .entry("title".to_string())
50 .or_insert_with(|| Value::Str(p.title.clone()));
51
52 let tags = collect_tags(&p.frontmatter, &p.body_md);
53 let links = collect_links(&p.frontmatter, &p.body_md);
54
55 Note {
56 properties,
57 name,
58 basename,
59 path: p.rel_path.clone(),
60 folder,
61 ext,
62 size: facts.size,
63 ctime: facts.ctime_ms.map(ms_to_date),
64 mtime: facts.mtime_ms.map(ms_to_date),
65 tags,
66 links,
67 slug: p.slug.clone(),
68 }
69}
70
71pub fn build_corpus(prepared: &[PreparedDoc], facts: &dyn Fn(&str) -> FileFacts) -> Corpus {
74 Corpus::new(
75 prepared
76 .iter()
77 .map(|p| note_from_doc(p, facts(&p.rel_path)))
78 .collect(),
79 )
80}
81
82fn ms_to_date(ms: i64) -> docgen_bases::BaseDate {
83 docgen_bases::eval::date_from_epoch_millis(ms, true)
84}
85
86fn collect_tags(fm: &serde_yml::Value, body: &str) -> Vec<String> {
90 let mut out: Vec<String> = Vec::new();
91 let mut push = |t: String| {
92 let t = t.trim_start_matches('#').to_string();
93 if !t.is_empty() && !out.contains(&t) {
94 out.push(t);
95 }
96 };
97 for key in ["tags", "tag"] {
98 if let Some(v) = fm.get(key) {
99 match value_from_yaml(v) {
100 Value::Str(s) => s.split([',', ' ']).for_each(|t| push(t.to_string())),
101 Value::List(items) => {
102 for it in items {
103 push(it.display());
104 }
105 }
106 _ => {}
107 }
108 }
109 }
110 for tag in scan_inline_tags(body) {
111 push(tag);
112 }
113 out
114}
115
116fn scan_inline_tags(body: &str) -> Vec<String> {
120 let mut out = Vec::new();
121 let chars: Vec<char> = body.chars().collect();
122 let mut i = 0;
123 while i < chars.len() {
124 let boundary = i == 0 || chars[i - 1].is_whitespace();
125 if chars[i] == '#' && boundary {
126 let start = i + 1;
127 let mut j = start;
128 while j < chars.len()
129 && (chars[j].is_alphanumeric() || matches!(chars[j], '/' | '-' | '_'))
130 {
131 j += 1;
132 }
133 let tag: String = chars[start..j].iter().collect();
134 if !tag.is_empty() && tag.chars().any(|c| !c.is_ascii_digit()) {
136 out.push(tag);
137 }
138 i = j.max(start);
139 } else {
140 i += 1;
141 }
142 }
143 out
144}
145
146fn collect_links(fm: &serde_yml::Value, body: &str) -> Vec<BaseLink> {
149 let mut out: Vec<BaseLink> = Vec::new();
150 let mut push = |l: BaseLink| {
151 if !out.iter().any(|e| e.path == l.path) {
152 out.push(l);
153 }
154 };
155 collect_links_from_value(&value_from_yaml(fm), &mut push);
156 for link in scan_body_wikilinks(body) {
157 push(link);
158 }
159 out
160}
161
162fn collect_links_from_value(v: &Value, push: &mut impl FnMut(BaseLink)) {
163 match v {
164 Value::Link(l) => push(l.clone()),
165 Value::List(items) => items
166 .iter()
167 .for_each(|it| collect_links_from_value(it, push)),
168 Value::Object(map) => map
169 .values()
170 .for_each(|it| collect_links_from_value(it, push)),
171 _ => {}
172 }
173}
174
175fn scan_body_wikilinks(body: &str) -> Vec<BaseLink> {
177 let mut out = Vec::new();
178 let bytes = body.as_bytes();
179 let mut i = 0;
180 while i + 1 < bytes.len() {
181 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
182 if let Some(end) = body[i + 2..].find("]]") {
183 let inner = &body[i + 2..i + 2 + end];
184 let (target, label) = crate::wikilink::parse_wikilink(inner);
185 let target = target.split(['#', '^']).next().unwrap_or(&target).trim();
186 let target = target.strip_suffix(".md").unwrap_or(target);
187 if !target.is_empty() {
188 out.push(match label {
189 Some(d) => BaseLink::with_display(target, d),
190 None => BaseLink::new(target),
191 });
192 }
193 i = i + 2 + end + 2;
194 continue;
195 }
196 }
197 i += 1;
198 }
199 out
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use crate::model::RawDoc;
206 use crate::pipeline::prepare;
207
208 fn prep(path: &str, raw: &str) -> PreparedDoc {
209 prepare(RawDoc {
210 rel_path: path.into(),
211 raw: raw.into(),
212 })
213 }
214
215 #[test]
216 fn builds_note_metadata() {
217 let p = prep(
218 "guide/Intro.md",
219 "---\ntitle: Introduction\nstatus: done\ntags: [book, guide]\n---\n# Introduction\nSee [[guide/Advanced]] and #project/active.\n",
220 );
221 let note = note_from_doc(
222 &p,
223 FileFacts {
224 size: 42,
225 ..Default::default()
226 },
227 );
228 assert_eq!(note.name, "Intro.md");
229 assert_eq!(note.basename, "Intro");
230 assert_eq!(note.folder, "guide");
231 assert_eq!(note.ext, "md");
232 assert_eq!(note.size, 42);
233 assert_eq!(note.slug, "guide/Intro");
234 assert!(note.tags.contains(&"book".to_string()));
236 assert!(note.tags.contains(&"guide".to_string()));
237 assert!(note.tags.contains(&"project/active".to_string()));
238 assert!(note.links.iter().any(|l| l.path == "guide/Advanced"));
240 assert!(matches!(note.note_property("status"), Value::Str(s) if s == "done"));
242 assert!(matches!(note.note_property("title"), Value::Str(s) if s == "Introduction"));
243 }
244
245 #[test]
246 fn inline_tag_scan_ignores_numbers_and_midword() {
247 let tags = scan_inline_tags("a #real tag, issue #123, email a#b, and #nested/tag.");
248 assert!(tags.contains(&"real".to_string()));
249 assert!(tags.contains(&"nested/tag".to_string()));
250 assert!(!tags.iter().any(|t| t == "123"));
251 assert!(!tags.iter().any(|t| t == "b")); }
253
254 #[test]
255 fn corpus_from_multiple_docs() {
256 let docs = vec![
257 prep("a.md", "---\ntype: note\n---\n# A\n"),
258 prep("b.md", "# B\n"),
259 ];
260 let corpus = build_corpus(&docs, &|_| FileFacts::default());
261 assert_eq!(corpus.notes.len(), 2);
262 assert_eq!(corpus.notes[0].basename, "a");
263 }
264
265 #[test]
266 fn frontmatter_link_property_collected() {
267 let p = prep(
268 "x.md",
269 "---\nauthor: \"[[People/Herbert|Herbert]]\"\n---\n# X\n",
270 );
271 let note = note_from_doc(&p, FileFacts::default());
272 assert!(note.links.iter().any(|l| l.path == "People/Herbert"));
273 }
274}