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
5use crate::ExGuid;
6
7/// What an internal link opens.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub 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=…`.
26pub 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)]
68pub 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.
76pub 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.
111fn 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}