1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5
6use crate::frontmatter::{FrontMatter, parse_front_matter};
7use crate::model::Note;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ExportNote {
11 pub identifier: String,
12 pub title: String,
13 pub text: String,
14 pub pinned: bool,
15 pub created_at: Option<i64>,
16 pub modified_at: Option<i64>,
17 pub tags: Vec<String>,
18}
19
20impl From<Note> for ExportNote {
21 fn from(n: Note) -> Self {
22 ExportNote {
23 identifier: n.id,
24 title: n.title,
25 text: n.text,
26 pinned: n.pinned,
27 created_at: Some(n.created),
28 modified_at: Some(n.modified),
29 tags: n.tags,
30 }
31 }
32}
33
34impl From<&Note> for ExportNote {
35 fn from(n: &Note) -> Self {
36 ExportNote {
37 identifier: n.id.clone(),
38 title: n.title.clone(),
39 text: n.text.clone(),
40 pinned: n.pinned,
41 created_at: Some(n.created),
42 modified_at: Some(n.modified),
43 tags: n.tags.clone(),
44 }
45 }
46}
47
48pub fn export_notes(
49 output_dir: &Path,
50 notes: &[ExportNote],
51 include_frontmatter: bool,
52 by_tag: bool,
53) -> Result<Vec<PathBuf>> {
54 fs::create_dir_all(output_dir)
55 .with_context(|| format!("failed to create {}", output_dir.display()))?;
56
57 let mut written = Vec::new();
58 for note in notes {
59 let target = output_dir.join(export_path_for(note, by_tag));
60 if let Some(parent) = target.parent() {
61 fs::create_dir_all(parent)
62 .with_context(|| format!("failed to create {}", parent.display()))?;
63 }
64
65 let contents = render_exported_note(note, include_frontmatter);
66 fs::write(&target, contents)
67 .with_context(|| format!("failed to write {}", target.display()))?;
68 written.push(target);
69 }
70
71 Ok(written)
72}
73
74pub fn export_path_for(note: &ExportNote, by_tag: bool) -> PathBuf {
75 let filename = format!("{}.md", sanitize_filename(&display_title(note)));
76 if by_tag {
77 if let Some(tag) = note.tags.first() {
78 return PathBuf::from(sanitize_path_segment(tag)).join(filename);
79 }
80 }
81 PathBuf::from(filename)
82}
83
84pub fn render_exported_note(note: &ExportNote, include_frontmatter: bool) -> String {
85 if !include_frontmatter {
86 return note.text.clone();
87 }
88
89 let (frontmatter, body) = parse_front_matter(¬e.text);
90 let mut merged = frontmatter.unwrap_or_else(|| FrontMatter::new(Vec::new()));
91 merged.merge_missing_from(&generated_frontmatter(note));
92 merged.to_note_text(&body)
93}
94
95fn generated_frontmatter(note: &ExportNote) -> FrontMatter {
96 let mut fields = vec![
97 ("title".to_string(), display_title(note)),
98 ("id".to_string(), note.identifier.clone()),
99 (
100 "tags".to_string(),
101 format!(
102 "[{}]",
103 note.tags
104 .iter()
105 .map(|tag| format!("\"{}\"", tag.replace('"', "\\\"")))
106 .collect::<Vec<_>>()
107 .join(", ")
108 ),
109 ),
110 ("pinned".to_string(), note.pinned.to_string()),
111 ];
112
113 if let Some(created) = note.created_at {
114 fields.push(("created".to_string(), created.to_string()));
115 }
116 if let Some(modified) = note.modified_at {
117 fields.push(("modified".to_string(), modified.to_string()));
118 }
119
120 FrontMatter::new(fields)
121}
122
123fn display_title(note: &ExportNote) -> String {
124 let title = note.title.trim();
125 if title.is_empty() {
126 note.identifier.clone()
127 } else {
128 title.to_string()
129 }
130}
131
132pub fn sanitize_filename(value: &str) -> String {
133 let sanitized = value
134 .chars()
135 .map(|ch| match ch {
136 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
137 _ if ch.is_control() => ' ',
138 _ => ch,
139 })
140 .collect::<String>();
141 let collapsed = sanitized.split_whitespace().collect::<Vec<_>>().join(" ");
142 let trimmed = collapsed.trim().trim_matches('.').to_string();
143 if trimmed.is_empty() {
144 "untitled".to_string()
145 } else {
146 trimmed
147 }
148}
149
150fn sanitize_path_segment(value: &str) -> String {
151 sanitize_filename(&value.replace('/', "-"))
152}
153
154#[cfg(test)]
155mod tests {
156 use std::path::PathBuf;
157
158 use super::{ExportNote, export_path_for, render_exported_note, sanitize_filename};
159
160 fn sample_note() -> ExportNote {
161 ExportNote {
162 identifier: "NOTE-1".into(),
163 title: "Hello / Rust".into(),
164 text: "# Hello\n\nBody".into(),
165 pinned: true,
166 created_at: Some(10),
167 modified_at: Some(20),
168 tags: vec!["work/project".into(), "rust".into()],
169 }
170 }
171
172 #[test]
173 fn sanitizes_filenames() {
174 assert_eq!(sanitize_filename(" Hello:/Rust? "), "Hello--Rust-");
175 }
176
177 #[test]
178 fn merges_generated_frontmatter_without_overwriting_user_fields() {
179 let mut note = sample_note();
180 note.text = "---\ntitle: Custom\ntags: [\"mine\"]\n---\n# Hello\n\nBody".into();
181
182 let rendered = render_exported_note(¬e, true);
183
184 assert!(rendered.contains("title: Custom"));
185 assert!(rendered.contains("tags: [\"mine\"]"));
186 assert!(rendered.contains("id: NOTE-1"));
187 assert!(rendered.contains("pinned: true"));
188 }
189
190 #[test]
191 fn exports_by_first_tag_path() {
192 let note = sample_note();
193 assert_eq!(
194 export_path_for(¬e, true),
195 PathBuf::from("work-project/Hello - Rust.md")
196 );
197 }
198}