| 1 | #[path = "support/ops.rs"] |
| 2 | mod ops; |
| 3 | use 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. |
| 11 | const NATIVE: &[u8] = include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one"); |
| 12 | const NATIVE_READ: &str = include_str!("../../../corpus/m6/native-features-01/read/page-017.xml"); |
| 13 | const TITLE: &str = "Files and recording"; |
| 14 | |
| 15 | fn page(bytes: &[u8]) -> (ExGuid, Page) { |
| 16 | titled(bytes, TITLE) |
| 17 | } |
| 18 | |
| 19 | fn 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 | |
| 32 | fn 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 | |
| 38 | fn 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] |
| 60 | fn 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] |
| 78 | fn 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). |
| 167 | fn 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. |
| 184 | const 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. |
| 187 | const 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] |
| 192 | fn 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 | |
| 251 | fn 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 | |
| 261 | fn 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 | } |