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