1#[path = "support/ops.rs"]
2mod ops;
3use 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
13const OUTLINES: &[u8] =
14 include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
15const NATIVE_LINKS: &[u8] =
16 include_bytes!("../../../corpus/link-edit/native-links/notebook/links.one");
17const NATIVE_BASE_PATH: &str = r"C:\one-tests\runs\capture\notebook\links.one";
18const CODE: &str = "\u{fddf}HYPERLINK \"https://example.invalid/rust\"";
19
20fn 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
36fn 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
43fn 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.
54fn 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
74fn 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
82fn 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.
94fn 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]
116fn 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]
151fn 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
205fn 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]
223fn 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]
313fn 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]
402fn 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}