1#[path = "support/ops.rs"]
2mod ops;
3use onestore::{
4 ExGuid, RevisionIndex, Store,
5 document::{Document, Format},
6 page::{
7 Attachment, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id,
8 },
9};
10use std::sync::Arc;
11
12const NATIVE: &[u8] =
13 include_bytes!("../../../corpus/native/cold-05-06-attachment/notebook/synthetic.one");
14const PAYLOAD: &[u8] = b"Rust wrote this attachment.\n";
15
16fn 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
25fn 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
32fn 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
42fn 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
57fn 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]
84fn 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
103fn 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]
150fn 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]
176fn 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
238const FLOATING: &[u8] = include_bytes!("../../../corpus/attachment-floating/native/files.one");
239
240fn 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]
251fn 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]
287fn 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}