1//! Edits to a notebook's table of contents: the documented `jcidPersistablePropertyContainerForTOC`
2//! root carries the notebook's colour and lists section and section-group entries, each
3//! carrying a file identity, an ordering number and a filename. A section's colour lives in
4//! its own metadata (`op::SectionOp::Color`); its entry keeps 0xffffffff, as OneNote writes.
5
6use crate::{
7 Error, ExGuid, PropertySets, Store, Transaction, Value,
8 document::{Document, Kind},
9 revisions::RevisionIndex,
10 write::{PropertyObject, RevisionEdit, applied, build_on, check, fresh_guid},
11};
12use std::collections::{BTreeMap, BTreeSet};
13
14fn invalid(message: &'static str) -> Error {
15 Error { offset: 0, message }
16}
17
18/// One change to a table of contents, addressed by the entry's file identity.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum TocEdit {
21 /// A section (`name.one`) or, with `group`, a section-group folder appended last.
22 Add {
23 filename: String,
24 identity: [u8; 16],
25 group: bool,
26 },
27 Rename {
28 identity: [u8; 16],
29 filename: String,
30 },
31 /// The notebook's colour as COLORREF.
32 Color(u32),
33 /// Every entry, in the wanted order; entries left out keep their relative order after these.
34 Order(Vec<[u8; 16]>),
35 Remove {
36 identity: [u8; 16],
37 },
38 /// The entry's file took the identity `with`, as a section does whose password is set,
39 /// changed or removed.
40 Reidentify {
41 identity: [u8; 16],
42 with: [u8; 16],
43 },
44}
45
46fn component(name: &str) -> bool {
47 !name.is_empty() && !name.contains(['/', '\\', '\0']) && name != "." && name != ".."
48}
49
50/// Applies table-of-contents edits (sections and section groups: add, rename, order,
51/// remove; the notebook's colour) in order as one revision of a `.onetoc2` file; none when they change
52/// nothing.
53pub fn edit_table_of_contents(
54 source: &[u8],
55 edits: &[TocEdit],
56) -> Result<Option<Transaction>, Error> {
57 let store = Store::parse(source)?;
58 if store.header.file_type != crate::FileType::TableOfContents {
59 return Err(invalid("Choose a table-of-contents file"));
60 }
61 let index = RevisionIndex::parse(&store)?;
62 index.validate_current()?;
63 let document = Document::parse(&index)?;
64 let space = document.root;
65 let revision = document.active(space)?;
66 let root = *revision
67 .roots
68 .get(&1)
69 .ok_or_else(|| invalid("Missing notebook TOC root"))?;
70 let Some(Kind::Toc { entries, .. }) = revision.nodes.get(&root).map(|node| &node.kind) else {
71 return Err(invalid("Missing notebook TOC root"));
72 };
73 // Entries in display order (their ordering numbers, not their place in the root's list)
74 // with their identities and filenames.
75 let mut stored = Vec::new();
76 for id in entries {
77 let Some(Kind::Toc {
78 filename,
79 identity: Some(identity),
80 order,
81 ..
82 }) = revision.nodes.get(id).map(|node| &node.kind)
83 else {
84 return Err(invalid("Incomplete notebook TOC reference"));
85 };
86 stored.push((
87 order.unwrap_or(u32::MAX),
88 (*id, *identity, filename.clone().unwrap_or_default()),
89 ));
90 }
91 stored.sort_by_key(|(order, _)| *order);
92 let mut listed: Vec<(ExGuid, [u8; 16], String)> =
93 stored.into_iter().map(|(_, entry)| entry).collect();
94 let mut created: BTreeMap<ExGuid, PropertyObject> = BTreeMap::new();
95 let mut renamed = BTreeSet::new();
96 let mut reidentified = BTreeSet::new();
97 let mut color = None;
98 for edit in edits {
99 let position = |identity: &[u8; 16]| {
100 listed
101 .iter()
102 .position(|(_, known, _)| known == identity)
103 .ok_or_else(|| invalid("The table of contents has no entry with that identity"))
104 };
105 match edit {
106 TocEdit::Add {
107 filename,
108 identity,
109 group,
110 } => {
111 if !component(filename)
112 || (!group && !filename.to_ascii_lowercase().ends_with(".one"))
113 || *identity == [0; 16]
114 {
115 return Err(invalid(
116 "A TOC entry needs a section filename or group folder name and an identity",
117 ));
118 }
119 if listed.iter().any(|(_, known, name)| {
120 known == identity || name.eq_ignore_ascii_case(filename)
121 }) {
122 return Err(invalid(
123 "The table of contents already lists that section or group",
124 ));
125 }
126 let id = ExGuid {
127 guid: fresh_guid()?,
128 n: 10,
129 };
130 let mut values: Vec<(u32, Vec<u8>)> = vec![
131 (0x1c001d94, identity.to_vec()),
132 (0x14001cb9, 0u32.to_le_bytes().to_vec()),
133 (0x1c001d6b, crate::create::string(filename)),
134 ];
135 if !group {
136 values.push((0x14001cbe, vec![0xff; 4]));
137 }
138 let mut object = PropertyObject {
139 jcid: 0x20001,
140 bytes: crate::create::properties(&values)?,
141 global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])),
142 };
143 object.reference(id)?;
144 created.insert(id, object);
145 listed.push((id, *identity, filename.clone()));
146 }
147 TocEdit::Rename { identity, filename } => {
148 if !component(filename) {
149 return Err(invalid(
150 "A TOC entry needs a filename without path separators",
151 ));
152 }
153 let at = position(identity)?;
154 if listed
155 .iter()
156 .enumerate()
157 .any(|(i, (_, _, name))| i != at && name.eq_ignore_ascii_case(filename))
158 {
159 return Err(invalid("The table of contents already lists that name"));
160 }
161 let id = listed[at].0;
162 listed[at].2 = filename.clone();
163 renamed.insert(id);
164 }
165 TocEdit::Color(value) => color = Some(*value),
166 TocEdit::Order(wanted) => {
167 let mut ordered: Vec<(ExGuid, [u8; 16], String)> = Vec::new();
168 for identity in wanted {
169 let at = position(identity)?;
170 if ordered.iter().any(|(_, known, _)| known == identity) {
171 return Err(invalid("An entry is ordered twice"));
172 }
173 ordered.push(listed[at].clone());
174 }
175 let rest: Vec<_> = listed
176 .iter()
177 .filter(|(_, known, _)| !wanted.contains(known))
178 .cloned()
179 .collect();
180 ordered.extend(rest);
181 listed = ordered;
182 }
183 TocEdit::Remove { identity } => {
184 let at = position(identity)?;
185 let (id, _, _) = listed.remove(at);
186 created.remove(&id);
187 }
188 TocEdit::Reidentify { identity, with } => {
189 let at = position(identity)?;
190 if *with == [0; 16] || listed.iter().any(|(_, known, _)| known == with) {
191 return Err(invalid("The table of contents already lists that identity"));
192 }
193 listed[at].1 = *with;
194 reidentified.insert(listed[at].0);
195 }
196 }
197 }
198 let listed = listed;
199 // A rename or a colour keeps every number, gaps and ties included, as OneNote left them.
200 let resequence = edits.iter().any(|edit| {
201 matches!(
202 edit,
203 TocEdit::Add { .. } | TocEdit::Order(_) | TocEdit::Remove { .. }
204 )
205 });
206 let transaction = build_on(&index, &[], |index| {
207 let raw = index.resolve_active(space)?;
208 let mut changed = BTreeMap::new();
209 let mut root_object = PropertyObject::from_object(&raw.objects[&root])?;
210 let mut references = Vec::new();
211 for (order, (id, identity, filename)) in listed.iter().enumerate() {
212 let mut object = match created.remove(id) {
213 Some(object) => object,
214 None => PropertyObject::from_object(&raw.objects[id])?,
215 };
216 let stored_order = PropertySets::parse(&object.bytes)?.sets[0]
217 .iter()
218 .find(|p| p.id == 0x14001cb9)
219 .and_then(|p| match p.value {
220 Value::Bytes(b) => b.try_into().ok().map(u32::from_le_bytes),
221 _ => None,
222 });
223 let order = order as u32 + 1;
224 let mut updates: Vec<(u32, Vec<u8>)> = Vec::new();
225 if resequence && stored_order != Some(order) {
226 updates.push((0x14001cb9, order.to_le_bytes().to_vec()));
227 }
228 if renamed.contains(id) {
229 updates.push((0x1c001d6b, crate::create::string(filename)));
230 }
231 if reidentified.contains(id) {
232 updates.push((0x1c001d94, identity.to_vec()));
233 }
234 let updates: Vec<(u32, &[u8])> = updates
235 .iter()
236 .map(|(id, bytes)| (*id, bytes.as_slice()))
237 .collect();
238 if !updates.is_empty() {
239 object.set(&updates)?;
240 }
241 references.extend(root_object.reference(*id)?);
242 if !updates.is_empty() || created.contains_key(id) || !raw.objects.contains_key(id) {
243 changed.insert(*id, object);
244 }
245 }
246 root_object.set(&[(0x24001cf6, &references)])?;
247 if let Some(color) = color {
248 root_object.set(&[(0x14001cbe, &color.to_le_bytes())])?;
249 }
250 changed.insert(root, root_object);
251 Ok(BTreeMap::from([(space, RevisionEdit::Update(changed))]))
252 })?;
253 check(&applied(source, transaction.as_ref())?, true)?;
254 Ok(transaction)
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn apply(source: &[u8], transaction: Option<Transaction>) -> Vec<u8> {
262 applied(source, transaction.as_ref()).unwrap()
263 }
264
265 /// Each entry's filename and ordering number, as the root lists them.
266 fn entries(image: &[u8]) -> Vec<(ExGuid, String, u32)> {
267 let store = Store::parse(image).unwrap();
268 let index = RevisionIndex::parse(&store).unwrap();
269 let document = Document::parse(&index).unwrap();
270 let revision = document.active(document.root).unwrap();
271 let Kind::Toc { entries, .. } = &revision.nodes[&revision.roots[&1]].kind else {
272 panic!()
273 };
274 entries
275 .iter()
276 .map(|id| match &revision.nodes[id].kind {
277 Kind::Toc {
278 filename, order, ..
279 } => (*id, filename.clone().unwrap(), order.unwrap()),
280 _ => panic!(),
281 })
282 .collect()
283 }
284
285 fn displayed(image: &[u8]) -> Vec<(String, u32)> {
286 let mut entries: Vec<_> = entries(image)
287 .into_iter()
288 .map(|(_, name, order)| (name, order))
289 .collect();
290 entries.sort_by_key(|(_, order)| *order);
291 entries
292 }
293
294 #[test]
295 fn edits_follow_the_ordering_numbers_not_the_list() {
296 let [a, b, c] = [[1; 16], [2; 16], [3; 16]];
297 let toc = crate::create_table_of_contents(
298 "Open Notebook.onetoc2",
299 &[("Video.one", a), ("Song.one", b), ("Album.one", c)],
300 )
301 .unwrap();
302 // Numbers out of the list's order, with a gap and a tie, as native TOCs carry them.
303 let dragged = {
304 let store = Store::parse(&toc).unwrap();
305 let index = RevisionIndex::parse(&store).unwrap();
306 let space = Document::parse(&index).unwrap().root;
307 let listed = entries(&toc);
308 let transaction = build_on(&index, &[], |index| {
309 let raw = index.resolve_active(space)?;
310 let mut changed = BTreeMap::new();
311 for ((id, _, _), order) in listed.iter().zip([7u32, 2, 2]) {
312 let mut object = PropertyObject::from_object(&raw.objects[id])?;
313 object.set(&[(0x14001cb9, &order.to_le_bytes())])?;
314 changed.insert(*id, object);
315 }
316 Ok(BTreeMap::from([(space, RevisionEdit::Update(changed))]))
317 })
318 .unwrap();
319 apply(&toc, transaction)
320 };
321 let renamed = apply(
322 &dragged,
323 edit_table_of_contents(
324 &dragged,
325 &[TocEdit::Rename {
326 identity: a,
327 filename: "Kitchen.one".into(),
328 }],
329 )
330 .unwrap(),
331 );
332 assert_eq!(
333 displayed(&renamed),
334 [
335 ("Song.one".into(), 2),
336 ("Album.one".into(), 2),
337 ("Kitchen.one".into(), 7)
338 ]
339 );
340 let added = apply(
341 &renamed,
342 edit_table_of_contents(
343 &renamed,
344 &[TocEdit::Add {
345 filename: "Lore.one".into(),
346 identity: [4; 16],
347 group: false,
348 }],
349 )
350 .unwrap(),
351 );
352 assert_eq!(
353 entries(&added)
354 .into_iter()
355 .map(|(_, name, order)| (name, order))
356 .collect::<Vec<_>>(),
357 [
358 ("Song.one".into(), 1),
359 ("Album.one".into(), 2),
360 ("Kitchen.one".into(), 3),
361 ("Lore.one".into(), 4)
362 ]
363 );
364 }
365}