onenote-cli 0.1.2

Read and capture Microsoft OneNote notes through its Windows desktop application
Documentation
use crate::{cli::PageArgs, error::Error};
use roxmltree::{Document, Node};
use serde_json::{Value, json};

const NS: &str = "http://schemas.microsoft.com/office/onenote/2013/onenote";
pub const FIELDS: &[&str] = &[
    "id",
    "name",
    "kind",
    "parent_id",
    "path",
    "last_modified",
    "locked",
];

fn parse(xml: &str) -> Result<Document<'_>, Error> {
    let doc =
        Document::parse(xml).map_err(|e| Error::desktop(format!("Invalid OneNote XML: {e}")))?;
    if doc.root_element().tag_name().namespace() != Some(NS) {
        return Err(Error::desktop("Unexpected OneNote XML namespace"));
    }
    Ok(doc)
}
fn is(node: Node<'_, '_>, name: &str) -> bool {
    node.has_tag_name((NS, name))
}
fn hidden(node: Node<'_, '_>) -> bool {
    node.ancestors().any(|n| {
        n.attribute("isRecycleBin") == Some("true") || n.attribute("isInRecycleBin") == Some("true")
    })
}
fn record(node: Node<'_, '_>) -> Result<Value, Error> {
    let id = node
        .attribute("ID")
        .filter(|id| !id.is_empty())
        .ok_or_else(|| Error::desktop("OneNote returned a resource without an ID"))?;
    let path: Vec<_> = node
        .ancestors()
        .filter(|n| n.is_element())
        .filter_map(|n| n.attribute("name"))
        .collect();
    Ok(json!({
        "id": id, "name": node.attribute("name").unwrap_or("Untitled"),
        "kind": node.tag_name().name().to_ascii_lowercase(),
        "parent_id": node.parent_element().and_then(|n| n.attribute("ID")),
        "path": path.into_iter().rev().collect::<Vec<_>>().join(" / "),
        "last_modified": node.attribute("lastModifiedTime"),
        "locked": node.ancestors().any(|n| n.attribute("locked") == Some("true")),
    }))
}
pub fn validate_fields(page: &PageArgs) -> Result<(), Error> {
    for field in &page.fields {
        if !FIELDS.contains(&field.as_str()) {
            return Err(Error::invalid(format!(
                "Unknown field '{field}'; choose from {}",
                FIELDS.join(", ")
            )));
        }
    }
    Ok(())
}
pub fn collection(xml: &str, kind: &str, page: &PageArgs) -> Result<Value, Error> {
    collect(xml, kind, page, false)
}
pub fn search(xml: &str, page: &PageArgs) -> Result<Value, Error> {
    collect(xml, "Page", page, true)
}
fn collect(xml: &str, kind: &str, page: &PageArgs, search: bool) -> Result<Value, Error> {
    validate_fields(page)?;
    let doc = parse(xml)?;
    if kind == "Page" && doc.root_element().attribute("locked") == Some("true") {
        return Err(Error::desktop(
            "This section is password-protected. Unlock it in OneNote desktop and retry.",
        ));
    }
    let mut nodes: Vec<_> = doc
        .descendants()
        .filter(|n| is(*n, kind) && !hidden(*n))
        .collect();
    // FindPages includes every unindexed page, even when it does not match.
    // Keep that information as completeness metadata, never as a search hit.
    let unindexed_count = if search {
        let before = nodes.len();
        nodes.retain(|node| node.attribute("isIndexed") != Some("false"));
        before - nodes.len()
    } else {
        0
    };
    let total = nodes.len();
    let start = page.offset as usize;
    let mut items = Vec::new();
    for node in nodes.into_iter().skip(start).take(page.limit as usize) {
        let mut item = record(node)?;
        if !page.fields.is_empty() {
            item.as_object_mut()
                .unwrap()
                .retain(|k, _| page.fields.contains(k));
        }
        items.push(item);
    }
    let next = start + items.len();
    let mut result = json!({"items":items, "total":total, "next_offset": if next < total { Some(next) } else { None }, "truncated":next < total});
    if search {
        result["indexing_pending"] = json!(unindexed_count > 0);
        result["unindexed_count"] = json!(unindexed_count);
    }
    Ok(result)
}
pub fn read(xml: &str, expected_id: &str, include_xml: bool) -> Result<Value, Error> {
    let doc = parse(xml)?;
    let root = doc.root_element();
    if !is(root, "Page") || root.attribute("ID") != Some(expected_id) {
        return Err(Error::desktop(
            "OneNote returned a different page than requested",
        ));
    }
    let mut result = record(root)?;
    let mut fragments = Vec::new();
    for node in root.descendants().filter(|n| is(*n, "T")) {
        if node.ancestors().any(|n| is(n, "Title")) {
            continue;
        }
        let html: String = node.children().filter_map(|n| n.text()).collect();
        let rendered = html2md::parse_html(&html);
        if !rendered.trim().is_empty() {
            fragments.push(rendered.trim().to_owned());
        }
    }
    result["markdown"] = json!(fragments.join("\n\n"));
    result["has_images"] = json!(root.descendants().any(|n| is(n, "Image")));
    result["has_attachments"] = json!(root.descendants().any(|n| is(n, "InsertedFile")));
    if include_xml {
        result["xml"] = json!(xml);
    }
    Ok(result)
}

fn normalized(text: &str) -> String {
    text.split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
        .to_lowercase()
}

// Parse HTML rather than searching Markdown syntax, link targets, or attributes.
fn html_text(html: &str) -> String {
    use html5ever::{parse_document, tendril::TendrilSink};
    use markup5ever_rcdom::{NodeData, RcDom};
    let dom = parse_document(RcDom::default(), Default::default()).one(html);
    let mut stack = vec![(dom.document.clone(), false)];
    let mut text = String::new();
    while let Some((node, closing)) = stack.pop() {
        if closing {
            text.push(' ');
            continue;
        }
        match &node.data {
            NodeData::Text { contents } => text.push_str(&contents.borrow()),
            NodeData::Element { name, .. } => {
                if matches!(name.local.as_ref(), "script" | "style") {
                    continue;
                }
                if matches!(name.local.as_ref(), "br" | "p" | "div" | "li" | "tr" | "td") {
                    text.push(' ');
                    stack.push((node.clone(), true));
                }
            }
            _ => {}
        }
        stack.extend(
            node.children
                .borrow()
                .iter()
                .rev()
                .cloned()
                .map(|node| (node, false)),
        );
    }
    text
}

pub fn scan(batch: &Value, query: &str, page: &PageArgs) -> Result<Value, Error> {
    validate_fields(page)?;
    let hierarchy = parse(
        batch["xml"]
            .as_str()
            .ok_or_else(|| Error::desktop("Scan response has no hierarchy"))?,
    )?;
    let pages = batch["pages"]
        .as_array()
        .ok_or_else(|| Error::desktop("Scan response has no pages"))?;
    let mut skipped = batch["skipped_pages"]
        .as_array()
        .ok_or_else(|| Error::desktop("Scan response has no skipped-page metadata"))?
        .clone();
    let query = normalized(query);
    let mut matches = Vec::new();
    let mut scanned = 0;
    for entry in pages {
        let id = entry["id"]
            .as_str()
            .ok_or_else(|| Error::desktop("Scan page has no ID"))?;
        let node = hierarchy
            .descendants()
            .find(|n| is(*n, "Page") && n.attribute("ID") == Some(id) && !hidden(*n))
            .ok_or_else(|| {
                Error::desktop("Scan returned a page outside the requested hierarchy")
            })?;
        if node
            .ancestors()
            .any(|n| n.attribute("locked") == Some("true"))
        {
            return Err(Error::desktop(
                "Scan returned content from a locked section",
            ));
        }
        let content = entry["xml"]
            .as_str()
            .ok_or_else(|| Error::desktop("Scan page has no XML"))?;
        let doc = match parse(content) {
            Ok(doc)
                if is(doc.root_element(), "Page")
                    && doc.root_element().attribute("ID") == Some(id) =>
            {
                doc
            }
            _ => {
                skipped.push(json!({"id":id,"reason":"invalid_page_xml"}));
                continue;
            }
        };
        scanned += 1;
        let root = doc.root_element();
        let mut text = root.attribute("name").unwrap_or("").to_owned();
        for part in root.descendants().filter(|n| is(*n, "T")) {
            text.push('\n');
            let html: String = part.children().filter_map(|n| n.text()).collect();
            text.push_str(&html_text(&html));
        }
        if normalized(&text).contains(&query) {
            let mut item = record(node)?;
            if !page.fields.is_empty() {
                item.as_object_mut()
                    .unwrap()
                    .retain(|key, _| page.fields.contains(key));
            }
            matches.push(item);
        }
    }
    let total = matches.len();
    let start = page.offset as usize;
    let items: Vec<_> = matches
        .into_iter()
        .skip(start)
        .take(page.limit as usize)
        .collect();
    let next = start + items.len();
    let incomplete = !batch["next_scan_offset"].is_null()
        || !skipped.is_empty()
        || batch["skipped_locked_sections"].as_u64().unwrap_or(0) > 0;
    Ok(
        json!({"items":items,"total":total,"next_offset":if next < total {Some(next)} else {None},
        "truncated":next < total,"search_mode":"scan","incomplete":incomplete,
        "scan_offset":batch["scan_offset"],"next_scan_offset":batch["next_scan_offset"],
        "candidate_pages":batch["candidate_pages"],"attempted_pages":batch["attempted_pages"],
        "scanned_pages":scanned,"skipped_locked_sections":batch["skipped_locked_sections"],"skipped_pages":skipped}),
    )
}