| 1 | //! Links between pages, paragraphs and sections of the same notebook, in the form OneNote |
| 2 | //! stores them: a `HYPERLINK` field code whose URL names the section by its file identity |
| 3 | //! and the page by its notebook-management identity, with the section path as `base-path`. |
| 4 | |
| 5 | use crate::ExGuid; |
| 6 | |
| 7 | /// What an internal link opens. |
| 8 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 9 | pub enum LinkTarget<'a> { |
| 10 | Section, |
| 11 | Page { |
| 12 | identity: [u8; 16], |
| 13 | title: &'a str, |
| 14 | }, |
| 15 | /// A paragraph or other object on a page, named by its stored identity. |
| 16 | Object { |
| 17 | identity: [u8; 16], |
| 18 | title: &'a str, |
| 19 | object: ExGuid, |
| 20 | }, |
| 21 | } |
| 22 | |
| 23 | /// The URL OneNote 2010 stores for a link into the section whose file identity is `section` |
| 24 | /// and whose file is at `base_path` (the path as the linking client sees it): |
| 25 | /// `onenote:#Title&section-id={…}&page-id={…}&end&base-path=…`. |
| 26 | pub fn internal_link(section: [u8; 16], base_path: &str, target: LinkTarget<'_>) -> String { |
| 27 | let guid = |bytes: [u8; 16]| { |
| 28 | ExGuid { guid: bytes, n: 0 } |
| 29 | .to_string() |
| 30 | .split(',') |
| 31 | .next() |
| 32 | .unwrap() |
| 33 | .to_owned() |
| 34 | }; |
| 35 | let mut url = String::from("onenote:#"); |
| 36 | match target { |
| 37 | LinkTarget::Section => {} |
| 38 | LinkTarget::Page { title, .. } | LinkTarget::Object { title, .. } => { |
| 39 | url.push_str(&encoded(title)); |
| 40 | url.push('&'); |
| 41 | } |
| 42 | } |
| 43 | url.push_str(&format!("section-id={}", guid(section))); |
| 44 | match target { |
| 45 | LinkTarget::Section => url.push_str("&end"), |
| 46 | LinkTarget::Page { identity, .. } => { |
| 47 | url.push_str(&format!("&page-id={}&end", guid(identity))); |
| 48 | } |
| 49 | LinkTarget::Object { |
| 50 | identity, object, .. |
| 51 | } => { |
| 52 | url.push_str(&format!( |
| 53 | "&page-id={}&object-id={}&{}", |
| 54 | guid(identity), |
| 55 | guid(object.guid), |
| 56 | object.n |
| 57 | )); |
| 58 | } |
| 59 | } |
| 60 | url.push_str("&base-path="); |
| 61 | url.push_str(base_path); |
| 62 | url |
| 63 | } |
| 64 | |
| 65 | /// The identities a stored internal link names. OneNote resolves links by identity, so |
| 66 | /// the title and base path in the URL are hints only. |
| 67 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 68 | pub struct InternalLink { |
| 69 | pub section: [u8; 16], |
| 70 | pub page: Option<[u8; 16]>, |
| 71 | pub object: Option<ExGuid>, |
| 72 | } |
| 73 | |
| 74 | /// The identities in an `onenote:` link, whatever path it names before the `#` (none as |
| 75 | /// stored, the section file's as Copy Link to Page gives it), or `None` for any other URL. |
| 76 | pub fn parse_internal_link(url: &str) -> Option<InternalLink> { |
| 77 | let guid = |value: &str| { |
| 78 | format!("{value},0") |
| 79 | .parse::<ExGuid>() |
| 80 | .ok() |
| 81 | .map(|id| id.guid) |
| 82 | }; |
| 83 | let (_, fragment) = url.strip_prefix("onenote:")?.split_once('#')?; |
| 84 | let parts: Vec<&str> = fragment.split('&').collect(); |
| 85 | let value = |name: &str| { |
| 86 | parts.iter().find_map(|part| { |
| 87 | part.strip_prefix(name) |
| 88 | .and_then(|rest| rest.strip_prefix('=')) |
| 89 | }) |
| 90 | }; |
| 91 | let section = guid(value("section-id")?)?; |
| 92 | let page = match value("page-id") { |
| 93 | Some(id) => Some(guid(id)?), |
| 94 | None => None, |
| 95 | }; |
| 96 | let object = match parts.iter().position(|part| part.starts_with("object-id=")) { |
| 97 | Some(at) => Some(ExGuid { |
| 98 | guid: guid(&parts[at]["object-id=".len()..])?, |
| 99 | n: parts.get(at + 1)?.parse().ok()?, |
| 100 | }), |
| 101 | None => None, |
| 102 | }; |
| 103 | Some(InternalLink { |
| 104 | section, |
| 105 | page, |
| 106 | object, |
| 107 | }) |
| 108 | } |
| 109 | |
| 110 | /// Percent-encodes a page title the way OneNote does in a link fragment. |
| 111 | fn encoded(title: &str) -> String { |
| 112 | let mut out = String::new(); |
| 113 | for byte in title.bytes() { |
| 114 | match byte { |
| 115 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { |
| 116 | out.push(byte as char); |
| 117 | } |
| 118 | _ => out.push_str(&format!("%{byte:02X}")), |
| 119 | } |
| 120 | } |
| 121 | out |
| 122 | } |