| 1 | #[path = "support/ops.rs"] |
| 2 | mod ops; |
| 3 | use onestore::{ |
| 4 | ExGuid, RevisionIndex, Store, |
| 5 | document::Document, |
| 6 | page::{ |
| 7 | Page, PageObject, PageParagraph, Paragraph, |
| 8 | link::{InternalLink, LinkTarget, internal_link, parse_internal_link}, |
| 9 | text::Edit, |
| 10 | }, |
| 11 | }; |
| 12 | |
| 13 | const OUTLINES: &[u8] = |
| 14 | include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); |
| 15 | const NATIVE_LINKS: &[u8] = |
| 16 | include_bytes!("../../../corpus/link-edit/native-links/notebook/links.one"); |
| 17 | const NATIVE_BASE_PATH: &str = r"C:\one-tests\runs\capture\notebook\links.one"; |
| 18 | const CODE: &str = "\u{fddf}HYPERLINK \"https://example.invalid/rust\""; |
| 19 | |
| 20 | fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) { |
| 21 | let store = Store::parse(bytes).unwrap(); |
| 22 | assert!(store.checksum_mismatches.is_empty()); |
| 23 | let index = RevisionIndex::parse(&store).unwrap(); |
| 24 | let document = Document::parse(&index).unwrap(); |
| 25 | document |
| 26 | .pages() |
| 27 | .unwrap() |
| 28 | .into_iter() |
| 29 | .find_map(|(space, _)| { |
| 30 | let page = Page::from_space(&document, space).unwrap(); |
| 31 | (page.title == title).then_some((space, page)) |
| 32 | }) |
| 33 | .unwrap() |
| 34 | } |
| 35 | |
| 36 | fn page_in(bytes: &[u8], space: ExGuid) -> Page { |
| 37 | let store = Store::parse(bytes).unwrap(); |
| 38 | assert!(store.checksum_mismatches.is_empty()); |
| 39 | let index = RevisionIndex::parse(&store).unwrap(); |
| 40 | Page::from_space(&Document::parse(&index).unwrap(), space).unwrap() |
| 41 | } |
| 42 | |
| 43 | fn body_paragraphs(page: &mut Page) -> &mut Vec<PageParagraph> { |
| 44 | page.objects |
| 45 | .iter_mut() |
| 46 | .find_map(|object| match object { |
| 47 | PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs), |
| 48 | _ => None, |
| 49 | }) |
| 50 | .unwrap() |
| 51 | } |
| 52 | |
| 53 | /// Language tags stay as stored, so the comparison leaves them out. |
| 54 | fn without_language(mut page: Page) -> Page { |
| 55 | for object in &mut page.objects { |
| 56 | if let PageObject::Outline(outline) = object { |
| 57 | for paragraph in &mut outline.paragraphs { |
| 58 | if let Some(text) = paragraph.text_mut() { |
| 59 | let mut start = 0; |
| 60 | text.text = Paragraph::from_runs(text.text.spans().iter().map(|span| { |
| 61 | let value = text.text.text()[start..span.end].to_owned(); |
| 62 | start = span.end; |
| 63 | let mut format = span.format.clone(); |
| 64 | format.language = None; |
| 65 | (value, format) |
| 66 | })); |
| 67 | } |
| 68 | } |
| 69 | } |
| 70 | } |
| 71 | page |
| 72 | } |
| 73 | |
| 74 | fn assert_same(written: &[u8], space: ExGuid, expected: &Page) -> Page { |
| 75 | let stored = page_in(written, space); |
| 76 | let mut expected = expected.clone(); |
| 77 | expected.title = stored.title.clone(); |
| 78 | assert_eq!(without_language(stored.clone()), without_language(expected)); |
| 79 | stored |
| 80 | } |
| 81 | |
| 82 | fn linked(page: &mut Page) -> usize { |
| 83 | body_paragraphs(page) |
| 84 | .iter() |
| 85 | .position(|p| { |
| 86 | p.text() |
| 87 | .is_some_and(|t| t.text.text().contains("HYPERLINK")) |
| 88 | }) |
| 89 | .unwrap() |
| 90 | } |
| 91 | |
| 92 | /// Appends a hyperlink: a hidden field code run followed by the visible label, both |
| 93 | /// flagged as link runs like OneNote writes them. |
| 94 | fn append_link(text: &mut Paragraph, label: &str) { |
| 95 | let end = text.utf16_offset(text.text().len()).unwrap(); |
| 96 | let base = text.format_at(end).unwrap().clone(); |
| 97 | let mut code = base.clone(); |
| 98 | code.hyperlink = Some(true); |
| 99 | code.hyperlink_label = Some(true); |
| 100 | code.hidden = Some(true); |
| 101 | let mut visible = base.clone(); |
| 102 | visible.hyperlink = Some(true); |
| 103 | visible.hyperlink_label = Some(true); |
| 104 | text.apply(Edit { |
| 105 | range: end..end, |
| 106 | replacement: Paragraph::from_runs([ |
| 107 | (" ".to_owned(), base), |
| 108 | (CODE.to_owned(), code), |
| 109 | (label.to_owned(), visible), |
| 110 | ]), |
| 111 | }) |
| 112 | .unwrap(); |
| 113 | } |
| 114 | |
| 115 | #[test] |
| 116 | fn a_native_link_is_removed_leaving_plain_label_text() { |
| 117 | let (space, before) = page_by_title(OUTLINES, "Move leaf down"); |
| 118 | let mut after = before.clone(); |
| 119 | let at = linked(&mut after); |
| 120 | let text = &mut body_paragraphs(&mut after)[at].text_mut().unwrap().text; |
| 121 | let content = text.text().to_owned(); |
| 122 | let start = content.find('\u{fddf}').unwrap(); |
| 123 | let end = content.find("Link label").unwrap(); |
| 124 | let mut plain = text.format_at(0).unwrap().clone(); |
| 125 | plain.hyperlink = Some(false); |
| 126 | plain.hyperlink_label = Some(false); |
| 127 | plain.hidden = Some(false); |
| 128 | let (from, to) = ( |
| 129 | text.utf16_offset(start).unwrap(), |
| 130 | text.utf16_offset(content.len()).unwrap(), |
| 131 | ); |
| 132 | text.apply(Edit { |
| 133 | range: from..to, |
| 134 | replacement: Paragraph::new(content[end..].to_owned(), plain), |
| 135 | }) |
| 136 | .unwrap(); |
| 137 | let written = ops::saved(OUTLINES, space, &after).unwrap(); |
| 138 | let stored = assert_same(written.as_slice(), space, &after); |
| 139 | let mut stored = stored; |
| 140 | let text = &body_paragraphs(&mut stored)[at].text().unwrap().text; |
| 141 | assert!(text.text().ends_with(" Link label")); |
| 142 | assert!( |
| 143 | text.spans() |
| 144 | .iter() |
| 145 | .all(|span| span.format.hyperlink != Some(true) && span.format.hidden != Some(true)) |
| 146 | ); |
| 147 | } |
| 148 | |
| 149 | /// `ONESTORE_LINK_EXPORT` names a new directory receiving the candidate for a cold reopen. |
| 150 | #[test] |
| 151 | fn a_link_is_added_to_a_fresh_page_and_reads_back() { |
| 152 | let source = onestore::create_section("links.one", "Read about Rust", "Author").unwrap(); |
| 153 | let store = Store::parse(&source).unwrap(); |
| 154 | let index = RevisionIndex::parse(&store).unwrap(); |
| 155 | let document = Document::parse(&index).unwrap(); |
| 156 | let (space, _) = document.pages().unwrap()[0]; |
| 157 | let before = Page::from_space(&document, space).unwrap(); |
| 158 | let mut after = before.clone(); |
| 159 | append_link( |
| 160 | &mut body_paragraphs(&mut after)[0].text_mut().unwrap().text, |
| 161 | "the Rust site", |
| 162 | ); |
| 163 | let written = ops::saved(&source, space, &after).unwrap(); |
| 164 | let stored = assert_same(written.as_slice(), space, &after); |
| 165 | // As OneNote titles `NATIVE_LINKS`'s page holding this paragraph: without the field code. |
| 166 | assert_eq!(stored.title, "Read about Rust the Rust site"); |
| 167 | let mut stored = stored; |
| 168 | let text = &body_paragraphs(&mut stored)[0].text().unwrap().text; |
| 169 | let flags: Vec<(Option<bool>, Option<bool>, Option<bool>)> = text |
| 170 | .spans() |
| 171 | .iter() |
| 172 | .map(|s| { |
| 173 | ( |
| 174 | s.format.hyperlink, |
| 175 | s.format.hyperlink_label, |
| 176 | s.format.hidden, |
| 177 | ) |
| 178 | }) |
| 179 | .collect(); |
| 180 | assert_eq!( |
| 181 | flags, |
| 182 | [ |
| 183 | (None, None, None), |
| 184 | (Some(true), Some(true), Some(true)), |
| 185 | (Some(true), Some(true), None), |
| 186 | ] |
| 187 | ); |
| 188 | if let Some(directory) = std::env::var_os("ONESTORE_LINK_EXPORT") { |
| 189 | let directory = std::path::PathBuf::from(directory); |
| 190 | std::fs::create_dir(&directory).unwrap(); |
| 191 | std::fs::write(directory.join("links.one"), written.as_slice()).unwrap(); |
| 192 | let written_store = Store::parse(written.as_slice()).unwrap(); |
| 193 | std::fs::write( |
| 194 | directory.join("Open Notebook.onetoc2"), |
| 195 | onestore::create_table_of_contents( |
| 196 | "Open Notebook.onetoc2", |
| 197 | &[("links.one", written_store.header.file_id)], |
| 198 | ) |
| 199 | .unwrap(), |
| 200 | ) |
| 201 | .unwrap(); |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | fn field_codes(page: &mut Page) -> Vec<String> { |
| 206 | body_paragraphs(page) |
| 207 | .iter() |
| 208 | .filter_map(|p| p.text()) |
| 209 | .filter_map(|t| { |
| 210 | let text = t.text.text(); |
| 211 | let start = text.find("\u{fddf}HYPERLINK \"")? + "\u{fddf}HYPERLINK \"".len(); |
| 212 | let end = start + text[start..].find('"')?; |
| 213 | Some(text[start..end].to_owned()) |
| 214 | }) |
| 215 | .collect() |
| 216 | } |
| 217 | |
| 218 | /// The URLs OneNote 2010 wrote for links to a page, a paragraph and the section |
| 219 | /// (`corpus/link-edit/native-links`) are what `internal_link` produces from the stored |
| 220 | /// identities: the section file identity, the target page's notebook-management identity |
| 221 | /// and the paragraph's stored identity. |
| 222 | #[test] |
| 223 | fn internal_links_match_what_onenote_stores() { |
| 224 | let section = Store::parse(NATIVE_LINKS).unwrap().header.file_id; |
| 225 | let (_, target) = page_by_title(NATIVE_LINKS, "Link target"); |
| 226 | let mut target_paragraphs = target.clone(); |
| 227 | // OneNote rewrote the target outline after linking; the link keeps the paragraph |
| 228 | // identity it saw then (n 28), whose page half is the current paragraph's. |
| 229 | let paragraph = ExGuid { |
| 230 | guid: body_paragraphs(&mut target_paragraphs)[0].id.guid, |
| 231 | n: 28, |
| 232 | }; |
| 233 | let (_, mut source) = page_by_title(NATIVE_LINKS, "Read about Rust the Rust site"); |
| 234 | let stored = field_codes(&mut source); |
| 235 | let page = LinkTarget::Page { |
| 236 | identity: target.identity.unwrap(), |
| 237 | title: &target.title, |
| 238 | }; |
| 239 | let object = LinkTarget::Object { |
| 240 | identity: target.identity.unwrap(), |
| 241 | title: &target.title, |
| 242 | object: paragraph, |
| 243 | }; |
| 244 | assert_eq!( |
| 245 | stored, |
| 246 | [ |
| 247 | "https://example.invalid/rust".to_owned(), |
| 248 | internal_link(section, NATIVE_BASE_PATH, page), |
| 249 | internal_link(section, NATIVE_BASE_PATH, object), |
| 250 | internal_link(section, NATIVE_BASE_PATH, LinkTarget::Section), |
| 251 | ] |
| 252 | ); |
| 253 | assert_eq!( |
| 254 | internal_link( |
| 255 | [0; 16], |
| 256 | "p", |
| 257 | LinkTarget::Page { |
| 258 | identity: [0; 16], |
| 259 | title: "A b/c" |
| 260 | } |
| 261 | ), |
| 262 | "onenote:#A%20b%2Fc&section-id={00000000-0000-0000-0000-000000000000}&page-id={00000000-0000-0000-0000-000000000000}&end&base-path=p" |
| 263 | ); |
| 264 | let identity = target.identity.unwrap(); |
| 265 | assert_eq!( |
| 266 | stored[1..] |
| 267 | .iter() |
| 268 | .map(|url| parse_internal_link(url)) |
| 269 | .collect::<Vec<_>>(), |
| 270 | [ |
| 271 | Some(InternalLink { |
| 272 | section, |
| 273 | page: Some(identity), |
| 274 | object: None |
| 275 | }), |
| 276 | Some(InternalLink { |
| 277 | section, |
| 278 | page: Some(identity), |
| 279 | object: Some(paragraph) |
| 280 | }), |
| 281 | Some(InternalLink { |
| 282 | section, |
| 283 | page: None, |
| 284 | object: None |
| 285 | }), |
| 286 | ] |
| 287 | ); |
| 288 | assert_eq!(parse_internal_link(&stored[0]), None); |
| 289 | assert_eq!(parse_internal_link("onenote:#x&page-id={0}"), None); |
| 290 | // The Link dialog's page and Copy Link to Page name the section file before the `#`. |
| 291 | let ids = "section-id={0F1EFC25-DF7C-45CB-87A4-BDD03F42057E}&page-id={A9CCA318-A43E-4912-A1C7-F19F1EAF30CA}"; |
| 292 | for url in [ |
| 293 | format!("onenote:links.one#Page&{ids}&base-path=//C:/notebook"), |
| 294 | format!("onenote:///C:\\notebook\\links.one#Page&{ids}&end"), |
| 295 | ] { |
| 296 | let link = parse_internal_link(&url).unwrap(); |
| 297 | assert_eq!( |
| 298 | ( |
| 299 | link.section[..4].to_vec(), |
| 300 | link.page.map(|page| page[..4].to_vec()) |
| 301 | ), |
| 302 | ( |
| 303 | vec![0x25, 0xfc, 0x1e, 0x0f], |
| 304 | Some(vec![0x18, 0xa3, 0xcc, 0xa9]) |
| 305 | ) |
| 306 | ); |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | /// `ONESTORE_INTERNAL_LINK_EXPORT` names a new directory receiving the candidate for a cold |
| 311 | /// reopen: a section whose first page links to its second page and to a paragraph on it. |
| 312 | #[test] |
| 313 | fn a_page_links_to_another_page_and_its_paragraph() { |
| 314 | let source = onestore::create_section("links.one", "Linking page", "Author").unwrap(); |
| 315 | let creation = onestore::PageCreation::new(None, Some("Link target"), "Author").unwrap(); |
| 316 | let with_target = ops::edited( |
| 317 | &source, |
| 318 | vec![onestore::op::Op::Section(onestore::op::SectionOp::Create( |
| 319 | creation, |
| 320 | ))], |
| 321 | ) |
| 322 | .unwrap(); |
| 323 | let section = Store::parse(&with_target).unwrap().header.file_id; |
| 324 | let (_, target) = page_by_title(&with_target, "Link target"); |
| 325 | let (space, before) = page_by_title(&with_target, "Linking page"); |
| 326 | let paragraph = target |
| 327 | .objects |
| 328 | .iter() |
| 329 | .find_map(|object| match object { |
| 330 | PageObject::Outline(outline) if !outline.title => outline.paragraphs.first(), |
| 331 | _ => None, |
| 332 | }) |
| 333 | .map(|p| p.id); |
| 334 | let base_path = r"C:\one-tests\runs\capture\notebook\links.one"; |
| 335 | let page_url = internal_link( |
| 336 | section, |
| 337 | base_path, |
| 338 | LinkTarget::Page { |
| 339 | identity: target.identity.unwrap(), |
| 340 | title: &target.title, |
| 341 | }, |
| 342 | ); |
| 343 | let mut after = before.clone(); |
| 344 | let text = &mut body_paragraphs(&mut after)[0].text_mut().unwrap().text; |
| 345 | let end = text.utf16_offset(text.text().len()).unwrap(); |
| 346 | let base = text.format_at(end).unwrap().clone(); |
| 347 | let mut code = base.clone(); |
| 348 | code.hyperlink = Some(true); |
| 349 | code.hyperlink_label = Some(true); |
| 350 | code.hidden = Some(true); |
| 351 | let mut visible = base.clone(); |
| 352 | visible.hyperlink = Some(true); |
| 353 | visible.hyperlink_label = Some(true); |
| 354 | let mut runs = vec![ |
| 355 | (" ".to_owned(), base.clone()), |
| 356 | (format!("\u{fddf}HYPERLINK \"{page_url}\""), code.clone()), |
| 357 | ("Link target".to_owned(), visible.clone()), |
| 358 | ]; |
| 359 | if let Some(paragraph) = paragraph { |
| 360 | let object_url = internal_link( |
| 361 | section, |
| 362 | base_path, |
| 363 | LinkTarget::Object { |
| 364 | identity: target.identity.unwrap(), |
| 365 | title: &target.title, |
| 366 | object: paragraph, |
| 367 | }, |
| 368 | ); |
| 369 | runs.push((" ".to_owned(), base.clone())); |
| 370 | runs.push((format!("\u{fddf}HYPERLINK \"{object_url}\""), code)); |
| 371 | runs.push(("its paragraph".to_owned(), visible)); |
| 372 | } |
| 373 | text.apply(Edit { |
| 374 | range: end..end, |
| 375 | replacement: Paragraph::from_runs(runs), |
| 376 | }) |
| 377 | .unwrap(); |
| 378 | let written = ops::saved(&with_target, space, &after).unwrap(); |
| 379 | let stored = assert_same(written.as_slice(), space, &after); |
| 380 | // Titles show the link's label, not its field code, as OneNote stores them. |
| 381 | assert!( |
| 382 | stored.title.starts_with("Linking page Link target"), |
| 383 | "{}", |
| 384 | stored.title |
| 385 | ); |
| 386 | if let Some(directory) = std::env::var_os("ONESTORE_INTERNAL_LINK_EXPORT") { |
| 387 | let directory = std::path::PathBuf::from(directory); |
| 388 | std::fs::create_dir(&directory).unwrap(); |
| 389 | std::fs::write(directory.join("links.one"), written.as_slice()).unwrap(); |
| 390 | std::fs::write( |
| 391 | directory.join("Open Notebook.onetoc2"), |
| 392 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("links.one", section)]) |
| 393 | .unwrap(), |
| 394 | ) |
| 395 | .unwrap(); |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | /// A title stored with a link's field code, as an earlier writer left it |
| 400 | /// (`corpus/link-edit/internal/candidate`), reads as the label the page shows. |
| 401 | #[test] |
| 402 | fn titles_read_without_link_field_codes() { |
| 403 | let bytes = include_bytes!("../../../corpus/link-edit/internal/candidate/links.one").to_vec(); |
| 404 | let arena = onestore::Arena::default(); |
| 405 | let mut section = onestore::Section::open(&arena, bytes.clone()).unwrap(); |
| 406 | let titles: Vec<String> = section |
| 407 | .pages() |
| 408 | .unwrap() |
| 409 | .into_iter() |
| 410 | .map(|(_, title, _)| title) |
| 411 | .collect(); |
| 412 | assert_eq!(titles, ["Linking page Link target", "Link target"]); |
| 413 | page_by_title(&bytes, "Linking page Link target"); |
| 414 | } |