use std::path::PathBuf;
use std::process::Command;
use std::thread;
use std::time::Duration;
use rusqlite::{Connection, OpenFlags, params};
use crate::MacosError;
use crate::applescript::{escape, run};
use crate::notes::crud;
#[derive(serde::Serialize)]
pub struct QuickNote {
pub title: String,
pub folder: String,
}
fn list_by_title(title: &str) -> Result<Vec<QuickNote>, MacosError> {
query_notes(Some(title))
}
fn db_path() -> Result<PathBuf, MacosError> {
let home = std::env::var("HOME")
.map_err(|_| MacosError::Other("HOME environment variable not set".to_string()))?;
Ok(PathBuf::from(home).join("Library/Group Containers/group.com.apple.notes/NoteStore.sqlite"))
}
fn open_db() -> Result<Connection, MacosError> {
let path = db_path()?;
Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|err| MacosError::Other(format!("failed to open NoteStore.sqlite: {err}")))
}
fn query_notes(title_filter: Option<&str>) -> Result<Vec<QuickNote>, MacosError> {
let conn = open_db()?;
let mut stmt = conn
.prepare(
"SELECT n.ZTITLE1, f.ZTITLE2
FROM ZICCLOUDSYNCINGOBJECT n
LEFT JOIN ZICCLOUDSYNCINGOBJECT f ON n.ZFOLDER = f.Z_PK
WHERE n.Z_ENT = 11
AND n.ZISSYSTEMPAPER = 1
AND COALESCE(n.ZMARKEDFORDELETION, 0) != 1
AND (f.ZTITLE2 IS NULL OR f.ZTITLE2 != 'Recently Deleted')
AND (?1 IS NULL OR n.ZTITLE1 = ?1)",
)
.map_err(|err| MacosError::Other(format!("failed to prepare quick note query: {err}")))?;
let rows = stmt
.query_map(params![title_filter], |row| {
let title: Option<String> = row.get(0)?;
let folder: Option<String> = row.get(1)?;
Ok((title.unwrap_or_default(), folder.unwrap_or_default()))
})
.map_err(|err| MacosError::Other(format!("failed to query quick notes: {err}")))?;
let mut notes = Vec::new();
for row in rows {
let (title, folder) =
row.map_err(|err| MacosError::Other(format!("failed to read quick note row: {err}")))?;
if title.is_empty() {
continue;
}
notes.push(QuickNote { title, folder });
}
Ok(notes)
}
pub fn list() -> Result<Vec<QuickNote>, MacosError> {
query_notes(None)
}
fn find_folder(title: &str) -> Result<String, MacosError> {
list_by_title(title)?
.into_iter()
.next()
.map(|note| note.folder)
.ok_or_else(|| MacosError::Other(format!("quick note not found: {title}")))
}
fn find_unique(title: &str) -> Result<QuickNote, MacosError> {
let matches = list_by_title(title)?;
match matches.len() {
0 => Err(MacosError::Other(format!("quick note not found: {title}"))),
1 => Ok(matches.into_iter().next().unwrap()),
count => Err(MacosError::Other(format!(
"quick note title is ambiguous: {title} ({count} matches)"
))),
}
}
pub fn create(title: &str, body: &str) -> Result<(), MacosError> {
crud::create_note(title, body, "Quick Notes")
}
pub fn update(title: &str, body: &str) -> Result<(), MacosError> {
let folder = find_folder(title)?;
let escaped_title = escape(title);
let escaped_folder = escape(&folder);
let body_expr = format!("\"{}\"", escape(&html_body_for_update(title, body)));
let script = format!(
r#"
tell application "Notes"
set theNote to (first note of folder "{escaped_folder}" whose name is "{escaped_title}")
set body of theNote to {body_expr}
set name of theNote to "{escaped_title}"
end tell
"#
);
run(&script, "failed to update quick note")
}
pub fn delete(title: &str) -> Result<(), MacosError> {
let folder = find_folder(title)?;
crud::delete_note(title, &folder)
}
fn read_body_html(title: &str, folder: &str) -> Result<String, MacosError> {
let escaped_title = escape(title);
let escaped_folder = escape(folder);
let script = format!(
r#"
tell application "Notes"
set theNote to (first note of folder "{escaped_folder}" whose name is "{escaped_title}")
return body of theNote
end tell
"#
);
let output = Command::new("osascript")
.arg("-e")
.arg(&script)
.output()
.map_err(|e| MacosError::Other(format!("osascript: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(MacosError::Other(format!(
"failed to read quick note body: {stderr}"
)));
}
Ok(String::from_utf8_lossy(&output.stdout)
.trim_end()
.to_string())
}
fn escape_html_text(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn html_body_for_update(title: &str, body: &str) -> String {
let html_title = escape_html_text(title);
let body_html = if body.is_empty() {
String::new()
} else {
body.split('\n')
.map(|line| {
if line.is_empty() {
"<div><br></div>".to_string()
} else {
format!("<div>{}</div>", escape_html_text(line))
}
})
.collect::<Vec<_>>()
.join("")
};
format!("<h1>{html_title}</h1>{body_html}")
}
fn strip_title_block(title: &str, body_html: &str) -> String {
let title_div = format!("<div>{}</div>", escape_html_text(title));
if let Some(rest) = body_html.strip_prefix(&title_div) {
return rest.to_string();
}
body_html.to_string()
}
fn ensure_archive_destination(source_folder: &str, to_folder: &str) -> Result<(), MacosError> {
if source_folder == to_folder {
return Err(MacosError::Other(format!(
"archive destination must differ from the current quick note folder: {to_folder}"
)));
}
Ok(())
}
pub fn archive(title: &str, to_folder: &str) -> Result<(), MacosError> {
let note = find_unique(title)?;
ensure_archive_destination(¬e.folder, to_folder)?;
let body_html = read_body_html(¬e.title, ¬e.folder)?;
let body = strip_title_block(¬e.title, &body_html);
crud::create_note(¬e.title, &body, to_folder)?;
crud::delete_note(¬e.title, ¬e.folder)?;
for _ in 0..20 {
if list_by_title(title)?.is_empty() {
return Ok(());
}
thread::sleep(Duration::from_millis(500));
}
Ok(())
}
#[cfg(test)]
mod tests {
use rusqlite::Connection;
use crate::notes::test_support::with_temp_home;
use super::{ensure_archive_destination, html_body_for_update, query_notes, strip_title_block};
fn seed_quick_notes_db(home: &std::path::Path) {
let notes_dir = home.join("Library/Group Containers/group.com.apple.notes");
std::fs::create_dir_all(¬es_dir).expect("create notes dir");
let db_path = notes_dir.join("NoteStore.sqlite");
let conn = Connection::open(db_path).expect("open sqlite");
conn.execute_batch(
r#"
CREATE TABLE ZICCLOUDSYNCINGOBJECT (
Z_PK INTEGER PRIMARY KEY,
ZTITLE1 TEXT,
ZTITLE2 TEXT,
ZFOLDER INTEGER,
Z_ENT INTEGER,
ZISSYSTEMPAPER INTEGER,
ZMARKEDFORDELETION INTEGER
);
INSERT INTO ZICCLOUDSYNCINGOBJECT (Z_PK, ZTITLE2)
VALUES (10, 'Quick Notes');
INSERT INTO ZICCLOUDSYNCINGOBJECT (
Z_PK, ZTITLE1, ZFOLDER, Z_ENT, ZISSYSTEMPAPER, ZMARKEDFORDELETION
)
VALUES
(1, NULL, 10, 11, 1, 0),
(2, 'kept', 10, 11, 1, 0);
"#,
)
.expect("seed sqlite");
}
#[test]
fn strip_title_block_removes_leading_title_div() {
let html = "<div>Step 1</div><div>body line 1</div><div>body line 2</div>";
let body = strip_title_block("Step 1", html);
assert_eq!(body, "<div>body line 1</div><div>body line 2</div>");
}
#[test]
fn strip_title_block_preserves_links_and_blank_lines() {
let html = "<div>456</div>\n<div><br></div>\n<div><a href=https://example.com>https://example.com</a><br></div>";
let body = strip_title_block("456", html);
assert_eq!(
body,
"\n<div><br></div>\n<div><a href=https://example.com>https://example.com</a><br></div>"
);
}
#[test]
fn archive_same_folder_returns_clear_error() {
let err = ensure_archive_destination("Notes", "Notes").unwrap_err();
assert_eq!(
err.to_string(),
"archive destination must differ from the current quick note folder: Notes"
);
}
#[test]
fn html_body_for_update_escapes_title_and_body_lines() {
let html = html_body_for_update("A < B", "x < y & z\n\nq > r");
assert_eq!(
html,
"<h1>A < B</h1><div>x < y & z</div><div><br></div><div>q > r</div>"
);
}
#[test]
fn query_notes_ignores_null_titles_when_listing() {
with_temp_home(|home| {
seed_quick_notes_db(home);
let notes = query_notes(None).expect("query quick notes");
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].title, "kept");
});
}
}