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}