1use notebook::session::{Event, Section};
2use onestore::{
3 RevisionIndex, Store,
4 document::Document,
5 page::{Page, PageObject},
6};
7use std::{
8 collections::BTreeMap,
9 io::Write,
10 path::Path,
11 time::{Duration, Instant},
12};
13
14/// Pages the writer authored for other rows: text with an attachment and its icon, files on
15/// the page, a table, nested tables with cell subtrees, an inserted picture, a page-level ink
16/// drawing, tags, lists, equations and paragraph formatting.
17const SOURCES: &[&str] = &[
18 "attachment-edit/icon/candidate/files.one",
19 "attachment-floating/candidate/files.one",
20 "table-edit/created/candidate/tables.one",
21 "table-edit/nested/candidate/synthetic.one",
22 "picture-edit/inserted/candidate/pictures.one",
23 "ink-edit/drawing/candidate/ink.one",
24 "tag-edit/candidate/tags.one",
25 "list-edit/candidate/lists.one",
26 "math-edit/written/candidate/math.one",
27 "paragraph-format/candidate/synthetic.one",
28];
29
30fn pages(bytes: &[u8]) -> Vec<Page> {
31 let store = Store::parse(bytes).unwrap();
32 let index = RevisionIndex::parse(&store).unwrap();
33 let document = Document::parse(&index).unwrap();
34 document
35 .pages()
36 .unwrap()
37 .into_iter()
38 .map(|(space, _)| Page::from_space(&document, space).unwrap())
39 .collect()
40}
41
42/// Everything but identities and the title object: what a copy must preserve. Identities
43/// are numbered by first appearance in the content, so definitions compare by use.
44fn shape(page: &Page) -> String {
45 let mut page = page.clone();
46 flatten(&mut page);
47 let mut value = serde_json::to_value(&page).unwrap();
48 let mut names: BTreeMap<String, String> = BTreeMap::new();
49 fn name(names: &mut BTreeMap<String, String>, text: &str) -> String {
50 let next = format!("id{}", names.len());
51 names.entry(text.to_owned()).or_insert(next).clone()
52 }
53 fn scrub(value: &mut serde_json::Value, names: &mut BTreeMap<String, String>) {
54 match value {
55 serde_json::Value::String(text) if text.parse::<onestore::ExGuid>().is_ok() => {
56 *text = name(names, text);
57 }
58 serde_json::Value::Array(items) => {
59 for item in items {
60 scrub(item, names);
61 }
62 }
63 serde_json::Value::Object(fields) => {
64 // The writer's canonical formatting: an unset flag is false and unset
65 // spacing is zero.
66 fields.retain(|key, value| {
67 // OneNote lays outlines out again on open.
68 key != "max_height"
69 && !value.is_null()
70 && *value != serde_json::Value::Bool(false)
71 && value.as_f64() != Some(0.0)
72 });
73 let keys: Vec<String> = fields.keys().cloned().collect();
74 for key in keys {
75 let mut item = fields.remove(&key).unwrap();
76 scrub(&mut item, names);
77 let key = if key.parse::<onestore::ExGuid>().is_ok() {
78 name(names, &key)
79 } else {
80 key
81 };
82 fields.insert(key, item);
83 }
84 }
85 _ => {}
86 }
87 }
88 let object = value.as_object_mut().unwrap();
89 object.remove("identity");
90 object.remove("created");
91 object.remove("margin_origin");
92 let objects = object["objects"].as_array_mut().unwrap();
93 objects.retain(|object| object.get("Title").is_none());
94 scrub(&mut object["objects"], &mut names);
95 // The destination page brings its own title style.
96 object["definitions"]
97 .as_object_mut()
98 .unwrap()
99 .retain(|_, definition| definition["kind"]["type"] != "Style");
100 scrub(&mut object["definitions"], &mut names);
101 value.to_string()
102}
103
104/// Moves paragraph-level formatting into each span, where the reader resolves it: the
105/// writer keeps a copied style's formatting on the paragraph or the runs as it sees fit.
106fn flatten(page: &mut Page) {
107 fn paragraphs(list: &mut [onestore::page::PageParagraph]) {
108 for paragraph in list {
109 let format = std::mem::take(&mut paragraph.format);
110 match &mut paragraph.content {
111 onestore::page::ParagraphContent::Text(text) => {
112 let mut at = 0;
113 let runs: Vec<(String, onestore::document::Format)> = text
114 .text
115 .spans()
116 .iter()
117 .map(|span| {
118 let piece = text.text.text()[at..span.end].to_owned();
119 at = span.end;
120 (piece, span.format.inherit(&format))
121 })
122 .collect();
123 text.text = onestore::page::Paragraph::from_runs(runs);
124 }
125 onestore::page::ParagraphContent::Table(table) => {
126 for row in &mut table.rows {
127 for cell in &mut row.cells {
128 paragraphs(&mut cell.paragraphs);
129 }
130 }
131 }
132 _ => {}
133 }
134 }
135 }
136 for object in &mut page.objects {
137 if let PageObject::Outline(outline) = object {
138 paragraphs(&mut outline.paragraphs);
139 }
140 }
141}
142
143/// `NOTEBOOK_COPY_EXPORT` names a new directory receiving the destination section for a
144/// cold reopen.
145#[test]
146fn pages_copy_into_another_section_with_their_content_and_fresh_identities() {
147 let temporary = tempfile::tempdir().unwrap();
148 let file = temporary.path().join("copies.one");
149 let cache = temporary.path().join("cache.sqlite");
150 std::fs::write(
151 &file,
152 onestore::create_section("copies.one", "Destination", "Author").unwrap(),
153 )
154 .unwrap();
155 let section = Section::open(&file, &cache, || {}).unwrap();
156 let mut expected = Vec::new();
157 let mut grouped = Vec::new();
158 for source in SOURCES {
159 let bytes = std::fs::read(
160 Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus")).join(source),
161 )
162 .unwrap();
163 for page in pages(&bytes) {
164 if page
165 .objects
166 .iter()
167 .any(|object| matches!(object, PageObject::Unsupported(_)))
168 {
169 continue;
170 }
171 let copy = match page.copy() {
172 Ok(copy) => copy,
173 Err(error) if error.message == "Outline groups cannot be copied" => {
174 grouped.push(page.title.clone());
175 continue;
176 }
177 Err(error) => panic!("{source} {}: {error}", page.title),
178 };
179 assert_ne!(copy.identity, page.identity);
180 let space = section
181 .import_page(&page, "Copier")
182 .unwrap_or_else(|e| panic!("{source} {}: {e}", page.title));
183 expected.push((space, page.title.clone(), shape(&copy)));
184 }
185 }
186 assert!(expected.len() >= 8, "{} pages", expected.len());
187 // Outdenting leaves paragraphs at levels only an outline group carries.
188 assert_eq!(
189 grouped,
190 [
191 "Outdent first group",
192 "Delete only grouped subtree",
193 "Delete unindented sibling after group",
194 "Outdent child across indentation gap"
195 ]
196 );
197 let deadline = Instant::now() + Duration::from_secs(120);
198 while !section.pending().unwrap().is_empty() {
199 for event in section.events() {
200 if let Event::Failed(message) = event {
201 panic!("{message}");
202 }
203 }
204 assert!(
205 Instant::now() < deadline,
206 "publication did not finish: {:?}",
207 section.pending().unwrap()
208 );
209 std::thread::sleep(Duration::from_millis(50));
210 }
211 let stored = std::fs::read(&file).unwrap();
212 let written = pages(&stored);
213 assert_eq!(written.len(), expected.len() + 1);
214 for ((space, title, expected), page) in expected.iter().zip(&written[1..]) {
215 let _ = space;
216 assert_eq!(&page.title, title);
217 let stored = shape(page);
218 if &stored != expected {
219 let dump = std::env::temp_dir().join("copy-mismatch");
220 std::fs::create_dir_all(&dump).unwrap();
221 std::fs::write(dump.join("expected.json"), expected).unwrap();
222 std::fs::write(dump.join("stored.json"), &stored).unwrap();
223 panic!("{title}: copy differs, see {}", dump.display());
224 }
225 }
226 section.delete_pages(&[expected.last().unwrap().0]).unwrap();
227 let deadline = Instant::now() + Duration::from_secs(60);
228 while !section.pending().unwrap().is_empty() {
229 assert!(Instant::now() < deadline);
230 std::thread::sleep(Duration::from_millis(50));
231 }
232 assert_eq!(pages(&std::fs::read(&file).unwrap()).len(), expected.len());
233 section.close().unwrap();
234 if let Some(directory) = std::env::var_os("NOTEBOOK_COPY_EXPORT") {
235 let directory = std::path::PathBuf::from(directory);
236 std::fs::create_dir_all(&directory).unwrap();
237 let bytes = std::fs::read(&file).unwrap();
238 std::fs::File::create_new(directory.join("copies.one"))
239 .unwrap()
240 .write_all(&bytes)
241 .unwrap();
242 let file_id = Store::parse(&bytes).unwrap().header.file_id;
243 std::fs::write(
244 directory.join("Open Notebook.onetoc2"),
245 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("copies.one", file_id)])
246 .unwrap(),
247 )
248 .unwrap();
249 std::fs::write(
250 directory.join("expected-count.txt"),
251 expected.len().to_string(),
252 )
253 .unwrap();
254 }
255}