apple_notes_exporter_rs/
lib.rs

1//! Apple Notes Exporter Library
2//!
3//! A library for exporting Apple Notes folders to the file system via AppleScript.
4//!
5//! This library provides functions to list available Apple Notes folders and export them
6//! recursively to HTML files. It works by invoking an embedded AppleScript that interacts
7//! with the Notes app.
8//!
9//! ## Requirements
10//!
11//! - **macOS only** - This library relies on AppleScript and the Notes app, which are
12//!   only available on macOS. Running on other platforms will return an error at runtime.
13//! - Automation permissions for the Notes app must be granted in System Settings
14//!
15//! ## Quick Start
16//!
17//! ```no_run
18//! use apple_notes_exporter_rs::{list_folders, export_folder, export_folder_from_account};
19//!
20//! // List all available folders
21//! list_folders().expect("Failed to list folders");
22//!
23//! // Export a folder to a directory (searches all accounts)
24//! export_folder("My Notes", "./exports").expect("Failed to export");
25//!
26//! // Export from a specific account (useful when folder names are duplicated)
27//! export_folder_from_account("iCloud", "Work", "./exports").expect("Failed to export");
28//! ```
29//!
30//! ## Using a Custom Script
31//!
32//! If you need to use a custom AppleScript (e.g., a modified version), use the [`Exporter`] struct:
33//!
34//! ```no_run
35//! use apple_notes_exporter_rs::Exporter;
36//!
37//! let exporter = Exporter::with_script_path("./my_custom_script.applescript")
38//!     .expect("Script not found");
39//!
40//! exporter.list_folders().expect("Failed to list folders");
41//! exporter.export_folder("My Notes", "./exports").expect("Failed to export");
42//! ```
43
44use std::fs;
45use std::io::Write;
46use std::path::{Path, PathBuf};
47use std::process::Command;
48
49use base64::prelude::*;
50use scraper::{Html, Selector};
51use thiserror::Error;
52
53/// The embedded AppleScript used for exporting notes.
54const EMBEDDED_SCRIPT: &str =
55    include_str!("../vendor/apple-notes-exporter/scripts/export_notes.applescript");
56
57/// Checks if the current platform is macOS and returns an error if not.
58#[cfg(target_os = "macos")]
59fn check_platform() -> Result<()> {
60    Ok(())
61}
62
63/// Checks if the current platform is macOS and returns an error if not.
64#[cfg(not(target_os = "macos"))]
65fn check_platform() -> Result<()> {
66    Err(ExportError::UnsupportedPlatform(std::env::consts::OS))
67}
68
69/// Errors that can occur during Apple Notes export operations.
70#[derive(Error, Debug)]
71pub enum ExportError {
72    /// The current platform is not supported (only macOS is supported).
73    #[error(
74        "This tool only works on macOS. It relies on AppleScript and the Notes app, \
75         which are not available on {0}."
76    )]
77    UnsupportedPlatform(&'static str),
78
79    /// The AppleScript file was not found at the specified path.
80    #[error("AppleScript not found at {0}")]
81    ScriptNotFound(PathBuf),
82
83    /// Failed to create a temporary file for the embedded script.
84    #[error("Failed to create temporary script file: {0}")]
85    TempFileError(#[from] std::io::Error),
86
87    /// The output directory path is not valid UTF-8.
88    #[error("Output directory path is not valid UTF-8")]
89    InvalidUtf8Path,
90
91    /// Failed to launch the osascript process.
92    #[error("Failed to launch osascript: {0}")]
93    LaunchError(std::io::Error),
94
95    /// The AppleScript exited with a non-zero status code.
96    #[error("AppleScript exited with status {0}")]
97    ScriptFailed(i32),
98
99    /// Failed to decode base64 image data.
100    #[error("Failed to decode base64 image: {0}")]
101    Base64DecodeError(#[from] base64::DecodeError),
102}
103
104/// Result type alias for export operations.
105pub type Result<T> = std::result::Result<T, ExportError>;
106
107/// An Apple Notes exporter that can list folders and export notes.
108///
109/// Use [`Exporter::new()`] for the default embedded script, or
110/// [`Exporter::with_script_path()`] for a custom script.
111#[derive(Debug)]
112pub struct Exporter {
113    script_source: ScriptSource,
114}
115
116#[derive(Debug)]
117enum ScriptSource {
118    Embedded,
119    Path(PathBuf),
120}
121
122impl Default for Exporter {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128impl Exporter {
129    /// Creates a new exporter using the embedded AppleScript.
130    ///
131    /// This is the recommended way to create an exporter for most use cases.
132    ///
133    /// # Example
134    ///
135    /// ```no_run
136    /// use apple_notes_exporter_rs::Exporter;
137    ///
138    /// let exporter = Exporter::new();
139    /// exporter.list_folders().expect("Failed to list folders");
140    /// ```
141    pub fn new() -> Self {
142        Self {
143            script_source: ScriptSource::Embedded,
144        }
145    }
146
147    /// Creates a new exporter using a custom AppleScript at the specified path.
148    ///
149    /// Returns an error if the script file does not exist.
150    ///
151    /// # Example
152    ///
153    /// ```no_run
154    /// use apple_notes_exporter_rs::Exporter;
155    ///
156    /// let exporter = Exporter::with_script_path("./custom_script.applescript")
157    ///     .expect("Script not found");
158    /// ```
159    pub fn with_script_path<P: AsRef<Path>>(path: P) -> Result<Self> {
160        let path = path.as_ref().to_path_buf();
161        if !path.exists() {
162            return Err(ExportError::ScriptNotFound(path));
163        }
164        Ok(Self {
165            script_source: ScriptSource::Path(path),
166        })
167    }
168
169    /// Lists all available top-level folders across all Apple Notes accounts.
170    ///
171    /// The output is printed to stdout by the AppleScript.
172    ///
173    /// # Example
174    ///
175    /// ```no_run
176    /// use apple_notes_exporter_rs::Exporter;
177    ///
178    /// let exporter = Exporter::new();
179    /// exporter.list_folders().expect("Failed to list folders");
180    /// ```
181    pub fn list_folders(&self) -> Result<()> {
182        self.run_script(&["list"])
183    }
184
185    /// Exports a folder recursively to HTML files.
186    ///
187    /// The folder search uses breadth-first search and looks at all levels
188    /// (not just top-level) to find the folder. Once found, it exports that
189    /// folder and all its subfolders recursively.
190    ///
191    /// This method searches all accounts for the folder. If a folder with the
192    /// same name exists in multiple accounts, use [`export_folder_from_account`](Self::export_folder_from_account)
193    /// to specify which account to use.
194    ///
195    /// # Arguments
196    ///
197    /// * `folder` - The folder name to export.
198    /// * `output_dir` - The directory where exported notes will be saved.
199    ///   Will be created if it doesn't exist.
200    ///
201    /// # Example
202    ///
203    /// ```no_run
204    /// use apple_notes_exporter_rs::Exporter;
205    ///
206    /// let exporter = Exporter::new();
207    /// exporter.export_folder("My Notes", "./exports").expect("Failed to export");
208    /// ```
209    pub fn export_folder<P: AsRef<Path>>(&self, folder: &str, output_dir: P) -> Result<()> {
210        self.export_folder_impl(folder, output_dir)
211    }
212
213    /// Exports a folder from a specific account recursively to HTML files.
214    ///
215    /// This is useful when a folder with the same name exists in multiple accounts.
216    /// The folder search uses breadth-first search and looks at all levels
217    /// (not just top-level) to find the folder within the specified account.
218    ///
219    /// # Arguments
220    ///
221    /// * `account` - The account name (e.g., "iCloud", "Google", "On My Mac").
222    /// * `folder` - The folder name to export.
223    /// * `output_dir` - The directory where exported notes will be saved.
224    ///   Will be created if it doesn't exist.
225    ///
226    /// # Example
227    ///
228    /// ```no_run
229    /// use apple_notes_exporter_rs::Exporter;
230    ///
231    /// let exporter = Exporter::new();
232    ///
233    /// // Export "Work" folder from iCloud account
234    /// exporter.export_folder_from_account("iCloud", "Work", "./exports")
235    ///     .expect("Failed to export");
236    ///
237    /// // Export "Work" folder from Google account
238    /// exporter.export_folder_from_account("Google", "Work", "./google_exports")
239    ///     .expect("Failed to export");
240    /// ```
241    pub fn export_folder_from_account<P: AsRef<Path>>(
242        &self,
243        account: &str,
244        folder: &str,
245        output_dir: P,
246    ) -> Result<()> {
247        let folder_spec = format!("{account}:{folder}");
248        self.export_folder_impl(&folder_spec, output_dir)
249    }
250
251    fn export_folder_impl<P: AsRef<Path>>(&self, folder_spec: &str, output_dir: P) -> Result<()> {
252        let output_dir = output_dir.as_ref();
253        fs::create_dir_all(output_dir)?;
254
255        let output_dir = output_dir.canonicalize()?;
256        let output_dir_str = output_dir.to_str().ok_or(ExportError::InvalidUtf8Path)?;
257
258        self.run_script(&["export", folder_spec, output_dir_str])
259    }
260
261    /// Exports a folder and extracts all embedded images to attachment folders.
262    ///
263    /// This combines [`export_folder`](Self::export_folder) with
264    /// [`extract_attachments_from_directory`] for convenience.
265    ///
266    /// # Arguments
267    ///
268    /// * `folder` - The folder name to export.
269    /// * `output_dir` - The directory where exported notes will be saved.
270    ///
271    /// # Returns
272    ///
273    /// Returns a vector of `ExtractionResult` for each HTML file processed.
274    ///
275    /// # Example
276    ///
277    /// ```no_run
278    /// use apple_notes_exporter_rs::Exporter;
279    ///
280    /// let exporter = Exporter::new();
281    /// let results = exporter.export_folder_with_attachments("My Notes", "./exports")
282    ///     .expect("Failed to export");
283    ///
284    /// let total: usize = results.iter().map(|r| r.attachments.len()).sum();
285    /// println!("Extracted {total} attachments");
286    /// ```
287    pub fn export_folder_with_attachments<P: AsRef<Path>>(
288        &self,
289        folder: &str,
290        output_dir: P,
291    ) -> Result<Vec<ExtractionResult>> {
292        self.export_folder(folder, &output_dir)?;
293        extract_attachments_from_directory(&output_dir)
294    }
295
296    /// Exports a folder from a specific account and extracts all embedded images.
297    ///
298    /// This combines [`export_folder_from_account`](Self::export_folder_from_account) with
299    /// [`extract_attachments_from_directory`] for convenience.
300    ///
301    /// # Arguments
302    ///
303    /// * `account` - The account name (e.g., "iCloud", "Google", "On My Mac").
304    /// * `folder` - The folder name to export.
305    /// * `output_dir` - The directory where exported notes will be saved.
306    ///
307    /// # Returns
308    ///
309    /// Returns a vector of `ExtractionResult` for each HTML file processed.
310    pub fn export_folder_from_account_with_attachments<P: AsRef<Path>>(
311        &self,
312        account: &str,
313        folder: &str,
314        output_dir: P,
315    ) -> Result<Vec<ExtractionResult>> {
316        self.export_folder_from_account(account, folder, &output_dir)?;
317        extract_attachments_from_directory(&output_dir)
318    }
319
320    fn run_script(&self, args: &[&str]) -> Result<()> {
321        check_platform()?;
322
323        match &self.script_source {
324            ScriptSource::Embedded => self.run_embedded_script(args),
325            ScriptSource::Path(path) => self.run_script_file(path, args),
326        }
327    }
328
329    fn run_embedded_script(&self, args: &[&str]) -> Result<()> {
330        // Create a temporary file for the embedded script
331        let mut temp_file = tempfile::NamedTempFile::with_suffix(".applescript")?;
332        temp_file.write_all(EMBEDDED_SCRIPT.as_bytes())?;
333        temp_file.flush()?;
334
335        let status = Command::new("osascript")
336            .arg(temp_file.path())
337            .args(args)
338            .status()
339            .map_err(ExportError::LaunchError)?;
340
341        if !status.success() {
342            return Err(ExportError::ScriptFailed(status.code().unwrap_or(-1)));
343        }
344
345        Ok(())
346    }
347
348    fn run_script_file(&self, script_path: &Path, args: &[&str]) -> Result<()> {
349        let script = script_path.canonicalize()?;
350
351        let status = Command::new("osascript")
352            .arg(&script)
353            .args(args)
354            .status()
355            .map_err(ExportError::LaunchError)?;
356
357        if !status.success() {
358            return Err(ExportError::ScriptFailed(status.code().unwrap_or(-1)));
359        }
360
361        Ok(())
362    }
363}
364
365/// Lists all available top-level folders across all Apple Notes accounts.
366///
367/// This is a convenience function that uses the embedded AppleScript.
368/// For more control, use the [`Exporter`] struct.
369///
370/// # Example
371///
372/// ```no_run
373/// use apple_notes_exporter_rs::list_folders;
374///
375/// list_folders().expect("Failed to list folders");
376/// ```
377pub fn list_folders() -> Result<()> {
378    Exporter::new().list_folders()
379}
380
381/// Exports a folder recursively to HTML files.
382///
383/// This is a convenience function that uses the embedded AppleScript.
384/// For more control, use the [`Exporter`] struct.
385///
386/// This function searches all accounts for the folder. If a folder with the
387/// same name exists in multiple accounts, use [`export_folder_from_account`]
388/// to specify which account to use.
389///
390/// # Arguments
391///
392/// * `folder` - The folder name to export.
393/// * `output_dir` - The directory where exported notes will be saved.
394///
395/// # Example
396///
397/// ```no_run
398/// use apple_notes_exporter_rs::export_folder;
399///
400/// export_folder("My Notes", "./exports").expect("Failed to export");
401/// ```
402pub fn export_folder<P: AsRef<Path>>(folder: &str, output_dir: P) -> Result<()> {
403    Exporter::new().export_folder(folder, output_dir)
404}
405
406/// Exports a folder from a specific account recursively to HTML files.
407///
408/// This is a convenience function that uses the embedded AppleScript.
409/// For more control, use the [`Exporter`] struct.
410///
411/// This is useful when a folder with the same name exists in multiple accounts.
412///
413/// # Arguments
414///
415/// * `account` - The account name (e.g., "iCloud", "Google", "On My Mac").
416/// * `folder` - The folder name to export.
417/// * `output_dir` - The directory where exported notes will be saved.
418///
419/// # Example
420///
421/// ```no_run
422/// use apple_notes_exporter_rs::export_folder_from_account;
423///
424/// // Export "Work" folder from iCloud account
425/// export_folder_from_account("iCloud", "Work", "./exports").expect("Failed to export");
426///
427/// // Export "Work" folder from Google account
428/// export_folder_from_account("Google", "Work", "./google_exports").expect("Failed to export");
429/// ```
430pub fn export_folder_from_account<P: AsRef<Path>>(
431    account: &str,
432    folder: &str,
433    output_dir: P,
434) -> Result<()> {
435    Exporter::new().export_folder_from_account(account, folder, output_dir)
436}
437
438// =============================================================================
439// Attachment Extraction
440// =============================================================================
441
442/// Information about an extracted attachment.
443#[derive(Debug, Clone)]
444pub struct ExtractedAttachment {
445    /// The file path where the attachment was saved.
446    pub path: PathBuf,
447    /// The original data URL that was replaced.
448    pub original_data_url: String,
449    /// The MIME type of the attachment (e.g., "image/png").
450    pub mime_type: String,
451}
452
453/// Result of extracting attachments from an HTML file.
454#[derive(Debug)]
455pub struct ExtractionResult {
456    /// The HTML file that was processed.
457    pub html_path: PathBuf,
458    /// The attachments that were extracted.
459    pub attachments: Vec<ExtractedAttachment>,
460    /// Whether the HTML file was modified.
461    pub html_modified: bool,
462}
463
464/// Extracts base64-encoded images from an HTML file and saves them to an attachments folder.
465///
466/// For an HTML file like `My Note -- abc123.html`, images are saved to
467/// `My Note -- abc123-attachments/attachment-001.png`, etc.
468///
469/// The HTML file is updated in-place to reference the local files instead of data URLs.
470///
471/// # Arguments
472///
473/// * `html_path` - Path to the HTML file to process.
474///
475/// # Returns
476///
477/// Returns an `ExtractionResult` with details about what was extracted.
478///
479/// # Example
480///
481/// ```no_run
482/// use apple_notes_exporter_rs::extract_attachments_from_html;
483///
484/// let result = extract_attachments_from_html("./exports/My Note -- abc123.html")
485///     .expect("Failed to extract attachments");
486///
487/// println!("Extracted {} attachments", result.attachments.len());
488/// ```
489pub fn extract_attachments_from_html<P: AsRef<Path>>(html_path: P) -> Result<ExtractionResult> {
490    let html_path = html_path.as_ref();
491    let html_content = fs::read_to_string(html_path)?;
492
493    let document = Html::parse_document(&html_content);
494    let img_selector = Selector::parse("img").unwrap();
495
496    let mut attachments = Vec::new();
497    let mut modified_html = html_content.clone();
498    let mut attachment_count = 0;
499
500    // Determine the attachments folder name based on the HTML file stem
501    let html_stem = html_path
502        .file_stem()
503        .and_then(|s| s.to_str())
504        .unwrap_or("note");
505    let attachments_dir = html_path
506        .parent()
507        .unwrap_or(Path::new("."))
508        .join(format!("{html_stem}-attachments"));
509
510    for element in document.select(&img_selector) {
511        let Some(src) = element.value().attr("src") else {
512            continue;
513        };
514
515        // Check if this is a data URL
516        if !src.starts_with("data:image/") {
517            continue;
518        }
519
520        // Parse the data URL: data:image/png;base64,iVBORw0...
521        let Some((mime_part, base64_data)) = src.strip_prefix("data:").and_then(|s| s.split_once(",")) else {
522            continue;
523        };
524
525        // Extract MIME type (e.g., "image/png;base64" -> "image/png")
526        let mime_type = mime_part.split(';').next().unwrap_or("image/png");
527
528        // Determine file extension from MIME type
529        let extension = match mime_type {
530            "image/png" => "png",
531            "image/jpeg" | "image/jpg" => "jpg",
532            "image/gif" => "gif",
533            "image/webp" => "webp",
534            "image/svg+xml" => "svg",
535            "image/bmp" => "bmp",
536            "image/tiff" => "tiff",
537            _ => "bin",
538        };
539
540        // Decode base64 data
541        let decoded_data = BASE64_STANDARD.decode(base64_data)?;
542
543        // Create attachments directory if needed
544        if !attachments_dir.exists() {
545            fs::create_dir_all(&attachments_dir)?;
546        }
547
548        // Generate filename
549        attachment_count += 1;
550        let filename = format!("attachment-{attachment_count:03}.{extension}");
551        let attachment_path = attachments_dir.join(&filename);
552
553        // Write the attachment file
554        fs::write(&attachment_path, &decoded_data)?;
555
556        // Calculate relative path from HTML file to attachment
557        let attachments_folder_name = attachments_dir
558            .file_name()
559            .and_then(|s| s.to_str())
560            .unwrap_or("attachments");
561        let relative_path = format!("{attachments_folder_name}/{filename}");
562
563        // Replace the data URL with the relative path in the HTML
564        modified_html = modified_html.replace(src, &relative_path);
565
566        attachments.push(ExtractedAttachment {
567            path: attachment_path,
568            original_data_url: src.to_string(),
569            mime_type: mime_type.to_string(),
570        });
571    }
572
573    // Write modified HTML if any attachments were extracted
574    let html_modified = !attachments.is_empty();
575    if html_modified {
576        fs::write(html_path, &modified_html)?;
577    }
578
579    Ok(ExtractionResult {
580        html_path: html_path.to_path_buf(),
581        attachments,
582        html_modified,
583    })
584}
585
586/// Extracts attachments from all HTML files in a directory (recursively).
587///
588/// # Arguments
589///
590/// * `dir` - The directory to scan for HTML files.
591///
592/// # Returns
593///
594/// Returns a vector of `ExtractionResult` for each HTML file processed.
595///
596/// # Example
597///
598/// ```no_run
599/// use apple_notes_exporter_rs::extract_attachments_from_directory;
600///
601/// let results = extract_attachments_from_directory("./exports")
602///     .expect("Failed to extract attachments");
603///
604/// let total_attachments: usize = results.iter().map(|r| r.attachments.len()).sum();
605/// println!("Extracted {total_attachments} attachments from {} files", results.len());
606/// ```
607pub fn extract_attachments_from_directory<P: AsRef<Path>>(dir: P) -> Result<Vec<ExtractionResult>> {
608    let dir = dir.as_ref();
609    let mut results = Vec::new();
610
611    extract_attachments_recursive(dir, &mut results)?;
612
613    Ok(results)
614}
615
616fn extract_attachments_recursive(dir: &Path, results: &mut Vec<ExtractionResult>) -> Result<()> {
617    if !dir.is_dir() {
618        return Ok(());
619    }
620
621    for entry in fs::read_dir(dir)? {
622        let entry = entry?;
623        let path = entry.path();
624
625        if path.is_dir() {
626            // Skip attachment directories to avoid reprocessing
627            if path
628                .file_name()
629                .and_then(|s| s.to_str())
630                .is_some_and(|name| name.ends_with("-attachments"))
631            {
632                continue;
633            }
634            extract_attachments_recursive(&path, results)?;
635        } else if path.extension().is_some_and(|ext| ext == "html") {
636            let result = extract_attachments_from_html(&path)?;
637            results.push(result);
638        }
639    }
640
641    Ok(())
642}