Skip to main content

bear_rs/
export.rs

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(&note.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(&note, 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(&note, true),
195            PathBuf::from("work-project/Hello - Rust.md")
196        );
197    }
198}