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 body: String::new(),
71 }
72}
73
74pub fn set_note_bodies(corpus: &mut Corpus, body_html_by_slug: &dyn Fn(&str) -> Option<String>) {
80 for note in &mut corpus.notes {
81 if let Some(html) = body_html_by_slug(¬e.slug) {
82 note.body = html;
83 }
84 }
85}
86
87pub fn build_corpus(prepared: &[PreparedDoc], facts: &dyn Fn(&str) -> FileFacts) -> Corpus {
90 Corpus::new(
91 prepared
92 .iter()
93 .map(|p| note_from_doc(p, facts(&p.rel_path)))
94 .collect(),
95 )
96}
97
98fn ms_to_date(ms: i64) -> docgen_bases::BaseDate {
99 docgen_bases::eval::date_from_epoch_millis(ms, true)
100}
101
102fn collect_tags(fm: &serde_yml::Value, body: &str) -> Vec<String> {
106 let mut out: Vec<String> = Vec::new();
107 let mut push = |t: String| {
108 let t = t.trim_start_matches('#').to_string();
109 if !t.is_empty() && !out.contains(&t) {
110 out.push(t);
111 }
112 };
113 for key in ["tags", "tag"] {
114 if let Some(v) = fm.get(key) {
115 match value_from_yaml(v) {
116 Value::Str(s) => s.split([',', ' ']).for_each(|t| push(t.to_string())),
117 Value::List(items) => {
118 for it in items {
119 push(it.display());
120 }
121 }
122 _ => {}
123 }
124 }
125 }
126 for tag in scan_inline_tags(body) {
127 push(tag);
128 }
129 out
130}
131
132fn scan_inline_tags(body: &str) -> Vec<String> {
136 let mut out = Vec::new();
137 let chars: Vec<char> = body.chars().collect();
138 let mut i = 0;
139 while i < chars.len() {
140 let boundary = i == 0 || chars[i - 1].is_whitespace();
141 if chars[i] == '#' && boundary {
142 let start = i + 1;
143 let mut j = start;
144 while j < chars.len()
145 && (chars[j].is_alphanumeric() || matches!(chars[j], '/' | '-' | '_'))
146 {
147 j += 1;
148 }
149 let tag: String = chars[start..j].iter().collect();
150 if !tag.is_empty() && tag.chars().any(|c| !c.is_ascii_digit()) {
152 out.push(tag);
153 }
154 i = j.max(start);
155 } else {
156 i += 1;
157 }
158 }
159 out
160}
161
162fn collect_links(fm: &serde_yml::Value, body: &str) -> Vec<BaseLink> {
165 let mut out: Vec<BaseLink> = Vec::new();
166 let mut push = |l: BaseLink| {
167 if !out.iter().any(|e| e.path == l.path) {
168 out.push(l);
169 }
170 };
171 collect_links_from_value(&value_from_yaml(fm), &mut push);
172 for link in scan_body_wikilinks(body) {
173 push(link);
174 }
175 out
176}
177
178fn collect_links_from_value(v: &Value, push: &mut impl FnMut(BaseLink)) {
179 match v {
180 Value::Link(l) => push(l.clone()),
181 Value::List(items) => items
182 .iter()
183 .for_each(|it| collect_links_from_value(it, push)),
184 Value::Object(map) => map
185 .values()
186 .for_each(|it| collect_links_from_value(it, push)),
187 _ => {}
188 }
189}
190
191fn scan_body_wikilinks(body: &str) -> Vec<BaseLink> {
193 let mut out = Vec::new();
194 let bytes = body.as_bytes();
195 let mut i = 0;
196 while i + 1 < bytes.len() {
197 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
198 if let Some(end) = body[i + 2..].find("]]") {
199 let inner = &body[i + 2..i + 2 + end];
200 let (target, label) = crate::wikilink::parse_wikilink(inner);
201 let target = target.split(['#', '^']).next().unwrap_or(&target).trim();
202 let target = target.strip_suffix(".md").unwrap_or(target);
203 if !target.is_empty() {
204 out.push(match label {
205 Some(d) => BaseLink::with_display(target, d),
206 None => BaseLink::new(target),
207 });
208 }
209 i = i + 2 + end + 2;
210 continue;
211 }
212 }
213 i += 1;
214 }
215 out
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use crate::model::RawDoc;
222 use crate::pipeline::prepare;
223
224 fn prep(path: &str, raw: &str) -> PreparedDoc {
225 prepare(RawDoc {
226 rel_path: path.into(),
227 raw: raw.into(),
228 })
229 }
230
231 #[test]
232 fn builds_note_metadata() {
233 let p = prep(
234 "guide/Intro.md",
235 "---\ntitle: Introduction\nstatus: done\ntags: [book, guide]\n---\n# Introduction\nSee [[guide/Advanced]] and #project/active.\n",
236 );
237 let note = note_from_doc(
238 &p,
239 FileFacts {
240 size: 42,
241 ..Default::default()
242 },
243 );
244 assert_eq!(note.name, "Intro.md");
245 assert_eq!(note.basename, "Intro");
246 assert_eq!(note.folder, "guide");
247 assert_eq!(note.ext, "md");
248 assert_eq!(note.size, 42);
249 assert_eq!(note.slug, "guide/Intro");
250 assert!(note.tags.contains(&"book".to_string()));
252 assert!(note.tags.contains(&"guide".to_string()));
253 assert!(note.tags.contains(&"project/active".to_string()));
254 assert!(note.links.iter().any(|l| l.path == "guide/Advanced"));
256 assert!(matches!(note.note_property("status"), Value::Str(s) if s == "done"));
258 assert!(matches!(note.note_property("title"), Value::Str(s) if s == "Introduction"));
259 }
260
261 #[test]
262 fn inline_tag_scan_ignores_numbers_and_midword() {
263 let tags = scan_inline_tags("a #real tag, issue #123, email a#b, and #nested/tag.");
264 assert!(tags.contains(&"real".to_string()));
265 assert!(tags.contains(&"nested/tag".to_string()));
266 assert!(!tags.iter().any(|t| t == "123"));
267 assert!(!tags.iter().any(|t| t == "b")); }
269
270 #[test]
271 fn corpus_from_multiple_docs() {
272 let docs = vec![
273 prep("a.md", "---\ntype: note\n---\n# A\n"),
274 prep("b.md", "# B\n"),
275 ];
276 let corpus = build_corpus(&docs, &|_| FileFacts::default());
277 assert_eq!(corpus.notes.len(), 2);
278 assert_eq!(corpus.notes[0].basename, "a");
279 }
280
281 #[test]
282 fn frontmatter_link_property_collected() {
283 let p = prep(
284 "x.md",
285 "---\nauthor: \"[[People/Herbert|Herbert]]\"\n---\n# X\n",
286 );
287 let note = note_from_doc(&p, FileFacts::default());
288 assert!(note.links.iter().any(|l| l.path == "People/Herbert"));
289 }
290}