| 1 | #[path = "support/ops.rs"] |
| 2 | mod ops; |
| 3 | use onestore::{ |
| 4 | ExGuid, RevisionIndex, Store, |
| 5 | document::{Document, Format}, |
| 6 | page::{ |
| 7 | Attachment, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id, |
| 8 | }, |
| 9 | }; |
| 10 | use std::sync::Arc; |
| 11 | |
| 12 | const NATIVE: &[u8] = |
| 13 | include_bytes!("../../../corpus/native/cold-05-06-attachment/notebook/synthetic.one"); |
| 14 | const PAYLOAD: &[u8] = b"Rust wrote this attachment.\n"; |
| 15 | |
| 16 | fn first_page(bytes: &[u8]) -> (ExGuid, Page) { |
| 17 | let store = Store::parse(bytes).unwrap(); |
| 18 | assert!(store.checksum_mismatches.is_empty()); |
| 19 | let index = RevisionIndex::parse(&store).unwrap(); |
| 20 | let document = Document::parse(&index).unwrap(); |
| 21 | let (space, _) = document.pages().unwrap()[0]; |
| 22 | (space, Page::from_space(&document, space).unwrap()) |
| 23 | } |
| 24 | |
| 25 | fn page_in(bytes: &[u8], space: ExGuid) -> Page { |
| 26 | let store = Store::parse(bytes).unwrap(); |
| 27 | assert!(store.checksum_mismatches.is_empty()); |
| 28 | let index = RevisionIndex::parse(&store).unwrap(); |
| 29 | Page::from_space(&Document::parse(&index).unwrap(), space).unwrap() |
| 30 | } |
| 31 | |
| 32 | fn body_paragraphs(page: &mut Page) -> &mut Vec<PageParagraph> { |
| 33 | page.objects |
| 34 | .iter_mut() |
| 35 | .find_map(|object| match object { |
| 36 | PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs), |
| 37 | _ => None, |
| 38 | }) |
| 39 | .unwrap() |
| 40 | } |
| 41 | |
| 42 | fn attachments(page: &Page) -> Vec<&Attachment> { |
| 43 | page.objects |
| 44 | .iter() |
| 45 | .filter_map(|object| match object { |
| 46 | PageObject::Outline(outline) => Some(outline), |
| 47 | _ => None, |
| 48 | }) |
| 49 | .flat_map(|outline| outline.paragraphs.iter()) |
| 50 | .filter_map(|p| match &p.content { |
| 51 | ParagraphContent::Attachment(attachment) => Some(attachment), |
| 52 | _ => None, |
| 53 | }) |
| 54 | .collect() |
| 55 | } |
| 56 | |
| 57 | fn plain_paragraph(template: &PageParagraph, text: &str) -> PageParagraph { |
| 58 | let mut paragraph = template.clone(); |
| 59 | paragraph.id = new_id().unwrap(); |
| 60 | paragraph.parent = None; |
| 61 | paragraph.level = 1; |
| 62 | paragraph.lists.clear(); |
| 63 | paragraph.tags.clear(); |
| 64 | paragraph.style = None; |
| 65 | paragraph.collapsed = false; |
| 66 | paragraph.content = ParagraphContent::Text(TextObject { |
| 67 | id: new_id().unwrap(), |
| 68 | date_field: None, |
| 69 | text: onestore::page::Paragraph::new( |
| 70 | text.into(), |
| 71 | Format { |
| 72 | font: Some("Calibri".into()), |
| 73 | font_size: Some(11.0), |
| 74 | language: Some(1033), |
| 75 | ..Default::default() |
| 76 | }, |
| 77 | ), |
| 78 | tags: Vec::new(), |
| 79 | }); |
| 80 | paragraph |
| 81 | } |
| 82 | |
| 83 | #[test] |
| 84 | fn a_native_attachment_reads_as_paragraph_content_with_payload_and_preview() { |
| 85 | let (_, page) = first_page(NATIVE); |
| 86 | let found = attachments(&page); |
| 87 | let [attachment] = found.as_slice() else { |
| 88 | panic!("{page:?}"); |
| 89 | }; |
| 90 | assert_eq!(attachment.filename, "fictitious-attachment.txt"); |
| 91 | assert!( |
| 92 | attachment |
| 93 | .source_path |
| 94 | .as_deref() |
| 95 | .unwrap() |
| 96 | .ends_with("fictitious-attachment.txt") |
| 97 | ); |
| 98 | assert_eq!(attachment.bytes.as_ref().map(|b| b.len()), Some(43)); |
| 99 | assert_eq!(attachment.preview.as_ref().map(|b| b.len()), Some(724)); |
| 100 | assert_eq!(attachment.size, Some([24.0, 24.0])); |
| 101 | } |
| 102 | |
| 103 | fn insert_attachment(section: &str, preview: Option<Arc<[u8]>>) -> Vec<u8> { |
| 104 | let source = onestore::create_section(section, "Before the file", "Author").unwrap(); |
| 105 | let (space, before) = first_page(&source); |
| 106 | let mut after = before.clone(); |
| 107 | let template = body_paragraphs(&mut after)[0].clone(); |
| 108 | let mut holder = plain_paragraph(&template, ""); |
| 109 | holder.format = Format::default(); |
| 110 | holder.content = ParagraphContent::Attachment(Attachment { |
| 111 | id: new_id().unwrap(), |
| 112 | filename: "notes 🦀.txt".into(), |
| 113 | source_path: None, |
| 114 | size: Some([24.0, 24.0]), |
| 115 | layout: Default::default(), |
| 116 | bytes: Some(Arc::from(PAYLOAD)), |
| 117 | preview: preview.clone(), |
| 118 | recording: None, |
| 119 | tags: Vec::new(), |
| 120 | }); |
| 121 | body_paragraphs(&mut after).push(holder.clone()); |
| 122 | body_paragraphs(&mut after).push(plain_paragraph(&template, "After the file")); |
| 123 | let written = ops::saved(&source, space, &after).unwrap(); |
| 124 | let stored = page_in(written.as_slice(), space); |
| 125 | let mut expected = after.clone(); |
| 126 | expected.title = stored.title.clone(); |
| 127 | assert_eq!(stored, expected); |
| 128 | let found = attachments(&stored); |
| 129 | let [stored_attachment] = found.as_slice() else { |
| 130 | panic!() |
| 131 | }; |
| 132 | assert_eq!(stored_attachment.bytes.as_deref(), Some(PAYLOAD)); |
| 133 | assert_eq!(stored_attachment.preview, preview); |
| 134 | assert_eq!( |
| 135 | ops::saved(written.as_slice(), space, &stored) |
| 136 | .unwrap() |
| 137 | .as_slice(), |
| 138 | written.as_slice() |
| 139 | ); |
| 140 | let mut removed = stored.clone(); |
| 141 | body_paragraphs(&mut removed).retain(|p| p.id != holder.id); |
| 142 | let again = ops::saved(written.as_slice(), space, &removed).unwrap(); |
| 143 | assert!(attachments(&page_in(again.as_slice(), space)).is_empty()); |
| 144 | written.as_slice().to_vec() |
| 145 | } |
| 146 | |
| 147 | /// `ONESTORE_ATTACHMENT_EXPORT` names a directory receiving both candidates for a cold reopen: |
| 148 | /// `plain/` without an icon preview and `icon/` with the native fixture's icon. |
| 149 | #[test] |
| 150 | fn an_attachment_inserted_on_a_fresh_page_reads_back_and_can_be_removed() { |
| 151 | let (_, native) = first_page(NATIVE); |
| 152 | let icon = attachments(&native)[0].preview.clone().unwrap(); |
| 153 | let plain = insert_attachment("files.one", None); |
| 154 | let with_icon = insert_attachment("icon.one", Some(icon)); |
| 155 | if let Some(directory) = std::env::var_os("ONESTORE_ATTACHMENT_EXPORT") { |
| 156 | let directory = std::path::PathBuf::from(directory); |
| 157 | for (name, written) in [("plain", &plain), ("icon", &with_icon)] { |
| 158 | let directory = directory.join(name); |
| 159 | std::fs::create_dir_all(&directory).unwrap(); |
| 160 | std::fs::write(directory.join("files.one"), written).unwrap(); |
| 161 | let file_id = Store::parse(written).unwrap().header.file_id; |
| 162 | std::fs::write( |
| 163 | directory.join("Open Notebook.onetoc2"), |
| 164 | onestore::create_table_of_contents( |
| 165 | "Open Notebook.onetoc2", |
| 166 | &[("files.one", file_id)], |
| 167 | ) |
| 168 | .unwrap(), |
| 169 | ) |
| 170 | .unwrap(); |
| 171 | } |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | #[test] |
| 176 | fn attachments_need_a_file_name_and_stored_ones_are_renamed_in_place() { |
| 177 | let source = onestore::create_section("files.one", "Text", "Author").unwrap(); |
| 178 | let (space, before) = first_page(&source); |
| 179 | let mut after = before.clone(); |
| 180 | let template = body_paragraphs(&mut after)[0].clone(); |
| 181 | let mut holder = plain_paragraph(&template, ""); |
| 182 | holder.content = ParagraphContent::Attachment(Attachment { |
| 183 | id: new_id().unwrap(), |
| 184 | filename: "sub/dir.txt".into(), |
| 185 | source_path: None, |
| 186 | size: None, |
| 187 | layout: Default::default(), |
| 188 | bytes: Some(Arc::from(PAYLOAD)), |
| 189 | preview: None, |
| 190 | recording: None, |
| 191 | tags: Vec::new(), |
| 192 | }); |
| 193 | body_paragraphs(&mut after).push(holder); |
| 194 | assert!(ops::saved(&source, space, &after).is_err()); |
| 195 | let (space, native) = first_page(NATIVE); |
| 196 | let mut renamed = native.clone(); |
| 197 | let mut found = 0; |
| 198 | for object in &mut renamed.objects { |
| 199 | if let PageObject::Outline(outline) = object { |
| 200 | for paragraph in &mut outline.paragraphs { |
| 201 | if let ParagraphContent::Attachment(attachment) = &mut paragraph.content { |
| 202 | attachment.filename = "renamed \u{1f980}.txt".into(); |
| 203 | attachment.source_path = Some("C:\\inputs\\renamed \u{1f980}.txt".into()); |
| 204 | found += 1; |
| 205 | } |
| 206 | } |
| 207 | } |
| 208 | } |
| 209 | assert_eq!(found, 1); |
| 210 | let written = ops::saved(NATIVE, space, &renamed).unwrap(); |
| 211 | let stored = page_in(written.as_slice(), space); |
| 212 | let (before, after) = (attachments(&native)[0], attachments(&stored)[0]); |
| 213 | assert_eq!(after.filename, "renamed \u{1f980}.txt"); |
| 214 | assert_eq!( |
| 215 | after.source_path.as_deref(), |
| 216 | Some("C:\\inputs\\renamed \u{1f980}.txt") |
| 217 | ); |
| 218 | assert_eq!((after.id, after.size), (before.id, before.size)); |
| 219 | assert_eq!(after.bytes, before.bytes); |
| 220 | assert_eq!(after.preview, before.preview); |
| 221 | if let Some(directory) = std::env::var_os("ONESTORE_ATTACHMENT_RENAME_EXPORT") { |
| 222 | let directory = std::path::PathBuf::from(directory); |
| 223 | std::fs::create_dir_all(&directory).unwrap(); |
| 224 | std::fs::write(directory.join("synthetic.one"), written.as_slice()).unwrap(); |
| 225 | let file_id = Store::parse(written.as_slice()).unwrap().header.file_id; |
| 226 | std::fs::write( |
| 227 | directory.join("Open Notebook.onetoc2"), |
| 228 | onestore::create_table_of_contents( |
| 229 | "Open Notebook.onetoc2", |
| 230 | &[("synthetic.one", file_id)], |
| 231 | ) |
| 232 | .unwrap(), |
| 233 | ) |
| 234 | .unwrap(); |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | const FLOATING: &[u8] = include_bytes!("../../../corpus/attachment-floating/native/files.one"); |
| 239 | |
| 240 | fn floating(page: &Page) -> Vec<&Attachment> { |
| 241 | page.objects |
| 242 | .iter() |
| 243 | .filter_map(|object| match object { |
| 244 | PageObject::Attachment(file) => Some(file), |
| 245 | _ => None, |
| 246 | }) |
| 247 | .collect() |
| 248 | } |
| 249 | |
| 250 | #[test] |
| 251 | fn files_onenote_places_on_the_page_read_as_page_objects_sharing_one_payload() { |
| 252 | let store = Store::parse(FLOATING).unwrap(); |
| 253 | let index = RevisionIndex::parse(&store).unwrap(); |
| 254 | let document = Document::parse(&index).unwrap(); |
| 255 | let (space, _) = document.pages().unwrap()[1]; |
| 256 | let page = Page::from_space(&document, space).unwrap(); |
| 257 | let files = floating(&page); |
| 258 | let [moved, attached] = files.as_slice() else { |
| 259 | panic!("{page:?}"); |
| 260 | }; |
| 261 | // Inserted through COM then dragged, and attached at a click on blank page. |
| 262 | assert_eq!( |
| 263 | [moved.layout.x, moved.layout.y], |
| 264 | [Some(288.0), Some(140.40001)] |
| 265 | ); |
| 266 | assert_eq!(moved.layout.max_width, None); |
| 267 | assert_eq!( |
| 268 | [attached.layout.x, attached.layout.y], |
| 269 | [Some(342.0), Some(284.4)] |
| 270 | ); |
| 271 | assert_eq!( |
| 272 | [attached.layout.max_width, attached.layout.max_height], |
| 273 | [Some(54.0), Some(62.999985)] |
| 274 | ); |
| 275 | for file in files { |
| 276 | assert_eq!(file.filename, "float.txt"); |
| 277 | assert_eq!( |
| 278 | file.bytes.as_deref(), |
| 279 | Some(b"Floating file, stored at page level.\r\n".as_slice()) |
| 280 | ); |
| 281 | assert_eq!(file.size, Some([24.0, 24.0])); |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | /// `ONESTORE_FLOATING_EXPORT` names a directory receiving the section for a cold reopen. |
| 286 | #[test] |
| 287 | fn a_file_on_the_page_is_attached_moved_renamed_and_removed_as_ops() { |
| 288 | let source = onestore::create_section("files.one", "Floating", "Author").unwrap(); |
| 289 | let (space, before) = first_page(&source); |
| 290 | let (_, native) = first_page(NATIVE); |
| 291 | let file = Attachment { |
| 292 | id: new_id().unwrap(), |
| 293 | filename: "float 🦀.txt".into(), |
| 294 | source_path: Some(r"C:\inputs\float 🦀.txt".into()), |
| 295 | size: Some([24.0, 24.0]), |
| 296 | layout: onestore::document::Layout { |
| 297 | x: Some(342.0), |
| 298 | y: Some(284.4), |
| 299 | max_width: Some(54.0), |
| 300 | max_height: Some(63.0), |
| 301 | width_set_by_user: Some(false), |
| 302 | reserved_width: None, |
| 303 | }, |
| 304 | bytes: Some(Arc::from(PAYLOAD)), |
| 305 | preview: attachments(&native)[0].preview.clone(), |
| 306 | recording: None, |
| 307 | tags: Vec::new(), |
| 308 | }; |
| 309 | let steps: [&dyn Fn(&mut Page); 4] = [ |
| 310 | &|page| page.objects.push(PageObject::Attachment(file.clone())), |
| 311 | &|page| { |
| 312 | let PageObject::Attachment(file) = page.objects.last_mut().unwrap() else { |
| 313 | panic!() |
| 314 | }; |
| 315 | [file.layout.x, file.layout.y] = [Some(126.0), Some(410.4)]; |
| 316 | }, |
| 317 | &|page| { |
| 318 | let PageObject::Attachment(file) = page.objects.last_mut().unwrap() else { |
| 319 | panic!() |
| 320 | }; |
| 321 | file.filename = "renamed.txt".into(); |
| 322 | }, |
| 323 | &|page| { |
| 324 | page.objects.pop(); |
| 325 | }, |
| 326 | ]; |
| 327 | let mut image = source.clone(); |
| 328 | let mut expected = before.clone(); |
| 329 | for (step, edit) in steps.iter().enumerate() { |
| 330 | edit(&mut expected); |
| 331 | let arena = onestore::Arena::default(); |
| 332 | let section = onestore::Section::open(&arena, image.clone()).unwrap(); |
| 333 | let stored = section.page(space).unwrap(); |
| 334 | let lowered = onestore::op::lower_page(&stored, &expected).unwrap(); |
| 335 | let mut predicted = stored.clone(); |
| 336 | for op in &lowered { |
| 337 | onestore::op::predict(&mut predicted, op).unwrap(); |
| 338 | } |
| 339 | image = ops::page_edited(&image, space, lowered).unwrap(); |
| 340 | let read = page_in(&image, space); |
| 341 | assert_eq!(read, predicted, "step {step}"); |
| 342 | assert_eq!(read, expected, "step {step}"); |
| 343 | if let [file] = floating(&read)[..] { |
| 344 | assert_eq!(file.bytes.as_deref(), Some(PAYLOAD)); |
| 345 | assert_eq!(file.preview, attachments(&native)[0].preview); |
| 346 | } |
| 347 | if step == 1 |
| 348 | && let Some(directory) = std::env::var_os("ONESTORE_FLOATING_EXPORT") |
| 349 | { |
| 350 | let directory = std::path::PathBuf::from(directory); |
| 351 | std::fs::create_dir_all(&directory).unwrap(); |
| 352 | std::fs::write(directory.join("files.one"), &image).unwrap(); |
| 353 | let file_id = Store::parse(&image).unwrap().header.file_id; |
| 354 | std::fs::write( |
| 355 | directory.join("Open Notebook.onetoc2"), |
| 356 | onestore::create_table_of_contents( |
| 357 | "Open Notebook.onetoc2", |
| 358 | &[("files.one", file_id)], |
| 359 | ) |
| 360 | .unwrap(), |
| 361 | ) |
| 362 | .unwrap(); |
| 363 | } |
| 364 | } |
| 365 | assert!(floating(&page_in(&image, space)).is_empty()); |
| 366 | // A file on the page has a position; one in a paragraph has none. |
| 367 | let mut unplaced = before.clone(); |
| 368 | unplaced.objects.push(PageObject::Attachment(Attachment { |
| 369 | layout: Default::default(), |
| 370 | ..file.clone() |
| 371 | })); |
| 372 | assert!(ops::saved(&source, space, &unplaced).is_err()); |
| 373 | } |