| 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 | |
| 6 | use 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 | }; |
| 12 | use std::collections::{BTreeMap, BTreeSet}; |
| 13 | |
| 14 | fn 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)] |
| 20 | pub 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 | |
| 46 | fn 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. |
| 53 | pub 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)] |
| 258 | mod 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 | } |