Skip to main content

apple_notes_exporter/
lib.rs

1//! Apple Notes Exporter
2//!
3//! A library for exporting Apple Notes to Markdown files with support for images,
4//! attachments, and metadata preservation.
5//!
6//! # Example
7//! ```no_run
8//! use apple_notes_exporter::{export_notes, ExportConfig};
9//! use std::path::PathBuf;
10//!
11//! fn main() -> anyhow::Result<()> {
12//!     let config = ExportConfig::default();
13//!     let notes = export_notes(&config)?;
14//!     println!("Exported {} notes", notes.len());
15//!     Ok(())
16//! }
17//! ```
18
19use anyhow::{anyhow, Context, Result};
20use base64::{engine::general_purpose::STANDARD as base64, Engine as _};
21use scraper::{Html, Selector};
22use serde::{Deserialize, Serialize};
23use std::fs;
24use std::path::PathBuf;
25use std::process::Command;
26
27/// Represents a single Apple Note with its metadata and content.
28#[derive(Deserialize, Serialize, Debug, Clone)]
29pub struct Note {
30    /// The title of the note
31    pub title: String,
32    /// The HTML content of the note
33    pub content: String,
34    /// The folder containing the note
35    pub folder: String,
36    /// The account the note belongs to (e.g., "iCloud")
37    pub account: String,
38    /// Unique identifier for the note
39    pub id: String,
40    /// Creation date as a string
41    pub created: String,
42    /// Last modification date as a string
43    pub modified: String,
44}
45
46/// Configuration options for the export process.
47#[derive(Debug, Clone)]
48pub struct ExportConfig {
49    /// Directory where notes will be exported
50    pub output_dir: PathBuf,
51    /// Whether to store images in a separate attachments folder
52    pub use_attachments: bool,
53    /// Format string for filenames (supports &title, &folder, &account, &id)
54    pub filename_format: String,
55    /// Format string for subdirectories (supports &title, &folder, &account, &id)
56    pub subdir_format: String,
57    /// Whether to organize notes in subdirectories
58    pub use_subdirs: bool,
59    /// Whether to save HTML files alongside Markdown (for debugging)
60    pub save_html: bool,
61}
62
63impl Default for ExportConfig {
64    fn default() -> Self {
65        Self {
66            output_dir: PathBuf::from("."),
67            use_attachments: true,
68            filename_format: String::from("&title"),
69            subdir_format: String::from("&folder"),
70            use_subdirs: true,
71            save_html: false,
72        }
73    }
74}
75
76/// Exports all notes from Apple Notes to Markdown files.
77///
78/// This function:
79/// 1. Creates the output directory if it doesn't exist
80/// 2. Retrieves all notes using AppleScript
81/// 3. Processes each note (converts HTML to Markdown, handles images)
82/// 4. Saves notes with their metadata as Markdown files
83///
84/// # Arguments
85/// * `config` - Configuration options for the export process
86///
87/// # Returns
88/// * `Result<Vec<Note>>` - A vector of all exported notes on success
89///
90/// # Errors
91/// * If the output directory cannot be created
92/// * If the AppleScript execution fails
93/// * If any note processing or saving fails
94pub fn export_notes(config: &ExportConfig) -> Result<Vec<Note>> {
95    // Create output directory if it doesn't exist
96    fs::create_dir_all(&config.output_dir).context("Failed to create output directory")?;
97
98    // Get notes data from AppleScript
99    let notes = get_notes()?;
100
101    // Process each note
102    for note in &notes {
103        let markdown = process_note(note, config)?;
104        save_note(note, &markdown, config)?;
105    }
106
107    Ok(notes)
108}
109
110/// Retrieves all notes from Apple Notes using AppleScript.
111///
112/// # Returns
113/// * `Result<Vec<Note>>` - A vector of all notes on success
114///
115/// # Errors
116/// * If the AppleScript file is not found
117/// * If the AppleScript execution fails
118/// * If the output cannot be parsed as JSON
119pub fn get_notes() -> Result<Vec<Note>> {
120    let script_path = PathBuf::from("export-notes.applescript");
121    if !script_path.exists() {
122        return Err(anyhow!(
123            "export-notes.applescript not found in current directory"
124        ));
125    }
126
127    let output = Command::new("osascript")
128        .arg(script_path)
129        .output()
130        .context("Failed to execute AppleScript")?;
131
132    if !output.status.success() {
133        return Err(anyhow!(
134            "AppleScript execution failed: {}",
135            String::from_utf8_lossy(&output.stderr)
136        ));
137    }
138
139    let json_str =
140        String::from_utf8(output.stdout).context("Failed to parse AppleScript output as UTF-8")?;
141
142    let notes: Vec<Note> =
143        serde_json::from_str(&json_str).context("Failed to parse JSON output from AppleScript")?;
144
145    Ok(notes)
146}
147
148/// Processes a single note, converting it to Markdown and handling attachments.
149///
150/// # Arguments
151/// * `note` - The note to process
152/// * `config` - Export configuration options
153///
154/// # Returns
155/// * `Result<String>` - The processed Markdown content
156///
157/// # Errors
158/// * If image extraction fails
159/// * If HTML processing fails
160pub fn process_note(note: &Note, config: &ExportConfig) -> Result<String> {
161    // Extract images and get updated HTML
162    let html_with_local_images = extract_and_save_images(
163        &note.content,
164        &get_note_path(note, config)?,
165        config.use_attachments,
166    )?;
167
168    // Save the HTML for investigation (optional)
169    if config.save_html {
170        save_html(note, &html_with_local_images, config)?;
171    }
172
173    // Convert to markdown
174    let markdown = html2md::parse_html(&html_with_local_images);
175
176    // Handle split h1s if present
177    if note.content.contains("<h1>") {
178        let doc = Html::parse_document(&html_with_local_images);
179        let h1_selector = Selector::parse("h1").unwrap();
180        let h1_texts: Vec<String> = doc
181            .select(&h1_selector)
182            .map(|el| el.text().collect::<String>())
183            .collect();
184
185        if !h1_texts.is_empty() {
186            let joined_text = h1_texts.join("");
187            if !joined_text.trim().is_empty() {
188                return Ok(format!(
189                    "# {}\n\n{}",
190                    joined_text.trim(),
191                    markdown
192                        .lines()
193                        .filter(|line| !line.starts_with('#'))
194                        .collect::<Vec<_>>()
195                        .join("\n")
196                ));
197            }
198        }
199    }
200
201    Ok(markdown)
202}
203
204fn get_note_path(note: &Note, config: &ExportConfig) -> Result<PathBuf> {
205    let mut path = config.output_dir.clone();
206
207    if config.use_subdirs {
208        path = path.join(&note.folder);
209    }
210
211    Ok(path)
212}
213
214fn save_note(note: &Note, markdown: &str, config: &ExportConfig) -> Result<()> {
215    let mut output_path = get_note_path(note, config)?;
216    fs::create_dir_all(&output_path)
217        .with_context(|| format!("Failed to create directory: {:?}", output_path))?;
218
219    // Create filename from title (sanitize it)
220    let safe_title = note
221        .title
222        .replace(|c: char| !c.is_alphanumeric() && c != '-', "-");
223    output_path = output_path.join(format!("{}.md", safe_title));
224
225    // Create frontmatter
226    let mut content = String::new();
227    content.push_str("---\n");
228    content.push_str(&format!("title: \"{}\"\n", note.title));
229    content.push_str(&format!("folder: \"{}\"\n", note.folder));
230    content.push_str(&format!("account: \"{}\"\n", note.account));
231    content.push_str(&format!("id: \"{}\"\n", note.id));
232    content.push_str(&format!("created: \"{}\"\n", note.created));
233    content.push_str(&format!("modified: \"{}\"\n", note.modified));
234    content.push_str("---\n\n");
235
236    // Add the markdown content
237    content.push_str(markdown);
238
239    // Write the complete content
240    fs::write(&output_path, content.as_bytes())
241        .with_context(|| format!("Failed to write file: {:?}", output_path))?;
242
243    Ok(())
244}
245
246fn save_html(note: &Note, html: &str, config: &ExportConfig) -> Result<()> {
247    let mut output_path = get_note_path(note, config)?;
248    fs::create_dir_all(&output_path)
249        .with_context(|| format!("Failed to create directory: {:?}", output_path))?;
250
251    // Create filename from title (sanitize it)
252    let safe_title = note
253        .title
254        .replace(|c: char| !c.is_alphanumeric() && c != '-', "-");
255    output_path = output_path.join(format!("{}.html", safe_title));
256
257    // Write the HTML content
258    fs::write(&output_path, html.as_bytes())
259        .with_context(|| format!("Failed to write HTML file: {:?}", output_path))?;
260
261    Ok(())
262}
263
264fn extract_and_save_images(
265    html_content: &str,
266    output_dir: &PathBuf,
267    use_attachments: bool,
268) -> Result<String> {
269    let document = Html::parse_document(html_content);
270    let img_selector = Selector::parse("img").unwrap();
271    let mut modified_html = html_content.to_string();
272    let mut img_counter = 0;
273
274    // Determine attachments directory
275    let attachments_dir = if use_attachments {
276        output_dir.join("attachments")
277    } else {
278        output_dir.to_owned()
279    };
280
281    // Create attachments directory if it doesn't exist and we're using it
282    if use_attachments {
283        fs::create_dir_all(&attachments_dir).with_context(|| {
284            format!(
285                "Failed to create attachments directory: {:?}",
286                attachments_dir
287            )
288        })?;
289    }
290
291    // Find all img tags
292    for img in document.select(&img_selector) {
293        if let Some(src) = img.value().attr("src") {
294            if src.starts_with("data:image") {
295                img_counter += 1;
296
297                // Extract image format and data
298                let parts: Vec<&str> = src.split(',').collect();
299                if parts.len() != 2 {
300                    continue; // Skip malformed data URLs
301                }
302
303                // Get format from header (e.g., "data:image/jpeg;base64" -> "jpeg")
304                let format = parts[0]
305                    .split('/')
306                    .nth(1)
307                    .and_then(|s| s.split(';').next())
308                    .unwrap_or("png");
309
310                // Decode base64 data
311                let image_data = base64
312                    .decode(parts[1])
313                    .with_context(|| "Failed to decode base64 image data")?;
314
315                // Generate filename
316                let filename = format!("attachment-{:03}.{}", img_counter, format);
317                let image_path = attachments_dir.join(&filename);
318
319                // Save the image
320                fs::write(&image_path, image_data)
321                    .with_context(|| format!("Failed to write image file: {:?}", image_path))?;
322
323                // Update HTML to reference the local file
324                let new_src = if use_attachments {
325                    format!("attachments/{}", filename)
326                } else {
327                    filename
328                };
329
330                modified_html = modified_html.replace(src, &new_src);
331            }
332        }
333    }
334
335    Ok(modified_html)
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use std::fs;
342    use tempfile::tempdir;
343
344    #[test]
345    fn test_export_config_default() {
346        let config = ExportConfig::default();
347        assert_eq!(config.output_dir, PathBuf::from("."));
348        assert!(config.use_attachments);
349        assert_eq!(config.filename_format, "&title");
350        assert_eq!(config.subdir_format, "&folder");
351        assert!(config.use_subdirs);
352        assert!(!config.save_html);
353    }
354
355    #[test]
356    fn test_process_note_with_images() -> Result<()> {
357        let temp_dir = tempdir()?;
358        let config = ExportConfig {
359            output_dir: temp_dir.path().to_path_buf(),
360            use_attachments: true,
361            filename_format: String::from("&title"),
362            subdir_format: String::from("&folder"),
363            use_subdirs: true,
364            save_html: false,
365        };
366
367        let note = Note {
368            title: String::from("Test Note"),
369            content: String::from(
370                r#"<p>Test content</p><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="/>"#,
371            ),
372            folder: String::from("Test Folder"),
373            account: String::from("Test Account"),
374            id: String::from("test-id"),
375            created: String::from("2024-01-01"),
376            modified: String::from("2024-01-01"),
377        };
378
379        let markdown = process_note(&note, &config)?;
380        assert!(markdown.contains("![](attachments/attachment-001.png)"));
381
382        // Check if image was saved
383        let image_path = temp_dir
384            .path()
385            .join("Test Folder")
386            .join("attachments")
387            .join("attachment-001.png");
388        assert!(image_path.exists());
389
390        Ok(())
391    }
392
393    #[test]
394    fn test_process_note_with_h1() -> Result<()> {
395        let temp_dir = tempdir()?;
396        let config = ExportConfig {
397            output_dir: temp_dir.path().to_path_buf(),
398            use_attachments: true,
399            filename_format: String::from("&title"),
400            subdir_format: String::from("&folder"),
401            use_subdirs: true,
402            save_html: false,
403        };
404
405        let note = Note {
406            title: String::from("Test Note"),
407            content: String::from(
408                "<h1>Title 1</h1><p>Content 1</p><h1>Title 2</h1><p>Content 2</p>",
409            ),
410            folder: String::from("Test Folder"),
411            account: String::from("Test Account"),
412            id: String::from("test-id"),
413            created: String::from("2024-01-01"),
414            modified: String::from("2024-01-01"),
415        };
416
417        let markdown = process_note(&note, &config)?;
418        assert!(markdown.starts_with("# Title 1Title 2\n\n"));
419        assert!(markdown.contains("Content 1"));
420        assert!(markdown.contains("Content 2"));
421
422        Ok(())
423    }
424
425    #[test]
426    fn test_get_note_path() -> Result<()> {
427        let temp_dir = tempdir()?;
428        let config = ExportConfig {
429            output_dir: temp_dir.path().to_path_buf(),
430            use_attachments: true,
431            filename_format: String::from("&title"),
432            subdir_format: String::from("&folder"),
433            use_subdirs: true,
434            save_html: false,
435        };
436
437        let note = Note {
438            title: String::from("Test Note"),
439            content: String::from("Test content"),
440            folder: String::from("Test Folder"),
441            account: String::from("Test Account"),
442            id: String::from("test-id"),
443            created: String::from("2024-01-01"),
444            modified: String::from("2024-01-01"),
445        };
446
447        let path = get_note_path(&note, &config)?;
448        assert_eq!(path, temp_dir.path().join("Test Folder"));
449
450        let config_no_subdirs = ExportConfig {
451            use_subdirs: false,
452            ..config
453        };
454        let path_no_subdirs = get_note_path(&note, &config_no_subdirs)?;
455        assert_eq!(path_no_subdirs, temp_dir.path());
456
457        Ok(())
458    }
459}