1#[path = "support/ops.rs"]
2mod ops;
3use onestore::{
4 ExGuid, RevisionIndex, Store,
5 document::Document,
6 page::{MediaIndex, Page, PageObject, ParagraphContent, Recording},
7};
8
9/// A page OneNote 2010 recorded audio on (`corpus/m6/native-features-01`): a recording
10/// attachment and a paragraph annotated with a moment in it.
11const NATIVE: &[u8] = include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one");
12const NATIVE_READ: &str = include_str!("../../../corpus/m6/native-features-01/read/page-017.xml");
13const TITLE: &str = "Files and recording";
14
15fn page(bytes: &[u8]) -> (ExGuid, Page) {
16 titled(bytes, TITLE)
17}
18
19fn titled(bytes: &[u8], title: &str) -> (ExGuid, Page) {
20 let store = Store::parse(bytes).unwrap();
21 let index = RevisionIndex::parse(&store).unwrap();
22 let document = Document::parse(&index).unwrap();
23 document
24 .pages()
25 .unwrap()
26 .into_iter()
27 .map(|(space, _)| (space, Page::from_space(&document, space).unwrap()))
28 .find(|(_, page)| page.title == title)
29 .unwrap()
30}
31
32fn media_id(read: &str) -> [u8; 16] {
33 let start = read.find("mediaID=\"").unwrap() + 9;
34 let guid = &read[start..read[start..].find('"').unwrap() + start];
35 format!("{guid},0").parse::<ExGuid>().unwrap().guid
36}
37
38fn recording(page: &Page) -> (Recording, MediaIndex) {
39 let mut recording = None;
40 let mut annotation = None;
41 for object in &page.objects {
42 let PageObject::Outline(outline) = object else {
43 continue;
44 };
45 for paragraph in &outline.paragraphs {
46 if let ParagraphContent::Attachment(attachment) = &paragraph.content
47 && let Some(found) = attachment.recording
48 {
49 recording = Some(found);
50 }
51 if !paragraph.media.recordings.is_empty() {
52 annotation = Some(paragraph.media.clone());
53 }
54 }
55 }
56 (recording.unwrap(), annotation.unwrap())
57}
58
59#[test]
60fn a_native_recording_and_its_annotation_read_as_the_export_names_them() {
61 let (_, native) = page(NATIVE);
62 let (recording, annotation) = recording(&native);
63 let id = media_id(NATIVE_READ);
64 assert_eq!(recording.id, id);
65 assert_eq!(
66 annotation,
67 MediaIndex {
68 recordings: vec![id],
69 time_ms: Some(500)
70 }
71 );
72 assert!(NATIVE_READ.contains("timeIndex=\"500\""));
73}
74
75/// `ONESTORE_MEDIA_EXPORT` names a new directory receiving the edited section for a cold
76/// reopen: the recording and its annotation survive an ordinary edit of the page.
77#[test]
78fn editing_a_recorded_page_keeps_the_recording_and_its_annotation() {
79 use onestore::page::{TextObject, text::new_id};
80 let (space, before) = page(NATIVE);
81 let mut after = before.clone();
82 let outline = after
83 .objects
84 .iter_mut()
85 .find_map(|object| match object {
86 PageObject::Outline(outline) if !outline.title => Some(outline),
87 _ => None,
88 })
89 .unwrap();
90 let mut added = outline.paragraphs[0].clone();
91 added.id = new_id().unwrap();
92 added.parent = None;
93 added.level = 1;
94 added.style = None;
95 added.lists.clear();
96 added.tags.clear();
97 added.media = MediaIndex::default();
98 added.content = ParagraphContent::Text(TextObject {
99 id: new_id().unwrap(),
100 date_field: None,
101 text: onestore::page::Paragraph::new("Added after recording".into(), Default::default()),
102 tags: Vec::new(),
103 });
104 outline.paragraphs.push(added.clone());
105 let written = ops::saved(NATIVE, space, &after).unwrap();
106 let (_, stored) = page(written.as_slice());
107 assert_eq!(recording(&stored), recording(&before));
108 assert_eq!(stored.objects.len(), before.objects.len());
109
110 // A note linked to the recording, then unlinked, as Snowbound links notes taken while
111 // it records.
112 let annotate = |page: &Page, media: MediaIndex| {
113 let mut page = page.clone();
114 for object in &mut page.objects {
115 if let PageObject::Outline(outline) = object
116 && !outline.title
117 {
118 outline.paragraphs.last_mut().unwrap().media = media.clone();
119 }
120 }
121 page
122 };
123 let linked = ops::saved(
124 written.as_slice(),
125 space,
126 &annotate(&stored, recording(&before).1),
127 )
128 .unwrap();
129 let (_, relinked) = page(linked.as_slice());
130 assert_eq!(relinked, annotate(&stored, recording(&before).1));
131 let unlinked = ops::saved(linked.as_slice(), space, &stored).unwrap();
132 assert_eq!(page(unlinked.as_slice()).1, stored);
133 let mut recaptured = after.clone();
134 for object in &mut recaptured.objects {
135 if let PageObject::Outline(outline) = object {
136 for paragraph in &mut outline.paragraphs {
137 if let ParagraphContent::Attachment(attachment) = &mut paragraph.content {
138 attachment.recording = None;
139 }
140 }
141 }
142 }
143 assert!(ops::saved(written.as_slice(), space, &recaptured).is_err());
144
145 if let Some(directory) = std::env::var_os("ONESTORE_MEDIA_EXPORT") {
146 let directory = std::path::PathBuf::from(directory);
147 std::fs::create_dir_all(&directory).unwrap();
148 std::fs::write(directory.join("Features.one"), written.as_slice()).unwrap();
149 let file_id = Store::parse(written.as_slice()).unwrap().header.file_id;
150 std::fs::write(
151 directory.join("Open Notebook.onetoc2"),
152 onestore::create_table_of_contents(
153 "Open Notebook.onetoc2",
154 &[("Features.one", file_id)],
155 )
156 .unwrap(),
157 )
158 .unwrap();
159 let store = Store::parse(written.as_slice()).unwrap();
160 let index = RevisionIndex::parse(&store).unwrap();
161 let count = Document::parse(&index).unwrap().pages().unwrap().len();
162 std::fs::write(directory.join("expected-count.txt"), count.to_string()).unwrap();
163 }
164}
165
166/// The recordings page `space` lists (AudioRecordingGuids).
167fn playlist(bytes: &[u8], space: ExGuid) -> Vec<[u8; 16]> {
168 let store = Store::parse(bytes).unwrap();
169 let index = RevisionIndex::parse(&store).unwrap();
170 let document = Document::parse(&index).unwrap();
171 let revision = document.spaces[&space].active().unwrap();
172 revision
173 .nodes
174 .values()
175 .find_map(|node| match &node.kind {
176 onestore::document::Kind::Page { recordings, .. } => Some(recordings.clone()),
177 _ => None,
178 })
179 .unwrap()
180}
181
182/// OneNote 2010's own recording (`corpus/recording/native`): a WMA started after "Before
183/// recording", its line saying when and notes linked while it ran.
184const RECORDED: &[u8] = include_bytes!("../../../corpus/recording/native/notebook/files.one");
185/// The same page after OneNote deleted the recording: the page lists no recording, and the
186/// notes keep their links.
187const DELETED: &[u8] = include_bytes!("../../../corpus/recording/native/deleted/files.one");
188
189/// `ONESTORE_RECORDING_EDIT_EXPORT` names a new directory receiving the edited section for
190/// a cold reopen (`corpus/recording/edit`).
191#[test]
192fn onenote_s_recording_round_trips_through_deleting_restoring_and_linking() {
193 let (space, native) = titled(RECORDED, "Recorded");
194 let (native_recording, _) = recording(&native);
195 assert_eq!(native_recording.kind, 1);
196 assert!(native_recording.duration_ms.is_some_and(|ms| ms > 30_000));
197 assert_eq!(playlist(RECORDED, space), vec![native_recording.id]);
198 let (_, deleted) = titled(DELETED, "Recorded");
199 assert!(playlist(DELETED, space).is_empty());
200 assert!(recording_paragraph(&deleted).is_none());
201
202 // Deleting the recording unlists it, as OneNote does; the notes keep their links.
203 let mut removed = native.clone();
204 let outline = body(&mut removed);
205 let at = outline
206 .paragraphs
207 .iter()
208 .position(|paragraph| matches!(paragraph.content, ParagraphContent::Attachment(_)))
209 .unwrap();
210 let file = outline.paragraphs.remove(at);
211 let written = ops::saved(RECORDED, space, &removed).unwrap();
212 assert!(playlist(&written, space).is_empty());
213 assert_eq!(titled(&written, "Recorded").1, removed);
214
215 // Putting it back, as undo does, stores the same recording, listed again.
216 let written = ops::saved(&written, space, &native).unwrap();
217 let (_, restored) = titled(&written, "Recorded");
218 assert_eq!(restored, native);
219 let bytes = |page: &Page| match &recording_paragraph(page).unwrap().content {
220 ParagraphContent::Attachment(file) => file.bytes.clone(),
221 _ => unreachable!(),
222 };
223 assert_eq!(bytes(&restored), bytes(&native));
224 assert!(bytes(&restored).unwrap().starts_with(b"0&\xb2\x75"));
225 assert_eq!(playlist(&written, space), vec![native_recording.id]);
226 assert_eq!(recording_paragraph(&restored).unwrap(), &file);
227
228 // A note written while playing it back links to a moment, as one taken while recording.
229 let mut linked = restored.clone();
230 let mut added = ops::paragraph("Linked by Snowbound");
231 added.media = MediaIndex {
232 recordings: vec![native_recording.id],
233 time_ms: Some(25_000),
234 };
235 body(&mut linked).paragraphs.push(added);
236 let written = ops::saved(&written, space, &linked).unwrap();
237 assert_eq!(titled(&written, "Recorded").1, linked);
238
239 if let Some(directory) = std::env::var_os("ONESTORE_RECORDING_EDIT_EXPORT") {
240 let directory = std::path::PathBuf::from(directory);
241 std::fs::create_dir_all(&directory).unwrap();
242 std::fs::write(directory.join("files.one"), &written).unwrap();
243 std::fs::write(
244 directory.join("Open Notebook.onetoc2"),
245 include_bytes!("../../../corpus/recording/native/notebook/Open Notebook.onetoc2"),
246 )
247 .unwrap();
248 }
249}
250
251fn body(page: &mut Page) -> &mut onestore::page::Outline {
252 page.objects
253 .iter_mut()
254 .find_map(|object| match object {
255 PageObject::Outline(outline) if !outline.title => Some(outline),
256 _ => None,
257 })
258 .unwrap()
259}
260
261fn recording_paragraph(page: &Page) -> Option<&onestore::page::PageParagraph> {
262 page.objects
263 .iter()
264 .filter_map(|object| match object {
265 PageObject::Outline(outline) => Some(&outline.paragraphs),
266 _ => None,
267 })
268 .flatten()
269 .find(|paragraph| {
270 matches!(&paragraph.content, ParagraphContent::Attachment(file) if file.recording.is_some())
271 })
272}