diff --git a/corpus/link-edit/internal/candidate/Open Notebook.onetoc2 b/corpus/link-edit/internal/candidate/Open Notebook.onetoc2 index 706b7e2eb20b7d88a33f1a772e96c0301474b991..45ba18c99f5934accde1640689d5e7de327b3a46 100644 Binary files a/corpus/link-edit/internal/candidate/Open Notebook.onetoc2 and b/corpus/link-edit/internal/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/link-edit/internal/candidate/links.one b/corpus/link-edit/internal/candidate/links.one index 9cc5dedcc8cdea93cd116113909fa5aeeca20230..84df0d5240f60bfa0cbfa9f096c36f443bc3277a 100644 Binary files a/corpus/link-edit/internal/candidate/links.one and b/corpus/link-edit/internal/candidate/links.one differ diff --git a/corpus/link-edit/internal/cold/commands.jsonl b/corpus/link-edit/internal/cold/commands.jsonl index 99f483459bd62f15a2bf14f495f998abcfa2e1ef..984a662051e36d2500b4515cfb146959d8edcdde 100644 --- a/corpus/link-edit/internal/cold/commands.jsonl +++ b/corpus/link-edit/internal/cold/commands.jsonl @@ -1,3 +1,3 @@ {"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} -{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-A4418B81 -ExpectedPages 2", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-AFCAA40A -ExpectedPages 2", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null} {"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/link-edit/internal/cold/machine.json b/corpus/link-edit/internal/cold/machine.json index bbf8801f4361b349e73998e36f121ac37e8aacfb..50338609cb70b105197e0e31b310783541fab241 100644 --- a/corpus/link-edit/internal/cold/machine.json +++ b/corpus/link-edit/internal/cold/machine.json @@ -1 +1 @@ -{"name": "m6-a4418b81", "hostname": "ONE-M6-A4418B81"} +{"name": "m6-afcaa40a", "hostname": "ONE-M6-AFCAA40A"} diff --git a/corpus/link-edit/internal/cold/notebook/Open Notebook.onetoc2 b/corpus/link-edit/internal/cold/notebook/Open Notebook.onetoc2 index dfdfc3f4756aa719a5e4e6b4c8d7c39bca922112..6f76102420c18147713bc218d19661c3a29bb312 100644 Binary files a/corpus/link-edit/internal/cold/notebook/Open Notebook.onetoc2 and b/corpus/link-edit/internal/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/link-edit/internal/cold/notebook/links.one b/corpus/link-edit/internal/cold/notebook/links.one index c2daf85762bee9b487a48667250454d1faa5a9ed..58cb030e06118b891d1f6dbe6b3c2c484ea89c64 100644 Binary files a/corpus/link-edit/internal/cold/notebook/links.one and b/corpus/link-edit/internal/cold/notebook/links.one differ diff --git a/corpus/link-edit/internal/cold/read/environment.json b/corpus/link-edit/internal/cold/read/environment.json index 86799bad7ebf19b99589758ceb9baa77b641c4d5..8bfa19ede8ba13a8bcc8b0b8d2ffe6992bbc6356 100644 --- a/corpus/link-edit/internal/cold/read/environment.json +++ b/corpus/link-edit/internal/cold/read/environment.json @@ -1,7 +1,7 @@ { "powershell": "5.1.14409.1005", "schema": "xs2010", - "hostname": "ONE-M6-A4418B81", + "hostname": "ONE-M6-AFCAA40A", "cold": true, "onenote": "14.0.4763.1000" } diff --git a/corpus/link-edit/internal/cold/read/hierarchy.xml b/corpus/link-edit/internal/cold/read/hierarchy.xml index a386bc9495f54320ec819f931e8894bec190d797..8370594c9866fe0ba06ab328dcfd60e3797616d1 100644 --- a/corpus/link-edit/internal/cold/read/hierarchy.xml +++ b/corpus/link-edit/internal/cold/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/link-edit/internal/cold/read/page-000.xml b/corpus/link-edit/internal/cold/read/page-000.xml index 350680605480840543665e7b1768927b126eaa20..d6a5187341e8bfd5b439efd353c14546cefd2969 100644 --- a/corpus/link-edit/internal/cold/read/page-000.xml +++ b/corpus/link-edit/internal/cold/read/page-000.xml @@ -1,2 +1,3 @@  - +Link target]]> diff --git a/corpus/link-edit/internal/cold/read/page-001.xml b/corpus/link-edit/internal/cold/read/page-001.xml index 932908066288f0d2537155a759c57ccf92856540..01c299682d67cef5df183bdb514607e074a0ce0f 100644 --- a/corpus/link-edit/internal/cold/read/page-001.xml +++ b/corpus/link-edit/internal/cold/read/page-001.xml @@ -1,3 +1,2 @@  -Link target]]> + diff --git a/corpus/link-edit/internal/cold/run.json b/corpus/link-edit/internal/cold/run.json index 46d54750380e3863ca25f56c1b44e8dba711073d..5a285e729c826f6251e24783fb2d67f68f39059a 100644 --- a/corpus/link-edit/internal/cold/run.json +++ b/corpus/link-edit/internal/cold/run.json @@ -1,5 +1,5 @@ { - "notebook": "/private/tmp/m4/ilink", + "notebook": "/Users/clo/dev/one/evidence/titles/link-internal", "expected_pages": 2, "author": null, "author_timeout_seconds": 600, diff --git a/corpus/link-edit/internal/cold/source.json b/corpus/link-edit/internal/cold/source.json index 439edd6ce12404fddb1b88a971314a02bc4b5d5e..9e582c5b025b250bf3fea6a5db501d77760c59e8 100644 --- a/corpus/link-edit/internal/cold/source.json +++ b/corpus/link-edit/internal/cold/source.json @@ -2,13 +2,13 @@ { "path": "Open Notebook.onetoc2", "bytes": 1604, - "sha256": "fdee47831417ef5d21624890fe8d9f3d73a4ded56cacbbe2ebb978788786681b", - "mtime_ns": 1789251129798178239 + "sha256": "7cf4bd0ba068eaccb8e2d3306472ac133a08966eb9dc42cc2b4f7c3b0f44b03f", + "mtime_ns": 1790404368862297325 }, { "path": "links.one", - "bytes": 9200, - "sha256": "16b69f8178ce8a23e37ca844b89ae736f4e2c57827205fcfc1e51501b2865d17", - "mtime_ns": 1789251129797893821 + "bytes": 10120, + "sha256": "740b61f789cd951855f67a811394b77ed1cbcb500e728d4433b1e71904ab1986", + "mtime_ns": 1790404368862105283 } ] diff --git a/corpus/page-copy/candidate/Open Notebook.onetoc2 b/corpus/page-copy/candidate/Open Notebook.onetoc2 index 2e0a511b1b92e0f64c80cce4d24bafaa4926ebc2..8df257b61dd6ec1bafe1e14e67e5135018366fc5 100644 Binary files a/corpus/page-copy/candidate/Open Notebook.onetoc2 and b/corpus/page-copy/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/page-copy/candidate/copies.one b/corpus/page-copy/candidate/copies.one index 27136800811f3d444cf209ea58b23fc9ceb5b548..040d9ca772c6ff391c46b65dcf6f0b0ba1de0cee 100644 Binary files a/corpus/page-copy/candidate/copies.one and b/corpus/page-copy/candidate/copies.one differ diff --git a/corpus/page-copy/cold/commands.jsonl b/corpus/page-copy/cold/commands.jsonl index e40a10bd504057a5f4e5d179dad31cf1979234ff..b6758f1b1f055e1036681f864e182c03424b0c2c 100644 --- a/corpus/page-copy/cold/commands.jsonl +++ b/corpus/page-copy/cold/commands.jsonl @@ -1,3 +1,3 @@ {"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} -{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-33218F72 -ExpectedPages 17 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 17 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-DD3BC9D2 -ExpectedPages 17 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 17 pages.\r\n", "stderr": "", "error": null} {"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/page-copy/cold/machine.json b/corpus/page-copy/cold/machine.json index ca88d7b8816422f6f811419548ca2fa34ba9c91d..60514e476fad4f968acc2815e07dda467afa8585 100644 --- a/corpus/page-copy/cold/machine.json +++ b/corpus/page-copy/cold/machine.json @@ -1 +1 @@ -{"name": "m6-33218f72", "hostname": "ONE-M6-33218F72"} +{"name": "m6-dd3bc9d2", "hostname": "ONE-M6-DD3BC9D2"} diff --git a/corpus/page-copy/cold/notebook/Open Notebook.onetoc2 b/corpus/page-copy/cold/notebook/Open Notebook.onetoc2 index 6f08f3a38e387191947b0859f4057b56e058bca9..cd8c939066255428dae73adc33c52cb2fcffac8b 100644 Binary files a/corpus/page-copy/cold/notebook/Open Notebook.onetoc2 and b/corpus/page-copy/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/page-copy/cold/notebook/copies.one b/corpus/page-copy/cold/notebook/copies.one index 677026c229b03b17a16d6710f12f4520b88a8d9e..df41b5bc070171702c2639b45cd592949ff98975 100644 Binary files a/corpus/page-copy/cold/notebook/copies.one and b/corpus/page-copy/cold/notebook/copies.one differ diff --git a/corpus/page-copy/cold/read/environment.json b/corpus/page-copy/cold/read/environment.json index 61111c96f38b218f064910d6656937eae099ee09..0bae2ed563b53a7094eaa424a7d5bb3a2f29986c 100644 --- a/corpus/page-copy/cold/read/environment.json +++ b/corpus/page-copy/cold/read/environment.json @@ -1,7 +1,7 @@ { "powershell": "5.1.14409.1005", "schema": "xs2010", - "hostname": "ONE-M6-33218F72", + "hostname": "ONE-M6-DD3BC9D2", "cold": true, "onenote": "14.0.4763.1000" } diff --git a/corpus/page-copy/cold/read/hierarchy.xml b/corpus/page-copy/cold/read/hierarchy.xml index 461b41d26007b0c6cc8760aa9948081c52b8bd2d..96411ad5d6246d9f138c089de5bd1b5679592b0b 100644 --- a/corpus/page-copy/cold/read/hierarchy.xml +++ b/corpus/page-copy/cold/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-copy/cold/read/page-000.navigation.json b/corpus/page-copy/cold/read/page-000.navigation.json index 999c7b5056164cd4b506338d91c60949c9da068e..b2cc9630cdb428ace6cdb6faa135abf85f7b4fa0 100644 --- a/corpus/page-copy/cold/read/page-000.navigation.json +++ b/corpus/page-copy/cold/read/page-000.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Destination - Microsoft OneNote", + "title": "Before the file - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-000.png b/corpus/page-copy/cold/read/page-000.png index a605a262d190860e8b3a9726cbfcfd5fbfb5b59f..4c98cff758e439869b620848c25d731a7c228633 100644 Binary files a/corpus/page-copy/cold/read/page-000.png and b/corpus/page-copy/cold/read/page-000.png differ diff --git a/corpus/page-copy/cold/read/page-000.xml b/corpus/page-copy/cold/read/page-000.xml index 49df7d69df513a30feb5d15d2bf291f360c4d7ce..f4a2ff0ba797c3cee44d478da8329c62c945d798 100644 --- a/corpus/page-copy/cold/read/page-000.xml +++ b/corpus/page-copy/cold/read/page-000.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-copy/cold/read/page-001.navigation.json b/corpus/page-copy/cold/read/page-001.navigation.json index b65a0c422e83efcb89dd26be239dd1906202f810..d2b59e91271a6fdcb0b16e590b860b1146332688 100644 --- a/corpus/page-copy/cold/read/page-001.navigation.json +++ b/corpus/page-copy/cold/read/page-001.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Before the picture - Microsoft OneNote", + "title": "Delete sole cell paragraph - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-001.png b/corpus/page-copy/cold/read/page-001.png index 0885a25c4e0f1db206f23ea5bba63d1782f1be63..859366e09282c7ab06c15f7dc96b90fbecdcf198 100644 Binary files a/corpus/page-copy/cold/read/page-001.png and b/corpus/page-copy/cold/read/page-001.png differ diff --git a/corpus/page-copy/cold/read/page-001.xml b/corpus/page-copy/cold/read/page-001.xml index b9de3b9064ac76f8820e3271f3520c0dff320664..7dca0028bc9f9a1a5b97d4e653f28256921ea12c 100644 --- a/corpus/page-copy/cold/read/page-001.xml +++ b/corpus/page-copy/cold/read/page-001.xml @@ -1,4 +1,4 @@  -iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w -AAAAAElFTkSuQmCC - +Target 🦀 é Link label]]> diff --git a/corpus/page-copy/cold/read/page-002.navigation.json b/corpus/page-copy/cold/read/page-002.navigation.json index 666dae945adaeb59d3d5c742c7ba85778929706b..48f1601e8396f2c2f32ff0f85f3517904d41697d 100644 --- a/corpus/page-copy/cold/read/page-002.navigation.json +++ b/corpus/page-copy/cold/read/page-002.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Indent bullet subtree - Microsoft OneNote", + "title": "Move numbered subtree down - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-002.png b/corpus/page-copy/cold/read/page-002.png index d47da50b865823a76a40efece90dbfabb810ff02..61ea0db1e719869f0408504eaa6ca97c0d1e8f18 100644 Binary files a/corpus/page-copy/cold/read/page-002.png and b/corpus/page-copy/cold/read/page-002.png differ diff --git a/corpus/page-copy/cold/read/page-002.xml b/corpus/page-copy/cold/read/page-002.xml index 6354dc7fa36927b684bb57898b615e62ba1e5b25..8de2391fe2cd215288dc3d4ac630f8604dee92ed 100644 --- a/corpus/page-copy/cold/read/page-002.xml +++ b/corpus/page-copy/cold/read/page-002.xml @@ -1,5 +1,5 @@  -Target 🦀 é Link label]]>Child]]> +href="https://example.invalid/tree">Link label]]>Child]]> diff --git a/corpus/page-copy/cold/read/page-003.navigation.json b/corpus/page-copy/cold/read/page-003.navigation.json index b8578e6a2f723044d70c505e33406a480433361c..af6bcb2175c02024ff0ca87879c85b382404985b 100644 --- a/corpus/page-copy/cold/read/page-003.navigation.json +++ b/corpus/page-copy/cold/read/page-003.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Bullet item - Microsoft OneNote", + "title": "Equations below - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-003.png b/corpus/page-copy/cold/read/page-003.png index 1a87e98aaa70d8dd720d9744a2c7953450dc376b..e215c20d5124e4f25dbc11011bcb75d16be7da4f 100644 Binary files a/corpus/page-copy/cold/read/page-003.png and b/corpus/page-copy/cold/read/page-003.png differ diff --git a/corpus/page-copy/cold/read/page-003.xml b/corpus/page-copy/cold/read/page-003.xml index 6142d59085d2bc8e36d735444a9579e01f2131d4..0c1184802ccd9c090d276379731887fce1cc3d2e 100644 --- a/corpus/page-copy/cold/read/page-003.xml +++ b/corpus/page-copy/cold/read/page-003.xml @@ -1,2 +1,3 @@  - +x2+y2=z2]]>a+bc+d]]>Inline ]]>a1+b2]]>xi2]]>a+b]]>∫01x dx ]]>∑i=1ni ]]>x2+1]]>a+bc2]]> diff --git a/corpus/page-copy/cold/read/page-004.navigation.json b/corpus/page-copy/cold/read/page-004.navigation.json index d2b59e91271a6fdcb0b16e590b860b1146332688..666dae945adaeb59d3d5c742c7ba85778929706b 100644 --- a/corpus/page-copy/cold/read/page-004.navigation.json +++ b/corpus/page-copy/cold/read/page-004.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Delete sole cell paragraph - Microsoft OneNote", + "title": "Indent bullet subtree - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-004.png b/corpus/page-copy/cold/read/page-004.png index 2c489864709ee0eeb32b0101dc1d70722ca81ad2..23a983027a1943335958dc6c6634b9eab674ab77 100644 Binary files a/corpus/page-copy/cold/read/page-004.png and b/corpus/page-copy/cold/read/page-004.png differ diff --git a/corpus/page-copy/cold/read/page-004.xml b/corpus/page-copy/cold/read/page-004.xml index ee737109e45bbf1dc4d82e0773a0423ed635c0d2..0ebd501cdb976ad99beeeb062b70a9feebbbef91 100644 --- a/corpus/page-copy/cold/read/page-004.xml +++ b/corpus/page-copy/cold/read/page-004.xml @@ -1,4 +1,5 @@  -Target 🦀 é Link label]]> +href="https://example.invalid/tree">Link label]]>Child]]> diff --git a/corpus/page-copy/cold/read/page-005.navigation.json b/corpus/page-copy/cold/read/page-005.navigation.json index ab78af6f58f0491d903cbc8f945f3c70dbe06606..999c7b5056164cd4b506338d91c60949c9da068e 100644 --- a/corpus/page-copy/cold/read/page-005.navigation.json +++ b/corpus/page-copy/cold/read/page-005.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Indent first subtree - Microsoft OneNote", + "title": "Destination - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-005.png b/corpus/page-copy/cold/read/page-005.png index f56a82b5ad7e7c2eda2b38c788ed303a958ff14a..21d1454632f2b0813ebc8b7c4ea9f80b631291f6 100644 Binary files a/corpus/page-copy/cold/read/page-005.png and b/corpus/page-copy/cold/read/page-005.png differ diff --git a/corpus/page-copy/cold/read/page-005.xml b/corpus/page-copy/cold/read/page-005.xml index 1b0a3a46a89ab08addae397a1b46ccd921d412b0..0db79be190827e8ee7c13b042a49bee3470c106a 100644 --- a/corpus/page-copy/cold/read/page-005.xml +++ b/corpus/page-copy/cold/read/page-005.xml @@ -1,5 +1,2 @@  -Target 🦀 é Link label]]>Child]]> + diff --git a/corpus/page-copy/cold/read/page-006.navigation.json b/corpus/page-copy/cold/read/page-006.navigation.json index 365ff367b3c95a498dea1ea074b6874814b85ca9..c2e498f910f1c5412d881cfd7157bb6e0f1e816a 100644 --- a/corpus/page-copy/cold/read/page-006.navigation.json +++ b/corpus/page-copy/cold/read/page-006.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Bold \ud83e\udd80 italic e\u0301 color \u6771\u4eac - Microsoft OneNote", + "title": "Move cell subtree down - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-006.png b/corpus/page-copy/cold/read/page-006.png index bac1750ff3c53958251f4f579938b3599321161e..3c5dafbe8a84de3733a1a2ade409b116c31129e2 100644 Binary files a/corpus/page-copy/cold/read/page-006.png and b/corpus/page-copy/cold/read/page-006.png differ diff --git a/corpus/page-copy/cold/read/page-006.xml b/corpus/page-copy/cold/read/page-006.xml index f59feb7588b8753d120af6dc3f45e8154150bf3a..76a98dc5c2756e8f8b3d6a68909dd0daacbb5d3f 100644 --- a/corpus/page-copy/cold/read/page-006.xml +++ b/corpus/page-copy/cold/read/page-006.xml @@ -1,20 +1,5 @@  -Bold 🦀 italic é color 東京
-End
]]>
Fictitious: café, 東京, مرحبا]]>
+Target 🦀 é Link label]]>Child]]> diff --git a/corpus/page-copy/cold/read/page-007.navigation.json b/corpus/page-copy/cold/read/page-007.navigation.json index b2cc9630cdb428ace6cdb6faa135abf85f7b4fa0..f3ecc2514cfb245e8d524aba87942ac24e90c62e 100644 --- a/corpus/page-copy/cold/read/page-007.navigation.json +++ b/corpus/page-copy/cold/read/page-007.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Before the file - Microsoft OneNote", + "title": "Delete cell subtree - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-007.png b/corpus/page-copy/cold/read/page-007.png index 99a934c548c4888326b8f41d8c37141d08076f84..6700951faf151b8fe8636201708d3cfe827cc757 100644 Binary files a/corpus/page-copy/cold/read/page-007.png and b/corpus/page-copy/cold/read/page-007.png differ diff --git a/corpus/page-copy/cold/read/page-007.xml b/corpus/page-copy/cold/read/page-007.xml index 9192d32cf20c9d9ded0a6c2b0588f99e05ecad16..7b79a8860877b0c24092372de03a9417d4ffbc65 100644 --- a/corpus/page-copy/cold/read/page-007.xml +++ b/corpus/page-copy/cold/read/page-007.xml @@ -1,2 +1,5 @@  - +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/page-copy/cold/read/page-008.navigation.json b/corpus/page-copy/cold/read/page-008.navigation.json index 2ffaf3c40700297201f6a2e70966546ef83aa74a..a4ef2b1e701a5f5af5652851cf8d82fddf764464 100644 --- a/corpus/page-copy/cold/read/page-008.navigation.json +++ b/corpus/page-copy/cold/read/page-008.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Delete numbered subtree - Microsoft OneNote", + "title": "Before the table - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-008.png b/corpus/page-copy/cold/read/page-008.png index 91bda24a040ce1679fad1b6040ec10295c2ffead..fc6ebebe6723bbfd5856e365cf44ce2093acf779 100644 Binary files a/corpus/page-copy/cold/read/page-008.png and b/corpus/page-copy/cold/read/page-008.png differ diff --git a/corpus/page-copy/cold/read/page-008.xml b/corpus/page-copy/cold/read/page-008.xml index 90ac110d26e3d331803da3aa3079fcb9db66e06e..955e6837aabd6827af7ef4edb9b20d0f7070197d 100644 --- a/corpus/page-copy/cold/read/page-008.xml +++ b/corpus/page-copy/cold/read/page-008.xml @@ -1,5 +1,2 @@  -Target 🦀 é Link label]]>Child]]> + diff --git a/corpus/page-copy/cold/read/page-009.navigation.json b/corpus/page-copy/cold/read/page-009.navigation.json index af6bcb2175c02024ff0ca87879c85b382404985b..b65a0c422e83efcb89dd26be239dd1906202f810 100644 --- a/corpus/page-copy/cold/read/page-009.navigation.json +++ b/corpus/page-copy/cold/read/page-009.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Equations below - Microsoft OneNote", + "title": "Before the picture - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-009.png b/corpus/page-copy/cold/read/page-009.png index e266bbc7f9161db9138f076034cd4c313b86f3c8..ce092d4353acf4d865b1a686326466446378f743 100644 Binary files a/corpus/page-copy/cold/read/page-009.png and b/corpus/page-copy/cold/read/page-009.png differ diff --git a/corpus/page-copy/cold/read/page-009.xml b/corpus/page-copy/cold/read/page-009.xml index 68bf933e4a4e935aef6003424e4288f5b2a2e8b7..548c99ecc7c45f474f7803c06313277a301bd319 100644 --- a/corpus/page-copy/cold/read/page-009.xml +++ b/corpus/page-copy/cold/read/page-009.xml @@ -1,3 +1,4 @@  -x2+y2=z2]]>a+bc+d]]>Inline ]]>a1+b2]]>xi2]]>a+b]]>∫01x dx ]]>∑i=1ni ]]>x2+1]]>a+bc2]]> +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w +AAAAAElFTkSuQmCC + diff --git a/corpus/page-copy/cold/read/page-010.navigation.json b/corpus/page-copy/cold/read/page-010.navigation.json index 48f1601e8396f2c2f32ff0f85f3517904d41697d..2ffaf3c40700297201f6a2e70966546ef83aa74a 100644 --- a/corpus/page-copy/cold/read/page-010.navigation.json +++ b/corpus/page-copy/cold/read/page-010.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Move numbered subtree down - Microsoft OneNote", + "title": "Delete numbered subtree - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-010.png b/corpus/page-copy/cold/read/page-010.png index 71beaba4366dda094b97c9cfd2cdb79d9129acad..dcf29845514a859abe299b805666bee079e710b3 100644 Binary files a/corpus/page-copy/cold/read/page-010.png and b/corpus/page-copy/cold/read/page-010.png differ diff --git a/corpus/page-copy/cold/read/page-010.xml b/corpus/page-copy/cold/read/page-010.xml index d04b04070c8b520ded17357b000a518f77ee923e..5569e8a0d4fd9e93e79c3ff51e0a92bbb5c665a6 100644 --- a/corpus/page-copy/cold/read/page-010.xml +++ b/corpus/page-copy/cold/read/page-010.xml @@ -1,5 +1,5 @@  -Target 🦀 é Link label]]>Child]]> +href="https://example.invalid/tree">Link label]]>Child]]> diff --git a/corpus/page-copy/cold/read/page-011.navigation.json b/corpus/page-copy/cold/read/page-011.navigation.json index c1fbc1110d4cf29bc7a5645b4863263b05217e60..b8578e6a2f723044d70c505e33406a480433361c 100644 --- a/corpus/page-copy/cold/read/page-011.navigation.json +++ b/corpus/page-copy/cold/read/page-011.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Open task - Microsoft OneNote", + "title": "Bullet item - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-011.png b/corpus/page-copy/cold/read/page-011.png index 9ee0f609b9ac929e417c606961a2d6f8eec80812..8dd8214a55c351d3f75a033f9a37b841525872e8 100644 Binary files a/corpus/page-copy/cold/read/page-011.png and b/corpus/page-copy/cold/read/page-011.png differ diff --git a/corpus/page-copy/cold/read/page-011.xml b/corpus/page-copy/cold/read/page-011.xml index 6e950472e7284ff586d2916677d6bf1b548dfd13..65f5bdfabc0f4233619f5b7573653b06fabdd619 100644 --- a/corpus/page-copy/cold/read/page-011.xml +++ b/corpus/page-copy/cold/read/page-011.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-copy/cold/read/page-012.navigation.json b/corpus/page-copy/cold/read/page-012.navigation.json index c2e498f910f1c5412d881cfd7157bb6e0f1e816a..ab78af6f58f0491d903cbc8f945f3c70dbe06606 100644 --- a/corpus/page-copy/cold/read/page-012.navigation.json +++ b/corpus/page-copy/cold/read/page-012.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Move cell subtree down - Microsoft OneNote", + "title": "Indent first subtree - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-012.png b/corpus/page-copy/cold/read/page-012.png index b2fee73e41f3e5063edfde0b4ce5729f1cd2e1fd..fb3016a0f2854511478e7bf1165fbcbf2946989a 100644 Binary files a/corpus/page-copy/cold/read/page-012.png and b/corpus/page-copy/cold/read/page-012.png differ diff --git a/corpus/page-copy/cold/read/page-012.xml b/corpus/page-copy/cold/read/page-012.xml index 5c63c3d3e573ddd3c98d77c6d989534945615ca9..0ae977408a7fff48187158afbfb6a5beaff389da 100644 --- a/corpus/page-copy/cold/read/page-012.xml +++ b/corpus/page-copy/cold/read/page-012.xml @@ -1,5 +1,5 @@  -Target 🦀 é Link label]]>Child]]> +href="https://example.invalid/tree">Link label]]>Child]]> diff --git a/corpus/page-copy/cold/read/page-013.navigation.json b/corpus/page-copy/cold/read/page-013.navigation.json index f3ecc2514cfb245e8d524aba87942ac24e90c62e..957beaafccbcba166fba87cf91fc907f33c74e81 100644 --- a/corpus/page-copy/cold/read/page-013.navigation.json +++ b/corpus/page-copy/cold/read/page-013.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Delete cell subtree - Microsoft OneNote", + "title": "Beside the drawing - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-013.png b/corpus/page-copy/cold/read/page-013.png index 34a9d1aed316156cd1036fd21436173474513ab6..9c0ffd574cbf498de0318b31c9c0f46133f93e3a 100644 Binary files a/corpus/page-copy/cold/read/page-013.png and b/corpus/page-copy/cold/read/page-013.png differ diff --git a/corpus/page-copy/cold/read/page-013.xml b/corpus/page-copy/cold/read/page-013.xml index 3c369a5ea6d1986ae293922c03f5480b59d23204..49c8772ae24da550298a95698487cebeb972b248 100644 --- a/corpus/page-copy/cold/read/page-013.xml +++ b/corpus/page-copy/cold/read/page-013.xml @@ -1,5 +1,7 @@  -Target 🦀 é Link label]]>Child]]> +AM8CHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3 +URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj +RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln +jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEgZQaCZUd9r0ufE8XliyT+ +gAoAESCARDH9gE3dAQMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG +AAAAABIAESBryQ6p9QoiSLYaYOAFf0kKCgARIIBEMf2ATd0BAxVGAEAKP0Aib0YILvRYCoA= diff --git a/corpus/page-copy/cold/read/page-014.navigation.json b/corpus/page-copy/cold/read/page-014.navigation.json index 957beaafccbcba166fba87cf91fc907f33c74e81..002a1c45a5ba0121a28f35c11466c61b8a2aac13 100644 --- a/corpus/page-copy/cold/read/page-014.navigation.json +++ b/corpus/page-copy/cold/read/page-014.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Beside the drawing - Microsoft OneNote", + "title": "A notebook created in Rust. - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-014.png b/corpus/page-copy/cold/read/page-014.png index bc9f5393f0c343fc46c6d976d371d56b8a203575..f92db61d8852783629f4e340f2b8c4dabc9cf941 100644 Binary files a/corpus/page-copy/cold/read/page-014.png and b/corpus/page-copy/cold/read/page-014.png differ diff --git a/corpus/page-copy/cold/read/page-014.xml b/corpus/page-copy/cold/read/page-014.xml index e95ed1faa233bb439d6da5264e105dbc1ab804e2..60f62ed15ba4da5634cdb038f4adc09a03215fbc 100644 --- a/corpus/page-copy/cold/read/page-014.xml +++ b/corpus/page-copy/cold/read/page-014.xml @@ -1,7 +1,2 @@  -AM8CHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3 -URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj -RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln -jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEgNO2fBK2iXEeJsDGBngJF -LQoAESCAd0ywV0PdAQMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG -AAAAABIAESDm2ofXdOgLSJUWT/x4VvZtCgARIIB3TLBXQ90BAxVGAEAKP0Aib0YILvRYCoA= + diff --git a/corpus/page-copy/cold/read/page-015.navigation.json b/corpus/page-copy/cold/read/page-015.navigation.json index 002a1c45a5ba0121a28f35c11466c61b8a2aac13..c1fbc1110d4cf29bc7a5645b4863263b05217e60 100644 --- a/corpus/page-copy/cold/read/page-015.navigation.json +++ b/corpus/page-copy/cold/read/page-015.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "A notebook created in Rust. - Microsoft OneNote", + "title": "Open task - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-015.png b/corpus/page-copy/cold/read/page-015.png index 69b55b80c0927d590660231aad9a0c7e8ff7150b..38da3cca531b8b169b823f880d8d526c5deba7db 100644 Binary files a/corpus/page-copy/cold/read/page-015.png and b/corpus/page-copy/cold/read/page-015.png differ diff --git a/corpus/page-copy/cold/read/page-015.xml b/corpus/page-copy/cold/read/page-015.xml index 5d7053e3ce71263bbb7775a64c41fd4113d150cb..31ab0a711c5a1d3ecf35dd30e6de40dfb7efabb3 100644 --- a/corpus/page-copy/cold/read/page-015.xml +++ b/corpus/page-copy/cold/read/page-015.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-copy/cold/read/page-016.navigation.json b/corpus/page-copy/cold/read/page-016.navigation.json index a4ef2b1e701a5f5af5652851cf8d82fddf764464..365ff367b3c95a498dea1ea074b6874814b85ca9 100644 --- a/corpus/page-copy/cold/read/page-016.navigation.json +++ b/corpus/page-copy/cold/read/page-016.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Before the table - Microsoft OneNote", + "title": "Bold \ud83e\udd80 italic e\u0301 color \u6771\u4eac - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/page-copy/cold/read/page-016.png b/corpus/page-copy/cold/read/page-016.png index 01d4090ee402d3cf14100273333c46b7a7ca5c88..07f3411e4c73b57fa2ab5b215d3e7a17e433cdda 100644 Binary files a/corpus/page-copy/cold/read/page-016.png and b/corpus/page-copy/cold/read/page-016.png differ diff --git a/corpus/page-copy/cold/read/page-016.xml b/corpus/page-copy/cold/read/page-016.xml index e1edcf257bd581a0cda25c3d15bf276998d6ad44..43b01dc531d759a01ab0163411532580b97a00d6 100644 --- a/corpus/page-copy/cold/read/page-016.xml +++ b/corpus/page-copy/cold/read/page-016.xml @@ -1,2 +1,20 @@  - +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/page-copy/cold/read/payloads.json b/corpus/page-copy/cold/read/payloads.json index ae289407ebe7e1ff6123a19f938136bca64544f6..9069b3e58ab7c0715c24aa20cb3d70db920a65d7 100644 --- a/corpus/page-copy/cold/read/payloads.json +++ b/corpus/page-copy/cold/read/payloads.json @@ -2,9 +2,9 @@ { "sha256": "b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6", "name": "notes 🦀.txt", - "object": "{9E89195D-B7EC-0AC4-34FD-F47D633F0533}{1}{B0}", + "object": "{82855B32-C890-0FF0-3238-816FD0683ADC}{12}{B0}", "kind": "InsertedFile", - "page": "{62E12C49-D5FE-0850-17D5-B4DC95B736E9}{1}{B0}", + "page": "{0C0ECE53-81B9-0DE0-2721-D119E26F7632}{1}{B0}", "bytes": 28 } ] \ No newline at end of file diff --git a/corpus/page-copy/cold/run.json b/corpus/page-copy/cold/run.json index d7f1a4d003fadf4aea3e154c9553c35ce6a33bb3..a733729af3e3fa7b36595a45431b834be10bbec8 100644 --- a/corpus/page-copy/cold/run.json +++ b/corpus/page-copy/cold/run.json @@ -1,5 +1,5 @@ { - "notebook": "/private/tmp/m4/copy", + "notebook": "/Users/clo/dev/one/evidence/titles/page-copy", "expected_pages": 17, "author": null, "author_timeout_seconds": 600, diff --git a/corpus/page-copy/cold/source.json b/corpus/page-copy/cold/source.json index d2a8ee9c0a4a977ae4ee6d04eb6d840cb771f300..ccc18c6d7797f735139ddd61804287d4a08add4a 100644 --- a/corpus/page-copy/cold/source.json +++ b/corpus/page-copy/cold/source.json @@ -2,19 +2,19 @@ { "path": "Open Notebook.onetoc2", "bytes": 1604, - "sha256": "acb0747c250f7bebed1e90b18d5dbbf8c32731f9287571b1e732681921fd323a", - "mtime_ns": 1789287183577857848 + "sha256": "61afe63a5058198015858383382d2776cecfae45b6afea1a81c7d379f8b78ca8", + "mtime_ns": 1790404431194548185 }, { "path": "copies.one", - "bytes": 185400, - "sha256": "dcd7c226d86477adc1ad2f8da8a408ffa2b993b372517ab8d464e5746a971aac", - "mtime_ns": 1789287183575141938 + "bytes": 163632, + "sha256": "75253d18459607aee385ceb73499d3780903e39fe436132666e842fd100b8d40", + "mtime_ns": 1790404431191555148 }, { "path": "expected-count.txt", "bytes": 2, "sha256": "4523540f1504cd17100c4835e85b7eefd49911580f8efff0599a8f283be6b9e3", - "mtime_ns": 1789287183577930514 + "mtime_ns": 1790404431194611352 } ] diff --git a/corpus/page-lifecycle/creation/README.md b/corpus/page-lifecycle/creation/README.md index fa19f5425ee7f7d144d312df11408a4f36baec8b..9d4db668ff675e1b53ab38657793a82b7756b96f 100644 --- a/corpus/page-lifecycle/creation/README.md +++ b/corpus/page-lifecycle/creation/README.md @@ -14,7 +14,8 @@ The retained `provenance.json` records the native capture inputs and clone remov After building workspace examples, run `python -m unittest discover -s tools -p test_page_creation.py -v`. It compares native XML content and character formats, ordered identities, and complete active object graphs across each cold reopen. -These thirteen-page comparisons need no graph normalization. +Only `followup/cold` differs: OneNote refreshes the section's cached copies of the +two renamed titles, which the Rust title edit leaves stale. Export fresh Rust candidates by setting each variable to a new output directory: diff --git a/corpus/page-lifecycle/creation/candidate/Lifecycle.one b/corpus/page-lifecycle/creation/candidate/Lifecycle.one index 3dac230dfcad0e261e9327e945c79ed84af025b7..21fade1a0c7cbe6003d1778b0c03a2828572db5c 100644 Binary files a/corpus/page-lifecycle/creation/candidate/Lifecycle.one and b/corpus/page-lifecycle/creation/candidate/Lifecycle.one differ diff --git a/corpus/page-lifecycle/creation/cold/notebook/Lifecycle.one b/corpus/page-lifecycle/creation/cold/notebook/Lifecycle.one index 776a303621a5dc0fe041cfb0f6bcfb29784f5212..4aec8e2bc8464f893e674feae39701b31f338684 100644 Binary files a/corpus/page-lifecycle/creation/cold/notebook/Lifecycle.one and b/corpus/page-lifecycle/creation/cold/notebook/Lifecycle.one differ diff --git a/corpus/page-lifecycle/creation/cold/read/hierarchy.xml b/corpus/page-lifecycle/creation/cold/read/hierarchy.xml index 5f5f4541a71288ce6f039105b1bef2b6cc92bbce..bc48fd92326ec22b8f740a84cb7fe62f7fd104c0 100644 --- a/corpus/page-lifecycle/creation/cold/read/hierarchy.xml +++ b/corpus/page-lifecycle/creation/cold/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/cold/read/page-000.xml b/corpus/page-lifecycle/creation/cold/read/page-000.xml index b74db02740bf5f6664bec471f6ceb4edfa69e947..b492c8a81b79b0ce3d4a4d3cfc692cd98e63b734 100644 --- a/corpus/page-lifecycle/creation/cold/read/page-000.xml +++ b/corpus/page-lifecycle/creation/cold/read/page-000.xml @@ -1,4 +1,2 @@  -Body child 🦀 é preserved]]> + diff --git a/corpus/page-lifecycle/creation/cold/read/page-001.xml b/corpus/page-lifecycle/creation/cold/read/page-001.xml index 62991791c86033a1286a58fba2ca36558e3150e6..4fb79677980bbccff281765d47bf76b098233918 100644 --- a/corpus/page-lifecycle/creation/cold/read/page-001.xml +++ b/corpus/page-lifecycle/creation/cold/read/page-001.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/cold/read/page-002.xml b/corpus/page-lifecycle/creation/cold/read/page-002.xml index a4888c4c3a60c26e6a1e7527a818fa76c0000738..e8483dc59eb4c97402427ab02c6dc117dfe4faf5 100644 --- a/corpus/page-lifecycle/creation/cold/read/page-002.xml +++ b/corpus/page-lifecycle/creation/cold/read/page-002.xml @@ -1,2 +1,4 @@  - +Body child 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/cold/read/page-003.xml b/corpus/page-lifecycle/creation/cold/read/page-003.xml index 0a4fa38b36604ea31858926fc28563e3d48fce5e..463cb64b35cfb733b9eee1a3c638189b97379093 100644 --- a/corpus/page-lifecycle/creation/cold/read/page-003.xml +++ b/corpus/page-lifecycle/creation/cold/read/page-003.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/cold/read/page-004.xml b/corpus/page-lifecycle/creation/cold/read/page-004.xml index 36b3f48ded7416caa1a1a3493c5ce738d378dd59..de4aeb0430fcc69595c3f2930ad39da7a8cbc6c8 100644 --- a/corpus/page-lifecycle/creation/cold/read/page-004.xml +++ b/corpus/page-lifecycle/creation/cold/read/page-004.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/cold/read/page-005.xml b/corpus/page-lifecycle/creation/cold/read/page-005.xml index 2841ac8de01c2941a57d6a5781785ce87c58f233..34d5f049a21055a989fe1565a3b597bff488cbe0 100644 --- a/corpus/page-lifecycle/creation/cold/read/page-005.xml +++ b/corpus/page-lifecycle/creation/cold/read/page-005.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/cold/read/page-006.xml b/corpus/page-lifecycle/creation/cold/read/page-006.xml index 9e25e11773ae0c3c673cf196ba3c8d353f9cf201..844a2450dfd933478a6b137b674de2fb86e6a30b 100644 --- a/corpus/page-lifecycle/creation/cold/read/page-006.xml +++ b/corpus/page-lifecycle/creation/cold/read/page-006.xml @@ -1,4 +1,4 @@  -Body grandchild 🦀 éBody parent 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/cold/read/page-007.xml b/corpus/page-lifecycle/creation/cold/read/page-007.xml index d4d158edf3e7f04a47d7e6fe30985c6e43b4eba8..8658d51408df8406262094c4b8323bfb8d3a34eb 100644 --- a/corpus/page-lifecycle/creation/cold/read/page-007.xml +++ b/corpus/page-lifecycle/creation/cold/read/page-007.xml @@ -1,4 +1,4 @@  -Body parent 🦀 éBody grandchild 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/cold/read/page-008.xml b/corpus/page-lifecycle/creation/cold/read/page-008.xml index 8f25f7fb29c1de845d267ae0a7432186062da69a..c49d59bb7e3ffae13e462c53547d1154902f74f2 100644 --- a/corpus/page-lifecycle/creation/cold/read/page-008.xml +++ b/corpus/page-lifecycle/creation/cold/read/page-008.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/cold/read/page-009.xml b/corpus/page-lifecycle/creation/cold/read/page-009.xml index 9bb9dde27336654424657d5c9e644849d564507d..df7fdefc0b368fd984624230350c235f8129c47b 100644 --- a/corpus/page-lifecycle/creation/cold/read/page-009.xml +++ b/corpus/page-lifecycle/creation/cold/read/page-009.xml @@ -1,4 +1,4 @@  -Body duplicate 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/cold/read/page-010.xml b/corpus/page-lifecycle/creation/cold/read/page-010.xml index 265b775930e7d1769f636d1662b677a553696018..d8d4814e93eb9ce21e6730264e8311b6a85a6a1c 100644 --- a/corpus/page-lifecycle/creation/cold/read/page-010.xml +++ b/corpus/page-lifecycle/creation/cold/read/page-010.xml @@ -1,4 +1,4 @@  -Body automatic 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/cold/read/page-011.xml b/corpus/page-lifecycle/creation/cold/read/page-011.xml index 7c03f6ae8fca64df7719f2b108cb98ceb5ba8e83..f036410c986d346af08729b51dc87120adbde37f 100644 --- a/corpus/page-lifecycle/creation/cold/read/page-011.xml +++ b/corpus/page-lifecycle/creation/cold/read/page-011.xml @@ -1,2 +1,4 @@  - +Body trailing 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/cold/read/page-012.xml b/corpus/page-lifecycle/creation/cold/read/page-012.xml index 9890c342b59f523cac95cb9193722bad8b6ca62e..08ec67c593b4d218691ff08ff848acc7695a2357 100644 --- a/corpus/page-lifecycle/creation/cold/read/page-012.xml +++ b/corpus/page-lifecycle/creation/cold/read/page-012.xml @@ -1,4 +1,2 @@  -Body trailing 🦀 é preserved]]> + diff --git a/corpus/page-lifecycle/creation/followup/Lifecycle.one b/corpus/page-lifecycle/creation/followup/Lifecycle.one index b6f78fe9a2da590e540d55a842742eb36fe7d173..7ae80a1404a39096e4cb367ca26393c1b0a13ed0 100644 Binary files a/corpus/page-lifecycle/creation/followup/Lifecycle.one and b/corpus/page-lifecycle/creation/followup/Lifecycle.one differ diff --git a/corpus/page-lifecycle/creation/followup/cold/notebook/Lifecycle.one b/corpus/page-lifecycle/creation/followup/cold/notebook/Lifecycle.one index 206670d8221019c61e7133b8626a14c837a5d3f1..ad994356d76a70f11cfda7793c23fe86ee52bd11 100644 Binary files a/corpus/page-lifecycle/creation/followup/cold/notebook/Lifecycle.one and b/corpus/page-lifecycle/creation/followup/cold/notebook/Lifecycle.one differ diff --git a/corpus/page-lifecycle/creation/followup/cold/read/hierarchy.xml b/corpus/page-lifecycle/creation/followup/cold/read/hierarchy.xml index f7d9ebde7686be747df76426cc89501e37dcee14..d165c5ccbf6b95acadc5a27f054773c746887ca7 100644 --- a/corpus/page-lifecycle/creation/followup/cold/read/hierarchy.xml +++ b/corpus/page-lifecycle/creation/followup/cold/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-000.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-000.xml index 4742bf7a49b14bfeaec07003c7b4f461063d324a..dbbfed83ec7c7a62f275b14c57ddfaef8600e788 100644 --- a/corpus/page-lifecycle/creation/followup/cold/read/page-000.xml +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-000.xml @@ -1,4 +1,4 @@  -Body child 🦀 éBody duplicate 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-001.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-001.xml index a2161a614bca5619a0c11e013b97c1610daeb6c0..a161e6aeef2339667be87384a2c756ee94c53fc5 100644 --- a/corpus/page-lifecycle/creation/followup/cold/read/page-001.xml +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-001.xml @@ -1,2 +1,4 @@  - +Body automatic 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-002.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-002.xml index 8ae4e38d376aafaf3afe54f4fc83bcb156f694ef..e2530a2c17599cf93dee7fdf5b4081acc1f5b7c3 100644 --- a/corpus/page-lifecycle/creation/followup/cold/read/page-002.xml +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-002.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-003.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-003.xml index 8068f394f7b8bfa45dc97905c4c1f3e69ce07354..17b766ec3e072f56bdf8e38c71e5e5432bae908e 100644 --- a/corpus/page-lifecycle/creation/followup/cold/read/page-003.xml +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-003.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-004.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-004.xml index 9fa30270c14770a10545f36ef6b13e509c2bb1e0..b5ac4f9604e56acbfe35508d09f7c3b5e3258d34 100644 --- a/corpus/page-lifecycle/creation/followup/cold/read/page-004.xml +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-004.xml @@ -1,2 +1,4 @@  - +Body trailing 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-005.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-005.xml index 0edd9bcab1194c3d9d1e8e688c7975b2beebd093..eb2ed443a5c71207c5343cac2033fbfd60404b12 100644 --- a/corpus/page-lifecycle/creation/followup/cold/read/page-005.xml +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-005.xml @@ -1,2 +1,4 @@  - +Body child 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-006.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-006.xml index 4b64cee072f1fd557c142d26e335f3d8307cd2f1..55781eb83ad2a8e894ed9dd7a384f2cf333979d5 100644 --- a/corpus/page-lifecycle/creation/followup/cold/read/page-006.xml +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-006.xml @@ -1,4 +1,2 @@  -Body parent 🦀 é preserved]]> + diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-007.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-007.xml index d4fad8b51dc23795d32144dea512a0308d6bb8f1..3925ae6eefe15d93fcc7d1542616454903e4bbc3 100644 --- a/corpus/page-lifecycle/creation/followup/cold/read/page-007.xml +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-007.xml @@ -1,4 +1,2 @@  -Body grandchild 🦀 é preserved]]> + diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-008.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-008.xml index d26253e67ff7df8de34f3f04b9c19fd71b09ee97..0deaac98434c9ba695544964057d6b59f23b409d 100644 --- a/corpus/page-lifecycle/creation/followup/cold/read/page-008.xml +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-008.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-009.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-009.xml index 5457a4e122714c0a67224ed80bc9ec1a7aba3b4f..a21030ff8508327c1cb760bf8bf7d926f2005430 100644 --- a/corpus/page-lifecycle/creation/followup/cold/read/page-009.xml +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-009.xml @@ -1,4 +1,2 @@  -Body duplicate 🦀 é preserved]]> + diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-010.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-010.xml index e756008ceb91813adc6f64b434c7f1a84ac5bf4a..2fadaf2837df7783d6d9e5cd6092d1e8700ccbd1 100644 --- a/corpus/page-lifecycle/creation/followup/cold/read/page-010.xml +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-010.xml @@ -1,4 +1,4 @@  -Body automatic 🦀 éBody parent 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-011.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-011.xml index 42ea81a0966b70ee621212a3552ae3ae90be0989..212962b145c8efc8c21b0056de839ffbbb9f8a13 100644 --- a/corpus/page-lifecycle/creation/followup/cold/read/page-011.xml +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-011.xml @@ -1,2 +1,4 @@  - +Body grandchild 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-012.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-012.xml index 9abc381b9155abd5962d1a4f8c41d4d78862067d..e7557b4fc7eb0e6f0039001de64d1da47c3899fe 100644 --- a/corpus/page-lifecycle/creation/followup/cold/read/page-012.xml +++ b/corpus/page-lifecycle/creation/followup/cold/read/page-012.xml @@ -1,4 +1,2 @@  -Body trailing 🦀 é preserved]]> + diff --git a/corpus/page-lifecycle/creation/native/cold/read/hierarchy.xml b/corpus/page-lifecycle/creation/native/cold/read/hierarchy.xml index 4836e778e6af3fff3797afe9cad4ffcee75bb427..7dbaf0b8561d3f783dc5c695f882f24afac2267a 100644 --- a/corpus/page-lifecycle/creation/native/cold/read/hierarchy.xml +++ b/corpus/page-lifecycle/creation/native/cold/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-000.xml b/corpus/page-lifecycle/creation/native/cold/read/page-000.xml index 30e03f8c5d7c32095c10ab54d530f2834febf2c7..a59bdaeadd50460309f0efe84c48b2eabe8dbfc4 100644 --- a/corpus/page-lifecycle/creation/native/cold/read/page-000.xml +++ b/corpus/page-lifecycle/creation/native/cold/read/page-000.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-001.xml b/corpus/page-lifecycle/creation/native/cold/read/page-001.xml index 686a57f9a0006bf55f61dffc6ef0b48bd1ff2c31..7065539456b28671fcdddd6b92f4e3017f4c94f1 100644 --- a/corpus/page-lifecycle/creation/native/cold/read/page-001.xml +++ b/corpus/page-lifecycle/creation/native/cold/read/page-001.xml @@ -1,4 +1,4 @@  -Body grandchild 🦀 éBody automatic 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-002.xml b/corpus/page-lifecycle/creation/native/cold/read/page-002.xml index bf6f1f3285ced61f86352de7d9720ce3902b66a5..6dabe06117d34b3760d7cf6bed72b48559cd1941 100644 --- a/corpus/page-lifecycle/creation/native/cold/read/page-002.xml +++ b/corpus/page-lifecycle/creation/native/cold/read/page-002.xml @@ -1,4 +1,4 @@  -Body parent 🦀 éBody duplicate 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-003.xml b/corpus/page-lifecycle/creation/native/cold/read/page-003.xml index b4a0399dc12d1b401c358ef95898a6f7e7e9dd02..2faec065df993d24970b491a8816448e362b31e3 100644 --- a/corpus/page-lifecycle/creation/native/cold/read/page-003.xml +++ b/corpus/page-lifecycle/creation/native/cold/read/page-003.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-004.xml b/corpus/page-lifecycle/creation/native/cold/read/page-004.xml index bdde6f6c2dafbe40a1af2fdc132ae2ad526a51b0..2f1ec7b325e0fd25d376390b43916f3b1b62811a 100644 --- a/corpus/page-lifecycle/creation/native/cold/read/page-004.xml +++ b/corpus/page-lifecycle/creation/native/cold/read/page-004.xml @@ -1,4 +1,4 @@  -Body child 🦀 éBody trailing 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-005.xml b/corpus/page-lifecycle/creation/native/cold/read/page-005.xml index 556edf47723978c3db8c7ed78d6d841ad73fdde9..47934eb482a03b9b33aafa9478e89de3de572e97 100644 --- a/corpus/page-lifecycle/creation/native/cold/read/page-005.xml +++ b/corpus/page-lifecycle/creation/native/cold/read/page-005.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-006.xml b/corpus/page-lifecycle/creation/native/cold/read/page-006.xml index a7314ac9046523033e145cb67980fbb75e4b4cd1..a770819567fac575f2e548e83e41afbfb994c641 100644 --- a/corpus/page-lifecycle/creation/native/cold/read/page-006.xml +++ b/corpus/page-lifecycle/creation/native/cold/read/page-006.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-007.xml b/corpus/page-lifecycle/creation/native/cold/read/page-007.xml index 1af4f16e7bd0db14f4ba7f8d1679d336822795b3..e8de592fcdf1952dc895d413de4026a36e01a74c 100644 --- a/corpus/page-lifecycle/creation/native/cold/read/page-007.xml +++ b/corpus/page-lifecycle/creation/native/cold/read/page-007.xml @@ -1,2 +1,4 @@  - +Body child 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-008.xml b/corpus/page-lifecycle/creation/native/cold/read/page-008.xml index 7464cc7dc7fea87e99960b6ad14fb2cceff20be4..6a4e27b31a7424a5203ef85f6c058c2cdc16b649 100644 --- a/corpus/page-lifecycle/creation/native/cold/read/page-008.xml +++ b/corpus/page-lifecycle/creation/native/cold/read/page-008.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-009.xml b/corpus/page-lifecycle/creation/native/cold/read/page-009.xml index c8d4a0a8ed5b1ad9a362afce806bc09ed4008621..8da13b7591c392e2f14f678e8fe26a9dca2beb32 100644 --- a/corpus/page-lifecycle/creation/native/cold/read/page-009.xml +++ b/corpus/page-lifecycle/creation/native/cold/read/page-009.xml @@ -1,2 +1,4 @@  - +Body grandchild 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-010.xml b/corpus/page-lifecycle/creation/native/cold/read/page-010.xml index 2ac049eeed39fea44ae2da23e392b7591f1b06b7..1ad1dd2b9ff61ba067cdce0cdad172272bbcf521 100644 --- a/corpus/page-lifecycle/creation/native/cold/read/page-010.xml +++ b/corpus/page-lifecycle/creation/native/cold/read/page-010.xml @@ -1,4 +1,4 @@  -Body trailing 🦀 éBody parent 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-011.xml b/corpus/page-lifecycle/creation/native/cold/read/page-011.xml index 43386377e0063d58ee24dd56f0f58a8a856daccd..aa7367aba295cf4e5c403856dbb77e8050129d6d 100644 --- a/corpus/page-lifecycle/creation/native/cold/read/page-011.xml +++ b/corpus/page-lifecycle/creation/native/cold/read/page-011.xml @@ -1,4 +1,2 @@  -Body duplicate 🦀 é preserved]]> + diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-012.xml b/corpus/page-lifecycle/creation/native/cold/read/page-012.xml index d8b4366fe460f248ae133fd9ec8fd8e3e7c70568..f23f4521cb5a6e1ccaeeda7e4747ab4d8d4eca7a 100644 --- a/corpus/page-lifecycle/creation/native/cold/read/page-012.xml +++ b/corpus/page-lifecycle/creation/native/cold/read/page-012.xml @@ -1,4 +1,2 @@  -Body automatic 🦀 é preserved]]> + diff --git a/corpus/page-lifecycle/creation/native/notebook/Lifecycle.one b/corpus/page-lifecycle/creation/native/notebook/Lifecycle.one index b60dfff4780a6d8dd3826fc2a515033d2e5f0cae..4da1d1ebbdd8fa95595532683b62cd746fe610c7 100644 Binary files a/corpus/page-lifecycle/creation/native/notebook/Lifecycle.one and b/corpus/page-lifecycle/creation/native/notebook/Lifecycle.one differ diff --git a/corpus/page-lifecycle/creation/native/read/hierarchy.xml b/corpus/page-lifecycle/creation/native/read/hierarchy.xml index 46f7de2e422eaa62f29644d33dd752fb0527c358..4552d8eb47dfa4b537ce7888ef9cb73c5c4b467a 100644 --- a/corpus/page-lifecycle/creation/native/read/hierarchy.xml +++ b/corpus/page-lifecycle/creation/native/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/native/read/page-000.xml b/corpus/page-lifecycle/creation/native/read/page-000.xml index b8cab291fd9a7f09e47daa1b311825a5035b039e..d2370cf0a49df31c9fbe7fbe5dec43934fcecfb9 100644 --- a/corpus/page-lifecycle/creation/native/read/page-000.xml +++ b/corpus/page-lifecycle/creation/native/read/page-000.xml @@ -1,4 +1,4 @@  -Body trailing 🦀 éBody automatic 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/read/page-001.xml b/corpus/page-lifecycle/creation/native/read/page-001.xml index 3015c50816186e839b87dc7dc7d59d73f27fc535..78125d32d765915cb5aa74a60f02b07a9c50659d 100644 --- a/corpus/page-lifecycle/creation/native/read/page-001.xml +++ b/corpus/page-lifecycle/creation/native/read/page-001.xml @@ -1,2 +1,4 @@  - +Body duplicate 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/read/page-002.xml b/corpus/page-lifecycle/creation/native/read/page-002.xml index d7f2c174e35a8ae7f7eb175293702d85748e8bf5..ffa4360d97156d819bf97f6d42dbbf96ff726907 100644 --- a/corpus/page-lifecycle/creation/native/read/page-002.xml +++ b/corpus/page-lifecycle/creation/native/read/page-002.xml @@ -1,4 +1,2 @@  -Body duplicate 🦀 é preserved]]> + diff --git a/corpus/page-lifecycle/creation/native/read/page-003.xml b/corpus/page-lifecycle/creation/native/read/page-003.xml index e9d43bf99f32856357e6bbd970d6bcc248cd6111..ed196f7333f0c1c6f656c310f0f261540685cd62 100644 --- a/corpus/page-lifecycle/creation/native/read/page-003.xml +++ b/corpus/page-lifecycle/creation/native/read/page-003.xml @@ -1,4 +1,2 @@  -Body automatic 🦀 é preserved]]> + diff --git a/corpus/page-lifecycle/creation/native/read/page-004.xml b/corpus/page-lifecycle/creation/native/read/page-004.xml index 8ed9596a931a9c3ce235802b6c4b981ca388cb89..187129655d4ff01f40b971f5687b8503b4073177 100644 --- a/corpus/page-lifecycle/creation/native/read/page-004.xml +++ b/corpus/page-lifecycle/creation/native/read/page-004.xml @@ -1,2 +1,4 @@  - +Body trailing 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/read/page-005.xml b/corpus/page-lifecycle/creation/native/read/page-005.xml index 3b4a678ee36c1430ad8c7de33bb226d2b7ec9b0f..ed2d0dd32565a97d2f68b141b5bf3f91cde13356 100644 --- a/corpus/page-lifecycle/creation/native/read/page-005.xml +++ b/corpus/page-lifecycle/creation/native/read/page-005.xml @@ -1,2 +1,4 @@  - +Body child 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/read/page-006.xml b/corpus/page-lifecycle/creation/native/read/page-006.xml index 4693795ee03c300bd507cb4737d8c9550f6d24bf..8780287b59993770300fb4b424df6f9b173cea4d 100644 --- a/corpus/page-lifecycle/creation/native/read/page-006.xml +++ b/corpus/page-lifecycle/creation/native/read/page-006.xml @@ -1,4 +1,2 @@  -Body grandchild 🦀 é preserved]]> + diff --git a/corpus/page-lifecycle/creation/native/read/page-007.xml b/corpus/page-lifecycle/creation/native/read/page-007.xml index e818ae26b54fd7bd9b60249de373bceaaa087704..c1199952f09c49e30c8391ec57144e7d47acbb89 100644 --- a/corpus/page-lifecycle/creation/native/read/page-007.xml +++ b/corpus/page-lifecycle/creation/native/read/page-007.xml @@ -1,4 +1,2 @@  -Body parent 🦀 é preserved]]> + diff --git a/corpus/page-lifecycle/creation/native/read/page-008.xml b/corpus/page-lifecycle/creation/native/read/page-008.xml index b4ce7a9d9152ced7d6a6790478ac895cf31e2af5..f2b6df04d4201ae224dc2a8b16d16e6d9bb053ed 100644 --- a/corpus/page-lifecycle/creation/native/read/page-008.xml +++ b/corpus/page-lifecycle/creation/native/read/page-008.xml @@ -1,2 +1,4 @@  - +Body parent 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/read/page-009.xml b/corpus/page-lifecycle/creation/native/read/page-009.xml index 5ef8f5a66daa4a3e9e8d60818bfff6dccb6a319f..7b05e62f652b9d353dffdf6f73685c9acc9a58af 100644 --- a/corpus/page-lifecycle/creation/native/read/page-009.xml +++ b/corpus/page-lifecycle/creation/native/read/page-009.xml @@ -1,2 +1,4 @@  - +Body grandchild 🦀 é preserved]]> diff --git a/corpus/page-lifecycle/creation/native/read/page-010.xml b/corpus/page-lifecycle/creation/native/read/page-010.xml index 34434e16da993550ce35f3520fcfa45a2178e281..f8a553b980acb5604158467c8b0c26cc7e1d4177 100644 --- a/corpus/page-lifecycle/creation/native/read/page-010.xml +++ b/corpus/page-lifecycle/creation/native/read/page-010.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/native/read/page-011.xml b/corpus/page-lifecycle/creation/native/read/page-011.xml index b97f3354d80313f0e6a5c44e57a4b0a46c29f6c8..6cd4634ce2fef847c1c977a2a34cbfd8d3b09568 100644 --- a/corpus/page-lifecycle/creation/native/read/page-011.xml +++ b/corpus/page-lifecycle/creation/native/read/page-011.xml @@ -1,4 +1,2 @@  -Body child 🦀 é preserved]]> + diff --git a/corpus/page-lifecycle/creation/native/read/page-012.xml b/corpus/page-lifecycle/creation/native/read/page-012.xml index 5873da638217cf192a44a47a46ceb3eb70532f23..2328d804775a25e02a6ebbc1b01a4b813f19e372 100644 --- a/corpus/page-lifecycle/creation/native/read/page-012.xml +++ b/corpus/page-lifecycle/creation/native/read/page-012.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/creation/provenance.json b/corpus/page-lifecycle/creation/provenance.json index 09523b2d417a016d27dd110a7251ccc4491d729b..1ae1ed5c6450e59ba14211c7d5ee4fa4a680a8a9 100644 --- a/corpus/page-lifecycle/creation/provenance.json +++ b/corpus/page-lifecycle/creation/provenance.json @@ -1,8 +1,8 @@ { "cold": { - "capture": "evidence/m10/page-create-cold", + "capture": "evidence/titles/cold-creation", "run": { - "notebook": "/Users/clo/dev/one/evidence/m10/page-create-candidate", + "notebook": "/Users/clo/dev/one/evidence/titles/creation", "expected_pages": 13, "author": null, "author_timeout_seconds": 600, @@ -20,17 +20,17 @@ } }, "machine": { - "name": "m6-4b25125e", - "hostname": "ONE-M6-4B25125E" + "name": "m6-5472ef31", + "hostname": "ONE-M6-5472EF31" }, "teardown": { "absent": true } }, "native": { - "capture": "evidence/m10/page-create-native-edit", + "capture": "evidence/titles/native", "run": { - "notebook": "/Users/clo/dev/one/evidence/m10/page-create-cold/notebook", + "notebook": "/Users/clo/dev/one/evidence/titles/native-in", "expected_pages": 13, "author": "tools/native/created-pages.ps1", "author_timeout_seconds": 600, @@ -49,17 +49,17 @@ } }, "machine": { - "name": "m6-71dafa89", - "hostname": "ONE-M6-71DAFA89" + "name": "m6-ba7e55f4", + "hostname": "ONE-M6-BA7E55F4" }, "teardown": { "absent": true } }, "native/cold": { - "capture": "evidence/m10/page-create-native-edit-cold", + "capture": "evidence/titles/native-cold", "run": { - "notebook": "/Users/clo/dev/one/evidence/m10/page-create-native-edit/notebook", + "notebook": "/Users/clo/dev/one/evidence/titles/native/notebook", "expected_pages": 13, "author": null, "author_timeout_seconds": 600, @@ -77,17 +77,17 @@ } }, "machine": { - "name": "m6-eabc83c1", - "hostname": "ONE-M6-EABC83C1" + "name": "m6-426eea56", + "hostname": "ONE-M6-426EEA56" }, "teardown": { "absent": true } }, "followup/cold": { - "capture": "evidence/m10/page-create-rust-followup-cold", + "capture": "evidence/titles/followup-cold", "run": { - "notebook": "/Users/clo/dev/one/evidence/m10/page-create-rust-followup", + "notebook": "/Users/clo/dev/one/evidence/titles/followup", "expected_pages": 13, "author": null, "author_timeout_seconds": 600, @@ -105,8 +105,8 @@ } }, "machine": { - "name": "m6-5a91d9d1", - "hostname": "ONE-M6-5A91D9D1" + "name": "m6-44be46f3", + "hostname": "ONE-M6-44BE46F3" }, "teardown": { "absent": true diff --git a/corpus/page-lifecycle/offline-creation/candidate/pages.one b/corpus/page-lifecycle/offline-creation/candidate/pages.one index 128dcb4b988fd6a590f0b2295ad69e66745727cd..2cd97851d34d1b3dbe3349cee86190438bf38fa4 100644 Binary files a/corpus/page-lifecycle/offline-creation/candidate/pages.one and b/corpus/page-lifecycle/offline-creation/candidate/pages.one differ diff --git a/corpus/page-lifecycle/offline-creation/cold/notebook/pages.one b/corpus/page-lifecycle/offline-creation/cold/notebook/pages.one index d2b161ceb75989c1955371ce4b86ad8608fe150e..9fbf9f654ed785b96333bdf0a1c5842edda739b5 100644 Binary files a/corpus/page-lifecycle/offline-creation/cold/notebook/pages.one and b/corpus/page-lifecycle/offline-creation/cold/notebook/pages.one differ diff --git a/corpus/page-lifecycle/offline-creation/cold/read/hierarchy.xml b/corpus/page-lifecycle/offline-creation/cold/read/hierarchy.xml index 5853659977987a61b6cbfa87659e645a49a4ebf6..1c706763620d507a24fba0e60db8622eed51151f 100644 --- a/corpus/page-lifecycle/offline-creation/cold/read/hierarchy.xml +++ b/corpus/page-lifecycle/offline-creation/cold/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-000.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-000.xml index 4ab89e04f2fb00acbdd2a18accaccb210afb3fee..ec6761ffda00b5544bf76ea62c57aa4fbb8c7fd6 100644 --- a/corpus/page-lifecycle/offline-creation/cold/read/page-000.xml +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-000.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-001.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-001.xml index 6b2c687bb6cd5a4847c34c13ab97ccfa8b5942bd..5db0a537d32bf665e74be1f48fca9983b12841c4 100644 --- a/corpus/page-lifecycle/offline-creation/cold/read/page-001.xml +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-001.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-002.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-002.xml index 0cb80c3d598a97b3cf49f24517a50fe2003ca836..db38cc8c49b979b8415288a444748aee86922f34 100644 --- a/corpus/page-lifecycle/offline-creation/cold/read/page-002.xml +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-002.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-003.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-003.xml index 624ae72aef85046aae879bee4ba2366afe55af26..4c48621d9b6a439e9ee44210dca913ec1167dc03 100644 --- a/corpus/page-lifecycle/offline-creation/cold/read/page-003.xml +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-003.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-004.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-004.xml index 1866dec8c32dc4c15942d5dfdab07a6e741ab2e1..9d1c7952917053cbba7dbaa69a5ffc2ef3b52996 100644 --- a/corpus/page-lifecycle/offline-creation/cold/read/page-004.xml +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-004.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-005.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-005.xml index 72e2c7cbe3e6e6756b6c083e2e3731efd353de3c..a6b05139112bb4318ed931239ac9bfcf17f2b211 100644 --- a/corpus/page-lifecycle/offline-creation/cold/read/page-005.xml +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-005.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-006.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-006.xml index 77aec796dcc1d229296a99cae0a838ff0f2ede48..a90d08186d36da8b5b0d04be751d24ce766ad7be 100644 --- a/corpus/page-lifecycle/offline-creation/cold/read/page-006.xml +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-006.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-007.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-007.xml index 93079149c7b678adb6d125cbe07702ef714eb97b..ca8ed1ae862d5999e74e841e57aae17dfdae9e2a 100644 --- a/corpus/page-lifecycle/offline-creation/cold/read/page-007.xml +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-007.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-008.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-008.xml index 692f7fc332401b10fbfbfdc37a2a8218d7a7f2d0..f28d38abadb51a0b12507bc9ad2a509022e6b3e3 100644 --- a/corpus/page-lifecycle/offline-creation/cold/read/page-008.xml +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-008.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-009.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-009.xml index 3795d7efedb3e73366f3ccd9ee86a994658a5e12..8628c0abc366410bb5f77f92c9c7ada871d515aa 100644 --- a/corpus/page-lifecycle/offline-creation/cold/read/page-009.xml +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-009.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-010.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-010.xml index befb1525c176e8b6807fbf0fc38d5af6c92deaaf..dbd5c8a14997c3f26fccb33e650e6d4d4ade78dd 100644 --- a/corpus/page-lifecycle/offline-creation/cold/read/page-010.xml +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-010.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-011.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-011.xml index 84ecfebaf488eeb483fc2710b6b56c2d8d29f33a..9e6d5d33f8045cda9351de793652ecb7bd42bb45 100644 --- a/corpus/page-lifecycle/offline-creation/cold/read/page-011.xml +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-011.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-creation/cold/read/page-012.xml b/corpus/page-lifecycle/offline-creation/cold/read/page-012.xml index 84591ddf147a5a290d835bc4d34deb0a2a49ffe4..e71a6d17dfef9d67fc34753558a8026171cce110 100644 --- a/corpus/page-lifecycle/offline-creation/cold/read/page-012.xml +++ b/corpus/page-lifecycle/offline-creation/cold/read/page-012.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-creation/provenance.json b/corpus/page-lifecycle/offline-creation/provenance.json index 09bde4165d3cb3ebc9fe348505bd3f09734a3993..a847b2f8ed1f7b32276ca7e25b710fc28cc6de8c 100644 --- a/corpus/page-lifecycle/offline-creation/provenance.json +++ b/corpus/page-lifecycle/offline-creation/provenance.json @@ -1,6 +1,6 @@ { "run": { - "notebook": "/Users/clo/dev/one/evidence/m10/offline-page-candidate", + "notebook": "/Users/clo/dev/one/evidence/titles/offline-creation", "expected_pages": 13, "author": null, "author_timeout_seconds": 600, @@ -18,8 +18,8 @@ } }, "machine": { - "name": "m6-cc15a5ee", - "hostname": "ONE-M6-CC15A5EE" + "name": "m6-4d84f1a7", + "hostname": "ONE-M6-4D84F1A7" }, "teardown": { "absent": true diff --git a/corpus/page-lifecycle/offline-edits/provenance.json b/corpus/page-lifecycle/offline-edits/provenance.json index ae245465bbd18b4b6b21b86cbe05f39631e8dd27..6edd1246a572f0a42fa29cb1768acbd898e8c186 100644 --- a/corpus/page-lifecycle/offline-edits/provenance.json +++ b/corpus/page-lifecycle/offline-edits/provenance.json @@ -633,7 +633,7 @@ }, "twelve-clients": { "run": { - "notebook": "/private/tmp/m33/page-clients2/candidate", + "notebook": "/Users/clo/dev/one/evidence/titles/twelve-clients", "expected_pages": 37, "author": null, "author_timeout_seconds": 600, @@ -651,18 +651,24 @@ } }, "machine": { - "name": "m6-16c2e4d4", - "hostname": "ONE-M6-16C2E4D4" + "name": "m6-8eabb42e", + "hostname": "ONE-M6-8EABB42E" }, "teardown": { "absent": true }, "source": [ + { + "path": "manifest.json", + "bytes": 292907, + "sha256": "fea1d64b22da84d023d4c9014175f8e92a13acba099c7a1a3f8eae30d6c6407f", + "mtime_ns": 1790404395466294564 + }, { "path": "pages.one", - "bytes": 155344, - "sha256": "623ade8b5ee6fc837ae63d5293791039ecd17da9cb8bc5e243ea20cadf37958c", - "mtime_ns": 1789124063040528117 + "bytes": 152360, + "sha256": "302b6d2d31bc8f539a2217aed3391973678576b0aea1ce82c6e21760d6402e14", + "mtime_ns": 1790404395457867619 } ] }, diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/candidate/manifest.json b/corpus/page-lifecycle/offline-edits/twelve-clients/candidate/manifest.json index 6848c514d3c0d4f1149f35c3bf02604813bc5116..bb0ea213a31d55bef19d3ad52a9598255b83a13d 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/candidate/manifest.json +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/candidate/manifest.json @@ -1,151 +1,151 @@ { "expected_order": [ [ - "{869D5929-6E5E-4EDB-9639-4C56977DD447},1", + "{0B37E922-551A-48F8-BCAC-4AB3503B4D28},1", 1 ], [ - "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},1", + "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},1", 1 ], [ - "{B0B53F94-0105-4C59-B65B-9473F49BFF41},1", + "{74492C4A-164B-40E9-B297-4D3B4685347D},1", 1 ], [ - "{C837A033-F055-4455-B45F-17D82016921C},1", + "{8B766F11-2A4F-4D95-8473-D3CA68129E11},1", 2 ], [ - "{57E835EA-2799-4118-B55C-7BB1E51C6941},1", + "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},1", 1 ], [ - "{89D86121-BF5C-418B-BB92-CBFFA8EF545F},1", + "{28E0CC5F-68BC-4A86-9ED7-114FFD3BAE9A},1", 1 ], [ - "{F7EECCCF-B6B4-44F2-A543-36837EC18B8A},1", + "{AFCD09F6-1085-4582-BDB2-4B91E15D2088},1", 2 ], [ - "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},1", + "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},1", 1 ], [ - "{361A0F09-EA63-4F2D-9620-C061D4E4707E},1", + "{A94D5882-C902-4CAE-B38E-2ED56EEF177F},1", 1 ], [ - "{C2870D07-8ADC-44BB-9F12-15D6C3708689},1", + "{F212795E-0E4B-4844-A8A6-ABD7F7025124},1", 2 ], [ - "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},1", + "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},1", 1 ], [ - "{1E0C6331-32C9-4EF9-8087-150A8669FA91},1", + "{1786F2CA-0A43-4081-8B55-B207EEE0331C},1", 1 ], [ - "{17C30054-7129-4A10-9D00-4D5545E4F4BA},1", + "{0D361C76-92FD-4B76-901B-809C2C3C3CE6},1", 2 ], [ - "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},1", + "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},1", 1 ], [ - "{4C5FBA5B-E348-4750-ACE4-E9694D527BFF},1", + "{AE89305A-73F4-46B5-B41D-18884685D896},1", 1 ], [ - "{6FEA70FA-1451-4A27-8A8E-C4CC3B713CF6},1", + "{CBF82C86-3A8B-457F-92A4-1AC7BF436CE9},1", 2 ], [ - "{7E9041BD-301B-4B5B-9456-1C92050E4914},1", + "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},1", 1 ], [ - "{68A1E0E1-7A39-4AB6-AD71-4D8A18878D79},1", + "{42010094-E377-4A97-9395-AD45808D0552},1", 1 ], [ - "{EFA77BAE-8A98-4176-B536-B615D1D2ED2B},1", + "{F9DA3139-5856-4B11-95C0-356683E3ACFE},1", 2 ], [ - "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},1", + "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},1", 1 ], [ - "{87AC0239-51A5-47C6-BAF4-926CCF56385C},1", + "{439B80E0-9D4C-4A2F-9163-4E301491364D},1", 1 ], [ - "{FD8D07D0-C447-4516-A7DB-F8522B77D4A7},1", + "{A459C474-E6EB-4BE5-9193-271799B2860B},1", 2 ], [ - "{7F6702B3-C977-436F-82AF-403E2BE950E7},1", + "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},1", 1 ], [ - "{D2E1FE57-F45D-49C3-B388-95222ACA3886},1", + "{4ED14CEE-8560-4125-B488-9945C116741E},1", 1 ], [ - "{688C4EA1-80F5-4D85-B9BB-F8633FF3C0CA},1", + "{932BE37F-05AA-4EFB-85D8-A33445E738B3},1", 2 ], [ - "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},1", + "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},1", 1 ], [ - "{754FA957-C432-4502-A5CF-EE44449462C0},1", + "{9B755368-1D49-4D7E-8D33-6DC536F57848},1", 1 ], [ - "{87B83BA8-A906-4F71-A6CA-3E8133A75D34},1", + "{5CA4C59F-B400-4BC6-8B90-30ECAC35CB43},1", 2 ], [ - "{406687D7-F9E9-491B-85DA-166DE6CD5A60},1", + "{C6E54364-6CD0-4764-8180-873030B9EA53},1", 1 ], [ - "{F2D6AEBB-98EA-4F10-991F-5243E74AF450},1", + "{D63FB383-FB37-44F9-AEBB-E868E903A2BB},1", 1 ], [ - "{1F73BF36-9FC2-495D-844D-B370D5C47DE7},1", + "{DEEC391B-F2D1-4EB1-BEB1-57598DFBB982},1", 2 ], [ - "{189EA744-BE13-467A-95BA-2667A134C0B0},1", + "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},1", 1 ], [ - "{E9BD343F-4A66-4DE2-81F9-3789CCA22818},1", + "{9E7616ED-DE19-4625-8EA0-0C5DEB18B690},1", 1 ], [ - "{017F5881-F818-4C7C-B9F9-6BCB4085F1D0},1", + "{00B10C58-6649-428D-A081-3EBBA6E88E97},1", 2 ], [ - "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},1", + "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},1", 1 ], [ - "{CD0D17F4-25D8-4076-9AF7-47D9CDEFCF0A},1", + "{58E99482-710A-4938-8CFB-88A1C9B946C9},1", 1 ], [ - "{2B736B19-2D9C-4E76-A732-478D533D717A},1", + "{15A45C01-A9E8-42E2-A267-C7FCE728815E},1", 2 ] ], @@ -158,819 +158,885 @@ "edits": [ { "guid": [ - 86, - 0, - 111, - 192, + 155, + 49, + 40, + 55, + 110, + 61, + 248, + 78, + 186, + 245, + 33, + 60, + 24, 138, - 101, - 192, - 76, - 185, - 152, - 225, - 226, - 79, - 192, - 46, - 40 + 51, + 39 ], "level": 1, "position": { - "Before": "{B0B53F94-0105-4C59-B65B-9473F49BFF41},1" + "Before": "{74492C4A-164B-40E9-B297-4D3B4685347D},1" }, - "space": "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},1" + "space": "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},1" }, { "guid": [ - 133, - 52, + 124, + 69, + 164, + 134, + 185, + 207, + 146, 67, - 104, - 247, - 13, + 144, + 255, + 194, + 10, + 29, + 94, + 178, + 200 + ], + "level": 2, + "position": "Keep", + "space": "{8B766F11-2A4F-4D95-8473-D3CA68129E11},1" + } + ], + "observed": [ + [ + "{0B37E922-551A-48F8-BCAC-4AB3503B4D28},1", + 1 + ], + [ + "{74492C4A-164B-40E9-B297-4D3B4685347D},1", + 1 + ], + [ + "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},1", + 1 + ], + [ + "{8B766F11-2A4F-4D95-8473-D3CA68129E11},1", + 1 + ], + [ + "{28E0CC5F-68BC-4A86-9ED7-114FFD3BAE9A},1", + 1 + ], + [ + "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},1", + 1 + ], + [ + "{AFCD09F6-1085-4582-BDB2-4B91E15D2088},1", + 1 + ], + [ + "{A94D5882-C902-4CAE-B38E-2ED56EEF177F},1", + 1 + ], + [ + "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},1", + 1 + ], + [ + "{F212795E-0E4B-4844-A8A6-ABD7F7025124},1", + 1 + ], + [ + "{1786F2CA-0A43-4081-8B55-B207EEE0331C},1", + 1 + ], + [ + "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},1", + 1 + ], + [ + "{0D361C76-92FD-4B76-901B-809C2C3C3CE6},1", + 1 + ], + [ + "{AE89305A-73F4-46B5-B41D-18884685D896},1", + 1 + ], + [ + "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},1", + 1 + ], + [ + "{CBF82C86-3A8B-457F-92A4-1AC7BF436CE9},1", + 1 + ], + [ + "{42010094-E377-4A97-9395-AD45808D0552},1", + 1 + ], + [ + "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},1", + 1 + ], + [ + "{F9DA3139-5856-4B11-95C0-356683E3ACFE},1", + 1 + ], + [ + "{439B80E0-9D4C-4A2F-9163-4E301491364D},1", + 1 + ], + [ + "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},1", + 1 + ], + [ + "{A459C474-E6EB-4BE5-9193-271799B2860B},1", + 1 + ], + [ + "{4ED14CEE-8560-4125-B488-9945C116741E},1", + 1 + ], + [ + "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},1", + 1 + ], + [ + "{932BE37F-05AA-4EFB-85D8-A33445E738B3},1", + 1 + ], + [ + "{9B755368-1D49-4D7E-8D33-6DC536F57848},1", + 1 + ], + [ + "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},1", + 1 + ], + [ + "{5CA4C59F-B400-4BC6-8B90-30ECAC35CB43},1", + 1 + ], + [ + "{D63FB383-FB37-44F9-AEBB-E868E903A2BB},1", + 1 + ], + [ + "{C6E54364-6CD0-4764-8180-873030B9EA53},1", + 1 + ], + [ + "{DEEC391B-F2D1-4EB1-BEB1-57598DFBB982},1", + 1 + ], + [ + "{9E7616ED-DE19-4625-8EA0-0C5DEB18B690},1", + 1 + ], + [ + "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},1", + 1 + ], + [ + "{00B10C58-6649-428D-A081-3EBBA6E88E97},1", + 1 + ], + [ + "{58E99482-710A-4938-8CFB-88A1C9B946C9},1", + 1 + ], + [ + "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},1", + 1 + ], + [ + "{15A45C01-A9E8-42E2-A267-C7FCE728815E},1", + 1 + ] + ] + } + }, + "space": "{A5A2AFBE-1DF1-4190-AAC7-1AD29C9564F2},1" + }, + { + "id": 2, + "operation": { + "Page": { + "after": { + "created": 134348779890000000, + "definitions": { + "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},18": { + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "kind": { + "name": "PageTitle", + "type": "Style" + } + } + }, + "identity": [ + 61, + 83, + 255, + 218, + 139, + 96, + 53, + 71, + 186, + 29, + 35, + 84, + 232, + 203, + 138, + 105 + ], + "margin_origin": [ + 0.0, + 0.0 + ], + "objects": [ + { + "Outline": { + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},2", + "indents": [], + "layout": { + "max_height": null, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": 36.0, + "y": 36.0 + }, + "min_width": null, + "paragraphs": [ + { + "collapsed": false, + "content": { + "Text": { + "date_field": null, + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},1", + "tags": [], + "text": { + "spans": [ + { + "end": 16, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 11.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": 1033, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + } + } + ], + "text": "Actor 0 🦀 é" + } + } + }, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": null, + "font_size": null, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},3", + "level": 1, + "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, + "parent": null, + "style": null, + "tags": [] + } + ], + "title": false, + "unsupported": [] + } + }, + { + "Title": { + "date": null, + "id": "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},13", + "layout": { + "max_height": null, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": 0.0, + "y": 0.0 + }, + "outlines": [ + { + "id": "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], + "layout": { + "max_height": 21.600000381469727, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": null, + "y": null + }, + "min_width": 162.0, + "paragraphs": [ + { + "collapsed": false, + "content": { + "Text": { + "date_field": null, + "id": "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},16", + "tags": [], + "text": { + "spans": [ + { + "end": 10, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": 1033, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + } + } + ], + "text": "Same title" + } + } + }, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": null, + "font_size": null, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "id": "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},15", + "level": 1, + "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, + "parent": null, + "style": "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},18", + "tags": [] + } + ], + "title": true, + "unsupported": [] + } + ] + } + } + ], + "title": "Same title" + }, + "author": "Author", + "before": { + "created": 134348779890000000, + "definitions": { + "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},18": { + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "kind": { + "name": "PageTitle", + "type": "Style" + } + } + }, + "identity": [ + 61, + 83, + 255, + 218, + 139, + 96, + 53, + 71, + 186, + 29, + 35, + 84, + 232, + 203, + 138, + 105 + ], + "margin_origin": [ + 0.0, + 0.0 + ], + "objects": [ + { + "Title": { + "date": null, + "id": "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},13", + "layout": { + "max_height": null, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": 0.0, + "y": 0.0 + }, + "outlines": [ + { + "id": "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], + "layout": { + "max_height": 21.600000381469727, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": null, + "y": null + }, + "min_width": 162.0, + "paragraphs": [ + { + "collapsed": false, + "content": { + "Text": { + "date_field": null, + "id": "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},16", + "tags": [], + "text": { + "spans": [ + { + "end": 10, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": 1033, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + } + } + ], + "text": "Same title" + } + } + }, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": null, + "font_size": null, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "id": "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},15", + "level": 1, + "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, + "parent": null, + "style": "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},18", + "tags": [] + } + ], + "title": true, + "unsupported": [] + } + ] + } + } + ], + "title": "Same title" + } + } + }, + "space": "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},1" + } + ], + [ + { + "id": 1, + "operation": { + "Pages": { + "edits": [ + { + "guid": [ + 17, + 26, + 44, + 166, + 129, + 151, 120, - 79, - 190, - 78, - 126, - 126, - 12, - 116, + 77, + 170, + 191, + 124, + 194, + 153, + 11, 230, - 238 - ], - "level": 2, - "position": "Keep", - "space": "{C837A033-F055-4455-B45F-17D82016921C},1" - } - ], - "observed": [ - [ - "{869D5929-6E5E-4EDB-9639-4C56977DD447},1", - 1 - ], - [ - "{B0B53F94-0105-4C59-B65B-9473F49BFF41},1", - 1 - ], - [ - "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},1", - 1 - ], - [ - "{C837A033-F055-4455-B45F-17D82016921C},1", - 1 - ], - [ - "{89D86121-BF5C-418B-BB92-CBFFA8EF545F},1", - 1 - ], - [ - "{57E835EA-2799-4118-B55C-7BB1E51C6941},1", - 1 - ], - [ - "{F7EECCCF-B6B4-44F2-A543-36837EC18B8A},1", - 1 - ], - [ - "{361A0F09-EA63-4F2D-9620-C061D4E4707E},1", - 1 - ], - [ - "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},1", - 1 - ], - [ - "{C2870D07-8ADC-44BB-9F12-15D6C3708689},1", - 1 - ], - [ - "{1E0C6331-32C9-4EF9-8087-150A8669FA91},1", - 1 - ], - [ - "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},1", - 1 - ], - [ - "{17C30054-7129-4A10-9D00-4D5545E4F4BA},1", - 1 - ], - [ - "{4C5FBA5B-E348-4750-ACE4-E9694D527BFF},1", - 1 - ], - [ - "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},1", - 1 - ], - [ - "{6FEA70FA-1451-4A27-8A8E-C4CC3B713CF6},1", - 1 - ], - [ - "{68A1E0E1-7A39-4AB6-AD71-4D8A18878D79},1", - 1 - ], - [ - "{7E9041BD-301B-4B5B-9456-1C92050E4914},1", - 1 - ], - [ - "{EFA77BAE-8A98-4176-B536-B615D1D2ED2B},1", - 1 - ], - [ - "{87AC0239-51A5-47C6-BAF4-926CCF56385C},1", - 1 - ], - [ - "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},1", - 1 - ], - [ - "{FD8D07D0-C447-4516-A7DB-F8522B77D4A7},1", - 1 - ], - [ - "{D2E1FE57-F45D-49C3-B388-95222ACA3886},1", - 1 - ], - [ - "{7F6702B3-C977-436F-82AF-403E2BE950E7},1", - 1 - ], - [ - "{688C4EA1-80F5-4D85-B9BB-F8633FF3C0CA},1", - 1 - ], - [ - "{754FA957-C432-4502-A5CF-EE44449462C0},1", - 1 - ], - [ - "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},1", - 1 - ], - [ - "{87B83BA8-A906-4F71-A6CA-3E8133A75D34},1", - 1 - ], - [ - "{F2D6AEBB-98EA-4F10-991F-5243E74AF450},1", - 1 - ], - [ - "{406687D7-F9E9-491B-85DA-166DE6CD5A60},1", - 1 - ], - [ - "{1F73BF36-9FC2-495D-844D-B370D5C47DE7},1", - 1 - ], - [ - "{E9BD343F-4A66-4DE2-81F9-3789CCA22818},1", - 1 - ], - [ - "{189EA744-BE13-467A-95BA-2667A134C0B0},1", - 1 - ], - [ - "{017F5881-F818-4C7C-B9F9-6BCB4085F1D0},1", - 1 - ], - [ - "{CD0D17F4-25D8-4076-9AF7-47D9CDEFCF0A},1", - 1 - ], - [ - "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},1", - 1 - ], - [ - "{2B736B19-2D9C-4E76-A732-478D533D717A},1", - 1 - ] - ] - } - }, - "space": "{90FCDB0C-36AE-422F-B4FE-67676826FE36},1" - }, - { - "id": 2, - "operation": { - "Page": { - "after": { - "created": 134335976560000000, - "definitions": { - "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},18": { - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "kind": { - "name": "PageTitle", - "type": "Style" - } - } - }, - "margin_origin": [ - 0.0, - 0.0 - ], - "objects": [ - { - "Outline": { - "id": "{3517C5BC-B40B-45E6-8324-BAB9403F7F3B},0", - "indents": [], - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": 36.0, - "y": 36.0 - }, - "min_width": null, - "paragraphs": [ - { - "collapsed": false, - "content": { - "Text": { - "date_field": null, - "id": "{D76D17C9-C3D7-49A2-A78E-305604ABA8AF},0", - "tags": [], - "text": { - "spans": [ - { - "end": 16, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 11.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": 1033, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - } - } - ], - "text": "Actor 0 🦀 é" - } - } - }, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": null, - "font_size": null, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "id": "{1A80E3C3-180F-42AB-B946-1A365F043826},0", - "level": 1, - "lists": [], - "parent": null, - "style": null, - "tags": [] - } - ], - "title": false, - "unsupported": [] - } - }, - { - "Title": { - "date": null, - "id": "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},13", - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": 0.0, - "y": 0.0 - }, - "outlines": [ - { - "id": "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},14", - "indents": [], - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": null, - "y": null - }, - "min_width": null, - "paragraphs": [ - { - "collapsed": false, - "content": { - "Text": { - "date_field": null, - "id": "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},16", - "tags": [], - "text": { - "spans": [ - { - "end": 10, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": 1033, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - } - } - ], - "text": "Same title" - } - } - }, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": null, - "font_size": null, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "id": "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},15", - "level": 1, - "lists": [], - "parent": null, - "style": "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},18", - "tags": [] - } - ], - "title": false, - "unsupported": [] - } - ] - } - } - ], - "title": "Same title" - }, - "author": "Author", - "before": { - "created": 134335976560000000, - "definitions": { - "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},18": { - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "kind": { - "name": "PageTitle", - "type": "Style" - } - } - }, - "margin_origin": [ - 0.0, - 0.0 - ], - "objects": [ - { - "Title": { - "date": null, - "id": "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},13", - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": 0.0, - "y": 0.0 - }, - "outlines": [ - { - "id": "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},14", - "indents": [], - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": null, - "y": null - }, - "min_width": null, - "paragraphs": [ - { - "collapsed": false, - "content": { - "Text": { - "date_field": null, - "id": "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},16", - "tags": [], - "text": { - "spans": [ - { - "end": 10, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": 1033, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - } - } - ], - "text": "Same title" - } - } - }, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": null, - "font_size": null, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "id": "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},15", - "level": 1, - "lists": [], - "parent": null, - "style": "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},18", - "tags": [] - } - ], - "title": false, - "unsupported": [] - } - ] - } - } - ], - "title": "Same title" - } - } - }, - "space": "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},1" - } - ], - [ - { - "id": 1, - "operation": { - "Pages": { - "edits": [ - { - "guid": [ - 183, - 11, - 130, - 190, - 141, - 83, - 119, - 77, - 133, - 112, - 123, - 250, - 37, - 199, - 26, - 252 + 226 ], "level": 1, "position": { - "Before": "{89D86121-BF5C-418B-BB92-CBFFA8EF545F},1" + "Before": "{28E0CC5F-68BC-4A86-9ED7-114FFD3BAE9A},1" }, - "space": "{57E835EA-2799-4118-B55C-7BB1E51C6941},1" + "space": "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},1" }, { "guid": [ - 252, - 97, - 42, - 186, - 167, - 236, - 97, + 6, + 151, + 55, + 70, + 228, + 106, + 247, + 64, + 149, + 253, + 46, + 250, + 242, + 9, 65, - 143, - 137, - 54, - 20, - 165, - 72, - 170, - 170 + 14 ], "level": 2, "position": "Keep", - "space": "{F7EECCCF-B6B4-44F2-A543-36837EC18B8A},1" + "space": "{AFCD09F6-1085-4582-BDB2-4B91E15D2088},1" } ], "observed": [ [ - "{869D5929-6E5E-4EDB-9639-4C56977DD447},1", + "{0B37E922-551A-48F8-BCAC-4AB3503B4D28},1", 1 ], [ - "{B0B53F94-0105-4C59-B65B-9473F49BFF41},1", + "{74492C4A-164B-40E9-B297-4D3B4685347D},1", 1 ], [ - "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},1", + "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},1", 1 ], [ - "{C837A033-F055-4455-B45F-17D82016921C},1", + "{8B766F11-2A4F-4D95-8473-D3CA68129E11},1", 1 ], [ - "{89D86121-BF5C-418B-BB92-CBFFA8EF545F},1", + "{28E0CC5F-68BC-4A86-9ED7-114FFD3BAE9A},1", 1 ], [ - "{57E835EA-2799-4118-B55C-7BB1E51C6941},1", + "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},1", 1 ], [ - "{F7EECCCF-B6B4-44F2-A543-36837EC18B8A},1", + "{AFCD09F6-1085-4582-BDB2-4B91E15D2088},1", 1 ], [ - "{361A0F09-EA63-4F2D-9620-C061D4E4707E},1", + "{A94D5882-C902-4CAE-B38E-2ED56EEF177F},1", 1 ], [ - "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},1", + "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},1", 1 ], [ - "{C2870D07-8ADC-44BB-9F12-15D6C3708689},1", + "{F212795E-0E4B-4844-A8A6-ABD7F7025124},1", 1 ], [ - "{1E0C6331-32C9-4EF9-8087-150A8669FA91},1", + "{1786F2CA-0A43-4081-8B55-B207EEE0331C},1", 1 ], [ - "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},1", + "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},1", 1 ], [ - "{17C30054-7129-4A10-9D00-4D5545E4F4BA},1", + "{0D361C76-92FD-4B76-901B-809C2C3C3CE6},1", 1 ], [ - "{4C5FBA5B-E348-4750-ACE4-E9694D527BFF},1", + "{AE89305A-73F4-46B5-B41D-18884685D896},1", 1 ], [ - "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},1", + "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},1", 1 ], [ - "{6FEA70FA-1451-4A27-8A8E-C4CC3B713CF6},1", + "{CBF82C86-3A8B-457F-92A4-1AC7BF436CE9},1", 1 ], [ - "{68A1E0E1-7A39-4AB6-AD71-4D8A18878D79},1", + "{42010094-E377-4A97-9395-AD45808D0552},1", 1 ], [ - "{7E9041BD-301B-4B5B-9456-1C92050E4914},1", + "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},1", 1 ], [ - "{EFA77BAE-8A98-4176-B536-B615D1D2ED2B},1", + "{F9DA3139-5856-4B11-95C0-356683E3ACFE},1", 1 ], [ - "{87AC0239-51A5-47C6-BAF4-926CCF56385C},1", + "{439B80E0-9D4C-4A2F-9163-4E301491364D},1", 1 ], [ - "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},1", + "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},1", 1 ], [ - "{FD8D07D0-C447-4516-A7DB-F8522B77D4A7},1", + "{A459C474-E6EB-4BE5-9193-271799B2860B},1", 1 ], [ - "{D2E1FE57-F45D-49C3-B388-95222ACA3886},1", + "{4ED14CEE-8560-4125-B488-9945C116741E},1", 1 ], [ - "{7F6702B3-C977-436F-82AF-403E2BE950E7},1", + "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},1", 1 ], [ - "{688C4EA1-80F5-4D85-B9BB-F8633FF3C0CA},1", + "{932BE37F-05AA-4EFB-85D8-A33445E738B3},1", 1 ], [ - "{754FA957-C432-4502-A5CF-EE44449462C0},1", + "{9B755368-1D49-4D7E-8D33-6DC536F57848},1", 1 ], [ - "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},1", + "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},1", 1 ], [ - "{87B83BA8-A906-4F71-A6CA-3E8133A75D34},1", + "{5CA4C59F-B400-4BC6-8B90-30ECAC35CB43},1", 1 ], [ - "{F2D6AEBB-98EA-4F10-991F-5243E74AF450},1", + "{D63FB383-FB37-44F9-AEBB-E868E903A2BB},1", 1 ], [ - "{406687D7-F9E9-491B-85DA-166DE6CD5A60},1", + "{C6E54364-6CD0-4764-8180-873030B9EA53},1", 1 ], [ - "{1F73BF36-9FC2-495D-844D-B370D5C47DE7},1", + "{DEEC391B-F2D1-4EB1-BEB1-57598DFBB982},1", 1 ], [ - "{E9BD343F-4A66-4DE2-81F9-3789CCA22818},1", + "{9E7616ED-DE19-4625-8EA0-0C5DEB18B690},1", 1 ], [ - "{189EA744-BE13-467A-95BA-2667A134C0B0},1", + "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},1", 1 ], [ - "{017F5881-F818-4C7C-B9F9-6BCB4085F1D0},1", + "{00B10C58-6649-428D-A081-3EBBA6E88E97},1", 1 ], [ - "{CD0D17F4-25D8-4076-9AF7-47D9CDEFCF0A},1", + "{58E99482-710A-4938-8CFB-88A1C9B946C9},1", 1 ], [ - "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},1", + "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},1", 1 ], [ - "{2B736B19-2D9C-4E76-A732-478D533D717A},1", + "{15A45C01-A9E8-42E2-A267-C7FCE728815E},1", 1 ] ] } }, - "space": "{90FCDB0C-36AE-422F-B4FE-67676826FE36},1" + "space": "{A5A2AFBE-1DF1-4190-AAC7-1AD29C9564F2},1" }, { "id": 2, "operation": { "Page": { "after": { - "created": 134335976560000000, + "created": 134348779890000000, "definitions": { - "{57E835EA-2799-4118-B55C-7BB1E51C6941},18": { + "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},18": { "format": { "alignment": null, "bold": null, @@ -987,6 +1053,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1001,6 +1068,24 @@ } } }, + "identity": [ + 249, + 165, + 255, + 49, + 184, + 193, + 45, + 65, + 162, + 214, + 122, + 204, + 60, + 193, + 111, + 208 + ], "margin_origin": [ 0.0, 0.0 @@ -1008,7 +1093,7 @@ "objects": [ { "Outline": { - "id": "{0817794A-7E1B-41EF-A811-A0F65EE1CE99},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},5", "indents": [], "layout": { "max_height": null, @@ -1025,7 +1110,7 @@ "content": { "Text": { "date_field": null, - "id": "{BB519837-B3F7-4C9B-A428-3D9696CA0070},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},4", "tags": [], "text": { "spans": [ @@ -1047,6 +1132,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1077,6 +1163,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1085,9 +1172,13 @@ "superscript": null, "underline": null }, - "id": "{88AA1A5C-2020-4249-BF25-62D5B4957CC6},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},6", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, "style": null, "tags": [] @@ -1100,7 +1191,7 @@ { "Title": { "date": null, - "id": "{57E835EA-2799-4118-B55C-7BB1E51C6941},13", + "id": "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},13", "layout": { "max_height": null, "max_width": null, @@ -1111,24 +1202,29 @@ }, "outlines": [ { - "id": "{57E835EA-2799-4118-B55C-7BB1E51C6941},14", - "indents": [], + "id": "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{57E835EA-2799-4118-B55C-7BB1E51C6941},16", + "id": "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},16", "tags": [], "text": { "spans": [ @@ -1150,6 +1246,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1180,6 +1277,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1188,15 +1286,19 @@ "superscript": null, "underline": null }, - "id": "{57E835EA-2799-4118-B55C-7BB1E51C6941},15", + "id": "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, - "style": "{57E835EA-2799-4118-B55C-7BB1E51C6941},18", + "style": "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -1207,9 +1309,9 @@ }, "author": "Author", "before": { - "created": 134335976560000000, + "created": 134348779890000000, "definitions": { - "{57E835EA-2799-4118-B55C-7BB1E51C6941},18": { + "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},18": { "format": { "alignment": null, "bold": null, @@ -1226,6 +1328,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1240,6 +1343,24 @@ } } }, + "identity": [ + 249, + 165, + 255, + 49, + 184, + 193, + 45, + 65, + 162, + 214, + 122, + 204, + 60, + 193, + 111, + 208 + ], "margin_origin": [ 0.0, 0.0 @@ -1248,7 +1369,7 @@ { "Title": { "date": null, - "id": "{57E835EA-2799-4118-B55C-7BB1E51C6941},13", + "id": "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},13", "layout": { "max_height": null, "max_width": null, @@ -1259,24 +1380,29 @@ }, "outlines": [ { - "id": "{57E835EA-2799-4118-B55C-7BB1E51C6941},14", - "indents": [], + "id": "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{57E835EA-2799-4118-B55C-7BB1E51C6941},16", + "id": "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},16", "tags": [], "text": { "spans": [ @@ -1298,6 +1424,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1328,6 +1455,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1336,15 +1464,19 @@ "superscript": null, "underline": null }, - "id": "{57E835EA-2799-4118-B55C-7BB1E51C6941},15", + "id": "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, - "style": "{57E835EA-2799-4118-B55C-7BB1E51C6941},18", + "style": "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -1355,7 +1487,7 @@ } } }, - "space": "{57E835EA-2799-4118-B55C-7BB1E51C6941},1" + "space": "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},1" } ], [ @@ -1366,215 +1498,215 @@ "edits": [ { "guid": [ - 172, - 90, - 29, - 103, - 245, - 250, - 220, - 74, 178, - 30, - 220, - 119, - 124, - 185, - 174, - 198 + 65, + 17, + 57, + 15, + 50, + 208, + 79, + 129, + 89, + 215, + 81, + 45, + 182, + 161, + 182 ], "level": 1, "position": { - "Before": "{361A0F09-EA63-4F2D-9620-C061D4E4707E},1" + "Before": "{A94D5882-C902-4CAE-B38E-2ED56EEF177F},1" }, - "space": "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},1" + "space": "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},1" }, { "guid": [ - 43, - 65, + 92, + 129, + 87, + 179, + 42, + 26, + 81, + 77, + 175, 11, - 224, - 49, - 201, - 193, - 78, - 183, - 88, - 16, - 59, - 113, - 75, - 60, - 236 + 45, + 117, + 57, + 12, + 114, + 224 ], "level": 2, "position": "Keep", - "space": "{C2870D07-8ADC-44BB-9F12-15D6C3708689},1" + "space": "{F212795E-0E4B-4844-A8A6-ABD7F7025124},1" } ], "observed": [ [ - "{869D5929-6E5E-4EDB-9639-4C56977DD447},1", + "{0B37E922-551A-48F8-BCAC-4AB3503B4D28},1", 1 ], [ - "{B0B53F94-0105-4C59-B65B-9473F49BFF41},1", + "{74492C4A-164B-40E9-B297-4D3B4685347D},1", 1 ], [ - "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},1", + "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},1", 1 ], [ - "{C837A033-F055-4455-B45F-17D82016921C},1", + "{8B766F11-2A4F-4D95-8473-D3CA68129E11},1", 1 ], [ - "{89D86121-BF5C-418B-BB92-CBFFA8EF545F},1", + "{28E0CC5F-68BC-4A86-9ED7-114FFD3BAE9A},1", 1 ], [ - "{57E835EA-2799-4118-B55C-7BB1E51C6941},1", + "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},1", 1 ], [ - "{F7EECCCF-B6B4-44F2-A543-36837EC18B8A},1", + "{AFCD09F6-1085-4582-BDB2-4B91E15D2088},1", 1 ], [ - "{361A0F09-EA63-4F2D-9620-C061D4E4707E},1", + "{A94D5882-C902-4CAE-B38E-2ED56EEF177F},1", 1 ], [ - "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},1", + "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},1", 1 ], [ - "{C2870D07-8ADC-44BB-9F12-15D6C3708689},1", + "{F212795E-0E4B-4844-A8A6-ABD7F7025124},1", 1 ], [ - "{1E0C6331-32C9-4EF9-8087-150A8669FA91},1", + "{1786F2CA-0A43-4081-8B55-B207EEE0331C},1", 1 ], [ - "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},1", + "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},1", 1 ], [ - "{17C30054-7129-4A10-9D00-4D5545E4F4BA},1", + "{0D361C76-92FD-4B76-901B-809C2C3C3CE6},1", 1 ], [ - "{4C5FBA5B-E348-4750-ACE4-E9694D527BFF},1", + "{AE89305A-73F4-46B5-B41D-18884685D896},1", 1 ], [ - "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},1", + "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},1", 1 ], [ - "{6FEA70FA-1451-4A27-8A8E-C4CC3B713CF6},1", + "{CBF82C86-3A8B-457F-92A4-1AC7BF436CE9},1", 1 ], [ - "{68A1E0E1-7A39-4AB6-AD71-4D8A18878D79},1", + "{42010094-E377-4A97-9395-AD45808D0552},1", 1 ], [ - "{7E9041BD-301B-4B5B-9456-1C92050E4914},1", + "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},1", 1 ], [ - "{EFA77BAE-8A98-4176-B536-B615D1D2ED2B},1", + "{F9DA3139-5856-4B11-95C0-356683E3ACFE},1", 1 ], [ - "{87AC0239-51A5-47C6-BAF4-926CCF56385C},1", + "{439B80E0-9D4C-4A2F-9163-4E301491364D},1", 1 ], [ - "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},1", + "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},1", 1 ], [ - "{FD8D07D0-C447-4516-A7DB-F8522B77D4A7},1", + "{A459C474-E6EB-4BE5-9193-271799B2860B},1", 1 ], [ - "{D2E1FE57-F45D-49C3-B388-95222ACA3886},1", + "{4ED14CEE-8560-4125-B488-9945C116741E},1", 1 ], [ - "{7F6702B3-C977-436F-82AF-403E2BE950E7},1", + "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},1", 1 ], [ - "{688C4EA1-80F5-4D85-B9BB-F8633FF3C0CA},1", + "{932BE37F-05AA-4EFB-85D8-A33445E738B3},1", 1 ], [ - "{754FA957-C432-4502-A5CF-EE44449462C0},1", + "{9B755368-1D49-4D7E-8D33-6DC536F57848},1", 1 ], [ - "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},1", + "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},1", 1 ], [ - "{87B83BA8-A906-4F71-A6CA-3E8133A75D34},1", + "{5CA4C59F-B400-4BC6-8B90-30ECAC35CB43},1", 1 ], [ - "{F2D6AEBB-98EA-4F10-991F-5243E74AF450},1", + "{D63FB383-FB37-44F9-AEBB-E868E903A2BB},1", 1 ], [ - "{406687D7-F9E9-491B-85DA-166DE6CD5A60},1", + "{C6E54364-6CD0-4764-8180-873030B9EA53},1", 1 ], [ - "{1F73BF36-9FC2-495D-844D-B370D5C47DE7},1", + "{DEEC391B-F2D1-4EB1-BEB1-57598DFBB982},1", 1 ], [ - "{E9BD343F-4A66-4DE2-81F9-3789CCA22818},1", + "{9E7616ED-DE19-4625-8EA0-0C5DEB18B690},1", 1 ], [ - "{189EA744-BE13-467A-95BA-2667A134C0B0},1", + "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},1", 1 ], [ - "{017F5881-F818-4C7C-B9F9-6BCB4085F1D0},1", + "{00B10C58-6649-428D-A081-3EBBA6E88E97},1", 1 ], [ - "{CD0D17F4-25D8-4076-9AF7-47D9CDEFCF0A},1", + "{58E99482-710A-4938-8CFB-88A1C9B946C9},1", 1 ], [ - "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},1", + "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},1", 1 ], [ - "{2B736B19-2D9C-4E76-A732-478D533D717A},1", + "{15A45C01-A9E8-42E2-A267-C7FCE728815E},1", 1 ] ] } }, - "space": "{90FCDB0C-36AE-422F-B4FE-67676826FE36},1" + "space": "{A5A2AFBE-1DF1-4190-AAC7-1AD29C9564F2},1" }, { "id": 2, "operation": { "Page": { "after": { - "created": 134335976560000000, + "created": 134348779890000000, "definitions": { - "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},18": { + "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},18": { "format": { "alignment": null, "bold": null, @@ -1591,6 +1723,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1605,6 +1738,24 @@ } } }, + "identity": [ + 15, + 122, + 179, + 90, + 2, + 87, + 142, + 76, + 168, + 198, + 121, + 237, + 110, + 209, + 230, + 159 + ], "margin_origin": [ 0.0, 0.0 @@ -1612,7 +1763,7 @@ "objects": [ { "Outline": { - "id": "{5C473AF1-9E02-4E9A-BFC8-9E9CBBB289E1},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},8", "indents": [], "layout": { "max_height": null, @@ -1629,7 +1780,7 @@ "content": { "Text": { "date_field": null, - "id": "{B412B53F-86FF-4462-8750-FB4D73532A63},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},7", "tags": [], "text": { "spans": [ @@ -1651,6 +1802,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1681,6 +1833,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1689,9 +1842,13 @@ "superscript": null, "underline": null }, - "id": "{ADE09A18-DF3F-4995-BF31-D453831561D7},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},9", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, "style": null, "tags": [] @@ -1704,7 +1861,7 @@ { "Title": { "date": null, - "id": "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},13", + "id": "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},13", "layout": { "max_height": null, "max_width": null, @@ -1715,24 +1872,29 @@ }, "outlines": [ { - "id": "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},14", - "indents": [], + "id": "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},16", + "id": "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},16", "tags": [], "text": { "spans": [ @@ -1754,6 +1916,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1784,6 +1947,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1792,15 +1956,19 @@ "superscript": null, "underline": null }, - "id": "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},15", + "id": "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, - "style": "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},18", + "style": "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -1811,9 +1979,9 @@ }, "author": "Author", "before": { - "created": 134335976560000000, + "created": 134348779890000000, "definitions": { - "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},18": { + "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},18": { "format": { "alignment": null, "bold": null, @@ -1830,6 +1998,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1844,6 +2013,24 @@ } } }, + "identity": [ + 15, + 122, + 179, + 90, + 2, + 87, + 142, + 76, + 168, + 198, + 121, + 237, + 110, + 209, + 230, + 159 + ], "margin_origin": [ 0.0, 0.0 @@ -1852,7 +2039,7 @@ { "Title": { "date": null, - "id": "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},13", + "id": "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},13", "layout": { "max_height": null, "max_width": null, @@ -1863,24 +2050,29 @@ }, "outlines": [ { - "id": "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},14", - "indents": [], + "id": "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},16", + "id": "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},16", "tags": [], "text": { "spans": [ @@ -1902,6 +2094,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1932,6 +2125,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -1940,15 +2134,19 @@ "superscript": null, "underline": null }, - "id": "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},15", + "id": "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, - "style": "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},18", + "style": "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -1959,7 +2157,7 @@ } } }, - "space": "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},1" + "space": "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},1" } ], [ @@ -1970,215 +2168,215 @@ "edits": [ { "guid": [ - 220, - 22, + 202, + 70, + 93, + 136, + 36, + 165, 109, - 148, - 199, - 94, - 248, - 76, - 168, - 183, - 186, - 119, - 172, - 76, - 100, - 21 + 65, + 139, + 122, + 213, + 126, + 228, + 97, + 103, + 255 ], "level": 1, "position": { - "Before": "{1E0C6331-32C9-4EF9-8087-150A8669FA91},1" + "Before": "{1786F2CA-0A43-4081-8B55-B207EEE0331C},1" }, - "space": "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},1" + "space": "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},1" }, { "guid": [ - 177, - 33, - 19, - 250, - 24, - 200, - 209, - 74, - 175, - 22, - 210, - 214, - 251, - 44, + 233, + 86, + 59, + 11, + 202, + 9, + 167, + 68, + 172, + 213, + 140, + 32, + 40, + 205, 125, - 123 + 172 ], "level": 2, "position": "Keep", - "space": "{17C30054-7129-4A10-9D00-4D5545E4F4BA},1" + "space": "{0D361C76-92FD-4B76-901B-809C2C3C3CE6},1" } ], "observed": [ [ - "{869D5929-6E5E-4EDB-9639-4C56977DD447},1", + "{0B37E922-551A-48F8-BCAC-4AB3503B4D28},1", 1 ], [ - "{B0B53F94-0105-4C59-B65B-9473F49BFF41},1", + "{74492C4A-164B-40E9-B297-4D3B4685347D},1", 1 ], [ - "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},1", + "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},1", 1 ], [ - "{C837A033-F055-4455-B45F-17D82016921C},1", + "{8B766F11-2A4F-4D95-8473-D3CA68129E11},1", 1 ], [ - "{89D86121-BF5C-418B-BB92-CBFFA8EF545F},1", + "{28E0CC5F-68BC-4A86-9ED7-114FFD3BAE9A},1", 1 ], [ - "{57E835EA-2799-4118-B55C-7BB1E51C6941},1", + "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},1", 1 ], [ - "{F7EECCCF-B6B4-44F2-A543-36837EC18B8A},1", + "{AFCD09F6-1085-4582-BDB2-4B91E15D2088},1", 1 ], [ - "{361A0F09-EA63-4F2D-9620-C061D4E4707E},1", + "{A94D5882-C902-4CAE-B38E-2ED56EEF177F},1", 1 ], [ - "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},1", + "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},1", 1 ], [ - "{C2870D07-8ADC-44BB-9F12-15D6C3708689},1", + "{F212795E-0E4B-4844-A8A6-ABD7F7025124},1", 1 ], [ - "{1E0C6331-32C9-4EF9-8087-150A8669FA91},1", + "{1786F2CA-0A43-4081-8B55-B207EEE0331C},1", 1 ], [ - "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},1", + "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},1", 1 ], [ - "{17C30054-7129-4A10-9D00-4D5545E4F4BA},1", + "{0D361C76-92FD-4B76-901B-809C2C3C3CE6},1", 1 ], [ - "{4C5FBA5B-E348-4750-ACE4-E9694D527BFF},1", + "{AE89305A-73F4-46B5-B41D-18884685D896},1", 1 ], [ - "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},1", + "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},1", 1 ], [ - "{6FEA70FA-1451-4A27-8A8E-C4CC3B713CF6},1", + "{CBF82C86-3A8B-457F-92A4-1AC7BF436CE9},1", 1 ], [ - "{68A1E0E1-7A39-4AB6-AD71-4D8A18878D79},1", + "{42010094-E377-4A97-9395-AD45808D0552},1", 1 ], [ - "{7E9041BD-301B-4B5B-9456-1C92050E4914},1", + "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},1", 1 ], [ - "{EFA77BAE-8A98-4176-B536-B615D1D2ED2B},1", + "{F9DA3139-5856-4B11-95C0-356683E3ACFE},1", 1 ], [ - "{87AC0239-51A5-47C6-BAF4-926CCF56385C},1", + "{439B80E0-9D4C-4A2F-9163-4E301491364D},1", 1 ], [ - "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},1", + "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},1", 1 ], [ - "{FD8D07D0-C447-4516-A7DB-F8522B77D4A7},1", + "{A459C474-E6EB-4BE5-9193-271799B2860B},1", 1 ], [ - "{D2E1FE57-F45D-49C3-B388-95222ACA3886},1", + "{4ED14CEE-8560-4125-B488-9945C116741E},1", 1 ], [ - "{7F6702B3-C977-436F-82AF-403E2BE950E7},1", + "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},1", 1 ], [ - "{688C4EA1-80F5-4D85-B9BB-F8633FF3C0CA},1", + "{932BE37F-05AA-4EFB-85D8-A33445E738B3},1", 1 ], [ - "{754FA957-C432-4502-A5CF-EE44449462C0},1", + "{9B755368-1D49-4D7E-8D33-6DC536F57848},1", 1 ], [ - "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},1", + "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},1", 1 ], [ - "{87B83BA8-A906-4F71-A6CA-3E8133A75D34},1", + "{5CA4C59F-B400-4BC6-8B90-30ECAC35CB43},1", 1 ], [ - "{F2D6AEBB-98EA-4F10-991F-5243E74AF450},1", + "{D63FB383-FB37-44F9-AEBB-E868E903A2BB},1", 1 ], [ - "{406687D7-F9E9-491B-85DA-166DE6CD5A60},1", + "{C6E54364-6CD0-4764-8180-873030B9EA53},1", 1 ], [ - "{1F73BF36-9FC2-495D-844D-B370D5C47DE7},1", + "{DEEC391B-F2D1-4EB1-BEB1-57598DFBB982},1", 1 ], [ - "{E9BD343F-4A66-4DE2-81F9-3789CCA22818},1", + "{9E7616ED-DE19-4625-8EA0-0C5DEB18B690},1", 1 ], [ - "{189EA744-BE13-467A-95BA-2667A134C0B0},1", + "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},1", 1 ], [ - "{017F5881-F818-4C7C-B9F9-6BCB4085F1D0},1", + "{00B10C58-6649-428D-A081-3EBBA6E88E97},1", 1 ], [ - "{CD0D17F4-25D8-4076-9AF7-47D9CDEFCF0A},1", + "{58E99482-710A-4938-8CFB-88A1C9B946C9},1", 1 ], [ - "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},1", + "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},1", 1 ], [ - "{2B736B19-2D9C-4E76-A732-478D533D717A},1", + "{15A45C01-A9E8-42E2-A267-C7FCE728815E},1", 1 ] ] } }, - "space": "{90FCDB0C-36AE-422F-B4FE-67676826FE36},1" + "space": "{A5A2AFBE-1DF1-4190-AAC7-1AD29C9564F2},1" }, { "id": 2, "operation": { "Page": { "after": { - "created": 134335976560000000, + "created": 134348779890000000, "definitions": { - "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},18": { + "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},18": { "format": { "alignment": null, "bold": null, @@ -2195,6 +2393,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -2209,6 +2408,24 @@ } } }, + "identity": [ + 248, + 110, + 150, + 62, + 180, + 183, + 63, + 72, + 178, + 65, + 143, + 33, + 223, + 49, + 138, + 179 + ], "margin_origin": [ 0.0, 0.0 @@ -2216,7 +2433,7 @@ "objects": [ { "Outline": { - "id": "{930570FE-0B42-4598-A6B7-74E90CE526D4},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},11", "indents": [], "layout": { "max_height": null, @@ -2233,7 +2450,7 @@ "content": { "Text": { "date_field": null, - "id": "{917ABDDE-781F-45C9-90B5-A63A0FBCAEE4},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},10", "tags": [], "text": { "spans": [ @@ -2255,6 +2472,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -2285,6 +2503,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -2293,9 +2512,13 @@ "superscript": null, "underline": null }, - "id": "{A4C1F94C-C7D9-4C9A-8F97-D7437FF3F499},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},12", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, "style": null, "tags": [] @@ -2308,7 +2531,7 @@ { "Title": { "date": null, - "id": "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},13", + "id": "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},13", "layout": { "max_height": null, "max_width": null, @@ -2319,24 +2542,29 @@ }, "outlines": [ { - "id": "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},14", - "indents": [], + "id": "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},16", + "id": "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},16", "tags": [], "text": { "spans": [ @@ -2358,6 +2586,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -2388,6 +2617,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -2396,15 +2626,19 @@ "superscript": null, "underline": null }, - "id": "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},15", + "id": "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, - "style": "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},18", + "style": "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -2415,9 +2649,9 @@ }, "author": "Author", "before": { - "created": 134335976560000000, + "created": 134348779890000000, "definitions": { - "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},18": { + "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},18": { "format": { "alignment": null, "bold": null, @@ -2434,6 +2668,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -2448,6 +2683,24 @@ } } }, + "identity": [ + 248, + 110, + 150, + 62, + 180, + 183, + 63, + 72, + 178, + 65, + 143, + 33, + 223, + 49, + 138, + 179 + ], "margin_origin": [ 0.0, 0.0 @@ -2456,7 +2709,7 @@ { "Title": { "date": null, - "id": "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},13", + "id": "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},13", "layout": { "max_height": null, "max_width": null, @@ -2467,24 +2720,29 @@ }, "outlines": [ { - "id": "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},14", - "indents": [], + "id": "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},16", + "id": "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},16", "tags": [], "text": { "spans": [ @@ -2506,6 +2764,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -2536,6 +2795,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -2544,15 +2804,19 @@ "superscript": null, "underline": null }, - "id": "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},15", + "id": "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, - "style": "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},18", + "style": "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -2563,7 +2827,7 @@ } } }, - "space": "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},1" + "space": "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},1" } ], [ @@ -2574,215 +2838,215 @@ "edits": [ { "guid": [ - 240, - 213, - 212, - 204, - 25, - 156, - 182, - 67, - 143, - 206, - 96, - 209, - 2, - 198, - 208, - 94 + 164, + 233, + 248, + 53, + 50, + 163, + 52, + 65, + 145, + 221, + 232, + 231, + 172, + 110, + 51, + 85 ], "level": 1, "position": { - "Before": "{4C5FBA5B-E348-4750-ACE4-E9694D527BFF},1" + "Before": "{AE89305A-73F4-46B5-B41D-18884685D896},1" }, - "space": "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},1" + "space": "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},1" }, { "guid": [ - 82, - 173, - 19, - 143, - 3, - 17, - 177, - 66, - 183, - 218, - 131, + 127, + 137, + 251, + 136, + 118, 72, - 199, - 191, + 18, + 67, + 156, 233, - 214 + 237, + 236, + 165, + 97, + 49, + 92 ], "level": 2, "position": "Keep", - "space": "{6FEA70FA-1451-4A27-8A8E-C4CC3B713CF6},1" + "space": "{CBF82C86-3A8B-457F-92A4-1AC7BF436CE9},1" } ], "observed": [ [ - "{869D5929-6E5E-4EDB-9639-4C56977DD447},1", + "{0B37E922-551A-48F8-BCAC-4AB3503B4D28},1", 1 ], [ - "{B0B53F94-0105-4C59-B65B-9473F49BFF41},1", + "{74492C4A-164B-40E9-B297-4D3B4685347D},1", 1 ], [ - "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},1", + "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},1", 1 ], [ - "{C837A033-F055-4455-B45F-17D82016921C},1", + "{8B766F11-2A4F-4D95-8473-D3CA68129E11},1", 1 ], [ - "{89D86121-BF5C-418B-BB92-CBFFA8EF545F},1", + "{28E0CC5F-68BC-4A86-9ED7-114FFD3BAE9A},1", 1 ], [ - "{57E835EA-2799-4118-B55C-7BB1E51C6941},1", + "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},1", 1 ], [ - "{F7EECCCF-B6B4-44F2-A543-36837EC18B8A},1", + "{AFCD09F6-1085-4582-BDB2-4B91E15D2088},1", 1 ], [ - "{361A0F09-EA63-4F2D-9620-C061D4E4707E},1", + "{A94D5882-C902-4CAE-B38E-2ED56EEF177F},1", 1 ], [ - "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},1", + "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},1", 1 ], [ - "{C2870D07-8ADC-44BB-9F12-15D6C3708689},1", + "{F212795E-0E4B-4844-A8A6-ABD7F7025124},1", 1 ], [ - "{1E0C6331-32C9-4EF9-8087-150A8669FA91},1", + "{1786F2CA-0A43-4081-8B55-B207EEE0331C},1", 1 ], [ - "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},1", + "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},1", 1 ], [ - "{17C30054-7129-4A10-9D00-4D5545E4F4BA},1", + "{0D361C76-92FD-4B76-901B-809C2C3C3CE6},1", 1 ], [ - "{4C5FBA5B-E348-4750-ACE4-E9694D527BFF},1", + "{AE89305A-73F4-46B5-B41D-18884685D896},1", 1 ], [ - "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},1", + "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},1", 1 ], [ - "{6FEA70FA-1451-4A27-8A8E-C4CC3B713CF6},1", + "{CBF82C86-3A8B-457F-92A4-1AC7BF436CE9},1", 1 ], [ - "{68A1E0E1-7A39-4AB6-AD71-4D8A18878D79},1", + "{42010094-E377-4A97-9395-AD45808D0552},1", 1 ], [ - "{7E9041BD-301B-4B5B-9456-1C92050E4914},1", + "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},1", 1 ], [ - "{EFA77BAE-8A98-4176-B536-B615D1D2ED2B},1", + "{F9DA3139-5856-4B11-95C0-356683E3ACFE},1", 1 ], [ - "{87AC0239-51A5-47C6-BAF4-926CCF56385C},1", + "{439B80E0-9D4C-4A2F-9163-4E301491364D},1", 1 ], [ - "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},1", + "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},1", 1 ], [ - "{FD8D07D0-C447-4516-A7DB-F8522B77D4A7},1", + "{A459C474-E6EB-4BE5-9193-271799B2860B},1", 1 ], [ - "{D2E1FE57-F45D-49C3-B388-95222ACA3886},1", + "{4ED14CEE-8560-4125-B488-9945C116741E},1", 1 ], [ - "{7F6702B3-C977-436F-82AF-403E2BE950E7},1", + "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},1", 1 ], [ - "{688C4EA1-80F5-4D85-B9BB-F8633FF3C0CA},1", + "{932BE37F-05AA-4EFB-85D8-A33445E738B3},1", 1 ], [ - "{754FA957-C432-4502-A5CF-EE44449462C0},1", + "{9B755368-1D49-4D7E-8D33-6DC536F57848},1", 1 ], [ - "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},1", + "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},1", 1 ], [ - "{87B83BA8-A906-4F71-A6CA-3E8133A75D34},1", + "{5CA4C59F-B400-4BC6-8B90-30ECAC35CB43},1", 1 ], [ - "{F2D6AEBB-98EA-4F10-991F-5243E74AF450},1", + "{D63FB383-FB37-44F9-AEBB-E868E903A2BB},1", 1 ], [ - "{406687D7-F9E9-491B-85DA-166DE6CD5A60},1", + "{C6E54364-6CD0-4764-8180-873030B9EA53},1", 1 ], [ - "{1F73BF36-9FC2-495D-844D-B370D5C47DE7},1", + "{DEEC391B-F2D1-4EB1-BEB1-57598DFBB982},1", 1 ], [ - "{E9BD343F-4A66-4DE2-81F9-3789CCA22818},1", + "{9E7616ED-DE19-4625-8EA0-0C5DEB18B690},1", 1 ], [ - "{189EA744-BE13-467A-95BA-2667A134C0B0},1", + "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},1", 1 ], [ - "{017F5881-F818-4C7C-B9F9-6BCB4085F1D0},1", + "{00B10C58-6649-428D-A081-3EBBA6E88E97},1", 1 ], [ - "{CD0D17F4-25D8-4076-9AF7-47D9CDEFCF0A},1", + "{58E99482-710A-4938-8CFB-88A1C9B946C9},1", 1 ], [ - "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},1", + "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},1", 1 ], [ - "{2B736B19-2D9C-4E76-A732-478D533D717A},1", + "{15A45C01-A9E8-42E2-A267-C7FCE728815E},1", 1 ] ] } }, - "space": "{90FCDB0C-36AE-422F-B4FE-67676826FE36},1" + "space": "{A5A2AFBE-1DF1-4190-AAC7-1AD29C9564F2},1" }, { "id": 2, "operation": { "Page": { "after": { - "created": 134335976560000000, + "created": 134348779890000000, "definitions": { - "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},18": { + "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},18": { "format": { "alignment": null, "bold": null, @@ -2799,6 +3063,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -2813,6 +3078,24 @@ } } }, + "identity": [ + 55, + 38, + 29, + 85, + 167, + 42, + 35, + 73, + 178, + 221, + 49, + 194, + 225, + 72, + 159, + 210 + ], "margin_origin": [ 0.0, 0.0 @@ -2820,7 +3103,7 @@ "objects": [ { "Outline": { - "id": "{6A6E0E73-D2A2-4C82-B0B4-E8A3695B4D5A},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},14", "indents": [], "layout": { "max_height": null, @@ -2837,7 +3120,7 @@ "content": { "Text": { "date_field": null, - "id": "{A11F6C01-70A2-44C7-90E9-91C455D075E7},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},13", "tags": [], "text": { "spans": [ @@ -2859,6 +3142,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -2889,6 +3173,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -2897,9 +3182,13 @@ "superscript": null, "underline": null }, - "id": "{1B1F5EFD-3EE8-4031-BEB2-C3D27A332144},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, "style": null, "tags": [] @@ -2912,7 +3201,7 @@ { "Title": { "date": null, - "id": "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},13", + "id": "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},13", "layout": { "max_height": null, "max_width": null, @@ -2923,24 +3212,29 @@ }, "outlines": [ { - "id": "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},14", - "indents": [], + "id": "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},16", + "id": "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},16", "tags": [], "text": { "spans": [ @@ -2962,6 +3256,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -2992,6 +3287,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -3000,15 +3296,19 @@ "superscript": null, "underline": null }, - "id": "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},15", + "id": "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, - "style": "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},18", + "style": "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -3019,9 +3319,9 @@ }, "author": "Author", "before": { - "created": 134335976560000000, + "created": 134348779890000000, "definitions": { - "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},18": { + "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},18": { "format": { "alignment": null, "bold": null, @@ -3038,6 +3338,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -3052,6 +3353,24 @@ } } }, + "identity": [ + 55, + 38, + 29, + 85, + 167, + 42, + 35, + 73, + 178, + 221, + 49, + 194, + 225, + 72, + 159, + 210 + ], "margin_origin": [ 0.0, 0.0 @@ -3060,7 +3379,7 @@ { "Title": { "date": null, - "id": "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},13", + "id": "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},13", "layout": { "max_height": null, "max_width": null, @@ -3071,24 +3390,29 @@ }, "outlines": [ { - "id": "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},14", - "indents": [], + "id": "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},16", + "id": "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},16", "tags": [], "text": { "spans": [ @@ -3110,6 +3434,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -3140,6 +3465,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -3148,15 +3474,19 @@ "superscript": null, "underline": null }, - "id": "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},15", + "id": "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, - "style": "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},18", + "style": "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -3167,7 +3497,7 @@ } } }, - "space": "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},1" + "space": "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},1" } ], [ @@ -3178,215 +3508,215 @@ "edits": [ { "guid": [ - 170, - 199, - 91, - 6, - 149, + 2, 108, - 28, - 78, - 169, + 104, + 0, + 158, + 109, + 176, + 79, + 171, + 232, + 110, + 168, + 146, + 200, 241, - 201, - 191, - 131, - 111, - 13, - 167 + 35 ], "level": 1, "position": { - "Before": "{68A1E0E1-7A39-4AB6-AD71-4D8A18878D79},1" + "Before": "{42010094-E377-4A97-9395-AD45808D0552},1" }, - "space": "{7E9041BD-301B-4B5B-9456-1C92050E4914},1" + "space": "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},1" }, { "guid": [ - 18, - 19, - 211, - 10, - 209, - 7, + 98, 195, - 66, - 172, - 223, - 64, - 131, - 73, - 37, - 59, - 187 + 206, + 208, + 54, + 190, + 72, + 65, + 153, + 87, + 208, + 13, + 10, + 87, + 170, + 34 ], "level": 2, "position": "Keep", - "space": "{EFA77BAE-8A98-4176-B536-B615D1D2ED2B},1" + "space": "{F9DA3139-5856-4B11-95C0-356683E3ACFE},1" } ], "observed": [ [ - "{869D5929-6E5E-4EDB-9639-4C56977DD447},1", + "{0B37E922-551A-48F8-BCAC-4AB3503B4D28},1", 1 ], [ - "{B0B53F94-0105-4C59-B65B-9473F49BFF41},1", + "{74492C4A-164B-40E9-B297-4D3B4685347D},1", 1 ], [ - "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},1", + "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},1", 1 ], [ - "{C837A033-F055-4455-B45F-17D82016921C},1", + "{8B766F11-2A4F-4D95-8473-D3CA68129E11},1", 1 ], [ - "{89D86121-BF5C-418B-BB92-CBFFA8EF545F},1", + "{28E0CC5F-68BC-4A86-9ED7-114FFD3BAE9A},1", 1 ], [ - "{57E835EA-2799-4118-B55C-7BB1E51C6941},1", + "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},1", 1 ], [ - "{F7EECCCF-B6B4-44F2-A543-36837EC18B8A},1", + "{AFCD09F6-1085-4582-BDB2-4B91E15D2088},1", 1 ], [ - "{361A0F09-EA63-4F2D-9620-C061D4E4707E},1", + "{A94D5882-C902-4CAE-B38E-2ED56EEF177F},1", 1 ], [ - "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},1", + "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},1", 1 ], [ - "{C2870D07-8ADC-44BB-9F12-15D6C3708689},1", + "{F212795E-0E4B-4844-A8A6-ABD7F7025124},1", 1 ], [ - "{1E0C6331-32C9-4EF9-8087-150A8669FA91},1", + "{1786F2CA-0A43-4081-8B55-B207EEE0331C},1", 1 ], [ - "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},1", + "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},1", 1 ], [ - "{17C30054-7129-4A10-9D00-4D5545E4F4BA},1", + "{0D361C76-92FD-4B76-901B-809C2C3C3CE6},1", 1 ], [ - "{4C5FBA5B-E348-4750-ACE4-E9694D527BFF},1", + "{AE89305A-73F4-46B5-B41D-18884685D896},1", 1 ], [ - "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},1", + "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},1", 1 ], [ - "{6FEA70FA-1451-4A27-8A8E-C4CC3B713CF6},1", + "{CBF82C86-3A8B-457F-92A4-1AC7BF436CE9},1", 1 ], [ - "{68A1E0E1-7A39-4AB6-AD71-4D8A18878D79},1", + "{42010094-E377-4A97-9395-AD45808D0552},1", 1 ], [ - "{7E9041BD-301B-4B5B-9456-1C92050E4914},1", + "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},1", 1 ], [ - "{EFA77BAE-8A98-4176-B536-B615D1D2ED2B},1", + "{F9DA3139-5856-4B11-95C0-356683E3ACFE},1", 1 ], [ - "{87AC0239-51A5-47C6-BAF4-926CCF56385C},1", + "{439B80E0-9D4C-4A2F-9163-4E301491364D},1", 1 ], [ - "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},1", + "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},1", 1 ], [ - "{FD8D07D0-C447-4516-A7DB-F8522B77D4A7},1", + "{A459C474-E6EB-4BE5-9193-271799B2860B},1", 1 ], [ - "{D2E1FE57-F45D-49C3-B388-95222ACA3886},1", + "{4ED14CEE-8560-4125-B488-9945C116741E},1", 1 ], [ - "{7F6702B3-C977-436F-82AF-403E2BE950E7},1", + "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},1", 1 ], [ - "{688C4EA1-80F5-4D85-B9BB-F8633FF3C0CA},1", + "{932BE37F-05AA-4EFB-85D8-A33445E738B3},1", 1 ], [ - "{754FA957-C432-4502-A5CF-EE44449462C0},1", + "{9B755368-1D49-4D7E-8D33-6DC536F57848},1", 1 ], [ - "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},1", + "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},1", 1 ], [ - "{87B83BA8-A906-4F71-A6CA-3E8133A75D34},1", + "{5CA4C59F-B400-4BC6-8B90-30ECAC35CB43},1", 1 ], [ - "{F2D6AEBB-98EA-4F10-991F-5243E74AF450},1", + "{D63FB383-FB37-44F9-AEBB-E868E903A2BB},1", 1 ], [ - "{406687D7-F9E9-491B-85DA-166DE6CD5A60},1", + "{C6E54364-6CD0-4764-8180-873030B9EA53},1", 1 ], [ - "{1F73BF36-9FC2-495D-844D-B370D5C47DE7},1", + "{DEEC391B-F2D1-4EB1-BEB1-57598DFBB982},1", 1 ], [ - "{E9BD343F-4A66-4DE2-81F9-3789CCA22818},1", + "{9E7616ED-DE19-4625-8EA0-0C5DEB18B690},1", 1 ], [ - "{189EA744-BE13-467A-95BA-2667A134C0B0},1", + "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},1", 1 ], [ - "{017F5881-F818-4C7C-B9F9-6BCB4085F1D0},1", + "{00B10C58-6649-428D-A081-3EBBA6E88E97},1", 1 ], [ - "{CD0D17F4-25D8-4076-9AF7-47D9CDEFCF0A},1", + "{58E99482-710A-4938-8CFB-88A1C9B946C9},1", 1 ], [ - "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},1", + "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},1", 1 ], [ - "{2B736B19-2D9C-4E76-A732-478D533D717A},1", + "{15A45C01-A9E8-42E2-A267-C7FCE728815E},1", 1 ] ] } }, - "space": "{90FCDB0C-36AE-422F-B4FE-67676826FE36},1" + "space": "{A5A2AFBE-1DF1-4190-AAC7-1AD29C9564F2},1" }, { "id": 2, "operation": { "Page": { "after": { - "created": 134335976560000000, + "created": 134348779890000000, "definitions": { - "{7E9041BD-301B-4B5B-9456-1C92050E4914},18": { + "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},18": { "format": { "alignment": null, "bold": null, @@ -3403,6 +3733,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -3417,6 +3748,24 @@ } } }, + "identity": [ + 94, + 187, + 106, + 15, + 33, + 160, + 29, + 78, + 177, + 135, + 122, + 114, + 231, + 64, + 0, + 220 + ], "margin_origin": [ 0.0, 0.0 @@ -3424,7 +3773,7 @@ "objects": [ { "Outline": { - "id": "{9D60419E-FC09-4547-8A45-5083DF888B11},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},17", "indents": [], "layout": { "max_height": null, @@ -3441,7 +3790,7 @@ "content": { "Text": { "date_field": null, - "id": "{A30B6C43-D76B-4629-B1C4-AC8F2FF6CBEE},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},16", "tags": [], "text": { "spans": [ @@ -3463,6 +3812,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -3493,6 +3843,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -3501,9 +3852,13 @@ "superscript": null, "underline": null }, - "id": "{AD6C0452-3D66-42DF-8865-A679E80B3F05},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},18", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, "style": null, "tags": [] @@ -3516,7 +3871,7 @@ { "Title": { "date": null, - "id": "{7E9041BD-301B-4B5B-9456-1C92050E4914},13", + "id": "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},13", "layout": { "max_height": null, "max_width": null, @@ -3527,24 +3882,29 @@ }, "outlines": [ { - "id": "{7E9041BD-301B-4B5B-9456-1C92050E4914},14", - "indents": [], + "id": "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{7E9041BD-301B-4B5B-9456-1C92050E4914},16", + "id": "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},16", "tags": [], "text": { "spans": [ @@ -3566,6 +3926,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -3596,6 +3957,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -3604,15 +3966,19 @@ "superscript": null, "underline": null }, - "id": "{7E9041BD-301B-4B5B-9456-1C92050E4914},15", + "id": "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, - "style": "{7E9041BD-301B-4B5B-9456-1C92050E4914},18", + "style": "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -3623,9 +3989,9 @@ }, "author": "Author", "before": { - "created": 134335976560000000, + "created": 134348779890000000, "definitions": { - "{7E9041BD-301B-4B5B-9456-1C92050E4914},18": { + "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},18": { "format": { "alignment": null, "bold": null, @@ -3642,6 +4008,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -3656,6 +4023,24 @@ } } }, + "identity": [ + 94, + 187, + 106, + 15, + 33, + 160, + 29, + 78, + 177, + 135, + 122, + 114, + 231, + 64, + 0, + 220 + ], "margin_origin": [ 0.0, 0.0 @@ -3664,7 +4049,7 @@ { "Title": { "date": null, - "id": "{7E9041BD-301B-4B5B-9456-1C92050E4914},13", + "id": "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},13", "layout": { "max_height": null, "max_width": null, @@ -3675,24 +4060,29 @@ }, "outlines": [ { - "id": "{7E9041BD-301B-4B5B-9456-1C92050E4914},14", - "indents": [], + "id": "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{7E9041BD-301B-4B5B-9456-1C92050E4914},16", + "id": "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},16", "tags": [], "text": { "spans": [ @@ -3714,6 +4104,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -3744,6 +4135,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -3752,15 +4144,19 @@ "superscript": null, "underline": null }, - "id": "{7E9041BD-301B-4B5B-9456-1C92050E4914},15", + "id": "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, - "style": "{7E9041BD-301B-4B5B-9456-1C92050E4914},18", + "style": "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -3771,7 +4167,7 @@ } } }, - "space": "{7E9041BD-301B-4B5B-9456-1C92050E4914},1" + "space": "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},1" } ], [ @@ -3780,217 +4176,217 @@ "operation": { "Pages": { "edits": [ + { + "guid": [ + 83, + 35, + 225, + 13, + 184, + 191, + 39, + 73, + 187, + 161, + 191, + 106, + 185, + 164, + 169, + 110 + ], + "level": 1, + "position": { + "Before": "{439B80E0-9D4C-4A2F-9163-4E301491364D},1" + }, + "space": "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},1" + }, { "guid": [ 235, - 237, - 5, - 138, - 5, - 243, - 246, - 64, - 171, + 25, + 178, + 87, + 182, + 233, + 248, 74, - 60, - 18, - 85, - 228, - 190, - 225 - ], - "level": 1, - "position": { - "Before": "{87AC0239-51A5-47C6-BAF4-926CCF56385C},1" - }, - "space": "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},1" - }, - { - "guid": [ - 154, - 175, - 180, - 52, - 102, - 86, - 241, - 66, - 129, - 168, - 227, - 222, - 69, - 213, - 66, - 187 + 163, + 238, + 127, + 45, + 119, + 155, + 249, + 77 ], "level": 2, "position": "Keep", - "space": "{FD8D07D0-C447-4516-A7DB-F8522B77D4A7},1" + "space": "{A459C474-E6EB-4BE5-9193-271799B2860B},1" } ], "observed": [ [ - "{869D5929-6E5E-4EDB-9639-4C56977DD447},1", + "{0B37E922-551A-48F8-BCAC-4AB3503B4D28},1", 1 ], [ - "{B0B53F94-0105-4C59-B65B-9473F49BFF41},1", + "{74492C4A-164B-40E9-B297-4D3B4685347D},1", 1 ], [ - "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},1", + "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},1", 1 ], [ - "{C837A033-F055-4455-B45F-17D82016921C},1", + "{8B766F11-2A4F-4D95-8473-D3CA68129E11},1", 1 ], [ - "{89D86121-BF5C-418B-BB92-CBFFA8EF545F},1", + "{28E0CC5F-68BC-4A86-9ED7-114FFD3BAE9A},1", 1 ], [ - "{57E835EA-2799-4118-B55C-7BB1E51C6941},1", + "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},1", 1 ], [ - "{F7EECCCF-B6B4-44F2-A543-36837EC18B8A},1", + "{AFCD09F6-1085-4582-BDB2-4B91E15D2088},1", 1 ], [ - "{361A0F09-EA63-4F2D-9620-C061D4E4707E},1", + "{A94D5882-C902-4CAE-B38E-2ED56EEF177F},1", 1 ], [ - "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},1", + "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},1", 1 ], [ - "{C2870D07-8ADC-44BB-9F12-15D6C3708689},1", + "{F212795E-0E4B-4844-A8A6-ABD7F7025124},1", 1 ], [ - "{1E0C6331-32C9-4EF9-8087-150A8669FA91},1", + "{1786F2CA-0A43-4081-8B55-B207EEE0331C},1", 1 ], [ - "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},1", + "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},1", 1 ], [ - "{17C30054-7129-4A10-9D00-4D5545E4F4BA},1", + "{0D361C76-92FD-4B76-901B-809C2C3C3CE6},1", 1 ], [ - "{4C5FBA5B-E348-4750-ACE4-E9694D527BFF},1", + "{AE89305A-73F4-46B5-B41D-18884685D896},1", 1 ], [ - "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},1", + "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},1", 1 ], [ - "{6FEA70FA-1451-4A27-8A8E-C4CC3B713CF6},1", + "{CBF82C86-3A8B-457F-92A4-1AC7BF436CE9},1", 1 ], [ - "{68A1E0E1-7A39-4AB6-AD71-4D8A18878D79},1", + "{42010094-E377-4A97-9395-AD45808D0552},1", 1 ], [ - "{7E9041BD-301B-4B5B-9456-1C92050E4914},1", + "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},1", 1 ], [ - "{EFA77BAE-8A98-4176-B536-B615D1D2ED2B},1", + "{F9DA3139-5856-4B11-95C0-356683E3ACFE},1", 1 ], [ - "{87AC0239-51A5-47C6-BAF4-926CCF56385C},1", + "{439B80E0-9D4C-4A2F-9163-4E301491364D},1", 1 ], [ - "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},1", + "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},1", 1 ], [ - "{FD8D07D0-C447-4516-A7DB-F8522B77D4A7},1", + "{A459C474-E6EB-4BE5-9193-271799B2860B},1", 1 ], [ - "{D2E1FE57-F45D-49C3-B388-95222ACA3886},1", + "{4ED14CEE-8560-4125-B488-9945C116741E},1", 1 ], [ - "{7F6702B3-C977-436F-82AF-403E2BE950E7},1", + "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},1", 1 ], [ - "{688C4EA1-80F5-4D85-B9BB-F8633FF3C0CA},1", + "{932BE37F-05AA-4EFB-85D8-A33445E738B3},1", 1 ], [ - "{754FA957-C432-4502-A5CF-EE44449462C0},1", + "{9B755368-1D49-4D7E-8D33-6DC536F57848},1", 1 ], [ - "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},1", + "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},1", 1 ], [ - "{87B83BA8-A906-4F71-A6CA-3E8133A75D34},1", + "{5CA4C59F-B400-4BC6-8B90-30ECAC35CB43},1", 1 ], [ - "{F2D6AEBB-98EA-4F10-991F-5243E74AF450},1", + "{D63FB383-FB37-44F9-AEBB-E868E903A2BB},1", 1 ], [ - "{406687D7-F9E9-491B-85DA-166DE6CD5A60},1", + "{C6E54364-6CD0-4764-8180-873030B9EA53},1", 1 ], [ - "{1F73BF36-9FC2-495D-844D-B370D5C47DE7},1", + "{DEEC391B-F2D1-4EB1-BEB1-57598DFBB982},1", 1 ], [ - "{E9BD343F-4A66-4DE2-81F9-3789CCA22818},1", + "{9E7616ED-DE19-4625-8EA0-0C5DEB18B690},1", 1 ], [ - "{189EA744-BE13-467A-95BA-2667A134C0B0},1", + "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},1", 1 ], [ - "{017F5881-F818-4C7C-B9F9-6BCB4085F1D0},1", + "{00B10C58-6649-428D-A081-3EBBA6E88E97},1", 1 ], [ - "{CD0D17F4-25D8-4076-9AF7-47D9CDEFCF0A},1", + "{58E99482-710A-4938-8CFB-88A1C9B946C9},1", 1 ], [ - "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},1", + "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},1", 1 ], [ - "{2B736B19-2D9C-4E76-A732-478D533D717A},1", + "{15A45C01-A9E8-42E2-A267-C7FCE728815E},1", 1 ] ] } }, - "space": "{90FCDB0C-36AE-422F-B4FE-67676826FE36},1" + "space": "{A5A2AFBE-1DF1-4190-AAC7-1AD29C9564F2},1" }, { "id": 2, "operation": { "Page": { "after": { - "created": 134335976560000000, + "created": 134348779890000000, "definitions": { - "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},18": { + "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},18": { "format": { "alignment": null, "bold": null, @@ -4007,6 +4403,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -4021,6 +4418,24 @@ } } }, + "identity": [ + 60, + 151, + 59, + 176, + 188, + 80, + 154, + 75, + 153, + 20, + 177, + 213, + 192, + 207, + 97, + 151 + ], "margin_origin": [ 0.0, 0.0 @@ -4028,7 +4443,7 @@ "objects": [ { "Outline": { - "id": "{69602664-892F-4537-9BFE-860DAD71A595},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},20", "indents": [], "layout": { "max_height": null, @@ -4045,7 +4460,7 @@ "content": { "Text": { "date_field": null, - "id": "{C533D56B-11C4-4867-B1E2-3E7520E7939A},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},19", "tags": [], "text": { "spans": [ @@ -4067,6 +4482,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -4097,6 +4513,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -4105,9 +4522,13 @@ "superscript": null, "underline": null }, - "id": "{FB8451BB-46B9-44F1-972F-A543F55F06B5},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},21", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, "style": null, "tags": [] @@ -4120,7 +4541,7 @@ { "Title": { "date": null, - "id": "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},13", + "id": "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},13", "layout": { "max_height": null, "max_width": null, @@ -4131,172 +4552,29 @@ }, "outlines": [ { - "id": "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},14", - "indents": [], - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": null, - "y": null - }, - "min_width": null, - "paragraphs": [ - { - "collapsed": false, - "content": { - "Text": { - "date_field": null, - "id": "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},16", - "tags": [], - "text": { - "spans": [ - { - "end": 10, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": 1033, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - } - } - ], - "text": "Same title" - } - } - }, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": null, - "font_size": null, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "id": "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},15", - "level": 1, - "lists": [], - "parent": null, - "style": "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},18", - "tags": [] - } + "id": "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 ], - "title": false, - "unsupported": [] - } - ] - } - } - ], - "title": "Same title" - }, - "author": "Author", - "before": { - "created": 134335976560000000, - "definitions": { - "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},18": { - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "kind": { - "name": "PageTitle", - "type": "Style" - } - } - }, - "margin_origin": [ - 0.0, - 0.0 - ], - "objects": [ - { - "Title": { - "date": null, - "id": "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},13", - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": 0.0, - "y": 0.0 - }, - "outlines": [ - { - "id": "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},14", - "indents": [], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},16", + "id": "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},16", "tags": [], "text": { "spans": [ @@ -4318,6 +4596,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -4348,6 +4627,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -4356,471 +4636,19 @@ "superscript": null, "underline": null }, - "id": "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},15", + "id": "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},15", "level": 1, "lists": [], - "parent": null, - "style": "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},18", - "tags": [] - } - ], - "title": false, - "unsupported": [] - } - ] - } - } - ], - "title": "Same title" - } - } - }, - "space": "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},1" - } - ], - [ - { - "id": 1, - "operation": { - "Pages": { - "edits": [ - { - "guid": [ - 133, - 13, - 22, - 96, - 19, - 247, - 50, - 69, - 147, - 11, - 146, - 207, - 6, - 153, - 24, - 94 - ], - "level": 1, - "position": { - "Before": "{D2E1FE57-F45D-49C3-B388-95222ACA3886},1" - }, - "space": "{7F6702B3-C977-436F-82AF-403E2BE950E7},1" - }, - { - "guid": [ - 159, - 110, - 141, - 39, - 253, - 156, - 37, - 64, - 172, - 95, - 144, - 82, - 194, - 61, - 40, - 198 - ], - "level": 2, - "position": "Keep", - "space": "{688C4EA1-80F5-4D85-B9BB-F8633FF3C0CA},1" - } - ], - "observed": [ - [ - "{869D5929-6E5E-4EDB-9639-4C56977DD447},1", - 1 - ], - [ - "{B0B53F94-0105-4C59-B65B-9473F49BFF41},1", - 1 - ], - [ - "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},1", - 1 - ], - [ - "{C837A033-F055-4455-B45F-17D82016921C},1", - 1 - ], - [ - "{89D86121-BF5C-418B-BB92-CBFFA8EF545F},1", - 1 - ], - [ - "{57E835EA-2799-4118-B55C-7BB1E51C6941},1", - 1 - ], - [ - "{F7EECCCF-B6B4-44F2-A543-36837EC18B8A},1", - 1 - ], - [ - "{361A0F09-EA63-4F2D-9620-C061D4E4707E},1", - 1 - ], - [ - "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},1", - 1 - ], - [ - "{C2870D07-8ADC-44BB-9F12-15D6C3708689},1", - 1 - ], - [ - "{1E0C6331-32C9-4EF9-8087-150A8669FA91},1", - 1 - ], - [ - "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},1", - 1 - ], - [ - "{17C30054-7129-4A10-9D00-4D5545E4F4BA},1", - 1 - ], - [ - "{4C5FBA5B-E348-4750-ACE4-E9694D527BFF},1", - 1 - ], - [ - "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},1", - 1 - ], - [ - "{6FEA70FA-1451-4A27-8A8E-C4CC3B713CF6},1", - 1 - ], - [ - "{68A1E0E1-7A39-4AB6-AD71-4D8A18878D79},1", - 1 - ], - [ - "{7E9041BD-301B-4B5B-9456-1C92050E4914},1", - 1 - ], - [ - "{EFA77BAE-8A98-4176-B536-B615D1D2ED2B},1", - 1 - ], - [ - "{87AC0239-51A5-47C6-BAF4-926CCF56385C},1", - 1 - ], - [ - "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},1", - 1 - ], - [ - "{FD8D07D0-C447-4516-A7DB-F8522B77D4A7},1", - 1 - ], - [ - "{D2E1FE57-F45D-49C3-B388-95222ACA3886},1", - 1 - ], - [ - "{7F6702B3-C977-436F-82AF-403E2BE950E7},1", - 1 - ], - [ - "{688C4EA1-80F5-4D85-B9BB-F8633FF3C0CA},1", - 1 - ], - [ - "{754FA957-C432-4502-A5CF-EE44449462C0},1", - 1 - ], - [ - "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},1", - 1 - ], - [ - "{87B83BA8-A906-4F71-A6CA-3E8133A75D34},1", - 1 - ], - [ - "{F2D6AEBB-98EA-4F10-991F-5243E74AF450},1", - 1 - ], - [ - "{406687D7-F9E9-491B-85DA-166DE6CD5A60},1", - 1 - ], - [ - "{1F73BF36-9FC2-495D-844D-B370D5C47DE7},1", - 1 - ], - [ - "{E9BD343F-4A66-4DE2-81F9-3789CCA22818},1", - 1 - ], - [ - "{189EA744-BE13-467A-95BA-2667A134C0B0},1", - 1 - ], - [ - "{017F5881-F818-4C7C-B9F9-6BCB4085F1D0},1", - 1 - ], - [ - "{CD0D17F4-25D8-4076-9AF7-47D9CDEFCF0A},1", - 1 - ], - [ - "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},1", - 1 - ], - [ - "{2B736B19-2D9C-4E76-A732-478D533D717A},1", - 1 - ] - ] - } - }, - "space": "{90FCDB0C-36AE-422F-B4FE-67676826FE36},1" - }, - { - "id": 2, - "operation": { - "Page": { - "after": { - "created": 134335976560000000, - "definitions": { - "{7F6702B3-C977-436F-82AF-403E2BE950E7},18": { - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "kind": { - "name": "PageTitle", - "type": "Style" - } - } - }, - "margin_origin": [ - 0.0, - 0.0 - ], - "objects": [ - { - "Outline": { - "id": "{F5ABCB2B-29D3-411E-A836-02A85749FF09},0", - "indents": [], - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": 36.0, - "y": 36.0 - }, - "min_width": null, - "paragraphs": [ - { - "collapsed": false, - "content": { - "Text": { - "date_field": null, - "id": "{3500B3EC-E613-4C17-8520-F7B22DB266FD},0", - "tags": [], - "text": { - "spans": [ - { - "end": 16, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 11.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": 1033, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - } - } - ], - "text": "Actor 7 🦀 é" - } - } - }, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": null, - "font_size": null, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "id": "{DFF3BBDE-C14D-4D31-8E0D-CBCED9BB1195},0", - "level": 1, - "lists": [], - "parent": null, - "style": null, - "tags": [] - } - ], - "title": false, - "unsupported": [] - } - }, - { - "Title": { - "date": null, - "id": "{7F6702B3-C977-436F-82AF-403E2BE950E7},13", - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": 0.0, - "y": 0.0 - }, - "outlines": [ - { - "id": "{7F6702B3-C977-436F-82AF-403E2BE950E7},14", - "indents": [], - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": null, - "y": null - }, - "min_width": null, - "paragraphs": [ - { - "collapsed": false, - "content": { - "Text": { - "date_field": null, - "id": "{7F6702B3-C977-436F-82AF-403E2BE950E7},16", - "tags": [], - "text": { - "spans": [ - { - "end": 10, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": 1033, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - } - } - ], - "text": "Same title" - } - } + "media": { + "recordings": [], + "time_ms": null }, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": null, - "font_size": null, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "id": "{7F6702B3-C977-436F-82AF-403E2BE950E7},15", - "level": 1, - "lists": [], "parent": null, - "style": "{7F6702B3-C977-436F-82AF-403E2BE950E7},18", + "style": "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -4831,9 +4659,9 @@ }, "author": "Author", "before": { - "created": 134335976560000000, + "created": 134348779890000000, "definitions": { - "{7F6702B3-C977-436F-82AF-403E2BE950E7},18": { + "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},18": { "format": { "alignment": null, "bold": null, @@ -4850,6 +4678,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -4864,6 +4693,24 @@ } } }, + "identity": [ + 60, + 151, + 59, + 176, + 188, + 80, + 154, + 75, + 153, + 20, + 177, + 213, + 192, + 207, + 97, + 151 + ], "margin_origin": [ 0.0, 0.0 @@ -4872,7 +4719,7 @@ { "Title": { "date": null, - "id": "{7F6702B3-C977-436F-82AF-403E2BE950E7},13", + "id": "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},13", "layout": { "max_height": null, "max_width": null, @@ -4883,24 +4730,29 @@ }, "outlines": [ { - "id": "{7F6702B3-C977-436F-82AF-403E2BE950E7},14", - "indents": [], + "id": "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{7F6702B3-C977-436F-82AF-403E2BE950E7},16", + "id": "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},16", "tags": [], "text": { "spans": [ @@ -4922,6 +4774,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -4952,6 +4805,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -4960,15 +4814,19 @@ "superscript": null, "underline": null }, - "id": "{7F6702B3-C977-436F-82AF-403E2BE950E7},15", + "id": "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, - "style": "{7F6702B3-C977-436F-82AF-403E2BE950E7},18", + "style": "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -4979,7 +4837,7 @@ } } }, - "space": "{7F6702B3-C977-436F-82AF-403E2BE950E7},1" + "space": "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},1" } ], [ @@ -4990,819 +4848,215 @@ "edits": [ { "guid": [ - 60, - 96, - 70, - 223, + 73, + 224, + 214, + 216, + 142, 197, - 137, - 63, - 78, - 170, - 199, 81, - 158, - 99, - 18, - 163, - 133 + 64, + 131, + 8, + 236, + 81, + 238, + 204, + 152, + 204 ], "level": 1, "position": { - "Before": "{754FA957-C432-4502-A5CF-EE44449462C0},1" + "Before": "{4ED14CEE-8560-4125-B488-9945C116741E},1" }, - "space": "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},1" + "space": "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},1" }, { "guid": [ - 218, + 187, + 19, + 240, + 247, + 140, + 21, + 1, + 78, 181, - 208, - 213, - 72, - 152, - 253, - 76, - 176, - 213, - 138, - 85, - 141, - 127, - 170, - 160 - ], - "level": 2, - "position": "Keep", - "space": "{87B83BA8-A906-4F71-A6CA-3E8133A75D34},1" - } - ], - "observed": [ - [ - "{869D5929-6E5E-4EDB-9639-4C56977DD447},1", - 1 - ], - [ - "{B0B53F94-0105-4C59-B65B-9473F49BFF41},1", - 1 - ], - [ - "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},1", - 1 - ], - [ - "{C837A033-F055-4455-B45F-17D82016921C},1", - 1 - ], - [ - "{89D86121-BF5C-418B-BB92-CBFFA8EF545F},1", - 1 - ], - [ - "{57E835EA-2799-4118-B55C-7BB1E51C6941},1", - 1 - ], - [ - "{F7EECCCF-B6B4-44F2-A543-36837EC18B8A},1", - 1 - ], - [ - "{361A0F09-EA63-4F2D-9620-C061D4E4707E},1", - 1 - ], - [ - "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},1", - 1 - ], - [ - "{C2870D07-8ADC-44BB-9F12-15D6C3708689},1", - 1 - ], - [ - "{1E0C6331-32C9-4EF9-8087-150A8669FA91},1", - 1 - ], - [ - "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},1", - 1 - ], - [ - "{17C30054-7129-4A10-9D00-4D5545E4F4BA},1", - 1 - ], - [ - "{4C5FBA5B-E348-4750-ACE4-E9694D527BFF},1", - 1 - ], - [ - "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},1", - 1 - ], - [ - "{6FEA70FA-1451-4A27-8A8E-C4CC3B713CF6},1", - 1 - ], - [ - "{68A1E0E1-7A39-4AB6-AD71-4D8A18878D79},1", - 1 - ], - [ - "{7E9041BD-301B-4B5B-9456-1C92050E4914},1", - 1 - ], - [ - "{EFA77BAE-8A98-4176-B536-B615D1D2ED2B},1", - 1 - ], - [ - "{87AC0239-51A5-47C6-BAF4-926CCF56385C},1", - 1 - ], - [ - "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},1", - 1 - ], - [ - "{FD8D07D0-C447-4516-A7DB-F8522B77D4A7},1", - 1 - ], - [ - "{D2E1FE57-F45D-49C3-B388-95222ACA3886},1", - 1 - ], - [ - "{7F6702B3-C977-436F-82AF-403E2BE950E7},1", - 1 - ], - [ - "{688C4EA1-80F5-4D85-B9BB-F8633FF3C0CA},1", - 1 - ], - [ - "{754FA957-C432-4502-A5CF-EE44449462C0},1", - 1 - ], - [ - "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},1", - 1 - ], - [ - "{87B83BA8-A906-4F71-A6CA-3E8133A75D34},1", - 1 - ], - [ - "{F2D6AEBB-98EA-4F10-991F-5243E74AF450},1", - 1 - ], - [ - "{406687D7-F9E9-491B-85DA-166DE6CD5A60},1", - 1 - ], - [ - "{1F73BF36-9FC2-495D-844D-B370D5C47DE7},1", - 1 - ], - [ - "{E9BD343F-4A66-4DE2-81F9-3789CCA22818},1", - 1 - ], - [ - "{189EA744-BE13-467A-95BA-2667A134C0B0},1", - 1 - ], - [ - "{017F5881-F818-4C7C-B9F9-6BCB4085F1D0},1", - 1 - ], - [ - "{CD0D17F4-25D8-4076-9AF7-47D9CDEFCF0A},1", - 1 - ], - [ - "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},1", - 1 - ], - [ - "{2B736B19-2D9C-4E76-A732-478D533D717A},1", - 1 - ] - ] - } - }, - "space": "{90FCDB0C-36AE-422F-B4FE-67676826FE36},1" - }, - { - "id": 2, - "operation": { - "Page": { - "after": { - "created": 134335976560000000, - "definitions": { - "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},18": { - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "kind": { - "name": "PageTitle", - "type": "Style" - } - } - }, - "margin_origin": [ - 0.0, - 0.0 - ], - "objects": [ - { - "Outline": { - "id": "{5A041F4B-D387-4A35-8723-9F16A833AC7E},0", - "indents": [], - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": 36.0, - "y": 36.0 - }, - "min_width": null, - "paragraphs": [ - { - "collapsed": false, - "content": { - "Text": { - "date_field": null, - "id": "{8A12422A-9108-4831-9889-D95B1B47F85A},0", - "tags": [], - "text": { - "spans": [ - { - "end": 16, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 11.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": 1033, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - } - } - ], - "text": "Actor 8 🦀 é" - } - } - }, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": null, - "font_size": null, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "id": "{8906C7DF-89B1-40E4-9D47-6C794C39F974},0", - "level": 1, - "lists": [], - "parent": null, - "style": null, - "tags": [] - } - ], - "title": false, - "unsupported": [] - } - }, - { - "Title": { - "date": null, - "id": "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},13", - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": 0.0, - "y": 0.0 - }, - "outlines": [ - { - "id": "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},14", - "indents": [], - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": null, - "y": null - }, - "min_width": null, - "paragraphs": [ - { - "collapsed": false, - "content": { - "Text": { - "date_field": null, - "id": "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},16", - "tags": [], - "text": { - "spans": [ - { - "end": 10, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": 1033, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - } - } - ], - "text": "Same title" - } - } - }, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": null, - "font_size": null, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "id": "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},15", - "level": 1, - "lists": [], - "parent": null, - "style": "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},18", - "tags": [] - } - ], - "title": false, - "unsupported": [] - } - ] - } - } - ], - "title": "Same title" - }, - "author": "Author", - "before": { - "created": 134335976560000000, - "definitions": { - "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},18": { - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "kind": { - "name": "PageTitle", - "type": "Style" - } - } - }, - "margin_origin": [ - 0.0, - 0.0 - ], - "objects": [ - { - "Title": { - "date": null, - "id": "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},13", - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": 0.0, - "y": 0.0 - }, - "outlines": [ - { - "id": "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},14", - "indents": [], - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": null, - "y": null - }, - "min_width": null, - "paragraphs": [ - { - "collapsed": false, - "content": { - "Text": { - "date_field": null, - "id": "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},16", - "tags": [], - "text": { - "spans": [ - { - "end": 10, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": 1033, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - } - } - ], - "text": "Same title" - } - } - }, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": null, - "font_size": null, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "id": "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},15", - "level": 1, - "lists": [], - "parent": null, - "style": "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},18", - "tags": [] - } - ], - "title": false, - "unsupported": [] - } - ] - } - } - ], - "title": "Same title" - } - } - }, - "space": "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},1" - } - ], - [ - { - "id": 1, - "operation": { - "Pages": { - "edits": [ - { - "guid": [ - 99, - 82, - 79, - 67, - 136, - 178, - 110, - 79, - 138, - 9, - 172, - 171, - 67, - 243, - 71, - 142 - ], - "level": 1, - "position": { - "Before": "{F2D6AEBB-98EA-4F10-991F-5243E74AF450},1" - }, - "space": "{406687D7-F9E9-491B-85DA-166DE6CD5A60},1" - }, - { - "guid": [ - 198, + 106, 58, - 35, - 75, - 157, - 18, - 195, - 77, - 154, - 80, - 148, - 1, - 210, - 118, - 195, - 115 + 144, + 123, + 115, + 155, + 206 ], "level": 2, "position": "Keep", - "space": "{1F73BF36-9FC2-495D-844D-B370D5C47DE7},1" + "space": "{932BE37F-05AA-4EFB-85D8-A33445E738B3},1" } ], "observed": [ [ - "{869D5929-6E5E-4EDB-9639-4C56977DD447},1", + "{0B37E922-551A-48F8-BCAC-4AB3503B4D28},1", 1 ], [ - "{B0B53F94-0105-4C59-B65B-9473F49BFF41},1", + "{74492C4A-164B-40E9-B297-4D3B4685347D},1", 1 ], [ - "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},1", + "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},1", 1 ], [ - "{C837A033-F055-4455-B45F-17D82016921C},1", + "{8B766F11-2A4F-4D95-8473-D3CA68129E11},1", 1 ], [ - "{89D86121-BF5C-418B-BB92-CBFFA8EF545F},1", + "{28E0CC5F-68BC-4A86-9ED7-114FFD3BAE9A},1", 1 ], [ - "{57E835EA-2799-4118-B55C-7BB1E51C6941},1", + "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},1", 1 ], [ - "{F7EECCCF-B6B4-44F2-A543-36837EC18B8A},1", + "{AFCD09F6-1085-4582-BDB2-4B91E15D2088},1", 1 ], [ - "{361A0F09-EA63-4F2D-9620-C061D4E4707E},1", + "{A94D5882-C902-4CAE-B38E-2ED56EEF177F},1", 1 ], [ - "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},1", + "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},1", 1 ], [ - "{C2870D07-8ADC-44BB-9F12-15D6C3708689},1", + "{F212795E-0E4B-4844-A8A6-ABD7F7025124},1", 1 ], [ - "{1E0C6331-32C9-4EF9-8087-150A8669FA91},1", + "{1786F2CA-0A43-4081-8B55-B207EEE0331C},1", 1 ], [ - "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},1", + "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},1", 1 ], [ - "{17C30054-7129-4A10-9D00-4D5545E4F4BA},1", + "{0D361C76-92FD-4B76-901B-809C2C3C3CE6},1", 1 ], [ - "{4C5FBA5B-E348-4750-ACE4-E9694D527BFF},1", + "{AE89305A-73F4-46B5-B41D-18884685D896},1", 1 ], [ - "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},1", + "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},1", 1 ], [ - "{6FEA70FA-1451-4A27-8A8E-C4CC3B713CF6},1", + "{CBF82C86-3A8B-457F-92A4-1AC7BF436CE9},1", 1 ], [ - "{68A1E0E1-7A39-4AB6-AD71-4D8A18878D79},1", + "{42010094-E377-4A97-9395-AD45808D0552},1", 1 ], [ - "{7E9041BD-301B-4B5B-9456-1C92050E4914},1", + "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},1", 1 ], [ - "{EFA77BAE-8A98-4176-B536-B615D1D2ED2B},1", + "{F9DA3139-5856-4B11-95C0-356683E3ACFE},1", 1 ], [ - "{87AC0239-51A5-47C6-BAF4-926CCF56385C},1", + "{439B80E0-9D4C-4A2F-9163-4E301491364D},1", 1 ], [ - "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},1", + "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},1", 1 ], [ - "{FD8D07D0-C447-4516-A7DB-F8522B77D4A7},1", + "{A459C474-E6EB-4BE5-9193-271799B2860B},1", 1 ], [ - "{D2E1FE57-F45D-49C3-B388-95222ACA3886},1", + "{4ED14CEE-8560-4125-B488-9945C116741E},1", 1 ], [ - "{7F6702B3-C977-436F-82AF-403E2BE950E7},1", + "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},1", 1 ], [ - "{688C4EA1-80F5-4D85-B9BB-F8633FF3C0CA},1", + "{932BE37F-05AA-4EFB-85D8-A33445E738B3},1", 1 ], [ - "{754FA957-C432-4502-A5CF-EE44449462C0},1", + "{9B755368-1D49-4D7E-8D33-6DC536F57848},1", 1 ], [ - "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},1", + "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},1", 1 ], [ - "{87B83BA8-A906-4F71-A6CA-3E8133A75D34},1", + "{5CA4C59F-B400-4BC6-8B90-30ECAC35CB43},1", 1 ], [ - "{F2D6AEBB-98EA-4F10-991F-5243E74AF450},1", + "{D63FB383-FB37-44F9-AEBB-E868E903A2BB},1", 1 ], [ - "{406687D7-F9E9-491B-85DA-166DE6CD5A60},1", + "{C6E54364-6CD0-4764-8180-873030B9EA53},1", 1 ], [ - "{1F73BF36-9FC2-495D-844D-B370D5C47DE7},1", + "{DEEC391B-F2D1-4EB1-BEB1-57598DFBB982},1", 1 ], [ - "{E9BD343F-4A66-4DE2-81F9-3789CCA22818},1", + "{9E7616ED-DE19-4625-8EA0-0C5DEB18B690},1", 1 ], [ - "{189EA744-BE13-467A-95BA-2667A134C0B0},1", + "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},1", 1 ], [ - "{017F5881-F818-4C7C-B9F9-6BCB4085F1D0},1", + "{00B10C58-6649-428D-A081-3EBBA6E88E97},1", 1 ], [ - "{CD0D17F4-25D8-4076-9AF7-47D9CDEFCF0A},1", + "{58E99482-710A-4938-8CFB-88A1C9B946C9},1", 1 ], [ - "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},1", + "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},1", 1 ], [ - "{2B736B19-2D9C-4E76-A732-478D533D717A},1", + "{15A45C01-A9E8-42E2-A267-C7FCE728815E},1", 1 ] ] } }, - "space": "{90FCDB0C-36AE-422F-B4FE-67676826FE36},1" + "space": "{A5A2AFBE-1DF1-4190-AAC7-1AD29C9564F2},1" }, { "id": 2, "operation": { "Page": { "after": { - "created": 134335976560000000, + "created": 134348779900000000, "definitions": { - "{406687D7-F9E9-491B-85DA-166DE6CD5A60},18": { + "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},18": { "format": { "alignment": null, "bold": null, @@ -5819,6 +5073,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -5833,6 +5088,24 @@ } } }, + "identity": [ + 228, + 143, + 255, + 196, + 67, + 76, + 31, + 70, + 152, + 221, + 235, + 31, + 242, + 168, + 99, + 178 + ], "margin_origin": [ 0.0, 0.0 @@ -5840,7 +5113,7 @@ "objects": [ { "Outline": { - "id": "{69906E37-5C35-4F2F-B74F-B5D45950739B},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},23", "indents": [], "layout": { "max_height": null, @@ -5857,7 +5130,7 @@ "content": { "Text": { "date_field": null, - "id": "{3A6E4FCA-F6C5-4418-ADF4-9E92782F26CD},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},22", "tags": [], "text": { "spans": [ @@ -5879,6 +5152,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -5889,7 +5163,7 @@ } } ], - "text": "Actor 9 🦀 é" + "text": "Actor 7 🦀 é" } } }, @@ -5909,6 +5183,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -5917,9 +5192,13 @@ "superscript": null, "underline": null }, - "id": "{FFE94E14-C2A1-4CB1-BBDB-0D72059C0A9D},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},24", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, "style": null, "tags": [] @@ -5932,7 +5211,7 @@ { "Title": { "date": null, - "id": "{406687D7-F9E9-491B-85DA-166DE6CD5A60},13", + "id": "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},13", "layout": { "max_height": null, "max_width": null, @@ -5943,24 +5222,29 @@ }, "outlines": [ { - "id": "{406687D7-F9E9-491B-85DA-166DE6CD5A60},14", - "indents": [], + "id": "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{406687D7-F9E9-491B-85DA-166DE6CD5A60},16", + "id": "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},16", "tags": [], "text": { "spans": [ @@ -5982,6 +5266,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -6012,6 +5297,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -6020,15 +5306,19 @@ "superscript": null, "underline": null }, - "id": "{406687D7-F9E9-491B-85DA-166DE6CD5A60},15", + "id": "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, - "style": "{406687D7-F9E9-491B-85DA-166DE6CD5A60},18", + "style": "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -6039,9 +5329,9 @@ }, "author": "Author", "before": { - "created": 134335976560000000, + "created": 134348779900000000, "definitions": { - "{406687D7-F9E9-491B-85DA-166DE6CD5A60},18": { + "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},18": { "format": { "alignment": null, "bold": null, @@ -6058,6 +5348,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -6072,6 +5363,24 @@ } } }, + "identity": [ + 228, + 143, + 255, + 196, + 67, + 76, + 31, + 70, + 152, + 221, + 235, + 31, + 242, + 168, + 99, + 178 + ], "margin_origin": [ 0.0, 0.0 @@ -6080,7 +5389,7 @@ { "Title": { "date": null, - "id": "{406687D7-F9E9-491B-85DA-166DE6CD5A60},13", + "id": "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},13", "layout": { "max_height": null, "max_width": null, @@ -6091,24 +5400,29 @@ }, "outlines": [ { - "id": "{406687D7-F9E9-491B-85DA-166DE6CD5A60},14", - "indents": [], + "id": "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{406687D7-F9E9-491B-85DA-166DE6CD5A60},16", + "id": "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},16", "tags": [], "text": { "spans": [ @@ -6130,6 +5444,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -6160,6 +5475,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -6168,15 +5484,19 @@ "superscript": null, "underline": null }, - "id": "{406687D7-F9E9-491B-85DA-166DE6CD5A60},15", + "id": "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, - "style": "{406687D7-F9E9-491B-85DA-166DE6CD5A60},18", + "style": "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -6187,7 +5507,7 @@ } } }, - "space": "{406687D7-F9E9-491B-85DA-166DE6CD5A60},1" + "space": "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},1" } ], [ @@ -6198,819 +5518,2225 @@ "edits": [ { "guid": [ - 99, - 113, - 79, - 243, - 201, - 17, - 203, - 78, - 185, - 122, - 45, - 83, - 216, - 140, - 115, - 52 - ], - "level": 1, - "position": { - "Before": "{E9BD343F-4A66-4DE2-81F9-3789CCA22818},1" - }, - "space": "{189EA744-BE13-467A-95BA-2667A134C0B0},1" - }, - { - "guid": [ - 240, + 171, + 123, 160, - 93, - 114, - 5, - 25, - 128, - 75, - 161, - 252, - 39, + 66, + 159, 123, - 203, - 137, - 98, - 106 - ], - "level": 2, - "position": "Keep", - "space": "{017F5881-F818-4C7C-B9F9-6BCB4085F1D0},1" - } - ], - "observed": [ - [ - "{869D5929-6E5E-4EDB-9639-4C56977DD447},1", - 1 - ], - [ - "{B0B53F94-0105-4C59-B65B-9473F49BFF41},1", - 1 - ], - [ - "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},1", - 1 - ], - [ - "{C837A033-F055-4455-B45F-17D82016921C},1", - 1 - ], - [ - "{89D86121-BF5C-418B-BB92-CBFFA8EF545F},1", - 1 - ], - [ - "{57E835EA-2799-4118-B55C-7BB1E51C6941},1", - 1 - ], - [ - "{F7EECCCF-B6B4-44F2-A543-36837EC18B8A},1", - 1 - ], - [ - "{361A0F09-EA63-4F2D-9620-C061D4E4707E},1", - 1 - ], - [ - "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},1", - 1 - ], - [ - "{C2870D07-8ADC-44BB-9F12-15D6C3708689},1", - 1 - ], - [ - "{1E0C6331-32C9-4EF9-8087-150A8669FA91},1", - 1 - ], - [ - "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},1", - 1 - ], - [ - "{17C30054-7129-4A10-9D00-4D5545E4F4BA},1", - 1 - ], - [ - "{4C5FBA5B-E348-4750-ACE4-E9694D527BFF},1", - 1 - ], - [ - "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},1", - 1 - ], - [ - "{6FEA70FA-1451-4A27-8A8E-C4CC3B713CF6},1", - 1 - ], - [ - "{68A1E0E1-7A39-4AB6-AD71-4D8A18878D79},1", - 1 - ], - [ - "{7E9041BD-301B-4B5B-9456-1C92050E4914},1", - 1 - ], - [ - "{EFA77BAE-8A98-4176-B536-B615D1D2ED2B},1", - 1 - ], - [ - "{87AC0239-51A5-47C6-BAF4-926CCF56385C},1", - 1 - ], - [ - "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},1", - 1 - ], - [ - "{FD8D07D0-C447-4516-A7DB-F8522B77D4A7},1", - 1 - ], - [ - "{D2E1FE57-F45D-49C3-B388-95222ACA3886},1", - 1 - ], - [ - "{7F6702B3-C977-436F-82AF-403E2BE950E7},1", - 1 - ], - [ - "{688C4EA1-80F5-4D85-B9BB-F8633FF3C0CA},1", - 1 - ], - [ - "{754FA957-C432-4502-A5CF-EE44449462C0},1", - 1 - ], - [ - "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},1", - 1 - ], - [ - "{87B83BA8-A906-4F71-A6CA-3E8133A75D34},1", - 1 - ], - [ - "{F2D6AEBB-98EA-4F10-991F-5243E74AF450},1", - 1 - ], - [ - "{406687D7-F9E9-491B-85DA-166DE6CD5A60},1", - 1 - ], - [ - "{1F73BF36-9FC2-495D-844D-B370D5C47DE7},1", - 1 - ], - [ - "{E9BD343F-4A66-4DE2-81F9-3789CCA22818},1", - 1 - ], - [ - "{189EA744-BE13-467A-95BA-2667A134C0B0},1", - 1 - ], - [ - "{017F5881-F818-4C7C-B9F9-6BCB4085F1D0},1", - 1 - ], - [ - "{CD0D17F4-25D8-4076-9AF7-47D9CDEFCF0A},1", - 1 - ], - [ - "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},1", - 1 - ], - [ - "{2B736B19-2D9C-4E76-A732-478D533D717A},1", - 1 - ] - ] - } - }, - "space": "{90FCDB0C-36AE-422F-B4FE-67676826FE36},1" - }, - { - "id": 2, - "operation": { - "Page": { - "after": { - "created": 134335976560000000, - "definitions": { - "{189EA744-BE13-467A-95BA-2667A134C0B0},18": { - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "kind": { - "name": "PageTitle", - "type": "Style" - } - } - }, - "margin_origin": [ - 0.0, - 0.0 - ], - "objects": [ - { - "Outline": { - "id": "{A05DF797-7FAA-4593-A1F4-7E35812A5257},0", - "indents": [], - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": 36.0, - "y": 36.0 - }, - "min_width": null, - "paragraphs": [ - { - "collapsed": false, - "content": { - "Text": { - "date_field": null, - "id": "{D134F5D0-A81E-4663-9AE8-5FF66C8882F1},0", - "tags": [], - "text": { - "spans": [ - { - "end": 17, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 11.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": 1033, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - } - } - ], - "text": "Actor 10 🦀 é" - } - } - }, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": null, - "font_size": null, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "id": "{D783BABC-D1D5-45A9-86F8-1169830488FC},0", - "level": 1, - "lists": [], - "parent": null, - "style": null, - "tags": [] - } - ], - "title": false, - "unsupported": [] - } - }, - { - "Title": { - "date": null, - "id": "{189EA744-BE13-467A-95BA-2667A134C0B0},13", - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": 0.0, - "y": 0.0 - }, - "outlines": [ - { - "id": "{189EA744-BE13-467A-95BA-2667A134C0B0},14", - "indents": [], - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": null, - "y": null - }, - "min_width": null, - "paragraphs": [ - { - "collapsed": false, - "content": { - "Text": { - "date_field": null, - "id": "{189EA744-BE13-467A-95BA-2667A134C0B0},16", - "tags": [], - "text": { - "spans": [ - { - "end": 10, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": 1033, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - } - } - ], - "text": "Same title" - } - } - }, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": null, - "font_size": null, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "id": "{189EA744-BE13-467A-95BA-2667A134C0B0},15", - "level": 1, - "lists": [], - "parent": null, - "style": "{189EA744-BE13-467A-95BA-2667A134C0B0},18", - "tags": [] - } - ], - "title": false, - "unsupported": [] - } - ] - } - } - ], - "title": "Same title" - }, - "author": "Author", - "before": { - "created": 134335976560000000, - "definitions": { - "{189EA744-BE13-467A-95BA-2667A134C0B0},18": { - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "kind": { - "name": "PageTitle", - "type": "Style" - } - } - }, - "margin_origin": [ - 0.0, - 0.0 - ], - "objects": [ - { - "Title": { - "date": null, - "id": "{189EA744-BE13-467A-95BA-2667A134C0B0},13", - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": 0.0, - "y": 0.0 - }, - "outlines": [ - { - "id": "{189EA744-BE13-467A-95BA-2667A134C0B0},14", - "indents": [], - "layout": { - "max_height": null, - "max_width": null, - "reserved_width": null, - "width_set_by_user": null, - "x": null, - "y": null - }, - "min_width": null, - "paragraphs": [ - { - "collapsed": false, - "content": { - "Text": { - "date_field": null, - "id": "{189EA744-BE13-467A-95BA-2667A134C0B0},16", - "tags": [], - "text": { - "spans": [ - { - "end": 10, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": "Calibri", - "font_size": 17.0, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": 1033, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - } - } - ], - "text": "Same title" - } - } - }, - "format": { - "alignment": null, - "bold": null, - "color": null, - "embedded_object": null, - "font": null, - "font_size": null, - "hidden": null, - "highlight": null, - "hyperlink": null, - "hyperlink_label": null, - "italic": null, - "language": null, - "line_spacing": null, - "list_spacing": null, - "math": null, - "rtl": null, - "space_after": null, - "space_before": null, - "strike": null, - "subscript": null, - "superscript": null, - "underline": null - }, - "id": "{189EA744-BE13-467A-95BA-2667A134C0B0},15", - "level": 1, - "lists": [], - "parent": null, - "style": "{189EA744-BE13-467A-95BA-2667A134C0B0},18", - "tags": [] - } - ], - "title": false, - "unsupported": [] - } - ] - } - } - ], - "title": "Same title" - } - } - }, - "space": "{189EA744-BE13-467A-95BA-2667A134C0B0},1" - } - ], - [ - { - "id": 1, - "operation": { - "Pages": { - "edits": [ - { - "guid": [ - 137, - 150, - 22, - 112, - 113, - 130, - 213, - 65, - 143, - 6, - 73, - 88, - 176, - 192, 23, - 119 + 73, + 161, + 60, + 51, + 152, + 144, + 104, + 92, + 228 ], "level": 1, "position": { - "Before": "{CD0D17F4-25D8-4076-9AF7-47D9CDEFCF0A},1" + "Before": "{9B755368-1D49-4D7E-8D33-6DC536F57848},1" }, - "space": "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},1" + "space": "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},1" }, { "guid": [ - 65, - 77, + 243, + 76, 116, - 151, + 146, + 38, + 89, + 244, + 65, + 170, + 135, + 180, + 163, + 152, + 15, + 148, + 54 + ], + "level": 2, + "position": "Keep", + "space": "{5CA4C59F-B400-4BC6-8B90-30ECAC35CB43},1" + } + ], + "observed": [ + [ + "{0B37E922-551A-48F8-BCAC-4AB3503B4D28},1", + 1 + ], + [ + "{74492C4A-164B-40E9-B297-4D3B4685347D},1", + 1 + ], + [ + "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},1", + 1 + ], + [ + "{8B766F11-2A4F-4D95-8473-D3CA68129E11},1", + 1 + ], + [ + "{28E0CC5F-68BC-4A86-9ED7-114FFD3BAE9A},1", + 1 + ], + [ + "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},1", + 1 + ], + [ + "{AFCD09F6-1085-4582-BDB2-4B91E15D2088},1", + 1 + ], + [ + "{A94D5882-C902-4CAE-B38E-2ED56EEF177F},1", + 1 + ], + [ + "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},1", + 1 + ], + [ + "{F212795E-0E4B-4844-A8A6-ABD7F7025124},1", + 1 + ], + [ + "{1786F2CA-0A43-4081-8B55-B207EEE0331C},1", + 1 + ], + [ + "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},1", + 1 + ], + [ + "{0D361C76-92FD-4B76-901B-809C2C3C3CE6},1", + 1 + ], + [ + "{AE89305A-73F4-46B5-B41D-18884685D896},1", + 1 + ], + [ + "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},1", + 1 + ], + [ + "{CBF82C86-3A8B-457F-92A4-1AC7BF436CE9},1", + 1 + ], + [ + "{42010094-E377-4A97-9395-AD45808D0552},1", + 1 + ], + [ + "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},1", + 1 + ], + [ + "{F9DA3139-5856-4B11-95C0-356683E3ACFE},1", + 1 + ], + [ + "{439B80E0-9D4C-4A2F-9163-4E301491364D},1", + 1 + ], + [ + "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},1", + 1 + ], + [ + "{A459C474-E6EB-4BE5-9193-271799B2860B},1", + 1 + ], + [ + "{4ED14CEE-8560-4125-B488-9945C116741E},1", + 1 + ], + [ + "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},1", + 1 + ], + [ + "{932BE37F-05AA-4EFB-85D8-A33445E738B3},1", + 1 + ], + [ + "{9B755368-1D49-4D7E-8D33-6DC536F57848},1", + 1 + ], + [ + "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},1", + 1 + ], + [ + "{5CA4C59F-B400-4BC6-8B90-30ECAC35CB43},1", + 1 + ], + [ + "{D63FB383-FB37-44F9-AEBB-E868E903A2BB},1", + 1 + ], + [ + "{C6E54364-6CD0-4764-8180-873030B9EA53},1", + 1 + ], + [ + "{DEEC391B-F2D1-4EB1-BEB1-57598DFBB982},1", + 1 + ], + [ + "{9E7616ED-DE19-4625-8EA0-0C5DEB18B690},1", + 1 + ], + [ + "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},1", + 1 + ], + [ + "{00B10C58-6649-428D-A081-3EBBA6E88E97},1", + 1 + ], + [ + "{58E99482-710A-4938-8CFB-88A1C9B946C9},1", + 1 + ], + [ + "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},1", + 1 + ], + [ + "{15A45C01-A9E8-42E2-A267-C7FCE728815E},1", + 1 + ] + ] + } + }, + "space": "{A5A2AFBE-1DF1-4190-AAC7-1AD29C9564F2},1" + }, + { + "id": 2, + "operation": { + "Page": { + "after": { + "created": 134348779900000000, + "definitions": { + "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},18": { + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "kind": { + "name": "PageTitle", + "type": "Style" + } + } + }, + "identity": [ + 175, + 234, + 69, + 208, + 233, + 172, + 79, + 75, + 151, + 155, + 227, + 72, + 164, + 173, + 230, + 147 + ], + "margin_origin": [ + 0.0, + 0.0 + ], + "objects": [ + { + "Outline": { + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},26", + "indents": [], + "layout": { + "max_height": null, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": 36.0, + "y": 36.0 + }, + "min_width": null, + "paragraphs": [ + { + "collapsed": false, + "content": { + "Text": { + "date_field": null, + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},25", + "tags": [], + "text": { + "spans": [ + { + "end": 16, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 11.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": 1033, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + } + } + ], + "text": "Actor 8 🦀 é" + } + } + }, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": null, + "font_size": null, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},27", + "level": 1, + "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, + "parent": null, + "style": null, + "tags": [] + } + ], + "title": false, + "unsupported": [] + } + }, + { + "Title": { + "date": null, + "id": "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},13", + "layout": { + "max_height": null, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": 0.0, + "y": 0.0 + }, + "outlines": [ + { + "id": "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], + "layout": { + "max_height": 21.600000381469727, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": null, + "y": null + }, + "min_width": 162.0, + "paragraphs": [ + { + "collapsed": false, + "content": { + "Text": { + "date_field": null, + "id": "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},16", + "tags": [], + "text": { + "spans": [ + { + "end": 10, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": 1033, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + } + } + ], + "text": "Same title" + } + } + }, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": null, + "font_size": null, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "id": "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},15", + "level": 1, + "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, + "parent": null, + "style": "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},18", + "tags": [] + } + ], + "title": true, + "unsupported": [] + } + ] + } + } + ], + "title": "Same title" + }, + "author": "Author", + "before": { + "created": 134348779900000000, + "definitions": { + "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},18": { + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "kind": { + "name": "PageTitle", + "type": "Style" + } + } + }, + "identity": [ + 175, + 234, + 69, + 208, + 233, + 172, + 79, + 75, + 151, + 155, + 227, + 72, + 164, + 173, + 230, + 147 + ], + "margin_origin": [ + 0.0, + 0.0 + ], + "objects": [ + { + "Title": { + "date": null, + "id": "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},13", + "layout": { + "max_height": null, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": 0.0, + "y": 0.0 + }, + "outlines": [ + { + "id": "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], + "layout": { + "max_height": 21.600000381469727, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": null, + "y": null + }, + "min_width": 162.0, + "paragraphs": [ + { + "collapsed": false, + "content": { + "Text": { + "date_field": null, + "id": "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},16", + "tags": [], + "text": { + "spans": [ + { + "end": 10, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": 1033, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + } + } + ], + "text": "Same title" + } + } + }, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": null, + "font_size": null, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "id": "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},15", + "level": 1, + "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, + "parent": null, + "style": "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},18", + "tags": [] + } + ], + "title": true, + "unsupported": [] + } + ] + } + } + ], + "title": "Same title" + } + } + }, + "space": "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},1" + } + ], + [ + { + "id": 1, + "operation": { + "Pages": { + "edits": [ + { + "guid": [ + 9, + 243, + 107, + 19, 20, - 16, + 192, + 128, + 78, + 179, + 30, + 140, + 253, + 10, + 214, + 221, + 155 + ], + "level": 1, + "position": { + "Before": "{D63FB383-FB37-44F9-AEBB-E868E903A2BB},1" + }, + "space": "{C6E54364-6CD0-4764-8180-873030B9EA53},1" + }, + { + "guid": [ + 9, + 192, + 29, + 108, + 238, + 78, + 236, + 72, + 128, + 200, + 112, + 224, + 246, 115, - 66, - 138, - 169, - 25, - 24, - 111, - 125, + 92, + 133 + ], + "level": 2, + "position": "Keep", + "space": "{DEEC391B-F2D1-4EB1-BEB1-57598DFBB982},1" + } + ], + "observed": [ + [ + "{0B37E922-551A-48F8-BCAC-4AB3503B4D28},1", + 1 + ], + [ + "{74492C4A-164B-40E9-B297-4D3B4685347D},1", + 1 + ], + [ + "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},1", + 1 + ], + [ + "{8B766F11-2A4F-4D95-8473-D3CA68129E11},1", + 1 + ], + [ + "{28E0CC5F-68BC-4A86-9ED7-114FFD3BAE9A},1", + 1 + ], + [ + "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},1", + 1 + ], + [ + "{AFCD09F6-1085-4582-BDB2-4B91E15D2088},1", + 1 + ], + [ + "{A94D5882-C902-4CAE-B38E-2ED56EEF177F},1", + 1 + ], + [ + "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},1", + 1 + ], + [ + "{F212795E-0E4B-4844-A8A6-ABD7F7025124},1", + 1 + ], + [ + "{1786F2CA-0A43-4081-8B55-B207EEE0331C},1", + 1 + ], + [ + "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},1", + 1 + ], + [ + "{0D361C76-92FD-4B76-901B-809C2C3C3CE6},1", + 1 + ], + [ + "{AE89305A-73F4-46B5-B41D-18884685D896},1", + 1 + ], + [ + "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},1", + 1 + ], + [ + "{CBF82C86-3A8B-457F-92A4-1AC7BF436CE9},1", + 1 + ], + [ + "{42010094-E377-4A97-9395-AD45808D0552},1", + 1 + ], + [ + "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},1", + 1 + ], + [ + "{F9DA3139-5856-4B11-95C0-356683E3ACFE},1", + 1 + ], + [ + "{439B80E0-9D4C-4A2F-9163-4E301491364D},1", + 1 + ], + [ + "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},1", + 1 + ], + [ + "{A459C474-E6EB-4BE5-9193-271799B2860B},1", + 1 + ], + [ + "{4ED14CEE-8560-4125-B488-9945C116741E},1", + 1 + ], + [ + "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},1", + 1 + ], + [ + "{932BE37F-05AA-4EFB-85D8-A33445E738B3},1", + 1 + ], + [ + "{9B755368-1D49-4D7E-8D33-6DC536F57848},1", + 1 + ], + [ + "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},1", + 1 + ], + [ + "{5CA4C59F-B400-4BC6-8B90-30ECAC35CB43},1", + 1 + ], + [ + "{D63FB383-FB37-44F9-AEBB-E868E903A2BB},1", + 1 + ], + [ + "{C6E54364-6CD0-4764-8180-873030B9EA53},1", + 1 + ], + [ + "{DEEC391B-F2D1-4EB1-BEB1-57598DFBB982},1", + 1 + ], + [ + "{9E7616ED-DE19-4625-8EA0-0C5DEB18B690},1", + 1 + ], + [ + "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},1", + 1 + ], + [ + "{00B10C58-6649-428D-A081-3EBBA6E88E97},1", + 1 + ], + [ + "{58E99482-710A-4938-8CFB-88A1C9B946C9},1", + 1 + ], + [ + "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},1", + 1 + ], + [ + "{15A45C01-A9E8-42E2-A267-C7FCE728815E},1", + 1 + ] + ] + } + }, + "space": "{A5A2AFBE-1DF1-4190-AAC7-1AD29C9564F2},1" + }, + { + "id": 2, + "operation": { + "Page": { + "after": { + "created": 134348779900000000, + "definitions": { + "{C6E54364-6CD0-4764-8180-873030B9EA53},18": { + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "kind": { + "name": "PageTitle", + "type": "Style" + } + } + }, + "identity": [ + 100, + 67, + 229, + 198, + 208, + 108, + 100, + 71, + 129, + 128, + 135, + 48, + 48, + 185, + 234, + 83 + ], + "margin_origin": [ + 0.0, + 0.0 + ], + "objects": [ + { + "Outline": { + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},29", + "indents": [], + "layout": { + "max_height": null, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": 36.0, + "y": 36.0 + }, + "min_width": null, + "paragraphs": [ + { + "collapsed": false, + "content": { + "Text": { + "date_field": null, + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},28", + "tags": [], + "text": { + "spans": [ + { + "end": 16, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 11.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": 1033, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + } + } + ], + "text": "Actor 9 🦀 é" + } + } + }, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": null, + "font_size": null, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},30", + "level": 1, + "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, + "parent": null, + "style": null, + "tags": [] + } + ], + "title": false, + "unsupported": [] + } + }, + { + "Title": { + "date": null, + "id": "{C6E54364-6CD0-4764-8180-873030B9EA53},13", + "layout": { + "max_height": null, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": 0.0, + "y": 0.0 + }, + "outlines": [ + { + "id": "{C6E54364-6CD0-4764-8180-873030B9EA53},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], + "layout": { + "max_height": 21.600000381469727, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": null, + "y": null + }, + "min_width": 162.0, + "paragraphs": [ + { + "collapsed": false, + "content": { + "Text": { + "date_field": null, + "id": "{C6E54364-6CD0-4764-8180-873030B9EA53},16", + "tags": [], + "text": { + "spans": [ + { + "end": 10, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": 1033, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + } + } + ], + "text": "Same title" + } + } + }, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": null, + "font_size": null, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "id": "{C6E54364-6CD0-4764-8180-873030B9EA53},15", + "level": 1, + "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, + "parent": null, + "style": "{C6E54364-6CD0-4764-8180-873030B9EA53},18", + "tags": [] + } + ], + "title": true, + "unsupported": [] + } + ] + } + } + ], + "title": "Same title" + }, + "author": "Author", + "before": { + "created": 134348779900000000, + "definitions": { + "{C6E54364-6CD0-4764-8180-873030B9EA53},18": { + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "kind": { + "name": "PageTitle", + "type": "Style" + } + } + }, + "identity": [ + 100, + 67, + 229, + 198, + 208, + 108, + 100, + 71, + 129, + 128, + 135, + 48, + 48, + 185, + 234, + 83 + ], + "margin_origin": [ + 0.0, + 0.0 + ], + "objects": [ + { + "Title": { + "date": null, + "id": "{C6E54364-6CD0-4764-8180-873030B9EA53},13", + "layout": { + "max_height": null, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": 0.0, + "y": 0.0 + }, + "outlines": [ + { + "id": "{C6E54364-6CD0-4764-8180-873030B9EA53},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], + "layout": { + "max_height": 21.600000381469727, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": null, + "y": null + }, + "min_width": 162.0, + "paragraphs": [ + { + "collapsed": false, + "content": { + "Text": { + "date_field": null, + "id": "{C6E54364-6CD0-4764-8180-873030B9EA53},16", + "tags": [], + "text": { + "spans": [ + { + "end": 10, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": 1033, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + } + } + ], + "text": "Same title" + } + } + }, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": null, + "font_size": null, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "id": "{C6E54364-6CD0-4764-8180-873030B9EA53},15", + "level": 1, + "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, + "parent": null, + "style": "{C6E54364-6CD0-4764-8180-873030B9EA53},18", + "tags": [] + } + ], + "title": true, + "unsupported": [] + } + ] + } + } + ], + "title": "Same title" + } + } + }, + "space": "{C6E54364-6CD0-4764-8180-873030B9EA53},1" + } + ], + [ + { + "id": 1, + "operation": { + "Pages": { + "edits": [ + { + "guid": [ + 223, + 187, + 17, + 5, + 4, + 237, + 130, + 69, + 161, + 20, + 5, + 179, + 161, + 87, + 94, + 148 + ], + "level": 1, + "position": { + "Before": "{9E7616ED-DE19-4625-8EA0-0C5DEB18B690},1" + }, + "space": "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},1" + }, + { + "guid": [ + 219, + 20, + 156, + 115, + 106, + 105, + 134, + 64, + 131, + 56, + 1, + 213, + 21, + 147, + 139, + 107 + ], + "level": 2, + "position": "Keep", + "space": "{00B10C58-6649-428D-A081-3EBBA6E88E97},1" + } + ], + "observed": [ + [ + "{0B37E922-551A-48F8-BCAC-4AB3503B4D28},1", + 1 + ], + [ + "{74492C4A-164B-40E9-B297-4D3B4685347D},1", + 1 + ], + [ + "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},1", + 1 + ], + [ + "{8B766F11-2A4F-4D95-8473-D3CA68129E11},1", + 1 + ], + [ + "{28E0CC5F-68BC-4A86-9ED7-114FFD3BAE9A},1", + 1 + ], + [ + "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},1", + 1 + ], + [ + "{AFCD09F6-1085-4582-BDB2-4B91E15D2088},1", + 1 + ], + [ + "{A94D5882-C902-4CAE-B38E-2ED56EEF177F},1", + 1 + ], + [ + "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},1", + 1 + ], + [ + "{F212795E-0E4B-4844-A8A6-ABD7F7025124},1", + 1 + ], + [ + "{1786F2CA-0A43-4081-8B55-B207EEE0331C},1", + 1 + ], + [ + "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},1", + 1 + ], + [ + "{0D361C76-92FD-4B76-901B-809C2C3C3CE6},1", + 1 + ], + [ + "{AE89305A-73F4-46B5-B41D-18884685D896},1", + 1 + ], + [ + "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},1", + 1 + ], + [ + "{CBF82C86-3A8B-457F-92A4-1AC7BF436CE9},1", + 1 + ], + [ + "{42010094-E377-4A97-9395-AD45808D0552},1", + 1 + ], + [ + "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},1", + 1 + ], + [ + "{F9DA3139-5856-4B11-95C0-356683E3ACFE},1", + 1 + ], + [ + "{439B80E0-9D4C-4A2F-9163-4E301491364D},1", + 1 + ], + [ + "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},1", + 1 + ], + [ + "{A459C474-E6EB-4BE5-9193-271799B2860B},1", + 1 + ], + [ + "{4ED14CEE-8560-4125-B488-9945C116741E},1", + 1 + ], + [ + "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},1", + 1 + ], + [ + "{932BE37F-05AA-4EFB-85D8-A33445E738B3},1", + 1 + ], + [ + "{9B755368-1D49-4D7E-8D33-6DC536F57848},1", + 1 + ], + [ + "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},1", + 1 + ], + [ + "{5CA4C59F-B400-4BC6-8B90-30ECAC35CB43},1", + 1 + ], + [ + "{D63FB383-FB37-44F9-AEBB-E868E903A2BB},1", + 1 + ], + [ + "{C6E54364-6CD0-4764-8180-873030B9EA53},1", + 1 + ], + [ + "{DEEC391B-F2D1-4EB1-BEB1-57598DFBB982},1", + 1 + ], + [ + "{9E7616ED-DE19-4625-8EA0-0C5DEB18B690},1", + 1 + ], + [ + "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},1", + 1 + ], + [ + "{00B10C58-6649-428D-A081-3EBBA6E88E97},1", + 1 + ], + [ + "{58E99482-710A-4938-8CFB-88A1C9B946C9},1", + 1 + ], + [ + "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},1", + 1 + ], + [ + "{15A45C01-A9E8-42E2-A267-C7FCE728815E},1", + 1 + ] + ] + } + }, + "space": "{A5A2AFBE-1DF1-4190-AAC7-1AD29C9564F2},1" + }, + { + "id": 2, + "operation": { + "Page": { + "after": { + "created": 134348779900000000, + "definitions": { + "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},18": { + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "kind": { + "name": "PageTitle", + "type": "Style" + } + } + }, + "identity": [ + 141, + 116, + 241, + 137, + 73, + 103, + 208, + 74, + 186, + 246, + 7, + 233, + 249, + 192, + 88, + 229 + ], + "margin_origin": [ + 0.0, + 0.0 + ], + "objects": [ + { + "Outline": { + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},32", + "indents": [], + "layout": { + "max_height": null, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": 36.0, + "y": 36.0 + }, + "min_width": null, + "paragraphs": [ + { + "collapsed": false, + "content": { + "Text": { + "date_field": null, + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},31", + "tags": [], + "text": { + "spans": [ + { + "end": 17, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 11.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": 1033, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + } + } + ], + "text": "Actor 10 🦀 é" + } + } + }, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": null, + "font_size": null, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},33", + "level": 1, + "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, + "parent": null, + "style": null, + "tags": [] + } + ], + "title": false, + "unsupported": [] + } + }, + { + "Title": { + "date": null, + "id": "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},13", + "layout": { + "max_height": null, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": 0.0, + "y": 0.0 + }, + "outlines": [ + { + "id": "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], + "layout": { + "max_height": 21.600000381469727, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": null, + "y": null + }, + "min_width": 162.0, + "paragraphs": [ + { + "collapsed": false, + "content": { + "Text": { + "date_field": null, + "id": "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},16", + "tags": [], + "text": { + "spans": [ + { + "end": 10, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": 1033, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + } + } + ], + "text": "Same title" + } + } + }, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": null, + "font_size": null, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "id": "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},15", + "level": 1, + "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, + "parent": null, + "style": "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},18", + "tags": [] + } + ], + "title": true, + "unsupported": [] + } + ] + } + } + ], + "title": "Same title" + }, + "author": "Author", + "before": { + "created": 134348779900000000, + "definitions": { + "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},18": { + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "kind": { + "name": "PageTitle", + "type": "Style" + } + } + }, + "identity": [ + 141, + 116, + 241, + 137, + 73, + 103, + 208, + 74, + 186, + 246, + 7, + 233, + 249, + 192, + 88, + 229 + ], + "margin_origin": [ + 0.0, + 0.0 + ], + "objects": [ + { + "Title": { + "date": null, + "id": "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},13", + "layout": { + "max_height": null, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": 0.0, + "y": 0.0 + }, + "outlines": [ + { + "id": "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], + "layout": { + "max_height": 21.600000381469727, + "max_width": null, + "reserved_width": null, + "width_set_by_user": null, + "x": null, + "y": null + }, + "min_width": 162.0, + "paragraphs": [ + { + "collapsed": false, + "content": { + "Text": { + "date_field": null, + "id": "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},16", + "tags": [], + "text": { + "spans": [ + { + "end": 10, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": "Calibri", + "font_size": 17.0, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": 1033, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + } + } + ], + "text": "Same title" + } + } + }, + "format": { + "alignment": null, + "bold": null, + "color": null, + "embedded_object": null, + "font": null, + "font_size": null, + "hidden": null, + "highlight": null, + "hyperlink": null, + "hyperlink_label": null, + "italic": null, + "language": null, + "line_spacing": null, + "list_spacing": null, + "math": null, + "math_object": null, + "rtl": null, + "space_after": null, + "space_before": null, + "strike": null, + "subscript": null, + "superscript": null, + "underline": null + }, + "id": "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},15", + "level": 1, + "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, + "parent": null, + "style": "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},18", + "tags": [] + } + ], + "title": true, + "unsupported": [] + } + ] + } + } + ], + "title": "Same title" + } + } + }, + "space": "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},1" + } + ], + [ + { + "id": 1, + "operation": { + "Pages": { + "edits": [ + { + "guid": [ + 69, + 87, + 190, 154, - 123 + 161, + 45, + 245, + 76, + 131, + 158, + 203, + 234, + 216, + 244, + 254, + 235 + ], + "level": 1, + "position": { + "Before": "{58E99482-710A-4938-8CFB-88A1C9B946C9},1" + }, + "space": "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},1" + }, + { + "guid": [ + 35, + 252, + 68, + 95, + 80, + 200, + 215, + 77, + 190, + 11, + 114, + 173, + 3, + 70, + 77, + 30 ], "level": 2, "position": "Keep", - "space": "{2B736B19-2D9C-4E76-A732-478D533D717A},1" + "space": "{15A45C01-A9E8-42E2-A267-C7FCE728815E},1" } ], "observed": [ [ - "{869D5929-6E5E-4EDB-9639-4C56977DD447},1", + "{0B37E922-551A-48F8-BCAC-4AB3503B4D28},1", 1 ], [ - "{B0B53F94-0105-4C59-B65B-9473F49BFF41},1", + "{74492C4A-164B-40E9-B297-4D3B4685347D},1", 1 ], [ - "{0E98EE82-B78F-4872-8BD9-5D9B23A817C5},1", + "{DAFF533D-608B-4735-BA1D-2354E8CB8A69},1", 1 ], [ - "{C837A033-F055-4455-B45F-17D82016921C},1", + "{8B766F11-2A4F-4D95-8473-D3CA68129E11},1", 1 ], [ - "{89D86121-BF5C-418B-BB92-CBFFA8EF545F},1", + "{28E0CC5F-68BC-4A86-9ED7-114FFD3BAE9A},1", 1 ], [ - "{57E835EA-2799-4118-B55C-7BB1E51C6941},1", + "{31FFA5F9-C1B8-412D-A2D6-7ACC3CC16FD0},1", 1 ], [ - "{F7EECCCF-B6B4-44F2-A543-36837EC18B8A},1", + "{AFCD09F6-1085-4582-BDB2-4B91E15D2088},1", 1 ], [ - "{361A0F09-EA63-4F2D-9620-C061D4E4707E},1", + "{A94D5882-C902-4CAE-B38E-2ED56EEF177F},1", 1 ], [ - "{EB3940F1-F255-42BE-8C31-DD09E86DBAAF},1", + "{5AB37A0F-5702-4C8E-A8C6-79ED6ED1E69F},1", 1 ], [ - "{C2870D07-8ADC-44BB-9F12-15D6C3708689},1", + "{F212795E-0E4B-4844-A8A6-ABD7F7025124},1", 1 ], [ - "{1E0C6331-32C9-4EF9-8087-150A8669FA91},1", + "{1786F2CA-0A43-4081-8B55-B207EEE0331C},1", 1 ], [ - "{ED9769DD-99D6-4BE2-B980-580DA9C4DFCE},1", + "{3E966EF8-B7B4-483F-B241-8F21DF318AB3},1", 1 ], [ - "{17C30054-7129-4A10-9D00-4D5545E4F4BA},1", + "{0D361C76-92FD-4B76-901B-809C2C3C3CE6},1", 1 ], [ - "{4C5FBA5B-E348-4750-ACE4-E9694D527BFF},1", + "{AE89305A-73F4-46B5-B41D-18884685D896},1", 1 ], [ - "{BD0EF658-4D0F-42A7-B546-B4F4A77D246B},1", + "{551D2637-2AA7-4923-B2DD-31C2E1489FD2},1", 1 ], [ - "{6FEA70FA-1451-4A27-8A8E-C4CC3B713CF6},1", + "{CBF82C86-3A8B-457F-92A4-1AC7BF436CE9},1", 1 ], [ - "{68A1E0E1-7A39-4AB6-AD71-4D8A18878D79},1", + "{42010094-E377-4A97-9395-AD45808D0552},1", 1 ], [ - "{7E9041BD-301B-4B5B-9456-1C92050E4914},1", + "{0F6ABB5E-A021-4E1D-B187-7A72E74000DC},1", 1 ], [ - "{EFA77BAE-8A98-4176-B536-B615D1D2ED2B},1", + "{F9DA3139-5856-4B11-95C0-356683E3ACFE},1", 1 ], [ - "{87AC0239-51A5-47C6-BAF4-926CCF56385C},1", + "{439B80E0-9D4C-4A2F-9163-4E301491364D},1", 1 ], [ - "{C50BF7FC-F848-4B91-B8C8-743D0AA5F195},1", + "{B03B973C-50BC-4B9A-9914-B1D5C0CF6197},1", 1 ], [ - "{FD8D07D0-C447-4516-A7DB-F8522B77D4A7},1", + "{A459C474-E6EB-4BE5-9193-271799B2860B},1", 1 ], [ - "{D2E1FE57-F45D-49C3-B388-95222ACA3886},1", + "{4ED14CEE-8560-4125-B488-9945C116741E},1", 1 ], [ - "{7F6702B3-C977-436F-82AF-403E2BE950E7},1", + "{C4FF8FE4-4C43-461F-98DD-EB1FF2A863B2},1", 1 ], [ - "{688C4EA1-80F5-4D85-B9BB-F8633FF3C0CA},1", + "{932BE37F-05AA-4EFB-85D8-A33445E738B3},1", 1 ], [ - "{754FA957-C432-4502-A5CF-EE44449462C0},1", + "{9B755368-1D49-4D7E-8D33-6DC536F57848},1", 1 ], [ - "{D2197C55-D962-4DF0-8B14-26F88C06D6FA},1", + "{D045EAAF-ACE9-4B4F-979B-E348A4ADE693},1", 1 ], [ - "{87B83BA8-A906-4F71-A6CA-3E8133A75D34},1", + "{5CA4C59F-B400-4BC6-8B90-30ECAC35CB43},1", 1 ], [ - "{F2D6AEBB-98EA-4F10-991F-5243E74AF450},1", + "{D63FB383-FB37-44F9-AEBB-E868E903A2BB},1", 1 ], [ - "{406687D7-F9E9-491B-85DA-166DE6CD5A60},1", + "{C6E54364-6CD0-4764-8180-873030B9EA53},1", 1 ], [ - "{1F73BF36-9FC2-495D-844D-B370D5C47DE7},1", + "{DEEC391B-F2D1-4EB1-BEB1-57598DFBB982},1", 1 ], [ - "{E9BD343F-4A66-4DE2-81F9-3789CCA22818},1", + "{9E7616ED-DE19-4625-8EA0-0C5DEB18B690},1", 1 ], [ - "{189EA744-BE13-467A-95BA-2667A134C0B0},1", + "{89F1748D-6749-4AD0-BAF6-07E9F9C058E5},1", 1 ], [ - "{017F5881-F818-4C7C-B9F9-6BCB4085F1D0},1", + "{00B10C58-6649-428D-A081-3EBBA6E88E97},1", 1 ], [ - "{CD0D17F4-25D8-4076-9AF7-47D9CDEFCF0A},1", + "{58E99482-710A-4938-8CFB-88A1C9B946C9},1", 1 ], [ - "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},1", + "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},1", 1 ], [ - "{2B736B19-2D9C-4E76-A732-478D533D717A},1", + "{15A45C01-A9E8-42E2-A267-C7FCE728815E},1", 1 ] ] } }, - "space": "{90FCDB0C-36AE-422F-B4FE-67676826FE36},1" + "space": "{A5A2AFBE-1DF1-4190-AAC7-1AD29C9564F2},1" }, { "id": 2, "operation": { "Page": { "after": { - "created": 134335976560000000, + "created": 134348779900000000, "definitions": { - "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},18": { + "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},18": { "format": { "alignment": null, "bold": null, @@ -7027,6 +7753,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -7041,6 +7768,24 @@ } } }, + "identity": [ + 213, + 201, + 33, + 247, + 106, + 154, + 95, + 64, + 148, + 201, + 155, + 133, + 167, + 238, + 185, + 128 + ], "margin_origin": [ 0.0, 0.0 @@ -7048,7 +7793,7 @@ "objects": [ { "Outline": { - "id": "{00E55A56-1362-4A11-ACD3-2117BCCE619E},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},35", "indents": [], "layout": { "max_height": null, @@ -7065,7 +7810,7 @@ "content": { "Text": { "date_field": null, - "id": "{AD3FBA83-B057-48DF-9731-DEBD6F95BDBE},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},34", "tags": [], "text": { "spans": [ @@ -7087,6 +7832,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -7117,6 +7863,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -7125,9 +7872,13 @@ "superscript": null, "underline": null }, - "id": "{77E85A17-9EF5-4C79-B11B-48A5CE7FE0FD},0", + "id": "{91379CE6-91DA-48BA-B306-77E4CEB068B6},36", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, "style": null, "tags": [] @@ -7140,7 +7891,7 @@ { "Title": { "date": null, - "id": "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},13", + "id": "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},13", "layout": { "max_height": null, "max_width": null, @@ -7151,24 +7902,29 @@ }, "outlines": [ { - "id": "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},14", - "indents": [], + "id": "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},16", + "id": "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},16", "tags": [], "text": { "spans": [ @@ -7190,6 +7946,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -7220,6 +7977,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -7228,15 +7986,19 @@ "superscript": null, "underline": null }, - "id": "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},15", + "id": "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, - "style": "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},18", + "style": "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -7247,9 +8009,9 @@ }, "author": "Author", "before": { - "created": 134335976560000000, + "created": 134348779900000000, "definitions": { - "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},18": { + "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},18": { "format": { "alignment": null, "bold": null, @@ -7266,6 +8028,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -7280,6 +8043,24 @@ } } }, + "identity": [ + 213, + 201, + 33, + 247, + 106, + 154, + 95, + 64, + 148, + 201, + 155, + 133, + 167, + 238, + 185, + 128 + ], "margin_origin": [ 0.0, 0.0 @@ -7288,7 +8069,7 @@ { "Title": { "date": null, - "id": "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},13", + "id": "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},13", "layout": { "max_height": null, "max_width": null, @@ -7299,24 +8080,29 @@ }, "outlines": [ { - "id": "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},14", - "indents": [], + "id": "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},14", + "indents": [ + 18.0, + 0.0, + 27.0, + 27.0 + ], "layout": { - "max_height": null, + "max_height": 21.600000381469727, "max_width": null, "reserved_width": null, "width_set_by_user": null, "x": null, "y": null }, - "min_width": null, + "min_width": 162.0, "paragraphs": [ { "collapsed": false, "content": { "Text": { "date_field": null, - "id": "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},16", + "id": "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},16", "tags": [], "text": { "spans": [ @@ -7338,6 +8124,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -7368,6 +8155,7 @@ "line_spacing": null, "list_spacing": null, "math": null, + "math_object": null, "rtl": null, "space_after": null, "space_before": null, @@ -7376,15 +8164,19 @@ "superscript": null, "underline": null }, - "id": "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},15", + "id": "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},15", "level": 1, "lists": [], + "media": { + "recordings": [], + "time_ms": null + }, "parent": null, - "style": "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},18", + "style": "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},18", "tags": [] } ], - "title": false, + "title": true, "unsupported": [] } ] @@ -7395,7 +8187,7 @@ } } }, - "space": "{CCBB5969-BCE8-498B-BE1A-98FD3A44DB1A},1" + "space": "{F721C9D5-9A6A-405F-94C9-9B85A7EEB980},1" } ] ], diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/candidate/pages.one b/corpus/page-lifecycle/offline-edits/twelve-clients/candidate/pages.one index 2eeab58e5d1fbb41822281555635300f75e4b5b6..9b6e76a7773ef0b59eabeb8d17fa2ef308f35bee 100644 Binary files a/corpus/page-lifecycle/offline-edits/twelve-clients/candidate/pages.one and b/corpus/page-lifecycle/offline-edits/twelve-clients/candidate/pages.one differ diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/commands.jsonl b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/commands.jsonl index 39cb578ade0cf61be05f9d640e0e5b37c168fc0a..06597f625d9483210f4de67a9889ac55b9b53817 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/commands.jsonl +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/commands.jsonl @@ -1,3 +1,3 @@ {"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} -{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-16C2E4D4 -ExpectedPages 37", "exit": 0, "stdout": "Read 1 sections and 37 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-8EABB42E -ExpectedPages 37", "exit": 0, "stdout": "Read 1 sections and 37 pages.\r\n", "stderr": "", "error": null} {"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/machine.json b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/machine.json index f3647d86a305f6004b3249b3cb1cea0accb16037..ff2ebdbb2963c8cd6b155add9ecdefbc44e6be26 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/machine.json +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/machine.json @@ -1 +1 @@ -{"name": "m6-16c2e4d4", "hostname": "ONE-M6-16C2E4D4"} +{"name": "m6-8eabb42e", "hostname": "ONE-M6-8EABB42E"} diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/notebook/Open Notebook.onetoc2 b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/notebook/Open Notebook.onetoc2 index 3d74ced3a87a8c55ef5f637d4a211d3c0717d838..6837468ab622401a4e663df3a90ef55780cc8947 100644 Binary files a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/notebook/Open Notebook.onetoc2 and b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/notebook/pages.one b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/notebook/pages.one index 629137c244ba6e9c8c0f461e46d2551adae3d066..b8133cd8158dd671b7dc8674bbdea6497ec50570 100644 Binary files a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/notebook/pages.one and b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/notebook/pages.one differ diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/environment.json b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/environment.json index 747e291e640b73db771af0151c2ebe1ecb3627e6..5bc3099f6417b20ac85d015222d5e04f2b86638d 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/environment.json +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/environment.json @@ -1,7 +1,7 @@ { "powershell": "5.1.14409.1005", "schema": "xs2010", - "hostname": "ONE-M6-16C2E4D4", + "hostname": "ONE-M6-8EABB42E", "cold": true, "onenote": "14.0.4763.1000" } diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/hierarchy.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/hierarchy.xml index 569bd9d2624ab7dc4f36e751f769b5ed2b492d19..d4e5408b7110781675badce1132255454692e856 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/hierarchy.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-000.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-000.xml index ddc49e628e7e1f06935c6038502eecf01489308e..f956d2bc0d8ebcb15763862cc25f573f106f5bb6 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-000.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-000.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-001.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-001.xml index ea93ab8f4c1d76eaf9c633fbf2ab1a8a58b18e51..ae141a6149e9721d7d0177cf4b3802f63ff49262 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-001.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-001.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-002.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-002.xml index 2d8525d02868d0f1287b344cc030bac1f5798efe..1fee8db2fc02a1a2a2d381c3b0362d8cf1ecd20a 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-002.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-002.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-003.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-003.xml index 6a6d89afdf7796f811b0cd05a0dc0124da540dac..3e56be4f60c0ce6a16b7ac849697a55a9933ac78 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-003.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-003.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-004.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-004.xml index 91ca1b13844bddd0066ef9b4b51502b034f3003d..10195a025ab5c617a13ea3282851f4ddbd436b7b 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-004.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-004.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-005.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-005.xml index 54244aa786c49bd758072198f180fa18e428f132..e5d3e7c8ad10e447a5511264525a4f957c325059 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-005.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-005.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-006.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-006.xml index 0b65c27a4cb7593a90534f1ae39f8ac2034e0e1c..c16489d9a41200f4343d3dd29345e344e694b476 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-006.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-006.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-007.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-007.xml index ab512d7f8beab5e69ca851b04ef0c4822431cf42..16b0461fa75431284f0080b60d83b8ff3b0edffd 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-007.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-007.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-008.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-008.xml index 8bbea50d74a13c69d55e0804031265973456d572..2e84c7ba03966e3e33df68748aed2d018c1202af 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-008.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-008.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-009.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-009.xml index 045fcb701b9f60ea1d82b63dfbbd936c10a0d91d..3642d3fb103d90160706f2d7891acb0b0322d472 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-009.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-009.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-010.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-010.xml index 3d16a64f844515662eb47ca89db8eaca383f37bd..8cbdb433032edcb24c9d130526f988b74de62be2 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-010.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-010.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-011.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-011.xml index 04b3240f657876147e0982ee8a4e828930d92df9..af6b915766a5debc5a376a647184c2962f7a281a 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-011.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-011.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-012.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-012.xml index 8b383a7af0f74c659ebf68f068b3e660c3d58d06..1ffa4533ae6c5d74bb63f0ad650f73ed8bded3c6 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-012.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-012.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-013.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-013.xml index 8c1d476171c64a10f1465826b3e4208ed2d13d7a..1794ab1fe1dc217cde6acbae4f6892e636e97aec 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-013.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-013.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-014.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-014.xml index 08c872d0ad481307923505f968cc3683259b630c..874b1dd3ee8a63ef23f5f160c1ac972ce7b08549 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-014.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-014.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-015.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-015.xml index 6f61dc1a76e074e554a5c25d23137ac02d203390..8e27a1bbae8a87eb791dfc9ea1f8c48a56b5b43a 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-015.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-015.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-016.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-016.xml index 0c0d0e6ad4ea243067b5114bc23658cb70fb9a7e..88a9f53d15a772ff208d244526aeac8d82889818 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-016.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-016.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-017.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-017.xml index 9814cd630806eefc93ee9efa5e1d18fa17a524d9..130b32e55785dd0830853e3ff4539669798d5ec6 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-017.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-017.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-018.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-018.xml index c5070fd54278cf962a0213a7016a096db7ddf28b..b7a0fe083e4db08e3fcb40e78a7eb7817ea0b0c4 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-018.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-018.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-019.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-019.xml index e8fa05da717a35ae9070c0754343dcbcb6cd760d..a3d371ef5fdbf0a79a5025f5c4fc210fbc739c65 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-019.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-019.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-020.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-020.xml index b31abf3925f0c62ba09ce2c11b22f566ec63a0dd..c49d76ebbade837cb66354116df116cab56e629d 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-020.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-020.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-021.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-021.xml index a21a4dd734a31553b4c6a69ccd8542ed6f80f834..0d7e61b61d337a91ea28d2ae80a5c4f6a314540d 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-021.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-021.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-022.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-022.xml index e44c331c3277c2a6cf462b78082895a1ae6265c3..c9c53e96b57d4e0e4a6c3b74c6b77928ea8ab840 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-022.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-022.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-023.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-023.xml index 7c314f860d8a9b791be5c9d6421d67b5751f3674..8855f6c8d97e2f7583817ea668dec1c72ab15c0f 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-023.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-023.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-024.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-024.xml index c6e2f5e91f62380a0aa8245e28fce80bf5523fa6..22f7a7e07f0828a62b346324ff4b70367f549a64 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-024.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-024.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-025.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-025.xml index 2463cde990f11f382f3e5cf07b844396ebe3f1d9..6cfa6fa1617e39520c4cb9f73739821535eb4e26 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-025.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-025.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-026.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-026.xml index da1e7da17cd18a1e94fbb4fd3175f186c1d12eea..7b18e8a03e744926baa8c5b57241ee81fe4d75e7 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-026.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-026.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-027.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-027.xml index 126f6719eb016d3e246dd0e921b97e7e94634d4f..d8ca847ff3f4580943079fdf3dc79a74651e07ca 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-027.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-027.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-028.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-028.xml index c258afb97ddcaccf28f6faca8c9aec7c2c80925b..98a35d0a50b28c89796e47d3ea1503c5ba9cd7d1 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-028.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-028.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-029.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-029.xml index 8a4e8bae89e5afdbcc79408532e77f0d30e65726..49840d7d61f3d52fc5d3ebdc7b64bf0306fa6f0b 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-029.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-029.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-030.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-030.xml index f8f903f4ce64e3b1ca3bd9cf6e3afba2d34b6f73..ab1b309913b242d127e7996a05427c62445bc275 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-030.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-030.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-031.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-031.xml index 2b25bc0ee284254da60a0fa8afb549b0d17705b7..ce5ac8195e502cbf22f8c365ba31a1d86ff1df15 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-031.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-031.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-032.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-032.xml index 365f61997ffcc80c2cf7f2766f40d546333596c8..9164405b9103bed6b032fedd045b32b499617165 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-032.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-032.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-033.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-033.xml index a666cdc6381ff2f08d9e4a612fb678d27ed8e31b..ec6b60bf7eee61dc27edb850e7aecbb351b90de4 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-033.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-033.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-034.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-034.xml index 34923dd48e15d94c96eb55cefbd03f4d70737607..1d4e3f7a637e4a08a0fde419c8e90d54fcd1cc52 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-034.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-034.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-035.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-035.xml index 0220fa4fc4bac580f38180ac4ac58a5c419533a5..2ef2198577a1eef93cf20880f3a141bc1925a145 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-035.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-035.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-036.xml b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-036.xml index 1ebc46fdbeb645acc7f5e0c820039c95253bbd48..77deb2d431d3caa80ec2da3888f947fdc4e20a1a 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-036.xml +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/read/page-036.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/run.json b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/run.json index 6c89101554490de6bed888dd5cc6762ec72d1559..02a7b4ecd7bea33ed69ef4d827f4f0c8cb99ddc2 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/run.json +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/run.json @@ -1,5 +1,5 @@ { - "notebook": "/private/tmp/m33/page-clients2/candidate", + "notebook": "/Users/clo/dev/one/evidence/titles/twelve-clients", "expected_pages": 37, "author": null, "author_timeout_seconds": 600, diff --git a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/source.json b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/source.json index 5a7b7bdc33c9835a75ee62c9515d3481b0c6c5ae..c910569548551aae0de5ef213251a15706e376a9 100644 --- a/corpus/page-lifecycle/offline-edits/twelve-clients/cold/source.json +++ b/corpus/page-lifecycle/offline-edits/twelve-clients/cold/source.json @@ -1,8 +1,14 @@ [ + { + "path": "manifest.json", + "bytes": 292907, + "sha256": "fea1d64b22da84d023d4c9014175f8e92a13acba099c7a1a3f8eae30d6c6407f", + "mtime_ns": 1790404395466294564 + }, { "path": "pages.one", - "bytes": 155344, - "sha256": "623ade8b5ee6fc837ae63d5293791039ecd17da9cb8bc5e243ea20cadf37958c", - "mtime_ns": 1789124063040528117 + "bytes": 152360, + "sha256": "302b6d2d31bc8f539a2217aed3391973678576b0aea1ce82c6e21760d6402e14", + "mtime_ns": 1790404395457867619 } ] diff --git a/corpus/page-lifecycle/page-edits/stress/candidate/movement.one b/corpus/page-lifecycle/page-edits/stress/candidate/movement.one index 82d300255880a5020cd967ddc023fd5663f478f3..e8be6da6e52065366b844a75b9b7373413803a5c 100644 Binary files a/corpus/page-lifecycle/page-edits/stress/candidate/movement.one and b/corpus/page-lifecycle/page-edits/stress/candidate/movement.one differ diff --git a/corpus/page-lifecycle/page-edits/stress/cold/notebook/Open Notebook.onetoc2 b/corpus/page-lifecycle/page-edits/stress/cold/notebook/Open Notebook.onetoc2 index bbec2b3cf53ddd4da36fa14c81cf6cee27fe9049..85153be06a16ae46c46a7c7fda7ca11450703c9c 100644 Binary files a/corpus/page-lifecycle/page-edits/stress/cold/notebook/Open Notebook.onetoc2 and b/corpus/page-lifecycle/page-edits/stress/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/page-lifecycle/page-edits/stress/cold/notebook/movement.one b/corpus/page-lifecycle/page-edits/stress/cold/notebook/movement.one index b6a432349a6a27d35eeda9597df192403f6dad04..f14e4c4ab2f0ea704501a5455fc12b4aa001168c 100644 Binary files a/corpus/page-lifecycle/page-edits/stress/cold/notebook/movement.one and b/corpus/page-lifecycle/page-edits/stress/cold/notebook/movement.one differ diff --git a/corpus/page-lifecycle/page-edits/stress/cold/read/environment.json b/corpus/page-lifecycle/page-edits/stress/cold/read/environment.json index c3ab70aea40dd3fb0ebc12be67640dc0feb2f474..5c7ed34e589a221ce9c7dacf9b6fe49f586fdb95 100644 --- a/corpus/page-lifecycle/page-edits/stress/cold/read/environment.json +++ b/corpus/page-lifecycle/page-edits/stress/cold/read/environment.json @@ -1,7 +1,7 @@ { "powershell": "5.1.14409.1005", "schema": "xs2010", - "hostname": "ONE-M6-40A088EC", + "hostname": "ONE-M6-02A94B20", "cold": true, "onenote": "14.0.4763.1000" } diff --git a/corpus/page-lifecycle/page-edits/stress/cold/read/hierarchy.xml b/corpus/page-lifecycle/page-edits/stress/cold/read/hierarchy.xml index ad6751baa8f875d81ab183864481c190c503b806..64973a37a20b9459d706489d5330f4d6dd40fef1 100644 --- a/corpus/page-lifecycle/page-edits/stress/cold/read/hierarchy.xml +++ b/corpus/page-lifecycle/page-edits/stress/cold/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/page-edits/stress/cold/read/page-000.xml b/corpus/page-lifecycle/page-edits/stress/cold/read/page-000.xml index 53ec8f0dc30ef0c4c5b6ee1aa373b10177b51339..c3571e8919eaa3402e2b419914972f30b41d9dab 100644 --- a/corpus/page-lifecycle/page-edits/stress/cold/read/page-000.xml +++ b/corpus/page-lifecycle/page-edits/stress/cold/read/page-000.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/page-edits/stress/cold/read/page-001.xml b/corpus/page-lifecycle/page-edits/stress/cold/read/page-001.xml index a01e9753e0c69fe00274d33b324c0a6b17163904..73a489faf30552b7354e9c95d7ccb0d82f4bcc02 100644 --- a/corpus/page-lifecycle/page-edits/stress/cold/read/page-001.xml +++ b/corpus/page-lifecycle/page-edits/stress/cold/read/page-001.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-lifecycle/page-edits/stress/provenance.json b/corpus/page-lifecycle/page-edits/stress/provenance.json index 201ee7ff57e0ba45a9524507a119cd26d1f247c3..6cd56bfea3b4fa0ebb29bbe72c32bd9062f6def1 100644 --- a/corpus/page-lifecycle/page-edits/stress/provenance.json +++ b/corpus/page-lifecycle/page-edits/stress/provenance.json @@ -1,10 +1,10 @@ { "generator": "crates/onestore/tests/page_movement.rs::repeated_nesting_retains_series_history_across_revision_checkpoints", "edits": 520, - "capture": "evidence/m10/page-edit-stress-cold-01", + "capture": "evidence/titles/cold-stress", "machine": { - "name": "m6-40a088ec", - "hostname": "ONE-M6-40A088EC" + "name": "m6-02a94b20", + "hostname": "ONE-M6-02A94B20" }, "teardown": { "absent": true @@ -12,9 +12,9 @@ "source": [ { "path": "movement.one", - "bytes": 2635536, - "sha256": "77dbec39ca25558b596b477d57117416f23ee133519f5179d7ebf1a33b38c248", - "mtime_ns": 1788936082839470214 + "bytes": 959216, + "sha256": "e2948ff3d01839a2c6a7908633739e97c4ebbe28e584c105bb1166669508758f", + "mtime_ns": 1790404366792422729 } ] } diff --git a/corpus/paragraph-edit/README.md b/corpus/paragraph-edit/README.md index c37d217f2c67e340b184f342ea562b514b76feb0..13ebe199e69cb9b6431fcd8a29d23a249168f47b 100644 --- a/corpus/paragraph-edit/README.md +++ b/corpus/paragraph-edit/README.md @@ -28,7 +28,8 @@ when regenerating the corpus. `rust-split` retains twelve `ParagraphSplit` outputs, their cold native captures, and a second native session typing into four empty boundary paragraphs. The -hyperlink case stays unchanged because this operation rejects fields. The Rust +hyperlink case has no output: the operation rejected fields when this row was +captured, and the writer now splits before a link as OneNote does. The Rust test `export_native_paragraph_splits` generates candidate notebooks; its output directory comes from `ONESTORE_PARAGRAPH_OUTPUT`. Native character/style comparisons use both the keyboard-generated controls and the Rust-written notebooks. Empty diff --git a/corpus/picture-edit/inserted/candidate/Open Notebook.onetoc2 b/corpus/picture-edit/inserted/candidate/Open Notebook.onetoc2 index 79f8f955db3b58cd2ae471c00ae2b66c95f81f62..d2fa7bd93a13d2c6527c96fb7ab415970dec9e22 100644 Binary files a/corpus/picture-edit/inserted/candidate/Open Notebook.onetoc2 and b/corpus/picture-edit/inserted/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/picture-edit/inserted/candidate/pictures.one b/corpus/picture-edit/inserted/candidate/pictures.one index af4169c68f7c76ead1f32f25685131438a9fc7a1..514ff7e2e0d9de555074590b90cf2cc6683d0d82 100644 Binary files a/corpus/picture-edit/inserted/candidate/pictures.one and b/corpus/picture-edit/inserted/candidate/pictures.one differ diff --git a/corpus/picture-edit/inserted/cold/commands.jsonl b/corpus/picture-edit/inserted/cold/commands.jsonl index f077978e124b796017ac989959a7b5e48d27684c..3f288baca3dd068b8e3f376ffc70b3dd1447c929 100644 --- a/corpus/picture-edit/inserted/cold/commands.jsonl +++ b/corpus/picture-edit/inserted/cold/commands.jsonl @@ -1,3 +1,3 @@ {"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} -{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-A5A8705B -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-424D1706 -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} {"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/picture-edit/inserted/cold/machine.json b/corpus/picture-edit/inserted/cold/machine.json index 80a47e2a0dd156babc8d6e6c895da3a3144fd28a..3477c66572b21da7d3586f5a6df5e54ff402d5a8 100644 --- a/corpus/picture-edit/inserted/cold/machine.json +++ b/corpus/picture-edit/inserted/cold/machine.json @@ -1 +1 @@ -{"name": "m6-a5a8705b", "hostname": "ONE-M6-A5A8705B"} +{"name": "m6-424d1706", "hostname": "ONE-M6-424D1706"} diff --git a/corpus/picture-edit/inserted/cold/notebook/Open Notebook.onetoc2 b/corpus/picture-edit/inserted/cold/notebook/Open Notebook.onetoc2 index 836282818b6fe532b91bf32edf78fba4fe160b7a..ae76ec5425f6faf5125d05880e345f96a56b592e 100644 Binary files a/corpus/picture-edit/inserted/cold/notebook/Open Notebook.onetoc2 and b/corpus/picture-edit/inserted/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/picture-edit/inserted/cold/notebook/pictures.one b/corpus/picture-edit/inserted/cold/notebook/pictures.one index 0dd87dcf4b41be4ca1b5b4b921d9462358aae3b1..6b396de0de3228128bd0d0c2c133c91c71b2b712 100644 Binary files a/corpus/picture-edit/inserted/cold/notebook/pictures.one and b/corpus/picture-edit/inserted/cold/notebook/pictures.one differ diff --git a/corpus/picture-edit/inserted/cold/read/environment.json b/corpus/picture-edit/inserted/cold/read/environment.json index f1032fa5ff0c8dddcee05b60f81366ec2671b1f9..ee895b3836477f946d2c238f156ceea0ebf39f6b 100644 --- a/corpus/picture-edit/inserted/cold/read/environment.json +++ b/corpus/picture-edit/inserted/cold/read/environment.json @@ -1,7 +1,7 @@ { "powershell": "5.1.14409.1005", "schema": "xs2010", - "hostname": "ONE-M6-A5A8705B", + "hostname": "ONE-M6-424D1706", "cold": true, "onenote": "14.0.4763.1000" } diff --git a/corpus/picture-edit/inserted/cold/read/hierarchy.xml b/corpus/picture-edit/inserted/cold/read/hierarchy.xml index dce86fad6744777127a6c19a6146cadb358f799f..35cd4ee6227ccc2c3c6878c3adfc1d64b719d385 100644 --- a/corpus/picture-edit/inserted/cold/read/hierarchy.xml +++ b/corpus/picture-edit/inserted/cold/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/picture-edit/inserted/cold/read/page-000.xml b/corpus/picture-edit/inserted/cold/read/page-000.xml index aa8840aa5f2fb3e14fe2cb7223dc8638d73f1a92..b2dc6e6e2d92ee748769d63567460efe89f7d3d5 100644 --- a/corpus/picture-edit/inserted/cold/read/page-000.xml +++ b/corpus/picture-edit/inserted/cold/read/page-000.xml @@ -1,4 +1,4 @@  -iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w AAAAAElFTkSuQmCC - + diff --git a/corpus/picture-edit/inserted/cold/run.json b/corpus/picture-edit/inserted/cold/run.json index 6e98c47fb0e8f3f15888fbc77851de46e6db6301..b1dce8446b05ce05cfde663279dd19c7dfb9c382 100644 --- a/corpus/picture-edit/inserted/cold/run.json +++ b/corpus/picture-edit/inserted/cold/run.json @@ -1,5 +1,5 @@ { - "notebook": "/private/tmp/m4/pictures", + "notebook": "/Users/clo/dev/one/evidence/titles/picture-inserted", "expected_pages": 1, "author": null, "author_timeout_seconds": 600, diff --git a/corpus/picture-edit/inserted/cold/source.json b/corpus/picture-edit/inserted/cold/source.json index 006d1122e0e6b26381b4b3c32834b6beb5866625..353bb8d4addd1e4959107513b93b3f4001dde9d5 100644 --- a/corpus/picture-edit/inserted/cold/source.json +++ b/corpus/picture-edit/inserted/cold/source.json @@ -2,13 +2,13 @@ { "path": "Open Notebook.onetoc2", "bytes": 1604, - "sha256": "0eddd691b52d26bac222cf7e52add3c9909446647b452fb193f03d49e273426a", - "mtime_ns": 1789243635388246438 + "sha256": "52e4f16e4fc2b25971d0b1e2504679373b413d65a5127833f874335115ace5c9", + "mtime_ns": 1790404370185679445 }, { "path": "pictures.one", - "bytes": 7064, - "sha256": "3076880e02cf74eb85400852c133060ab8ed50383c6b9df8a07bde8389891a70", - "mtime_ns": 1789243635387879438 + "bytes": 7776, + "sha256": "c468fdcd8d55b5161c9e8f104d846c4191c40efb4bdc9a6aa134f8d824627436", + "mtime_ns": 1790404370185326028 } ] diff --git a/corpus/picture-edit/page-level/candidate/Open Notebook.onetoc2 b/corpus/picture-edit/page-level/candidate/Open Notebook.onetoc2 index 52d19e53c867b3228411ceec37a5747fb72aea4d..f835d6eeef52041810b4410edaf30afa8b938afb 100644 Binary files a/corpus/picture-edit/page-level/candidate/Open Notebook.onetoc2 and b/corpus/picture-edit/page-level/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/picture-edit/page-level/candidate/pictures.one b/corpus/picture-edit/page-level/candidate/pictures.one index b656ae835d49ab68c65a0aa4629b82385df4ffd0..ee5d78bdb1f02d15ae14006ccc5b12578596e0fb 100644 Binary files a/corpus/picture-edit/page-level/candidate/pictures.one and b/corpus/picture-edit/page-level/candidate/pictures.one differ diff --git a/corpus/picture-edit/page-level/cold/commands.jsonl b/corpus/picture-edit/page-level/cold/commands.jsonl index 73b9bd3014f117c57e27064b4131c0747326f7c2..e4f08d1e983a4c36af815280a0a1690e89deb0da 100644 --- a/corpus/picture-edit/page-level/cold/commands.jsonl +++ b/corpus/picture-edit/page-level/cold/commands.jsonl @@ -1,3 +1,3 @@ {"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} -{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-3E57E5E3 -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-F9B47642 -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} {"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/picture-edit/page-level/cold/machine.json b/corpus/picture-edit/page-level/cold/machine.json index c8cb9f4c9ef121e59a02fbd224d6cdbf81a47347..bc15f36cb00684ed88357b1e70ef02ca92ce6993 100644 --- a/corpus/picture-edit/page-level/cold/machine.json +++ b/corpus/picture-edit/page-level/cold/machine.json @@ -1 +1 @@ -{"name": "m6-3e57e5e3", "hostname": "ONE-M6-3E57E5E3"} +{"name": "m6-f9b47642", "hostname": "ONE-M6-F9B47642"} diff --git a/corpus/picture-edit/page-level/cold/notebook/Open Notebook.onetoc2 b/corpus/picture-edit/page-level/cold/notebook/Open Notebook.onetoc2 index b9e7f4815ce3a65ccfb1f0e432e3d5ae94af1a2b..1d99242e53fc73f4346cb95c5a0318d77eceea0f 100644 Binary files a/corpus/picture-edit/page-level/cold/notebook/Open Notebook.onetoc2 and b/corpus/picture-edit/page-level/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/picture-edit/page-level/cold/notebook/pictures.one b/corpus/picture-edit/page-level/cold/notebook/pictures.one index c608c2f7e561582e3f90bc3facd1ed5518724bc2..0f5c761164c4a277e4c14a3553b94182d477023f 100644 Binary files a/corpus/picture-edit/page-level/cold/notebook/pictures.one and b/corpus/picture-edit/page-level/cold/notebook/pictures.one differ diff --git a/corpus/picture-edit/page-level/cold/read/environment.json b/corpus/picture-edit/page-level/cold/read/environment.json index 1ffd2b6d1693fdcc135f535dbe81348c8567f201..b5460391fb228773e62acf6e69815b57a07cb927 100644 --- a/corpus/picture-edit/page-level/cold/read/environment.json +++ b/corpus/picture-edit/page-level/cold/read/environment.json @@ -1,7 +1,7 @@ { "powershell": "5.1.14409.1005", "schema": "xs2010", - "hostname": "ONE-M6-3E57E5E3", + "hostname": "ONE-M6-F9B47642", "cold": true, "onenote": "14.0.4763.1000" } diff --git a/corpus/picture-edit/page-level/cold/read/hierarchy.xml b/corpus/picture-edit/page-level/cold/read/hierarchy.xml index cad5c63cbd32f13ff9454a9f0ce2fd2377b0f149..44863319a02f300f4bc1bcd6ca04ab40a393c812 100644 --- a/corpus/picture-edit/page-level/cold/read/hierarchy.xml +++ b/corpus/picture-edit/page-level/cold/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/picture-edit/page-level/cold/read/page-000.xml b/corpus/picture-edit/page-level/cold/read/page-000.xml index 6f9dbd9dc67dad811b4f892a4fe7f8853d43762e..f18463802a98f983a390afbe8a93a55e4c8258af 100644 --- a/corpus/picture-edit/page-level/cold/read/page-000.xml +++ b/corpus/picture-edit/page-level/cold/read/page-000.xml @@ -1,4 +1,4 @@  -iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w AAAAAElFTkSuQmCC diff --git a/corpus/picture-edit/page-level/cold/run.json b/corpus/picture-edit/page-level/cold/run.json index 8efa63abedb2bef67a9d90bff430c1a013d862e2..bdd0bc14e5430ec94438337d08ddd4f239c372ac 100644 --- a/corpus/picture-edit/page-level/cold/run.json +++ b/corpus/picture-edit/page-level/cold/run.json @@ -1,5 +1,5 @@ { - "notebook": "/private/tmp/m4/pagelevel", + "notebook": "/Users/clo/dev/one/evidence/titles/picture-page-level", "expected_pages": 1, "author": null, "author_timeout_seconds": 600, diff --git a/corpus/picture-edit/page-level/cold/source.json b/corpus/picture-edit/page-level/cold/source.json index 8791fc92eea165f04e91d51b94af79d1b470412c..25b8c54676e9de2a663ee9e23fc0179ec2bc9be0 100644 --- a/corpus/picture-edit/page-level/cold/source.json +++ b/corpus/picture-edit/page-level/cold/source.json @@ -2,13 +2,13 @@ { "path": "Open Notebook.onetoc2", "bytes": 1604, - "sha256": "3df6c788afbfe6ecda97d63cdbb3f42b269d61107e78dd0b3babaf55ae567389", - "mtime_ns": 1789249104766253246 + "sha256": "8d7fed8f27dfd76533e619068e37c1ee0de5187faebb129c5047c01a814f4c9f", + "mtime_ns": 1790404370294442700 }, { "path": "pictures.one", - "bytes": 5656, - "sha256": "b6c0a2e604c038a2d0fa93a6096c3fecfa08f370f268ad010d7419ee87dd1f02", - "mtime_ns": 1789249104766006120 + "bytes": 6560, + "sha256": "80dd6cc455bbbffe37d9cb7ad30d69a4a6a65b550a5de3d87a514bc0f99c5f41", + "mtime_ns": 1790404370294185825 } ] diff --git a/corpus/table-edit/README.md b/corpus/table-edit/README.md index e3d23c4df2c2041d42362d04e84651ee6e6b46af..24f1e88e56ac8043ec15f6b143fd8f39a5785ec6 100644 --- a/corpus/table-edit/README.md +++ b/corpus/table-edit/README.md @@ -23,8 +23,8 @@ two-row table with locked 72 pt column and visible borders. `nested/cold` is its cold read with both tables. Native rows and cells are plain containers; the writer creates them with the -modification time, the indent array copied from a sibling cell, and the flags -every native cell carries, rebuilds each table's row and cell order to the +modification time, the indent array copied from a sibling cell (OneNote's +default without one), and the flags every native cell carries, rebuilds each table's row and cell order to the model's, and writes the column count, widths, locks and border flag. New tables start as an empty paragraph whose content the structure pass replaces. An emptied cell keeps a replacement paragraph, as the tree writer provides. diff --git a/corpus/table-edit/created/candidate/Open Notebook.onetoc2 b/corpus/table-edit/created/candidate/Open Notebook.onetoc2 index 784f65349498d29d5f5fe5433dd751c740326029..b68a0b3659bea1846b344c7c4dec9ee57166d570 100644 Binary files a/corpus/table-edit/created/candidate/Open Notebook.onetoc2 and b/corpus/table-edit/created/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/table-edit/created/candidate/tables.one b/corpus/table-edit/created/candidate/tables.one index 75d44cc0da8f1738e0438647e48a8b09cb844710..2e2c3a0af67d1b0ef828305ec71e9558fdf0d973 100644 Binary files a/corpus/table-edit/created/candidate/tables.one and b/corpus/table-edit/created/candidate/tables.one differ diff --git a/corpus/table-edit/created/cold/commands.jsonl b/corpus/table-edit/created/cold/commands.jsonl index 613db96ba12c5a91daa79bc91aa59630b63ba5bc..1a2863cbd741045e0fa438faaa43c2a5997709d0 100644 --- a/corpus/table-edit/created/cold/commands.jsonl +++ b/corpus/table-edit/created/cold/commands.jsonl @@ -1,3 +1,3 @@ {"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} -{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-838F23A9 -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-D27A9E1E -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} {"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/table-edit/created/cold/machine.json b/corpus/table-edit/created/cold/machine.json index 0a5fbe5a162b6b7a317dc04cb9656215268b42fa..79b5f764561a5fa1d4da2bd7a7e631847b8937f0 100644 --- a/corpus/table-edit/created/cold/machine.json +++ b/corpus/table-edit/created/cold/machine.json @@ -1 +1 @@ -{"name": "m6-838f23a9", "hostname": "ONE-M6-838F23A9"} +{"name": "m6-d27a9e1e", "hostname": "ONE-M6-D27A9E1E"} diff --git a/corpus/table-edit/created/cold/notebook/Open Notebook.onetoc2 b/corpus/table-edit/created/cold/notebook/Open Notebook.onetoc2 index 72c04c527a22a5243a61e843e00244604e967e55..07b727ab19e9054269c8379543e640160a8c9164 100644 Binary files a/corpus/table-edit/created/cold/notebook/Open Notebook.onetoc2 and b/corpus/table-edit/created/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/table-edit/created/cold/notebook/tables.one b/corpus/table-edit/created/cold/notebook/tables.one index 7a1d5ea5e66d9b0e24563be6a4d23df29a2a3069..18ff04a48d6bdff111a55431fe9259c1797438b8 100644 Binary files a/corpus/table-edit/created/cold/notebook/tables.one and b/corpus/table-edit/created/cold/notebook/tables.one differ diff --git a/corpus/table-edit/created/cold/read/environment.json b/corpus/table-edit/created/cold/read/environment.json index 9c1eae28f4b03184eee090aa1d307e526b682b83..026a304222b4408435320afcbe4b734fca196ba3 100644 --- a/corpus/table-edit/created/cold/read/environment.json +++ b/corpus/table-edit/created/cold/read/environment.json @@ -1,7 +1,7 @@ { "powershell": "5.1.14409.1005", "schema": "xs2010", - "hostname": "ONE-M6-838F23A9", + "hostname": "ONE-M6-D27A9E1E", "cold": true, "onenote": "14.0.4763.1000" } diff --git a/corpus/table-edit/created/cold/read/hierarchy.xml b/corpus/table-edit/created/cold/read/hierarchy.xml index 89b9442957cc33dc9cd148141f94c3deeff7c163..f24b18c4be66f268c0dfea7cbdcc50ec088fdfa4 100644 --- a/corpus/table-edit/created/cold/read/hierarchy.xml +++ b/corpus/table-edit/created/cold/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/table-edit/created/cold/read/page-000.xml b/corpus/table-edit/created/cold/read/page-000.xml index 18e6e85052b8d934f3546717ebb4a168ee13528d..d2188df6d5559236cbe46692c47ec46eb1b971ca 100644 --- a/corpus/table-edit/created/cold/read/page-000.xml +++ b/corpus/table-edit/created/cold/read/page-000.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/table-edit/created/cold/run.json b/corpus/table-edit/created/cold/run.json index e5de875f30992feac8b4454db76cd812a676a4de..450d58ab4b80229c43c1e19ca7967d21f92a1727 100644 --- a/corpus/table-edit/created/cold/run.json +++ b/corpus/table-edit/created/cold/run.json @@ -1,5 +1,5 @@ { - "notebook": "/private/tmp/m4/tables", + "notebook": "/Users/clo/dev/one/evidence/titles/table-created", "expected_pages": 1, "author": null, "author_timeout_seconds": 600, diff --git a/corpus/table-edit/created/cold/source.json b/corpus/table-edit/created/cold/source.json index a2b298152ccd8c0d8d5c33b61065d236fe51e883..c3650f9767ba3ec651dde085f14464da8977690d 100644 --- a/corpus/table-edit/created/cold/source.json +++ b/corpus/table-edit/created/cold/source.json @@ -2,13 +2,13 @@ { "path": "Open Notebook.onetoc2", "bytes": 1604, - "sha256": "b39b2e142e0adce0de37ad336f40c3e9c2ba92ce6927c9d109139e74c1fb4a6e", - "mtime_ns": 1789239342755220465 + "sha256": "8e64e22a5381e45d42536c1d75cf266a70970828d0258c15571adc6f4d75391f", + "mtime_ns": 1790404369445796875 }, { "path": "tables.one", - "bytes": 11016, - "sha256": "e1c014220e05352a01c98752031b32c17512407295aa812ff8e9c776e48eef6e", - "mtime_ns": 1789239342754903465 + "bytes": 10936, + "sha256": "d66db22225eaf0064e53f14e6dcfdbefd822c292d839be6296bc442153c278e3", + "mtime_ns": 1790404369445435251 } ] diff --git a/corpus/table-edit/nested/candidate/Open Notebook.onetoc2 b/corpus/table-edit/nested/candidate/Open Notebook.onetoc2 index ac6404565fb5d7dc4eecd7d50f52a80fe56ea39d..9737dc9bc660b8cd3255933b7fd0547a24002f06 100644 Binary files a/corpus/table-edit/nested/candidate/Open Notebook.onetoc2 and b/corpus/table-edit/nested/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/table-edit/nested/candidate/synthetic.one b/corpus/table-edit/nested/candidate/synthetic.one index 266fcc28db50f2d19f54b1f2b89daa011b440cdb..a7d788077abaa2ed30afd330eaa3159b843cf1e1 100644 Binary files a/corpus/table-edit/nested/candidate/synthetic.one and b/corpus/table-edit/nested/candidate/synthetic.one differ diff --git a/corpus/table-edit/nested/cold/commands.jsonl b/corpus/table-edit/nested/cold/commands.jsonl index ca3daf4c4d9f68f3eb732da5aa3784363c6c7bc6..334800aae601b2ef191827b47e916d9ca0b92f9c 100644 --- a/corpus/table-edit/nested/cold/commands.jsonl +++ b/corpus/table-edit/nested/cold/commands.jsonl @@ -1,3 +1,3 @@ {"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} -{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-8E63A852 -ExpectedPages 12", "exit": 0, "stdout": "Read 1 sections and 12 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-B7496B80 -ExpectedPages 12", "exit": 0, "stdout": "Read 1 sections and 12 pages.\r\n", "stderr": "", "error": null} {"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/table-edit/nested/cold/machine.json b/corpus/table-edit/nested/cold/machine.json index af2731f4d9ddcf0999ea29a58d48135326fae541..38b94824a21f8ae2963c58fdc7a9f882f1b854a9 100644 --- a/corpus/table-edit/nested/cold/machine.json +++ b/corpus/table-edit/nested/cold/machine.json @@ -1 +1 @@ -{"name": "m6-8e63a852", "hostname": "ONE-M6-8E63A852"} +{"name": "m6-b7496b80", "hostname": "ONE-M6-B7496B80"} diff --git a/corpus/table-edit/nested/cold/notebook/Open Notebook.onetoc2 b/corpus/table-edit/nested/cold/notebook/Open Notebook.onetoc2 index dd056869ac900f0254b6015f73f69cec7ce64d2d..3f6ae3effafe9aa1185eaa6345112411039b3309 100644 Binary files a/corpus/table-edit/nested/cold/notebook/Open Notebook.onetoc2 and b/corpus/table-edit/nested/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/table-edit/nested/cold/notebook/synthetic.one b/corpus/table-edit/nested/cold/notebook/synthetic.one index b1ade032e1a1d530a0dbf143c386c72784717362..034d2d6aaa1e8d01833c8875ff744f450ef8953a 100644 Binary files a/corpus/table-edit/nested/cold/notebook/synthetic.one and b/corpus/table-edit/nested/cold/notebook/synthetic.one differ diff --git a/corpus/table-edit/nested/cold/read/environment.json b/corpus/table-edit/nested/cold/read/environment.json index 97826260818ae575e97f3e893c184ac96d2da42d..2ede7f94b2accbf19f99088b5ea5128cf3072361 100644 --- a/corpus/table-edit/nested/cold/read/environment.json +++ b/corpus/table-edit/nested/cold/read/environment.json @@ -1,7 +1,7 @@ { "powershell": "5.1.14409.1005", "schema": "xs2010", - "hostname": "ONE-M6-8E63A852", + "hostname": "ONE-M6-B7496B80", "cold": true, "onenote": "14.0.4763.1000" } diff --git a/corpus/table-edit/nested/cold/read/hierarchy.xml b/corpus/table-edit/nested/cold/read/hierarchy.xml index 30951414a1ed8f58e0f0bfa909f5dea2302e146e..662f359d228eede674a9378822f20eb7221bfc3b 100644 --- a/corpus/table-edit/nested/cold/read/hierarchy.xml +++ b/corpus/table-edit/nested/cold/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/table-edit/nested/cold/read/page-000.xml b/corpus/table-edit/nested/cold/read/page-000.xml index abe8e889db0e49cfacbcfec754d6dd8fe93ef84b..dd028cbbd5c4c34aceb40af0635ef67d3d2911fb 100644 --- a/corpus/table-edit/nested/cold/read/page-000.xml +++ b/corpus/table-edit/nested/cold/read/page-000.xml @@ -1,5 +1,5 @@  -Target 🦀 é Link label]]>Child]]> +href="https://example.invalid/tree">Link label]]>Child]]> diff --git a/corpus/table-edit/nested/cold/read/page-001.xml b/corpus/table-edit/nested/cold/read/page-001.xml index 73fc668530cfe3c925ec3e9899ca78cb17d8e79c..b9c498551df8d10025b2b8956cec4ab91058c986 100644 --- a/corpus/table-edit/nested/cold/read/page-001.xml +++ b/corpus/table-edit/nested/cold/read/page-001.xml @@ -1,5 +1,5 @@  -Target 🦀 é Link label]]>Child]]> +href="https://example.invalid/tree">Link label]]>Child]]> diff --git a/corpus/table-edit/nested/cold/read/page-002.xml b/corpus/table-edit/nested/cold/read/page-002.xml index 33249a4a63862188d11be1dc660fb502d1046764..2158e242f563d07ecfa4355bba8ee31aa524e2eb 100644 --- a/corpus/table-edit/nested/cold/read/page-002.xml +++ b/corpus/table-edit/nested/cold/read/page-002.xml @@ -1,5 +1,5 @@  -Target 🦀 é Link label]]>Child]]> +href="https://example.invalid/tree">Link label]]>Child]]> diff --git a/corpus/table-edit/nested/cold/read/page-003.xml b/corpus/table-edit/nested/cold/read/page-003.xml index 803070c943bb4c15bbde899ce815025e07ef30fb..e28f619582e1484f582ee52dd7758c71f296d75e 100644 --- a/corpus/table-edit/nested/cold/read/page-003.xml +++ b/corpus/table-edit/nested/cold/read/page-003.xml @@ -1,5 +1,5 @@  -Target 🦀 é Link label]]>Child]]> +href="https://example.invalid/tree">Link label]]>Child]]> diff --git a/corpus/table-edit/nested/cold/read/page-004.xml b/corpus/table-edit/nested/cold/read/page-004.xml index 1abeded6ec7686112d3fbf0321ee6a4523ed75e4..0b2245609db31a3585cd3f81c04c2be504235556 100644 --- a/corpus/table-edit/nested/cold/read/page-004.xml +++ b/corpus/table-edit/nested/cold/read/page-004.xml @@ -1,5 +1,4 @@  -Target 🦀 é Link label]]>Child]]> +href="https://example.invalid/tree">Link label]]> diff --git a/corpus/table-edit/nested/cold/read/page-005.xml b/corpus/table-edit/nested/cold/read/page-005.xml index ab5eb2ff035e76901c4cb67a0a0c7c6650232807..ff80f20afb2e930a0a778b3de04a1301a91dc4ae 100644 --- a/corpus/table-edit/nested/cold/read/page-005.xml +++ b/corpus/table-edit/nested/cold/read/page-005.xml @@ -1,5 +1,20 @@  -Target 🦀 é Link label]]>Child]]> +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/table-edit/nested/cold/read/page-006.xml b/corpus/table-edit/nested/cold/read/page-006.xml index f7e7c61fc66b3a05392b7a669554659eac7d9de7..23c451c6b2ae030d7a8dc557e8761467c3cc98a3 100644 --- a/corpus/table-edit/nested/cold/read/page-006.xml +++ b/corpus/table-edit/nested/cold/read/page-006.xml @@ -1,5 +1,5 @@  -Target 🦀 é Link label]]>Child]]> +href="https://example.invalid/tree">Link label]]>Child]]> diff --git a/corpus/table-edit/nested/cold/read/page-007.xml b/corpus/table-edit/nested/cold/read/page-007.xml index 5dce83b05f4dbb9134aa1f99de1655186742b0b5..534982d994f32141b7a251b970ca60e77a4efb1b 100644 --- a/corpus/table-edit/nested/cold/read/page-007.xml +++ b/corpus/table-edit/nested/cold/read/page-007.xml @@ -1,5 +1,4 @@  -Target 🦀 é Link label]]>Child]]> +href="https://example.invalid/tree">Link label]]> diff --git a/corpus/table-edit/nested/cold/read/page-008.xml b/corpus/table-edit/nested/cold/read/page-008.xml index 5404646b65f0848851705698b95df7575d6e1307..5f805e79876ab800c9861fec79096d1185881f8a 100644 --- a/corpus/table-edit/nested/cold/read/page-008.xml +++ b/corpus/table-edit/nested/cold/read/page-008.xml @@ -1,4 +1,5 @@  -Target 🦀 é Link label]]> +href="https://example.invalid/tree">Link label]]>Child]]> diff --git a/corpus/table-edit/nested/cold/read/page-009.xml b/corpus/table-edit/nested/cold/read/page-009.xml index 6f2d0a40c64f6c9f9d6708983f8a22623c0a07af..922348b64a8a60ad0cf253d22d93ecba7851da4f 100644 --- a/corpus/table-edit/nested/cold/read/page-009.xml +++ b/corpus/table-edit/nested/cold/read/page-009.xml @@ -1,5 +1,5 @@  -Target 🦀 é Link label]]>Child]]> +href="https://example.invalid/tree">Link label]]>Child]]> diff --git a/corpus/table-edit/nested/cold/read/page-010.xml b/corpus/table-edit/nested/cold/read/page-010.xml index 0ab9708e41484ef59f437b8d7c3809e8eba84602..0a9d3c7b3ae8662e886e3e12b3163926092f5eb1 100644 --- a/corpus/table-edit/nested/cold/read/page-010.xml +++ b/corpus/table-edit/nested/cold/read/page-010.xml @@ -1,20 +1,5 @@  -Bold 🦀 italic é color 東京
-End
]]>
Fictitious: café, 東京, مرحبا]]>
+Target 🦀 é Link label]]>Child]]> diff --git a/corpus/table-edit/nested/cold/read/page-011.xml b/corpus/table-edit/nested/cold/read/page-011.xml index edc9181409326c6c543bce336887d83568b3c897..57d8a4b96a32c11be2775864a5d47f6faae9f287 100644 --- a/corpus/table-edit/nested/cold/read/page-011.xml +++ b/corpus/table-edit/nested/cold/read/page-011.xml @@ -1,4 +1,5 @@  -Target 🦀 é Link label]]> +href="https://example.invalid/tree">Link label]]>Child]]> diff --git a/corpus/table-edit/nested/cold/run.json b/corpus/table-edit/nested/cold/run.json index 07af38adfa7635acd5034a97c61a9f59db1580a0..59f593d7ed1ada68dac9be40c6ce171fcbf94e18 100644 --- a/corpus/table-edit/nested/cold/run.json +++ b/corpus/table-edit/nested/cold/run.json @@ -1,5 +1,5 @@ { - "notebook": "/private/tmp/m4/nested", + "notebook": "/Users/clo/dev/one/evidence/titles/table-nested", "expected_pages": 12, "author": null, "author_timeout_seconds": 600, diff --git a/corpus/table-edit/nested/cold/source.json b/corpus/table-edit/nested/cold/source.json index 5e6c84c30728031d026ff42bdbfacfc80a21b384..6fe831f630abeb74801236a6cfed7136390c01c2 100644 --- a/corpus/table-edit/nested/cold/source.json +++ b/corpus/table-edit/nested/cold/source.json @@ -2,13 +2,13 @@ { "path": "Open Notebook.onetoc2", "bytes": 1612, - "sha256": "1c7c0e30cc2c2f029f87e20bba0981f0bf8a7f2f6643ad67a24e80834f539991", - "mtime_ns": 1789250020753460025 + "sha256": "1290deaddfd9b3dc7c08ee83da786bc057c7e18ec6a9212b0dc8453a7254c09b", + "mtime_ns": 1790404369595025987 }, { "path": "synthetic.one", - "bytes": 91192, - "sha256": "6f3b8737c318834e2958368ed9fd9bc2ff4c7490bbd4a29907fa76e472355b43", - "mtime_ns": 1789250020752551397 + "bytes": 90608, + "sha256": "2d0333a99c6cdf9499ebf972fb53a6cf987ceba1950738f01158e028e8b82fc1", + "mtime_ns": 1790404369594093530 } ] diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 3cfd958001ea8035da22eabbb9e80527537990af..15f7670c6620391bedfb27561208998dd227ce33 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -188,6 +188,9 @@ object space, which survives the tested native compaction that replaces the file An `Arc` can own one background worker. Supply a connection factory, poll interval and observer; successful publications drain immediately, durable local edits wake the worker, and `wake()` requests an immediate reachability retry. +While nothing is queued, a remote whose `Remote::stamp` (its header and length, read +without coordination) equals the last observed image's is not read again; publishing +against an unchanged stamp needs no read either. Transport failures discard the old connection and retry through the factory; Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy` reuse the connection. Contended `NotCommitted` operations use randomized backoff, @@ -383,6 +386,7 @@ experimental Rust API with native interoperability evidence in the repository's [Milestone 9](../../evidence/MILESTONE9.md). ```no_run +# #[cfg(feature = "smb")] { use notebook::smb::{Client, Credentials}; use std::time::Duration; @@ -396,6 +400,7 @@ let snapshot = client.read("Personal/Video.one", 64 * 1024 * 1024)?; let store = onestore::Store::parse(&snapshot)?; let revisions = onestore::RevisionIndex::parse(&store)?; let document = onestore::document::Document::parse(&revisions)?; +# } # Ok::<(), Box>(()) ``` @@ -404,8 +409,9 @@ snapshot. Call from a background thread outside a Tokio runtime. Use identities from the document and the same snapshot with `Client::commit_text` or `Client::commit_property_bytes`; their errors retain `onestore::CommitState`. `PreparedEdit::page` separates preparation from I/O: inspect the immutable image -and persist the intended revision identity before `Client::commit_prepared`. -`Client::confirm_snapshot` compares and flushes an observed image, then refreshes +and persist the intended revision identity before `Client::commit_transaction` publishes +its `transaction()`. `Client::stamp` reads a file's header and length without coordination, +for polling. `Client::confirm_snapshot` checks and flushes an observed image's stamp, then refreshes its header version metadata without adding a revision. The caller must first establish which intents that image contains and reread before another commit. diff --git a/crates/notebook/examples/queue_scale.rs b/crates/notebook/examples/queue_scale.rs index 6661921661fd22825c40abdeec5d9ff25c96e93b..84e424f42352dcbe4e0941bac01577904f90a9b1 100644 --- a/crates/notebook/examples/queue_scale.rs +++ b/crates/notebook/examples/queue_scale.rs @@ -1,6 +1,6 @@ use notebook::{EditStatus, Remote, Replica}; use onestore::{ - CommitError, PageCreation, PreparedEdit, RevisionIndex, Store, + CommitError, PageCreation, PreparedEdit, RevisionIndex, Store, Transaction, document::Document, page::{Page, PageObject, Paragraph, text::Edit}, }; @@ -12,8 +12,8 @@ impl Remote for FileRemote { fn read(&mut self) -> io::Result> { onestore::read_file(&self.0) } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - edit.commit_file(&self.0) + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { + transaction.commit_file(&self.0) } fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { onestore::confirm_file_snapshot(&self.0, snapshot) diff --git a/crates/notebook/examples/recovery_probe.rs b/crates/notebook/examples/recovery_probe.rs index 841d92c2aaa8a7b6dd47213c635bb2999d4f765e..0335d17dacaf0a81ac2a2ddb70d6ec8977056001 100644 --- a/crates/notebook/examples/recovery_probe.rs +++ b/crates/notebook/examples/recovery_probe.rs @@ -4,7 +4,7 @@ mod support { use support::view::view; use notebook::{EditStatus, Remote, Replica}; -use onestore::{CommitError, CommitIo, PreparedEdit}; +use onestore::{CommitError, CommitIo, Transaction}; use serde_json::json; use std::{ env, @@ -67,9 +67,9 @@ impl Remote for Disk { phase("read-after"); result } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { phase("publish-before"); - let result = edit.commit(self); + let result = transaction.commit(self); phase("publish-after"); result } diff --git a/crates/notebook/examples/smb_offline_client.rs b/crates/notebook/examples/smb_offline_client.rs index 34a31f03ba8ffb838d69ff32bb7a093993affe4f..3867a0f2b18b07e95c4a81829d9490cb9fa43055 100644 --- a/crates/notebook/examples/smb_offline_client.rs +++ b/crates/notebook/examples/smb_offline_client.rs @@ -4,7 +4,7 @@ mod concurrent; use notebook::smb::{Client, Credentials}; use notebook::{EditStatus, Error, Remote, Replica, SmbRemote}; use onestore::{ - CommitError, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, page::Paragraph, + CommitError, CommitState, ExGuid, RevisionIndex, Store, Transaction, page::Paragraph, }; use serde_json::json; use std::{ @@ -56,6 +56,8 @@ enum Pause { struct Traced { remote: R, before: Option<(String, Option)>, + /// The last image read, which publications apply to. + read: Vec, pause: Option, documents: bool, } @@ -75,15 +77,21 @@ impl Remote for Traced { json!({"event":"read", "started_us":started, "finished_us":now(), "text":observed.text, "documents":documents.as_ref().map(|view| &view.texts), "document_graph":documents.as_ref().map(|view| &view.graph)}) ); self.before = Some((observed.text, documents)); + self.read.clone_from(&bytes); Ok(bytes) } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - let after = view(edit.as_bytes()).map_err(|error| CommitError { + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { + let mut image = self.read.clone(); + transaction.apply(&mut image).map_err(|error| CommitError { + state: CommitState::NotCommitted, + error: io::Error::other(error.to_string()), + })?; + let after = view(&image).map_err(|error| CommitError { state: CommitState::NotCommitted, error: io::Error::other(error.to_string()), })?; let documents = if self.documents { - Some(document_view(edit.as_bytes()).map_err(|error| CommitError { + Some(document_view(&image).map_err(|error| CommitError { state: CommitState::NotCommitted, error: io::Error::other(error), })?) @@ -151,7 +159,7 @@ impl Remote for Traced { } } let started = now(); - let result = self.remote.publish(edit); + let result = self.remote.publish(transaction); let finished = now(); println!( "{}", @@ -399,7 +407,7 @@ fn main() -> Result<(), Box> { let worker = cache.start_sync(Duration::from_millis(50), move || { let client = Client::connect(&address, &share, Credentials::default(), Duration::from_secs(5))?; println!("{}", json!({"event":"transport_connected", "at_us":now()})); - Ok(Traced { remote: SmbRemote::new(client, &path, 256 * 1024 * 1024), before:None, pause:pause.clone(), documents }) + Ok(Traced { remote: SmbRemote::new(client, &path, 256 * 1024 * 1024), before:None, read:Vec::new(), pause:pause.clone(), documents }) }, move |result| { if let Err(error) = result { println!("{}", json!({"event":"sync_error", "error":error.to_string(), "at_us":now()})); diff --git a/crates/notebook/src/images.rs b/crates/notebook/src/images.rs index bf1e41265409a934469dd33e26afbe7b851e3ba1..963d76933beb2618d937bd1ac61d6441cd24d439 100644 --- a/crates/notebook/src/images.rs +++ b/crates/notebook/src/images.rs @@ -3,6 +3,7 @@ //! instead of the section. use crate::Result; +use onestore::Stamp; use rusqlite::{Connection, params}; use std::io; @@ -91,8 +92,9 @@ pub(crate) fn working_length(connection: &Connection) -> Result { "SELECT length(base), substr(working, 1, 8) FROM replica WHERE id=1", [], |row| { - let header: Vec = row.get(1)?; - Ok(match header.first_chunk::<8>() { + // SQLite's substr of an empty blob is NULL. + let header: Option> = row.get(1)?; + Ok(match header.as_deref().and_then(<[u8]>::first_chunk::<8>) { Some(length) => u64::from_le_bytes(*length), None => row.get::<_, i64>(0)? as u64, }) @@ -100,6 +102,31 @@ pub(crate) fn working_length(connection: &Connection) -> Result { )?) } +/// Whether no edit is queued and the working image is the base image, without reading +/// either image. +pub(crate) fn settled(connection: &Connection) -> Result { + Ok(connection.query_row( + "SELECT length(working) = 0 AND NOT EXISTS (SELECT 1 FROM edits) FROM replica WHERE id=1", + [], + |row| row.get(0), + )?) +} + +/// The base image's stamp, without reading the image. +pub(crate) fn stamp(connection: &Connection) -> Result { + let (header, length): (Vec, i64) = connection.query_row( + "SELECT substr(base, 1, 1024), length(base) FROM replica WHERE id=1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + Ok(Stamp { + header: header + .try_into() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Damaged base image"))?, + length: u64::try_from(length).map_err(io::Error::other)?, + }) +} + pub(crate) fn working(connection: &Connection) -> Result> { let (base, patch): (Vec, Vec) = connection.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| { diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index 39c32211ee29438d1a296c585618f01dc657b910..d60f120a5a131ca4ea7e4cab2c0041e0d2a5ef6b 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -255,6 +255,31 @@ impl Replica { images::working(&connection) } + /// The page in `space` as the working image stores it. + pub fn page(&self, space: ExGuid) -> Result { + let snapshot = self.snapshot()?; + let store = Store::parse(&snapshot)?; + let index = RevisionIndex::parse(&store)?; + Ok(Page::from_space(&Document::parse(&index)?, space)?) + } + + /// Whether no edit is queued and the remote still has the last observed image's stamp, + /// reading neither image; the cache is not locked during remote I/O. + pub(crate) fn settled(&self, remote: &mut impl Remote) -> Result { + let lock = || { + self.connection + .lock() + .map_err(|_| io::Error::other("Cache owner panicked")) + }; + if !images::settled(&*lock()?)? { + return Ok(false); + } + let Some(stamp) = remote.stamp().map_err(Error::RemoteIo)? else { + return Ok(false); + }; + Ok(images::stamp(&*lock()?)? == stamp) + } + pub fn pending(&self) -> Result> { let connection = self .connection @@ -263,6 +288,25 @@ impl Replica { pending(&connection) } + /// Identities and page spaces of the pending edits, oldest first, without reading their + /// operations. + pub(crate) fn queued(&self) -> Result> { + let connection = self + .connection + .lock() + .map_err(|_| io::Error::other("Cache owner panicked"))?; + let mut query = connection.prepare("SELECT id, space FROM edits ORDER BY id")?; + let mut rows = query.query([])?; + let mut edits = Vec::new(); + while let Some(row) = rows.next()? { + edits.push(( + u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?, + row.get::<_, String>(1)?.parse()?, + )); + } + Ok(edits) + } + /// Durably queues an edited page model using the supplied local snapshot. /// While the newest pending edit is an unattempted save of the same page, a new save /// replaces its result instead of queueing another publication, as OneNote does diff --git a/crates/notebook/src/merge.rs b/crates/notebook/src/merge.rs index 58f127b9553953ec57a3d21e9f45a26d4553504f..dfb7d4f455fa78f7f5c5008e610064ee8a5f8f3d 100644 --- a/crates/notebook/src/merge.rs +++ b/crates/notebook/src/merge.rs @@ -257,6 +257,18 @@ fn merge_paragraphs( } // Child order per container: the remote order with our repositioned paragraphs re-placed. let (bc, oc, tc) = (children(base), children(ours), children(theirs)); + // A paragraph we added after one they gave new children would sit after or among those + // children; Enter splitting a paragraph carries its children below, so neither is assumed. + for pair in oc.values().flat_map(|list| list.windows(2)) { + if b.contains_key(&pair[0]) + && !b.contains_key(&pair[1]) + && tc + .get(&Some(pair[0])) + .is_some_and(|children| children.iter().any(|id| !b.contains_key(id))) + { + return None; + } + } let mut order: BTreeMap, Vec> = BTreeMap::new(); let containers: BTreeSet> = bc .keys() @@ -459,6 +471,10 @@ fn merge_text( return None; } let mapped = crate::rebase::rebase(b, theirs.text(), start..end)?; + // Text they reformatted is not ours to replace, nor to move as a split would. + if start < end && theirs.slice(mapped.clone()).ok()? != base.slice(start..end).ok()? { + return None; + } let mut merged = theirs.clone(); merged .apply(Edit { diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 1d3f73ef0facec69bff3f416d2396a1f2647f4aa..9f379bb54b9651433e54ac842255b4369e427ed7 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -5,8 +5,8 @@ use crate::{ ConflictKind, EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover, }; use onestore::{ - CommitError, ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store, document::Document, - page::Page, + CommitError, ExGuid, PageCreation, PreparedEdit, RevisionIndex, Stamp, Store, Transaction, + document::Document, page::Page, }; use std::{ collections::{BTreeMap, VecDeque}, @@ -15,6 +15,7 @@ use std::{ path::{Path, PathBuf}, sync::{ Arc, Mutex, + atomic::{AtomicUsize, Ordering}, mpsc::{self, Receiver, Sender}, }, thread::{self, JoinHandle}, @@ -227,7 +228,9 @@ impl Storage for Share { } fn commit(&self, path: &str, edit: &PreparedEdit<'_>) -> Result<()> { - Ok(self.client.commit_prepared(&self.path(path), edit)?) + Ok(self + .client + .commit_transaction(&self.path(path), &edit.transaction())?) } fn set_property( @@ -760,6 +763,8 @@ pub struct Section { events: Receiver, saves: Option>, saved: Receiver<(ExGuid, std::result::Result)>, + /// Saves queued and not yet reported. + unsaved: Arc, saver: Option>, } @@ -850,12 +855,15 @@ impl Section { } }; let (saves, requests) = mpsc::channel(); + let unsaved = Arc::new(AtomicUsize::new(0)); let saver = { let (replica, signal) = (Arc::clone(&replica), signal.clone()); + let unsaved = Arc::clone(&unsaved); thread::Builder::new() .name("onestore-save".into()) .spawn(move || { - save_pages(&replica, &requests, |outcome| { + save_pages(&replica, &requests, |outcome, count| { + unsaved.fetch_sub(count, Ordering::Release); if saved_sender.send(outcome).is_ok() { signal(); } @@ -889,6 +897,7 @@ impl Section { events, saves: Some(saves), saved, + unsaved, saver: Some(saver), }) } @@ -930,10 +939,7 @@ impl Section { } pub fn page(&self, space: ExGuid) -> Result { - let snapshot = self.replica.snapshot()?; - let store = Store::parse(&snapshot)?; - let index = RevisionIndex::parse(&store)?; - Ok(Page::from_space(&Document::parse(&index)?, space)?) + self.replica.page(space) } /// Copies a page (usually from another section) to the end of this section as a @@ -999,12 +1005,18 @@ impl Section { after, author: author.to_owned(), }; + self.unsaved.fetch_add(1, Ordering::Relaxed); self.saves .as_ref() .and_then(|saves| saves.send(save).ok()) .ok_or_else(|| io::Error::other("The save thread stopped").into()) } + /// Whether a queued save has yet to report, so the stored page may trail the editor's. + pub fn saving(&self) -> bool { + self.unsaved.load(Ordering::Acquire) > 0 + } + pub fn status(&self, id: u64) -> Result> { self.replica.status(id) } @@ -1017,13 +1029,13 @@ impl Section { /// attempt, or a conflict awaiting review. pub fn queue(&self) -> Result> { self.replica - .pending()? + .queued()? .into_iter() - .map(|edit| { + .map(|(id, space)| { Ok(QueuedEdit { - id: edit.id, - space: edit.space, - status: self.replica.status(edit.id)?.unwrap_or(EditStatus::Pending), + id, + space, + status: self.replica.status(id)?.unwrap_or(EditStatus::Pending), }) }) .collect() @@ -1094,7 +1106,8 @@ impl Section { } } - pub fn replica(&self) -> &Replica { + /// The replica, which other threads may read pages from while the section is open. + pub fn replica(&self) -> &Arc { &self.replica } @@ -1160,7 +1173,7 @@ fn save( fn save_pages( replica: &Replica, requests: &Receiver, - report: impl Fn((ExGuid, std::result::Result)), + report: impl Fn((ExGuid, std::result::Result), usize), ) { let mut queue = VecDeque::new(); // The last save's page and the working image it left. @@ -1174,11 +1187,13 @@ fn save_pages( } queue.extend(requests.try_iter()); let mut request = queue.pop_front().unwrap(); + let mut count = 1; while let Some(next) = queue.front() && (next.space, &next.before, &next.author) == (request.space, &request.after, &request.author) { request.after = queue.pop_front().unwrap().after; + count += 1; } let known = last .take() @@ -1200,7 +1215,7 @@ fn save_pages( } Err(error) => Err(error.to_string()), }; - report((request.space, save)); + report((request.space, save), count); } } @@ -1212,8 +1227,19 @@ impl Remote for FileRemote { onestore::read_file(&self.0) } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> std::result::Result<(), CommitError> { - edit.commit_file(&self.0) + /// Read without the whole-file lock, which would block OneNote's readers: a change + /// detector, never a snapshot to edit. + fn stamp(&mut self) -> io::Result> { + use std::io::Read; + let mut file = std::fs::File::open(&self.0)?; + let mut header = [0; 1024]; + file.read_exact(&mut header)?; + let length = file.metadata()?.len(); + Ok(Some(Stamp { header, length })) + } + + fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> { + transaction.commit_file(&self.0) } fn confirm(&mut self, snapshot: &[u8]) -> std::result::Result<(), CommitError> { diff --git a/crates/notebook/src/smb/directory.rs b/crates/notebook/src/smb/directory.rs index 4997431a09200a47462e6573c515ead57de50eb9..d947b6d92e1baebad2130624677e690ef8a76847 100644 --- a/crates/notebook/src/smb/directory.rs +++ b/crates/notebook/src/smb/directory.rs @@ -38,6 +38,7 @@ impl Client { let file = File { client: self, id: Some(response.file_id), + length: response.end_of_file, }; let mut entries = BTreeMap::new(); loop { diff --git a/crates/notebook/src/smb/mod.rs b/crates/notebook/src/smb/mod.rs index 38f25a82d2b7e49af84cc8a8500713e0e54b2794..6b10a7fcc89c548290c3cfa4826a38bccdf57e38 100644 --- a/crates/notebook/src/smb/mod.rs +++ b/crates/notebook/src/smb/mod.rs @@ -218,6 +218,7 @@ impl Client { Ok(File { client: self, id: Some(response.file_id), + length: response.end_of_file, }) } @@ -374,13 +375,36 @@ impl Client { }) } - /// Publishes a prepared edit using the same native writer coordination as text commits. - pub fn commit_prepared( + /// Publishes a transaction using the same native writer coordination as text commits. + pub fn commit_transaction( &self, path: &str, - edit: &onestore::PreparedEdit<'_>, + transaction: &onestore::Transaction, ) -> Result<(), CommitError> { - self.commit(path, |file| edit.commit(file)) + self.commit(path, |file| transaction.commit(file)) + } + + /// The header and length of a revision store, read without writer coordination or path + /// identity checks: a change detector for polling, never a snapshot to edit. + pub fn stamp(&self, path: &str) -> io::Result { + let mut file = self.open(path, false)?; + let mut header = [0; 1024]; + let mut read = 0; + let result = loop { + match file.read_at(read as u64, &mut header[read..]) { + Ok(0) => break Err(io::ErrorKind::UnexpectedEof.into()), + Ok(count) => read += count, + Err(error) => break Err(error), + } + if read == header.len() { + break Ok(()); + } + }; + let length = file.length; + let closed = file.close(); + result?; + closed?; + Ok(onestore::Stamp { header, length }) } /// Confirms an observed snapshot's durability under native writer coordination. @@ -419,6 +443,8 @@ impl Drop for Client { struct File<'a> { client: &'a Client, id: Option, + /// The end of file when opened. + length: u64, } impl File<'_> { fn coordinate(self, path: &str, write: bool) -> io::Result { diff --git a/crates/notebook/src/smb/remote.rs b/crates/notebook/src/smb/remote.rs index 7dc3c146d32c21ada33ebedc6e4123232b953a3c..16fd2ba06ab25ceb5a2e0c08fb4329eced4fceb4 100644 --- a/crates/notebook/src/smb/remote.rs +++ b/crates/notebook/src/smb/remote.rs @@ -1,6 +1,6 @@ use super::Client; use crate::Remote; -use onestore::{CommitError, PreparedEdit}; +use onestore::{CommitError, Stamp, Transaction}; use std::io; /// Binds every reconciliation operation to one share-relative file and read limit. @@ -26,8 +26,12 @@ impl Remote for SmbRemote { self.client.read(&self.path, self.limit) } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - self.client.commit_prepared(&self.path, edit) + fn stamp(&mut self) -> io::Result> { + self.client.stamp(&self.path).map(Some) + } + + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { + self.client.commit_transaction(&self.path, transaction) } fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { diff --git a/crates/notebook/src/smb/tests.rs b/crates/notebook/src/smb/tests.rs index 241f954afc19c64630113def6aa4d3dd370d0cba..54078552e8a67c7b15b3a34a1d308dda2d0a2e57 100644 --- a/crates/notebook/src/smb/tests.rs +++ b/crates/notebook/src/smb/tests.rs @@ -120,6 +120,7 @@ fn create(client: &Client, path: &str, bytes: &[u8]) { let mut file = File { client, id: Some(response.file_id), + length: response.end_of_file, }; let mut offset = 0; while offset < bytes.len() { @@ -344,6 +345,53 @@ fn live_coordination() { ); } +/// Polling reads only the header, even while maintenance excludes snapshot readers, and a +/// transaction commits against the header it was built on. +#[test] +#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] +fn live_stamp_and_transaction() { + let poller = client(); + let source = onestore::create_section("stamp.one", "Stamped", "Author").unwrap(); + let path = format!( + "stamp-{}.one", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + create(&poller, &path, &source); + assert_eq!( + poller.stamp(&path).unwrap(), + onestore::Stamp::of(&source).unwrap() + ); + let maintenance = client(); + let guard = maintenance.open(&path, false).unwrap(); + guard.lock(0xfffffffb, 0x12).unwrap(); + assert_eq!( + poller.read(&path, 1 << 20).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + assert_eq!( + poller.stamp(&path).unwrap(), + onestore::Stamp::of(&source).unwrap() + ); + guard.close().unwrap(); + let (sid, oid, _) = text(&source); + let edit = onestore::PreparedEdit::text(&source, sid, oid, 0..0, "Changed ").unwrap(); + let transaction = edit.transaction(); + poller.commit_transaction(&path, &transaction).unwrap(); + assert_eq!( + poller.stamp(&path).unwrap(), + onestore::Stamp::of(edit.as_bytes()).unwrap() + ); + assert_eq!(poller.read(&path, 1 << 20).unwrap(), edit.as_bytes()); + let stale = poller.commit_transaction(&path, &transaction).unwrap_err(); + assert_eq!(stale.state, CommitState::NotCommitted); + assert_eq!(stale.error.kind(), io::ErrorKind::ResourceBusy); + assert_eq!(poller.read(&path, 1 << 20).unwrap(), edit.as_bytes()); + poller.delete(&path).unwrap(); +} + #[test] #[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] fn live_storage_inspection() { diff --git a/crates/notebook/src/sync.rs b/crates/notebook/src/sync.rs index ebd12be546cb0de0d75541a3ba5645cc42a974c9..40c35ad7d757852d1cf416d91be163fd2a96cbe9 100644 --- a/crates/notebook/src/sync.rs +++ b/crates/notebook/src/sync.rs @@ -1,5 +1,5 @@ use super::*; -use onestore::{CommitError, CommitState}; +use onestore::{CommitError, CommitState, Stamp, Transaction}; use rusqlite::OptionalExtension; use std::{ collections::BTreeMap, @@ -7,10 +7,16 @@ use std::{ }; /// A single remote file with fresh reads and native-compatible guarded publication. -/// Errors retain publication state; confirmation compares, flushes, and notifies cached readers. +/// Errors retain publication state; confirmation checks the stamp, flushes, and notifies +/// cached readers. pub trait Remote { fn read(&mut self) -> io::Result>; - fn publish(&mut self, edit: &PreparedEdit<'_>) -> std::result::Result<(), CommitError>; + /// The file's stamp without reading its body, when the remote can tell. While it is the + /// last observed image's, synchronization neither reads nor revalidates the file. + fn stamp(&mut self) -> io::Result> { + Ok(None) + } + fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError>; fn confirm(&mut self, snapshot: &[u8]) -> std::result::Result<(), CommitError>; } @@ -78,13 +84,14 @@ impl Replica { } } let _step = Step(self); - let snapshot = remote.read().map_err(Error::RemoteIo)?; + let base = self.remote_snapshot()?; + let changed = match remote.stamp().map_err(Error::RemoteIo)? { + Some(stamp) if stamp == Stamp::of(&base)? => None, + _ => Some(remote.read().map_err(Error::RemoteIo)?).filter(|read| *read != base), + }; // An unchanged remote is the base image, validated when it was stored. - let identity = if snapshot == self.remote_snapshot()? { - None - } else { - Some(validate(&snapshot)?) - }; + let identity = changed.as_deref().map(validate).transpose()?; + let snapshot = changed.unwrap_or(base); let (intent, attempted) = { let mut connection = self .connection @@ -92,6 +99,9 @@ impl Replica { .map_err(|_| io::Error::other("Cache owner panicked"))?; let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + if identity.is_none() && images::settled(&transaction)? { + return Ok(None); + } let (base, working) = images::both(&transaction)?; if let Some(identity) = identity && RevisionIndex::parse(&Store::parse(&base)?)?.root != identity @@ -267,7 +277,7 @@ impl Replica { )?; transaction.commit()?; } - match remote.publish(&prepared) { + match remote.publish(&prepared.transaction()) { Ok(()) => {} Err(error) if error.state == CommitState::NotCommitted => { let connection = self diff --git a/crates/notebook/src/worker.rs b/crates/notebook/src/worker.rs index d506a60361e4adc11ead3d22c4b73a052ea07268..6e3d73d209efb9e219eeabd227aee3cd61da4c44 100644 --- a/crates/notebook/src/worker.rs +++ b/crates/notebook/src/worker.rs @@ -60,7 +60,8 @@ impl Drop for SyncWorker { impl Replica { /// Starts one worker, reconnecting through `connect` after transport failures. - /// Local edits wake it; `interval` controls idle polling and transport retries. + /// Local edits wake it; `interval` controls idle polling and transport retries. While + /// nothing is queued, a remote whose `stamp` holds is not read again. /// Contended operations returning `NotCommitted` also back off by up to one second. /// `observe` runs on the worker after each attempt, including connection errors. /// Cache/document errors stop the worker; inspect them through `observe` or `stop`. @@ -99,12 +100,22 @@ impl Replica { let thread = thread::Builder::new() .name("onestore-sync".into()) .spawn(move || { - let mut remote = None; + let mut remote: Option = None; let jitter = RandomState::new(); let mut contention = 0_u32; + // The first attempt always runs, so `observe` hears once that the remote is + // reachable. + let mut attempted = false; while !worker_signal.stopped.load(Ordering::Acquire) { let result = match remote.as_mut() { - Some(remote) => replica.sync_once(remote), + Some(remote) => { + if attempted && replica.settled(remote).unwrap_or(false) { + let _ = receiver.recv_timeout(interval); + continue; + } + attempted = true; + replica.sync_once(remote) + } None => match connect() { Ok(connected) => { remote = Some(connected); diff --git a/crates/notebook/tests/assets.rs b/crates/notebook/tests/assets.rs index afbb4f12c061db43b8a2f95c02b4ec4d00d67671..1eb7baae18e498c29c115b026c8156b9f922db79 100644 --- a/crates/notebook/tests/assets.rs +++ b/crates/notebook/tests/assets.rs @@ -271,7 +271,7 @@ fn a_native_refresh_removing_the_reference_rejects_an_inflight_download() { fn read(&mut self) -> io::Result> { fs::read("../../corpus/native-external-assets/native/synthetic.one") } - fn publish(&mut self, _: &onestore::PreparedEdit<'_>) -> Result<(), onestore::CommitError> { + fn publish(&mut self, _: &onestore::Transaction) -> Result<(), onestore::CommitError> { panic!("Unexpected publication") } fn confirm(&mut self, _: &[u8]) -> Result<(), onestore::CommitError> { diff --git a/crates/notebook/tests/support/model_schedule.rs b/crates/notebook/tests/support/model_schedule.rs index c3882c981e984ebc6a12c881ab53821d260be6af..fa871299fc39192e63946d5791f5270d47cc94f2 100644 --- a/crates/notebook/tests/support/model_schedule.rs +++ b/crates/notebook/tests/support/model_schedule.rs @@ -5,7 +5,7 @@ use crate::model_ops::{self, AUTHOR}; use crate::server::{Fault, Server}; use notebook::{EditStatus, Operation, Remote, Replica}; use onestore::{ - CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, + CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, Transaction, document::Document, page::{Outline, Page, PageObject}, }; @@ -140,20 +140,23 @@ impl Remote for Session<'_> { self.server.read() } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { assert!(!self.retired, "a retired attempt was replayed"); self.publications += 1; assert_eq!(self.publications, 1); let Some((_, Operation::Page(intent))) = &self.intent else { panic!("the schedule queues page saves only") }; - if model_ops::page_of(&self.server.visible, SOURCE.1) == intent.before { + let mut image = self.server.visible.clone(); + if transaction.apply(&mut image).is_ok() + && model_ops::page_of(&self.server.visible, SOURCE.1) == intent.before + { assert_eq!( - shape(&model_ops::page_of(edit.as_bytes(), SOURCE.1)), + shape(&model_ops::page_of(&image, SOURCE.1)), shape(&intent.after) ); } - self.server.publish(edit) + self.server.publish(transaction) } fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { diff --git a/crates/notebook/tests/support/page_schedule.rs b/crates/notebook/tests/support/page_schedule.rs index cb89f32459cfe578eadf8a5080c25c69f33013e3..afaacb032c3d2c2bbc3e5817fdb71a2d02c04bc5 100644 --- a/crates/notebook/tests/support/page_schedule.rs +++ b/crates/notebook/tests/support/page_schedule.rs @@ -1,7 +1,7 @@ use crate::disk; use notebook::{EditStatus, Operation, Remote, Replica}; use onestore::{ - CommitError, ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store, + CommitError, ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store, Transaction, document::{Document, Format, Kind, Layout}, page::{ Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, @@ -125,7 +125,9 @@ impl Remote for Session<'_> { Ok(self.disk.visible.clone()) } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { + let mut image = self.disk.visible.clone(); + transaction.apply(&mut image).unwrap(); let mut expected = pages(&self.disk.visible); match self.operation.unwrap() { Operation::CreatePage(page) => expected.push((page.space(), page.object(), 1)), @@ -144,7 +146,7 @@ impl Remote for Session<'_> { } Operation::Page(intent) => { let body = body_text(&intent.after); - let store = Store::parse(edit.as_bytes()).unwrap(); + let store = Store::parse(&image).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); let matching: Vec<_> = document @@ -160,10 +162,10 @@ impl Remote for Session<'_> { } _ => panic!(), } - assert_eq!(pages(edit.as_bytes()), expected); + assert_eq!(pages(&image), expected); let old = current::current(&self.disk.durable); - let new = current::current(edit.as_bytes()); - let result = edit.commit(self.disk); + let new = current::current(&image); + let result = transaction.commit(self.disk); let observed = current::current(&self.disk.durable); assert!(observed == old || observed == new); if result.is_ok() { diff --git a/crates/notebook/tests/support/server.rs b/crates/notebook/tests/support/server.rs index 195ba2520618e68b0e9fa14abaa43be99b327876..504f8b4fdc74f51199a05e29713c7c8004c20b15 100644 --- a/crates/notebook/tests/support/server.rs +++ b/crates/notebook/tests/support/server.rs @@ -3,7 +3,7 @@ use notebook::Remote; use onestore::{ - CommitError, CommitIo, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, + CommitError, CommitIo, CommitState, ExGuid, RevisionIndex, Stamp, Store, Transaction, document::{Document, Kind}, }; use std::io; @@ -75,7 +75,10 @@ impl Remote for Server { fn read(&mut self) -> io::Result> { Ok(self.visible.clone()) } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + fn stamp(&mut self) -> io::Result> { + Ok(Stamp::of(&self.visible).ok()) + } + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { self.publications += 1; let fault = std::mem::take(&mut self.fault); match fault { @@ -85,7 +88,7 @@ impl Remote for Server { _ => {} } let old = self.durable.clone(); - edit.commit(self)?; + transaction.commit(self)?; match fault { Fault::UnknownAfter => { self.durable = old; diff --git a/crates/notebook/tests/sync.rs b/crates/notebook/tests/sync.rs index f2702fdfd81e39fc01c58932a45e2d5250ca3c1b..0a7daccb1b546b4f9cfb023b8b9e7b91f9a9f5a9 100644 --- a/crates/notebook/tests/sync.rs +++ b/crates/notebook/tests/sync.rs @@ -1,7 +1,7 @@ //! Replica protocol: durable receipts, retired attempts, conflict review and contention. use notebook::{ConflictKind, EditStatus, Error, Remote, Replica}; -use onestore::{CommitError, CommitState, ExGuid, PreparedEdit, page::Page}; +use onestore::{CommitError, CommitState, ExGuid, Transaction, page::Page}; use std::{io, ops::Range}; #[path = "support/server.rs"] @@ -25,6 +25,51 @@ fn save(cache: &Replica, text: ExGuid, range: Range, replacement: &str) -> .unwrap() } +#[test] +fn an_unchanged_stamp_publishes_and_settles_without_reading_the_remote() { + struct Counted { + server: Server, + reads: usize, + } + impl Remote for Counted { + fn read(&mut self) -> io::Result> { + self.reads += 1; + self.server.read() + } + fn stamp(&mut self) -> io::Result> { + self.server.stamp() + } + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { + self.server.publish(transaction) + } + fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { + self.server.confirm(snapshot) + } + } + let directory = tempfile::tempdir().unwrap(); + let source = onestore::create_section("stamp.one", "Original", "Fixture").unwrap(); + let (sid, object, _) = text(&source); + let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); + let id = save(&cache, object, 0..0, "Local ").unwrap(); + let mut remote = Counted { + server: Server::new(&source), + reads: 0, + }; + assert!(matches!( + cache.sync_once(&mut remote).unwrap(), + Some((published, EditStatus::Published { .. })) if published == id + )); + assert_eq!(cache.sync_once(&mut remote).unwrap(), None); + assert_eq!(remote.reads, 0); + // Another writer's commit moves the header, so the next step reads the file. + let native = + onestore::replace_text(&remote.server.visible, sid, object, 0..0, "Native ").unwrap(); + remote.server.visible.clone_from(&native); + assert_eq!(cache.sync_once(&mut remote).unwrap(), None); + assert_eq!(remote.reads, 1); + assert_eq!(cache.snapshot().unwrap(), native); +} + #[test] fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becoming_a_writer() { use notebook::{Recovery, RecoverySummary}; @@ -417,9 +462,9 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio self.wait("read"); self.server.read() } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { self.wait("publish"); - self.server.publish(edit) + self.server.publish(transaction) } fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { self.wait("confirm"); @@ -768,8 +813,8 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() { } Ok(snapshot) } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - self.server.publish(edit) + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { + self.server.publish(transaction) } fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { self.server.confirm(snapshot) diff --git a/crates/notebook/tests/sync_page.rs b/crates/notebook/tests/sync_page.rs index 93101f65f5f916dab60a1ef358379d73db35cdac..59fb372db5712cb4cdfbe2415e31fa0e92f528c0 100644 --- a/crates/notebook/tests/sync_page.rs +++ b/crates/notebook/tests/sync_page.rs @@ -209,6 +209,8 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() { // Replacing only the revision identity models maintenance retiring the section proof. let at = positions[0] + 4; server.visible[at] ^= 0x40; + // A rewrite publishes a new file version (MS-ONESTORE 2.3.1). + server.visible[212] ^= 1; let store = Store::parse(&server.visible).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); index.validate_current().unwrap(); diff --git a/crates/notebook/tests/sync_paragraph.rs b/crates/notebook/tests/sync_paragraph.rs index bf65b46c72c50cca93af4e975625c659b6f54461..1dd492545de64fbbff2b0d414920ff7493ade744 100644 --- a/crates/notebook/tests/sync_paragraph.rs +++ b/crates/notebook/tests/sync_paragraph.rs @@ -97,7 +97,9 @@ fn split_paragraph(page: &mut Page, text: ExGuid, offset: u32) { } /// Joins the paragraph carrying `right` into the one carrying `left`; an empty left text -/// object is replaced by a non-empty right one, which is the identity OneNote keeps. +/// object is replaced by a non-empty right one, which is the identity OneNote keeps. The +/// right paragraph's children keep their depth, under the left paragraph or the nearest of +/// its ancestors above them. fn join_paragraphs(page: &mut Page, left: ExGuid, right: ExGuid) { locate_in(page, left, |list, first| { let second = at(list, right).expect("the joined paragraphs share a container"); @@ -115,9 +117,17 @@ fn join_paragraphs(page: &mut Page, left: ExGuid, right: ExGuid) { } target.text = joined; list.remove(second); + let nesting: std::collections::BTreeMap<_, _> = + list.iter().map(|p| (p.id, (p.level, p.parent))).collect(); for paragraph in list { if paragraph.parent == Some(orphaned) { - paragraph.parent = Some(parent); + let mut adopter = Some(parent); + while let Some(id) = adopter + && nesting[&id].0 >= paragraph.level + { + adopter = nesting[&id].1; + } + paragraph.parent = adopter; } } }); diff --git a/crates/notebook/tests/sync_worker.rs b/crates/notebook/tests/sync_worker.rs index 1dced50807904d233f60917b3e42c38a3d13d7bc..9221beeadbb5296ff10b642b8e624a6d3496abc8 100644 --- a/crates/notebook/tests/sync_worker.rs +++ b/crates/notebook/tests/sync_worker.rs @@ -2,7 +2,7 @@ use notebook::{ConflictKind, EditStatus, Error, Remote, Replica}; use onestore::{ - CommitError, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, + CommitError, CommitState, ExGuid, RevisionIndex, Store, Transaction, document::Document, }; use std::io; @@ -25,8 +25,11 @@ impl Remote for Shared { fn read(&mut self) -> io::Result> { self.0.lock().unwrap().read() } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - self.0.lock().unwrap().publish(edit) + fn stamp(&mut self) -> io::Result> { + self.0.lock().unwrap().stamp() + } + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { + self.0.lock().unwrap().publish(transaction) } fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { self.0.lock().unwrap().confirm(snapshot) @@ -72,8 +75,8 @@ fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() { } self.shared.read() } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { - self.shared.publish(edit) + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { + self.shared.publish(transaction) } fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { self.shared.confirm(snapshot) @@ -235,10 +238,10 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove fn read(&mut self) -> io::Result> { self.shared.read() } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { self.entered.send(()).unwrap(); self.resume.recv_timeout(Duration::from_secs(5)).unwrap(); - self.shared.publish(edit) + self.shared.publish(transaction) } fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> { self.shared.confirm(snapshot) @@ -580,7 +583,7 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() { _ => self.server.read(), } } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { self.writes += 1; match self.writes { 1 | 2 => Err(CommitError { @@ -592,7 +595,7 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() { } .into(), }), - _ => self.server.publish(edit), + _ => self.server.publish(transaction), } } fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { @@ -638,7 +641,7 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() { fn read(&mut self) -> io::Result> { self.0.read() } - fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> { let server = &mut self.0; if server.publications == 0 { server.publications += 1; @@ -647,7 +650,7 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() { error: io::ErrorKind::ResourceBusy.into(), }); } - server.publish(edit) + server.publish(transaction) } fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> { self.0.confirm(source) diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 88c1eee232e4759bebe8df67b31a5785ff3d8106..c969393e74c3777ff8a682e6383fcaa4f92c3cb4 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -64,10 +64,13 @@ harness also accepts `--client-profile release`. | `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | | `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks (external and, via `page::link`, to pages, paragraphs and sections), bullets and numbering, note tags, table rows, columns, cell shading and indents, nested tables, inserted pictures (in paragraphs or on the page) and attachments, picture position, size and description, a stored attachment's shown name, source path and icon size, paragraph styles on new paragraphs, ink drawings and handwriting (strokes added and erased), equations (built from `page::Math` trees), paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities | | `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically | -| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter | +| `Transaction`, `Stamp`, `PreparedEdit::transaction` | The bytes a commit writes (appended data, in-place list-tail and log patches, header) and the header and length it requires unchanged; `commit` under caller-held exclusion, `commit_file` under the conservative filesystem adapter, `apply` to the base image in memory; serializable for queues | +| `Arena`, `Section` | Keep a section parsed across edits: `open` validates an image once; `seal` appends one revision per changed space as a `Transaction` on `stamp`, checking only what it appends; `replay` applies a queued transaction; `image` and `page` read the result | +| `op::{Edit, Op, PageOp, TableEdit, SectionOp}`, `Section::apply` | Object-level edits (text, formatting, links, equations, paragraph insertion/split/join/move/deletion/levels, outlines, lists, tags, styles, paragraph formatting, tables, pictures, attachments, ink, page creation/import/moves/removal) applied whole or not to a kept-open section with emitter-chosen identities, UTF-16 ranges and the edit's time; refusals name the target, identity or structure at fault | +| `op::lower`, `op::lower_page`, `Section::apply_page` | The ops turning a range of paragraphs, or a whole page model, into another, computed from the models alone | | `read_file` | Read a snapshot under whole-file exclusion | | `read_snapshot` | Read a validated snapshot through fresh positioned I/O while the caller excludes maintenance | -| `commit_file_property` | Lock, compare the source snapshot, append and flush, then publish the revision | +| `commit_file_property` | Lock, check the source snapshot's stamp, append and flush, then publish the revision | | `CommitIo`, `commit_property_bytes` | Supply another storage backend with equivalent exclusion and ordered durability | Scalar edits accept encoded values and require the caller to maintain MS-ONE @@ -212,7 +215,7 @@ with optional explicit IDs when conflict copies contain identical text. ## Commit behavior ```text -exclusive lock → exact snapshot comparison +exclusive lock → header and length check → append data → flush → prepare header metadata → flush → publish transaction counter → flush @@ -220,7 +223,9 @@ exclusive lock → exact snapshot comparison → notify cached readers → flush → unlock ``` -Stale snapshots fail before writing. Live readers must use equivalent exclusion. +Stale snapshots fail before writing: every committed transaction and placement rewrites +the header (MS-ONESTORE 2.3.1), so the body is never compared. Live readers must use +equivalent exclusion. Native conflict creation can still expose cross-space references before their targets are saved; such snapshots must be rejected and reread while synchronization proceeds. `read_file` and `commit_file_property` serialize within the process because diff --git a/crates/onestore/src/active.rs b/crates/onestore/src/active.rs index 8b05379d4e1b50bb111238e39b464605640c11d2..6ed47dcb39e3259ff8b0b688b87b5103e14f33bb 100644 --- a/crates/onestore/src/active.rs +++ b/crates/onestore/src/active.rs @@ -1,20 +1,27 @@ use crate::{ - Error, ExGuid, Object, RevisionIndex, Store, - document::{Document, Element, Revision, Space}, + Error, ExGuid, FileType, Object, RevisionIndex, Store, + document::{Document, Element, Kind, Revision, Space}, write::{Commit, LiveRevision, PropertyObject, chain_depth, declared, differing}, }; use bumpalo::Bump; -use std::collections::{BTreeMap, BTreeSet}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; type Result = std::result::Result; /// Objects a writer stores in one revision of a page space. pub(crate) type Changes = BTreeMap; +/// Payloads a store holds, by identity. +pub(crate) type Files<'a> = std::rc::Rc Result<&'a [u8]> + 'a>; + /// A page space's active revision with the views its writers read, kept as appending /// their revisions and parsing the result would leave it. +#[derive(Clone)] pub(crate) struct ActivePage<'a> { - store: &'a Store<'a>, + files: Files<'a>, pub pages: Vec, pub live: LiveRevision<'a>, /// Elements of the reachable objects. @@ -42,6 +49,28 @@ impl<'a> ActivePage<'a> { })?; let rid = index.active(space)?; let live = LiveRevision::new(index.resolve(space, rid)?, chain_depth(index, space, rid))?; + let store: &'a Store<'a> = index.store; + Self::viewed(std::rc::Rc::new(|guid| store.file_data(guid)), pages, live, view) + } + + /// The page space `live` holds, whose stored payloads `files` reads. + pub(crate) fn open( + space: ExGuid, + live: LiveRevision<'a>, + file_type: FileType, + files: Files<'a>, + ) -> Result { + let (view, _) = Revision::parse(space, &live.revision, file_type, &mut |guid| files(guid))?; + let pages = manifest_pages(&view); + Self::viewed(files, pages, live, view) + } + + fn viewed( + files: Files<'a>, + pages: Vec, + live: LiveRevision<'a>, + view: Revision<'a>, + ) -> Result { let parents = view.parents(&pages)?; let titles = view .nodes @@ -50,7 +79,7 @@ impl<'a> ActivePage<'a> { .map(|(id, _)| *id) .collect(); Ok(Self { - store: index.store, + files, pages, live, view, @@ -65,13 +94,13 @@ impl<'a> ActivePage<'a> { where 'a: 'o, { - Element::parse_with(object, self.store, &mut |guid| match self + Element::parse_with(object, FileType::Section, &mut |guid| match self .payloads .iter() .find(|(id, _)| *id == guid) { Some((_, payload)) => Ok(*payload), - None => self.store.file_data(guid), + None => (self.files)(guid), }) } @@ -108,12 +137,23 @@ impl<'a> ActivePage<'a> { payloads: &[([u8; 16], &[u8])], changes: Changes, ) -> Result { + Ok(self.store(arena, payloads, changes)?.is_some()) + } + + /// `write`, returning the objects whose content, reachability or reference count may + /// have moved; none when it stores nothing. + pub(crate) fn store( + &mut self, + arena: &'a Bump, + payloads: &[([u8; 16], &[u8])], + changes: Changes, + ) -> Result>> { let changes = self.live.prepare(changes, true)?; for (guid, payload) in payloads { self.payloads.push((*guid, arena.alloc_slice_copy(payload))); } if changes.is_empty() { - return Ok(!payloads.is_empty()); + return Ok((!payloads.is_empty()).then(BTreeSet::new)); } let mut objects = Vec::new(); for (id, change) in changes { @@ -132,8 +172,49 @@ impl<'a> ActivePage<'a> { } else { self.live.settle(changed)?; } - self.refresh(&replaced, touched)?; - Ok(true) + self.refresh(&replaced, touched.clone())?; + Ok(Some(touched)) + } + + /// Takes the stored form of `ids` from `file`, the revision a seal appended, where the + /// revisions written here since the previous seal left them otherwise: a seal groups + /// tables and aliases read-only objects across all of them at once. + pub(crate) fn adopt( + &mut self, + file: &LiveRevision<'a>, + ids: impl IntoIterator, + ) -> Result<()> { + let mut replacements = Vec::new(); + for id in ids { + let Some(stored) = file.revision.objects.get(&id) else { + continue; + }; + match self.live.revision.objects.get_mut(&id) { + Some(object) if object.jcid == stored.jcid && object.data == stored.data => { + object.global_ids = Arc::clone(&stored.global_ids); + object.reference_count = stored.reference_count; + } + _ if file.is_reachable(id) => replacements.push((id, stored.clone())), + // Nothing reads an object the file leaves unreachable. + _ => {} + } + } + if !replacements.is_empty() { + let replaced = replacements.iter().map(|(id, _)| *id).collect(); + let Commit { touched, .. } = self.live.commit(replacements)?; + for id in &touched { + if let (Some(object), Some(stored)) = ( + self.live.revision.objects.get_mut(id), + file.revision.objects.get(id), + ) { + object.global_ids = Arc::clone(&stored.global_ids); + object.reference_count = stored.reference_count; + } + } + self.refresh(&replaced, touched)?; + } + self.live.depth = file.depth; + Ok(()) } /// Brings the view, parents and titles up to date with the revision for the objects a @@ -215,6 +296,24 @@ impl<'a> ActivePage<'a> { } } +/// The pages a page space's manifest lists. +pub(crate) fn manifest_pages(view: &Revision<'_>) -> Vec { + match view.roots.get(&1).and_then(|id| view.nodes.get(id)) { + Some(manifest) if matches!(manifest.kind, Kind::Manifest { .. }) => manifest + .content + .iter() + .filter(|id| { + matches!( + view.nodes.get(id).map(|node| &node.kind), + Some(Kind::Page { .. }) + ) + }) + .copied() + .collect(), + _ => Vec::new(), + } +} + fn edges<'n>(node: &'n Element<'_>) -> impl Iterator + 'n { node.children .iter() @@ -245,10 +344,79 @@ pub(crate) fn write( } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::{Insertion, ParagraphSplit, TextAttribute, TreeEdit, document::Kind}; + /// A seeded source of the typed writers' changes: insertions, moves, deletions, splits + /// and formatting of what a page holds. + pub(crate) struct Writes(pub u64); + + impl Writes { + fn pick(&mut self, count: usize) -> usize { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (self.0 >> 33) as usize % count.max(1) + } + + pub(crate) fn next(&mut self, active: &ActivePage<'_>) -> Result { + let nodes = &active.view.nodes; + let listed = |kind: fn(&Kind<'_>) -> bool| -> Vec { + nodes + .iter() + .filter(|(id, node)| kind(&node.kind) && active.parents.contains_key(id)) + .map(|(id, _)| *id) + .collect() + }; + let paragraphs = listed(|kind| matches!(kind, Kind::Paragraph { .. })); + let containers = listed(|kind| { + matches!( + kind, + Kind::Paragraph { .. } | Kind::Outline { .. } | Kind::Cell { .. } + ) + }); + let texts = listed(|kind| matches!(kind, Kind::RichText { .. })); + let unavailable = Error { + offset: 0, + message: "The page has nothing to edit", + }; + let paragraph = *paragraphs + .get(self.pick(paragraphs.len())) + .ok_or(unavailable)?; + let container = *containers + .get(self.pick(containers.len())) + .ok_or(unavailable)?; + let anchor = nodes[&container].children.get(self.pick(4)).copied(); + let text = *texts.get(self.pick(texts.len())).ok_or(unavailable)?; + let words = ["", "a", "Two words", "東京 🦀", "longer text here"]; + let word = words[self.pick(words.len())]; + match self.pick(8) { + 0..=3 => Insertion::paragraph(container, anchor, word, "Author") + .and_then(|insertion| insertion.changes(active)), + 4 => TreeEdit::move_to(paragraph, container, anchor, "Author") + .and_then(|edit| edit.changes(active)), + 5 => TreeEdit::delete(paragraph, "Author").and_then(|edit| edit.changes(active)), + 6 => { + let at = self.pick(3) as u32; + ParagraphSplit::new(text, at, "Author").and_then(|split| split.changes(active)) + } + _ => { + let end = self.pick(2) as u32; + let bold = self.pick(2) == 0; + crate::formatting::format_changes( + active, + text, + 0..end, + &[TextAttribute::Bold(bold)], + &[], + ) + } + } + } + } + /// `active` equals the page `image` stores, views included. fn assert_stores(active: &ActivePage<'_>, image: &[u8], space: ExGuid) { let store = Store::parse(image).unwrap(); @@ -288,51 +456,9 @@ mod tests { let (space, _) = Document::parse(&index).unwrap().pages().unwrap()[0]; let arena = Bump::new(); let mut active = ActivePage::parse(&index, space).unwrap(); - let mut seed = 7_u64; - let mut pick = |count: usize| { - seed = seed - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - (seed >> 33) as usize % count.max(1) - }; + let mut writes = Writes(7); for step in 0..steps { - let nodes = &active.view.nodes; - let listed = |kind: fn(&Kind<'_>) -> bool| -> Vec { - nodes - .iter() - .filter(|(id, node)| kind(&node.kind) && active.parents.contains_key(id)) - .map(|(id, _)| *id) - .collect() - }; - let paragraphs = listed(|kind| matches!(kind, Kind::Paragraph { .. })); - let containers = listed(|kind| { - matches!( - kind, - Kind::Paragraph { .. } | Kind::Outline { .. } | Kind::Cell { .. } - ) - }); - let texts = listed(|kind| matches!(kind, Kind::RichText { .. })); - let paragraph = paragraphs[pick(paragraphs.len())]; - let container = containers[pick(containers.len())]; - let anchor = nodes[&container].children.get(pick(4)).copied(); - let text = texts[pick(texts.len())]; - let words = ["", "a", "Two words", "東京 🦀", "longer text here"]; - let word = words[pick(words.len())]; - let changes = match pick(8) { - 0..=3 => Insertion::paragraph(container, anchor, word, "Author") - .and_then(|insertion| insertion.changes(&active)), - 4 => TreeEdit::move_to(paragraph, container, anchor, "Author") - .and_then(|edit| edit.changes(&active)), - 5 => TreeEdit::delete(paragraph, "Author").and_then(|edit| edit.changes(&active)), - 6 => ParagraphSplit::new(text, pick(3) as u32, "Author") - .and_then(|split| split.changes(&active)), - _ => crate::formatting::format_changes( - &active, - text, - 0..pick(2) as u32, - &[TextAttribute::Bold(pick(2) == 0)], - ), - }; + let changes = writes.next(&active); let Ok(changes) = changes else { continue; }; diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index 2a325fcc92d319016dffdb7d17ebe89a362a0468..1ac0817662d22240f0ac7955e2b95262d37c61f2 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -383,22 +383,20 @@ impl<'a> PreparedEdit<'a> { &self.written } - /// Publishes these prepared bytes after comparing the entire original snapshot. - /// The caller must retain OneNote-compatible exclusion through the returned outcome. + /// The bytes publishing this edit writes, against its snapshot's stamp. + pub fn transaction(&self) -> Transaction { + Transaction::between(self.source, &self.written) + } + + /// `Transaction::commit` of this edit. pub fn commit(&self, io: &mut impl CommitIo) -> Result<(), CommitError> { - commit_bytes(io, self.source, &self.written) + self.transaction().commit(io) } - /// Publishes these exact bytes through the conservative whole-file filesystem adapter. - /// A changed source returns ResourceBusy; an uncertain outcome must be reconciled before replay. + /// `Transaction::commit_file` of this edit. #[cfg(any(unix, windows))] pub fn commit_file(&self, path: impl AsRef) -> Result<(), CommitError> { - let mut io = FileIo::open(path, true).map_err(|error| CommitError { - state: CommitState::NotCommitted, - error, - })?; - let result = self.commit(&mut io); - io.finish(result) + self.transaction().commit_file(path) } } @@ -413,7 +411,7 @@ pub fn confirm_file_snapshot(path: impl AsRef, source: &[u8]) -> Result<() io.finish(result) } -/// Compares and flushes a snapshot, then refreshes its header version metadata. +/// Checks and flushes a snapshot's stamp, then refreshes its header version metadata. /// No revision is added; reread before using the snapshot for another physical commit. /// The caller must hold OneNote-compatible exclusion and independently establish which /// intents the snapshot contains. A successful read alone is not a durable acknowledgement. @@ -429,7 +427,7 @@ pub fn confirm_snapshot(io: &mut impl CommitIo, source: &[u8]) -> Result<(), Com version[..16].copy_from_slice(&crate::write::fresh_guid().map_err(io::Error::other)?); version[16..24].copy_from_slice(&generation.to_le_bytes()); version[24..].copy_from_slice(&crate::write::fresh_guid().map_err(io::Error::other)?); - compare_snapshot(io, source)?; + Stamp::of(source).map_err(io::Error::other)?.check(io)?; state = CommitState::Unknown; io.flush()?; write_all(io, 212, &version)?; @@ -474,12 +472,12 @@ impl std::error::Error for CommitError { } } -fn write_all(io: &mut impl CommitIo, mut offset: usize, mut bytes: &[u8]) -> io::Result<()> { +fn write_all(io: &mut impl CommitIo, mut offset: u64, mut bytes: &[u8]) -> io::Result<()> { while !bytes.is_empty() { - match io.write_at(offset as u64, bytes) { + match io.write_at(offset, bytes) { Ok(0) => return Err(io::Error::from(ErrorKind::WriteZero)), Ok(count) if count <= bytes.len() => { - offset += count; + offset += count as u64; bytes = &bytes[count..]; } Ok(_) => { @@ -512,85 +510,203 @@ pub fn commit_property_bytes( error: io::Error::new(ErrorKind::InvalidData, error), } })?; - commit_bytes(io, source, &written) + Transaction::between(source, &written).commit(io) } -fn compare_snapshot(io: &mut impl CommitIo, source: &[u8]) -> io::Result<()> { - let capacity = source.len().clamp(1, 1024 * 1024); - let mut buffer = Vec::new(); - buffer - .try_reserve_exact(capacity) - .map_err(io::Error::other)?; - buffer.resize(capacity, 0); - let mut offset = 0; - while offset < source.len() { - let size = buffer.len().min(source.len() - offset); - let count = match io.read_at(offset as u64, &mut buffer[..size]) { - Err(error) if error.kind() == ErrorKind::Interrupted => continue, - result => result?, - }; - if count == 0 { - return Err(io::Error::from(ErrorKind::UnexpectedEof)); +/// What a commit requires unchanged since its snapshot: the header, which every committed +/// transaction and every placement rewrites (MS-ONESTORE 2.3.1 `guidFileVersion`), and the +/// length appends start from. Equal stamps name the same committed image. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stamp { + pub header: [u8; 1024], + pub length: u64, +} + +impl Stamp { + pub fn of(image: &[u8]) -> Result { + Ok(Self { + header: image.first_chunk().copied().ok_or(crate::Error { + offset: 0, + message: "Truncated revision-store header", + })?, + length: image.len() as u64, + }) + } + + /// Reads the header and probes the length, without reading the body. + fn check(&self, io: &mut impl CommitIo) -> io::Result<()> { + let mut header = [0; 1024]; + crate::snapshot::read_exact( + &mut |offset, output| io.read_at(offset, output), + 0, + &mut header, + )?; + if header != self.header { + return Err(io::Error::new( + ErrorKind::ResourceBusy, + "The file header changed after the edit snapshot", + )); } - if count > size || buffer[..count] != source[offset..offset + count] { + let last = self.length.checked_sub(1).ok_or(ErrorKind::InvalidInput)?; + let mut tail = [0; 2]; + let count = loop { + match io.read_at(last, &mut tail) { + Err(error) if error.kind() == ErrorKind::Interrupted => {} + result => break result?, + } + }; + if count != 1 { return Err(io::Error::new( ErrorKind::ResourceBusy, - "The locked file differs from the edit snapshot", + "The file length changed after the edit snapshot", )); } - offset += count; + Ok(()) } - if io.read_at(source.len() as u64, &mut buffer[..1])? != 0 { - return Err(io::Error::new( - ErrorKind::ResourceBusy, - "The locked file grew after the edit snapshot", - )); - } - Ok(()) } -pub(crate) fn commit_bytes( - io: &mut impl CommitIo, - source: &[u8], - written: &[u8], -) -> Result<(), CommitError> { - let mut state = CommitState::NotCommitted; - let result = (|| -> io::Result<()> { - compare_snapshot(io, source)?; - if written == source { - state = CommitState::Unknown; - io.flush()?; - return Ok(()); +/// One change to a revision store as the bytes committing it writes: `append` at the base +/// length, `patches` inside the base's data area (list tails and the transaction log), and +/// the header that publishes them. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(into = "Wire", try_from = "Wire")] +pub struct Transaction { + pub(crate) base: Stamp, + pub(crate) append: Vec, + pub(crate) patches: Vec<(u64, Vec)>, + pub(crate) header: [u8; 1024], +} + +/// A transaction as serialized: headers as byte strings, since serde stops at 32-byte arrays. +#[derive(serde::Serialize, serde::Deserialize)] +struct Wire { + base: Vec, + length: u64, + append: Vec, + patches: Vec<(u64, Vec)>, + header: Vec, +} + +impl From for Wire { + fn from(transaction: Transaction) -> Self { + Self { + base: transaction.base.header.to_vec(), + length: transaction.base.length, + append: transaction.append, + patches: transaction.patches, + header: transaction.header.to_vec(), } - write_all(io, source.len(), &written[source.len()..])?; + } +} + +impl TryFrom for Transaction { + type Error = &'static str; + + fn try_from(wire: Wire) -> Result { + let header = |bytes: Vec| { + <[u8; 1024]>::try_from(bytes).map_err(|_| "A transaction header is 1024 bytes") + }; + Ok(Self { + base: Stamp { + header: header(wire.base)?, + length: wire.length, + }, + append: wire.append, + patches: wire.patches, + header: header(wire.header)?, + }) + } +} + +impl Transaction { + /// The transaction turning `source`, a parsed image, into `written`, which extends it. + pub(crate) fn between(source: &[u8], written: &[u8]) -> Self { + let differs = |at: &usize| source[*at] != written[*at]; + let mut patches = Vec::new(); let mut offset = 1024; - while offset < source.len() { - if source[offset] == written[offset] { - offset += 1; - continue; + while let Some(start) = (offset..source.len()).find(differs) { + let mut end = start + 1; + // Equal gaps shorter than a write request's overhead join the patch. + while let Some(next) = (end..source.len().min(end + 64)).find(differs) { + end = next + 1; } - let start = offset; - while offset < source.len() && source[offset] != written[offset] { - offset += 1; - } - write_all(io, start, &written[start..offset])?; + patches.push((start as u64, written[start..end].to_vec())); + offset = end; + } + Self { + base: Stamp { + header: *source.first_chunk().unwrap(), + length: source.len() as u64, + }, + append: written[source.len()..].to_vec(), + patches, + header: *written.first_chunk().unwrap(), + } + } + + /// Writes this transaction into its base image, as a successful commit leaves the file. + pub fn apply(&self, image: &mut Vec) -> Result<(), crate::Error> { + if Stamp::of(image)? != self.base { + return Err(crate::Error { + offset: 0, + message: "The image is not this transaction's base", + }); } - io.flush()?; - write_all(io, 100, &written[100..212])?; - write_all(io, 252, &written[252..1024])?; - io.flush()?; - let highest = (96..100).rfind(|at| source[*at] != written[*at]).unwrap(); - state = CommitState::Unknown; - write_all(io, highest, &written[highest..highest + 1])?; - io.flush()?; - if highest > 96 { - write_all(io, 96, &written[96..highest])?; - io.flush()?; + image.extend_from_slice(&self.append); + for (offset, bytes) in &self.patches { + let offset = *offset as usize; + image[offset..offset + bytes.len()].copy_from_slice(bytes); } - // Native readers cache the version GUID without rechecking the transaction count. - write_all(io, 212, &written[212..252])?; - io.flush()?; + image[..1024].copy_from_slice(&self.header); Ok(()) - })(); - result.map_err(|error| CommitError { state, error }) + } + + /// Publishes under caller-held OneNote-compatible exclusion, provided the file still has + /// the base stamp; otherwise returns ResourceBusy without writing. Appended data and + /// patches are flushed before the header, and the transaction count commits them + /// (MS-ONESTORE 2.3.3). Flush must make preceding writes durable before later ones. + pub fn commit(&self, io: &mut impl CommitIo) -> Result<(), CommitError> { + let mut state = CommitState::NotCommitted; + let result = (|| -> io::Result<()> { + self.base.check(io)?; + if self.append.is_empty() && self.patches.is_empty() && self.header == self.base.header + { + state = CommitState::Unknown; + return io.flush(); + } + write_all(io, self.base.length, &self.append)?; + for (offset, bytes) in &self.patches { + write_all(io, *offset, bytes)?; + } + io.flush()?; + write_all(io, 100, &self.header[100..212])?; + write_all(io, 252, &self.header[252..])?; + io.flush()?; + state = CommitState::Unknown; + // At a counter carry the highest changed byte commits; the lower ones follow. + if let Some(highest) = (96..100).rfind(|at| self.base.header[*at] != self.header[*at]) { + write_all(io, highest as u64, &self.header[highest..highest + 1])?; + io.flush()?; + if highest > 96 { + write_all(io, 96, &self.header[96..highest])?; + io.flush()?; + } + } + // Native readers cache the version GUID without rechecking the transaction count. + write_all(io, 212, &self.header[212..252])?; + io.flush() + })(); + result.map_err(|error| CommitError { state, error }) + } + + /// `commit` under the conservative filesystem adapter's whole-file exclusion. + #[cfg(any(unix, windows))] + pub fn commit_file(&self, path: impl AsRef) -> Result<(), CommitError> { + let mut io = FileIo::open(path, true).map_err(|error| CommitError { + state: CommitState::NotCommitted, + error, + })?; + let result = self.commit(&mut io); + io.finish(result) + } } diff --git a/crates/onestore/src/create.rs b/crates/onestore/src/create.rs index 062d894eadd3e0b641e36b8ef78318b2f4ac3927..4c3824efd1c512308787f79d4b83d3fc60c52383 100644 --- a/crates/onestore/src/create.rs +++ b/crates/onestore/src/create.rs @@ -1,5 +1,6 @@ use crate::{ Chunk, Error, ExGuid, FileType, PropertySets, Reference, RevisionIndex, Store, + op::content::{NATIVE_INDENTS, measurement_bytes}, store::crc, write::{append, append_list, fresh_guid, node}, }; @@ -24,6 +25,20 @@ pub(crate) fn default_text_style() -> Vec<(u32, Vec)> { ] } +/// The page margins MS-ONE requires on every page, in half inches, as OneNote 2010 sets them +/// on the pages it creates. +pub(crate) fn page_margins() -> Vec<(u32, Vec)> { + [ + (0x14001c4c, 1.0_f32), + (0x14001c4d, 1.0), + (0x14001c4e, 2.0), + (0x14001c4f, 2.0), + ] + .into_iter() + .map(|(id, value)| (id, value.to_le_bytes().to_vec())) + .collect() +} + pub(crate) fn properties(values: &[(u32, Vec)]) -> Result> { let mut streams: [Vec; 3] = std::array::from_fn(|_| Vec::new()); let mut fields = Vec::new(); @@ -110,14 +125,38 @@ pub(crate) fn placement(ancestor: [u8; 16], name: &str) -> [u8; 20] { bytes } +thread_local! { + /// The FILETIME `at` gives writers in place of the system clock. + static CLOCK: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Runs `f` with the writers' clock reading `filetime`, as an op's modification time. +pub(crate) fn at(filetime: u64, f: impl FnOnce() -> T) -> T { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + CLOCK.set(self.0); + } + } + let _restore = Restore(CLOCK.replace(Some(filetime))); + f() +} + +/// Seconds since 1980 as OneNote's Time32, and FILETIME, of the writers' clock. pub(crate) fn current_timestamps() -> Result<(u32, u64)> { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| Error { + let now = match CLOCK.get() { + Some(filetime) => (filetime / 10_000_000).checked_sub(11644473600).ok_or(Error { offset: 0, - message: "System time precedes the Unix epoch", - })? - .as_secs(); + message: "An edit time precedes the Unix epoch", + })?, + None => SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error { + offset: 0, + message: "System time precedes the Unix epoch", + })? + .as_secs(), + }; let modified = now .checked_sub(315532800) .and_then(|time| u32::try_from(time).ok()) @@ -205,12 +244,16 @@ pub fn create_section(file_name: &str, text: &str, author: &str) -> Result Result Self { Self { $($field: self.$field.as_ref().or(parent.$field.as_ref()).cloned()),* } } + + /// The values `self` sets that `parent` does not already give. + pub fn over(&self, parent: &Self) -> Self { + Self { + $($field: self.$field.as_ref().filter(|value| parent.$field.as_ref() != Some(*value)).cloned()),* + } + } } }; } @@ -275,6 +282,98 @@ impl Revision<'_> { } } +impl<'a> Revision<'a> { + /// The elements reachable in `revision` of `space`, and the (space, context) pairs they + /// reference. + pub(crate) fn parse( + space: ExGuid, + revision: &crate::ResolvedRevision<'a>, + file_type: crate::FileType, + file_data: &mut impl FnMut([u8; 16]) -> Result<&'a [u8]>, + ) -> Result<(Self, Vec<(ExGuid, ExGuid)>)> { + let mut reachable = BTreeSet::new(); + let mut objects: Vec<_> = revision.roots.values().copied().collect(); + let mut encrypted = false; + while let Some(oid) = objects.pop() { + if !reachable.insert(oid) { + continue; + } + let object = revision + .objects + .get(&oid) + .ok_or_else(|| invalid("Document object has no declaration"))?; + if matches!(object.data, ObjectData::Encrypted(_)) { + encrypted = true; + } else { + objects.extend(object.references()?.objects); + } + } + if !encrypted { + revision.reachable()?; + } + let mut referenced = Vec::new(); + let mut nodes = BTreeMap::new(); + for oid in reachable { + let object = &revision.objects[&oid]; + if !matches!(object.data, ObjectData::Encrypted(_)) { + let refs = object.references()?; + referenced.extend( + refs.object_spaces + .into_iter() + .map(|sid| (sid, ExGuid::default())), + ); + referenced.extend(refs.contexts.into_iter().map(|context| (space, context))); + } + nodes.insert(oid, Element::parse_with(object, file_type, file_data)?); + } + for node in nodes.values() { + if let Kind::RichText { + runs, + paragraph_style, + .. + } = &node.kind + { + for target in paragraph_style + .iter() + .chain(runs.iter().filter_map(|r| r.format.as_ref())) + { + if !matches!(nodes.get(target).map(|n| &n.kind), Some(Kind::Style { .. })) { + return Err(invalid("Text formatting does not reference a style")); + } + } + } + let mut tag_types = BTreeSet::new(); + for tag in &node.tags { + let action_type = if tag.status & 4 != 0 { + tag.action_type + } else { + match tag + .definition + .and_then(|id| nodes.get(&id)) + .map(|n| &n.kind) + { + Some(Kind::TagDefinition { action_type, .. }) => *action_type, + _ => { + return Err(invalid("Note tag does not reference a tag definition")); + } + } + } + .ok_or_else(|| invalid("Note tag has no action type"))?; + if !tag_types.insert(action_type) { + return Err(invalid("Repeated note tag action type")); + } + } + } + Ok(( + Self { + roots: revision.roots.clone(), + nodes, + }, + referenced, + )) + } +} + impl Revision<'_> { /// Maps every element reachable from `roots` through children, content and structure /// references to its referencing parents, in visiting order. @@ -807,91 +906,14 @@ impl<'a> Document<'a> { if space.revisions.contains_key(&rid) { continue; } - let revision = resolve(id, rid)?; - let mut reachable = BTreeSet::new(); - let mut objects: Vec<_> = revision.roots.values().copied().collect(); - let mut encrypted = false; - while let Some(oid) = objects.pop() { - if !reachable.insert(oid) { - continue; - } - let object = revision - .objects - .get(&oid) - .ok_or_else(|| invalid("Document object has no declaration"))?; - if matches!(object.data, ObjectData::Encrypted(_)) { - encrypted = true; - } else { - objects.extend(object.references()?.objects); - } - } - if !encrypted { - revision.reachable()?; - } - let mut nodes = BTreeMap::new(); - for oid in reachable { - let object = &revision.objects[&oid]; - if !matches!(object.data, ObjectData::Encrypted(_)) { - let refs = object.references()?; - pending.extend( - refs.object_spaces - .into_iter() - .map(|sid| (sid, ExGuid::default())), - ); - pending.extend(refs.contexts.into_iter().map(|context| (id, context))); - } - nodes.insert( - oid, - Element::parse_with(object, index.store, &mut file_data)?, - ); - } - for node in nodes.values() { - if let Kind::RichText { - runs, - paragraph_style, - .. - } = &node.kind - { - for target in paragraph_style - .iter() - .chain(runs.iter().filter_map(|r| r.format.as_ref())) - { - if !matches!(nodes.get(target).map(|n| &n.kind), Some(Kind::Style { .. })) { - return Err(invalid("Text formatting does not reference a style")); - } - } - } - let mut tag_types = BTreeSet::new(); - for tag in &node.tags { - let action_type = if tag.status & 4 != 0 { - tag.action_type - } else { - match tag - .definition - .and_then(|id| nodes.get(&id)) - .map(|n| &n.kind) - { - Some(Kind::TagDefinition { action_type, .. }) => *action_type, - _ => { - return Err(invalid( - "Note tag does not reference a tag definition", - )); - } - } - } - .ok_or_else(|| invalid("Note tag has no action type"))?; - if !tag_types.insert(action_type) { - return Err(invalid("Repeated note tag action type")); - } - } - } - space.revisions.insert( - rid, - Revision { - roots: revision.roots, - nodes, - }, - ); + let (revision, referenced) = Revision::parse( + id, + &resolve(id, rid)?, + index.store.header.file_type, + &mut file_data, + )?; + pending.extend(referenced); + space.revisions.insert(rid, revision); } for space in spaces.values() { for node in space.revisions.values().flat_map(|r| r.nodes.values()) { @@ -931,7 +953,7 @@ impl<'a> Document<'a> { impl<'a> Element<'a> { pub(crate) fn parse_with( object: &Object<'a>, - store: &Store<'a>, + file_type: crate::FileType, file_data: &mut impl FnMut([u8; 16]) -> Result<&'a [u8]>, ) -> Result { let empty = |kind| Self { @@ -1311,7 +1333,7 @@ impl<'a> Element<'a> { color: f.u32(0x14003466)?, highlight: f.u32(0x14003465)?, }, - 0x20001 if store.header.file_type == crate::FileType::TableOfContents => Kind::Toc { + 0x20001 if file_type == crate::FileType::TableOfContents => Kind::Toc { entries: f.refs(0x24001cf6, IdStream::Objects)?, filename: f.text(0x1c001d6b)?, identity: f.fixed(0x1c001d94)?, diff --git a/crates/onestore/src/files.rs b/crates/onestore/src/files.rs index fdfe6a9a858aa8a251b9c0e3db60b9f891c9b773..afeae1b362b16f0fcf57bf72aa311e448b7700cd 100644 --- a/crates/onestore/src/files.rs +++ b/crates/onestore/src/files.rs @@ -131,42 +131,47 @@ impl<'a> Store<'a> { offset: 0, message: "File-data object is not declared", })?; - let mut c = Cursor { - bytes: self.chunk_data(chunk)?, - offset: usize::try_from(chunk.offset).unwrap(), - }; - if c.read::<16>()? - != [ - 0xe7, 0x16, 0xe3, 0xbd, 0x65, 0x26, 0x11, 0x45, 0xa4, 0xc4, 0x8d, 0x4d, 0x0b, 0x7a, - 0x9e, 0xac, - ] - { - return Err(Error { - offset: c.offset - 16, - message: "Invalid file-data object header", - }); - } - let length = usize::try_from(u64::from_le_bytes(c.read()?)).map_err(|_| Error { - offset: c.offset - 8, - message: "File-data length exceeds address space", - })?; - c.take(12)?; - let bytes = c.take(length)?; - // The object length is aligned; its position in the file need not be. - let padding = (8 - (36 + length) % 8) % 8; - c.take(padding)?; - if c.read::<16>()? - != [ - 0x22, 0xa7, 0xfb, 0x71, 0x79, 0x0f, 0x0b, 0x4a, 0xbb, 0x13, 0x89, 0x92, 0x56, 0x42, - 0x6b, 0x24, - ] - || !c.bytes.is_empty() - { - return Err(Error { - offset: c.offset - 16, - message: "Invalid file-data object footer or length", - }); - } - Ok(bytes) + payload( + self.chunk_data(chunk)?, + usize::try_from(chunk.offset).unwrap(), + ) } } + +/// The payload of a file-data store object, whose bytes start at file offset `offset`. +pub(crate) fn payload(bytes: &[u8], offset: usize) -> Result<&[u8], Error> { + let mut c = Cursor { bytes, offset }; + if c.read::<16>()? + != [ + 0xe7, 0x16, 0xe3, 0xbd, 0x65, 0x26, 0x11, 0x45, 0xa4, 0xc4, 0x8d, 0x4d, 0x0b, 0x7a, + 0x9e, 0xac, + ] + { + return Err(Error { + offset: c.offset - 16, + message: "Invalid file-data object header", + }); + } + let length = usize::try_from(u64::from_le_bytes(c.read()?)).map_err(|_| Error { + offset: c.offset - 8, + message: "File-data length exceeds address space", + })?; + c.take(12)?; + let bytes = c.take(length)?; + // The object length is aligned; its position in the file need not be. + let padding = (8 - (36 + length) % 8) % 8; + c.take(padding)?; + if c.read::<16>()? + != [ + 0x22, 0xa7, 0xfb, 0x71, 0x79, 0x0f, 0x0b, 0x4a, 0xbb, 0x13, 0x89, 0x92, 0x56, 0x42, + 0x6b, 0x24, + ] + || !c.bytes.is_empty() + { + return Err(Error { + offset: c.offset - 16, + message: "Invalid file-data object footer or length", + }); + } + Ok(bytes) +} diff --git a/crates/onestore/src/formatting.rs b/crates/onestore/src/formatting.rs index 79f0ed7c8d1204418f581d22b141fe60fa690472..44b35d32379deb52ba3e76da402d9355b1f7a8e0 100644 --- a/crates/onestore/src/formatting.rs +++ b/crates/onestore/src/formatting.rs @@ -12,6 +12,9 @@ use std::{ sync::Arc, }; +/// The font sizes OneNote accepts, in points; sizes are written in half points. +pub const FONT_SIZES: std::ops::RangeInclusive = 6.0..=130.0; + fn invalid(message: &'static str) -> Error { Error { offset: 0, message } } @@ -43,6 +46,8 @@ pub enum TextAttribute { Hyperlink(bool), /// The run is a hyperlink's visible label or field code rather than surrounding text. HyperlinkLabel(bool), + /// Only false: equation text is written whole through the page model. + Math(bool), } impl TextAttribute { @@ -58,6 +63,10 @@ impl TextAttribute { Self::Hidden(value) => boolean(0x08001e16, *value), Self::Hyperlink(value) => boolean(0x08001e14, *value), Self::HyperlinkLabel(value) => boolean(0x08001e19, *value), + Self::Math(true) => { + return Err(invalid("Equations are written through the page model")); + } + Self::Math(false) => boolean(0x08003401, false), Self::Font(font) => { if font.is_empty() || font.contains('\0') { return Err(invalid("Font names must be nonempty and contain no NUL")); @@ -66,7 +75,7 @@ impl TextAttribute { } Self::FontSize(points) => { if !points.is_finite() - || !(6.0..=130.0).contains(points) + || !FONT_SIZES.contains(points) || (points * 2.0).fract() != 0.0 { return Err(invalid( @@ -99,23 +108,29 @@ pub(crate) fn format_text( attributes: &[TextAttribute], ) -> Result, Error> { crate::active::write(source, space, |active| { - format_changes(active, object, range, attributes) + format_changes(active, object, range, attributes, &[]) }) } -/// `format_text` on an active page. +/// `format_text` on an active page, also removing the `cleared` properties from the range's +/// run styles so the text inherits them. pub(crate) fn format_changes( active: &ActivePage<'_>, object: ExGuid, range: Range, attributes: &[TextAttribute], + cleared: &[crate::op::TextProperty], ) -> Result { - if attributes.is_empty() || range.start > range.end { + let cleared: Vec = cleared.iter().map(|property| property.id()).collect(); + if (attributes.is_empty() && cleared.is_empty()) || range.start > range.end { return Err(invalid( "Select a text range and at least one formatting attribute", )); } - let values = attribute_values(attributes)?; + let values = match attributes { + [] => Vec::new(), + attributes => attribute_values(attributes)?, + }; let view = &active.view; let parents = active.editable_parents(object)?; let node = &view.nodes[&object]; @@ -217,6 +232,7 @@ pub(crate) fn format_changes( }; if selected { style.set(&changes)?; + style.remove(&cleared)?; } if let Some(id) = previous .filter(|id| raw.objects[id].data == crate::ObjectData::Properties(&style.bytes)) diff --git a/crates/onestore/src/insertion.rs b/crates/onestore/src/insertion.rs index c6070391d6bb85ce7ad0653de83b88124ffe9d2a..0ce998267f8effcd436520af297ca20ae50e6347 100644 --- a/crates/onestore/src/insertion.rs +++ b/crates/onestore/src/insertion.rs @@ -3,6 +3,7 @@ use crate::{ active::{ActivePage, Changes}, create::{current_timestamps, default_text_style, properties, string}, document::Kind, + op::content::{NATIVE_INDENTS, measurement_bytes}, write::{PropertyObject, fresh_guid}, }; use serde::{Deserialize, Serialize}; @@ -195,6 +196,36 @@ impl Insertion { } pub(crate) fn changes(&self, active: &ActivePage<'_>) -> Result { + let paragraph = ExGuid { + guid: self.guid, + n: if matches!(self.placement, Placement::Outline { .. }) { + 3 + } else { + 1 + }, + }; + let changes = self.changes_as(active, self.object(), paragraph, self.text_object())?; + let raw = &active.live.revision; + if changes + .keys() + .any(|id| id.guid == self.guid && raw.objects.contains_key(id)) + { + return Err(invalid( + "An insertion identity is already present; reconcile the existing edit", + )); + } + Ok(changes) + } + + /// `changes` creating the outline or paragraph `object`, the paragraph `paragraph` and its + /// text `text` under those identities. + pub(crate) fn changes_as( + &self, + active: &ActivePage<'_>, + object: ExGuid, + paragraph: ExGuid, + text: ExGuid, + ) -> Result { let [page] = active.pages.as_slice() else { return Err(invalid("Insertion requires a single active page")); }; @@ -247,42 +278,54 @@ impl Insertion { pending.extend(parents.get(&id).into_iter().flatten().copied()); } let modified = current_timestamps()?.0.to_le_bytes(); - let paragraph_n = if matches!(self.placement, Placement::Outline { .. }) { - 3 - } else { - 1 - }; - let table = Arc::new(BTreeMap::from([(0, self.guid)])); - let reference = |n: u32| n.to_le_bytes().to_vec(); + let (author, default) = ( + ExGuid { + guid: self.guid, + n: 4, + }, + ExGuid { + guid: self.guid, + n: 5, + }, + ); + let mut table = BTreeMap::from([(0, self.guid)]); + for id in [object, paragraph, text] { + if !table.values().any(|guid| *guid == id.guid) { + table.insert(table.len() as u32, id.guid); + } + } + let table = Arc::new(table); + let reference = + |id: ExGuid| crate::write::compact(id, &table).map(|compact| compact.to_vec()); let mut new = BTreeMap::new(); - for (n, jcid, values) in [ + for (id, jcid, values) in [ ( - paragraph_n, + paragraph, 0x6000d, vec![ (0x14001d7a, modified.to_vec()), (0x14001d09, self.created.to_le_bytes().to_vec()), (0x0c001c03, vec![1]), - (0x24001c1f, reference(2)), - (0x20001d78, reference(4)), - (0x20001d79, reference(4)), + (0x24001c1f, reference(text)?), + (0x20001d78, reference(author)?), + (0x20001d79, reference(author)?), ], ), ( - 2, + text, 0x6000e, vec![ (0x14001d7a, modified.to_vec()), (0x1c001c22, string(&self.text)), - (0x24001e13, reference(5)), + (0x24001e13, reference(default)?), (0x10001cfe, 0x409_u16.to_le_bytes().to_vec()), ], ), - (4, 0x120001, vec![(0x1c001d75, string(&self.author))]), - (5, 0x12004d, default_text_style()), + (author, 0x120001, vec![(0x1c001d75, string(&self.author))]), + (default, 0x12004d, default_text_style()), ] { new.insert( - ExGuid { guid: self.guid, n }, + id, PropertyObject { jcid, bytes: properties(&values)?, @@ -291,10 +334,6 @@ impl Insertion { ); } if !self.formats.is_empty() { - let default = ExGuid { - guid: self.guid, - n: 5, - }; let mut segments = Vec::new(); let mut end = 0; for span in &self.formats { @@ -329,7 +368,7 @@ impl Insertion { if !segments.iter().any(|(_, id)| *id == default) { new.remove(&default); } - let target = new.get_mut(&self.text_object()).unwrap(); + let target = new.get_mut(&text).unwrap(); let mut references = Vec::new(); let mut ends = Vec::new(); for (end, id) in segments { @@ -340,16 +379,17 @@ impl Insertion { target.set(&[(0x24001e13, &references), (0x1c001e12, &ends)])?; } if let Placement::Outline { x, y } = self.placement { + let children = reference(paragraph)?; new.insert( - self.object(), + object, PropertyObject { jcid: 0x6000c, global_ids: table, bytes: properties(&[ (0x14001d7a, modified.to_vec()), - (0x24001c20, reference(3)), + (0x24001c20, children), (0x0c001c03, vec![1]), - (0x1c001c12, vec![1, 0, 0, 0, 0, 0, 0, 0]), + (0x1c001c12, measurement_bytes(&NATIVE_INDENTS, 4)?), (0x14001c14, (x / 36.0).to_le_bytes().to_vec()), (0x14001c15, (y / 36.0).to_le_bytes().to_vec()), (0x14001c1b, 13_f32.to_le_bytes().to_vec()), @@ -359,16 +399,11 @@ impl Insertion { ); } let raw = &active.live.revision; - if new.keys().any(|id| raw.objects.contains_key(id)) { - return Err(invalid( - "An insertion identity is already present; reconcile the existing edit", - )); - } // Drop the overlay before moving the property bytes it borrows. let title = { let mut overlay = BTreeMap::new(); let mut parent = view.nodes[&self.parent].clone(); - parent.children.insert(position, self.object()); + parent.children.insert(position, object); overlay.insert(self.parent, parent); for (id, object) in &new { overlay.insert( @@ -397,7 +432,7 @@ impl Insertion { None => Vec::new(), _ => return Err(invalid("The parent has an invalid child list")), }; - let child = parent.reference(self.object())?; + let child = parent.reference(object)?; if position > ids.len() / 4 { return Err(invalid("The parent has an invalid child list")); } diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs index be75f348c7463b60e8c2f8d163a04d7fddc7b2ec..d60291cd2898af3dfb123177aa011dc332c413a1 100644 --- a/crates/onestore/src/lib.rs +++ b/crates/onestore/src/lib.rs @@ -12,6 +12,7 @@ mod flush; mod formatting; mod insertion; mod objects; +pub mod op; mod outline; pub mod page; mod pages; @@ -20,6 +21,7 @@ mod properties; #[cfg(feature = "protected")] pub mod protected; mod revisions; +mod section; mod snapshot; mod store; mod toc; @@ -27,8 +29,8 @@ mod tree; mod write; pub use commit::{ - CommitError, CommitIo, CommitState, PreparedEdit, commit_property_bytes, commit_text, - confirm_snapshot, place, + CommitError, CommitIo, CommitState, PreparedEdit, Stamp, Transaction, commit_property_bytes, + commit_text, confirm_snapshot, place, }; #[cfg(any(unix, windows))] pub use commit::{ @@ -38,7 +40,7 @@ pub use commit::{ pub use create::{create_section, create_table_of_contents}; pub use edit::replace_text; pub use files::FileDataReference; -pub use formatting::TextAttribute; +pub use formatting::{FONT_SIZES, TextAttribute}; pub use insertion::Insertion; pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision}; pub use outline::OutlineEdit; @@ -46,6 +48,7 @@ pub use pages::{PageCreation, PageEdit, PagePosition}; pub use paragraph::{ParagraphJoin, ParagraphSplit}; pub use properties::{IdStream, Property, PropertySets, Value}; pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex}; +pub use section::{Arena, Section}; pub use snapshot::{read_snapshot, read_storage_snapshot}; pub use store::{Chunk, Error, FileType, Header, Node, NodeList, Reference, Store}; pub use toc::TocEdit; diff --git a/crates/onestore/src/objects.rs b/crates/onestore/src/objects.rs index dd548ae0a0ac98e54f4c220cdb838955cb1392a7..5556312ca104bb117fd0d8f0bb1618e003ea589b 100644 --- a/crates/onestore/src/objects.rs +++ b/crates/onestore/src/objects.rs @@ -19,7 +19,7 @@ pub enum ObjectData<'a> { }, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Object<'a> { pub jcid: u32, pub reference_count: u32, @@ -27,7 +27,7 @@ pub struct Object<'a> { pub global_ids: Arc, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ResolvedRevision<'a> { pub roots: BTreeMap, pub objects: BTreeMap>, diff --git a/crates/onestore/src/op/apply.rs b/crates/onestore/src/op/apply.rs new file mode 100644 index 0000000000000000000000000000000000000000..6e624be1f405de5d128ca456c1879386d45aa296 --- /dev/null +++ b/crates/onestore/src/op/apply.rs @@ -0,0 +1,1188 @@ +//! `Section::apply`: each op checked against the page it names, then written through the +//! typed writers onto the section's copy of that page. + +use super::{ + Edit, Op, OpError, PageOp, SectionOp, TableEdit, TextProperty, + content::{self, AttachmentIds}, + levels, + lower::{ParagraphFields, format_edits, paragraph_fields}, + properties, + table::{self, Structure}, +}; +use crate::{ + Error, ExGuid, Insertion, OutlineEdit, ParagraphJoin, ParagraphSplit, Section, TextAttribute, + TreeEdit, + active::{ActivePage, Changes}, + document::Kind, + page::{ + Attachment, Image, Ink, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, + TableCell, TableColumn, + }, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + ops::Range, +}; + +fn invalid(message: &'static str) -> Error { + Error { offset: 0, message } +} + +impl<'a> Section<'a> { + /// Applies an edit whole or not at all, `author` naming who made it. Its modification + /// times are `edit.at`. + pub fn apply(&mut self, author: &str, edit: &Edit) -> Result<(), OpError> { + if author.contains('\0') { + return Err(OpError::Unsupported("Choose an author name without NUL")); + } + let undo = edit.ops.len() > 1 || edit.ops.iter().any(stores_more_than_once); + crate::create::at(edit.at, || { + self.atomically(undo, |section| { + for op in &edit.ops { + match op { + Op::Page { space, op } => Writer { + section: &mut *section, + space: *space, + author, + } + .apply(op)?, + Op::Section(op) => section.apply_section(author, op)?, + } + } + Ok(()) + }) + }) + .map_err(|error| self.classify(error)) + } + + /// Lowers `after` against the page `space` holds and applies the result as one edit; + /// returns the ops, which reach `after` where the writers can store it. + pub fn apply_page( + &mut self, + author: &str, + at: u64, + space: ExGuid, + after: &Page, + ) -> Result, OpError> { + let before = self.page(space).map_err(|error| self.classify(Failure::Rejected(error)))?; + let ops = super::lower_page(&before, after) + .map_err(|error| OpError::Unsupported(error.message))?; + let edit = Edit { + at, + ops: ops + .iter() + .map(|op| Op::Page { + space, + op: op.clone(), + }) + .collect(), + }; + self.apply(author, &edit)?; + Ok(ops) + } + + fn classify(&self, failure: Failure) -> OpError { + match failure { + Failure::Refused(error) => error, + Failure::Rejected(error) if self.broken() => OpError::Failed(error), + Failure::Rejected(error) => OpError::Unsupported(error.message), + } + } + + fn apply_section(&mut self, author: &str, op: &SectionOp) -> Result<(), Failure> { + match op { + SectionOp::Create(creation) => { + let (root, roots, objects) = creation.changes(self)?; + self.apply_changes(self.root(), &[], root)?; + self.create_space(creation.space(), roots, objects)?; + } + SectionOp::Import { creation, page } => { + self.apply_section(author, &SectionOp::Create(creation.clone()))?; + let space = creation.space(); + let before = self.page(space)?; + let mut after = page.clone(); + // The created page's title stays; the imported page brings the rest. + after.objects.retain(|object| !matches!(object, PageObject::Title(_))); + for object in before.objects.iter().rev() { + if let PageObject::Title(_) = object { + after.objects.insert(0, object.clone()); + } + } + after.title = before.title.clone(); + after.identity = before.identity; + after.created = before.created; + after.margin_origin = before.margin_origin; + for op in super::lower_page(&before, &after)? { + Writer { + section: self, + space, + author, + } + .apply(&op)?; + } + } + SectionOp::Pages(edits) => { + for (space, changes) in crate::pages::section_changes(self, edits, &[])? { + self.apply_changes(space, &[], changes)?; + } + } + SectionOp::Delete(pages) => { + for (space, changes) in crate::pages::section_changes(self, &[], pages)? { + self.apply_changes(space, &[], changes)?; + } + } + } + Ok(()) + } +} + +/// Whether an op may store more than one revision of a page, so its failure part way needs +/// the page as it was. +fn stores_more_than_once(op: &Op) -> bool { + match op { + Op::Section(_) => true, + Op::Page { op, .. } => matches!( + op, + PageOp::Link { .. } + | PageOp::Insert { .. } + | PageOp::Move { .. } + | PageOp::Add { .. } + | PageOp::Table { + edit: TableEdit::Rows { .. } | TableEdit::Column { .. }, + .. + } + ), + } +} + +/// Why an op was not applied: a precondition it names, or what a writer refused. +enum Failure { + Refused(OpError), + Rejected(Error), +} + +impl From for Failure { + fn from(error: Error) -> Self { + Self::Rejected(error) + } +} + +impl From for Failure { + fn from(error: OpError) -> Self { + Self::Refused(error) + } +} + +/// Writes ops onto one page of a section. +struct Writer<'s, 'a> { + section: &'s mut Section<'a>, + space: ExGuid, + author: &'s str, +} + +impl<'a> Writer<'_, 'a> { + fn page(&mut self) -> Result<&ActivePage<'a>, Error> { + self.section.active(self.space) + } + + fn write( + &mut self, + changes: impl FnOnce(&ActivePage<'a>) -> Result, + ) -> Result<(), Failure> { + self.write_with(&[], changes) + } + + fn write_with( + &mut self, + payloads: &[([u8; 16], &[u8])], + changes: impl FnOnce(&ActivePage<'a>) -> Result, + ) -> Result<(), Failure> { + let changes = changes(self.page()?)?; + self.section.apply_changes(self.space, payloads, changes)?; + Ok(()) + } + + /// Requires `id` reachable on the page. + fn target(&mut self, id: ExGuid) -> Result<(), Failure> { + let page = self.page()?; + if page.live.is_reachable(id) && page.view.nodes.contains_key(&id) { + Ok(()) + } else { + Err(OpError::TargetUnavailable(id).into()) + } + } + + /// Requires `id` free for a new object: not reachable on the page. + fn free(&mut self, id: ExGuid) -> Result<(), Failure> { + if id.guid == [0; 16] || self.page()?.live.is_reachable(id) { + Err(OpError::DuplicateIdentity(id).into()) + } else { + Ok(()) + } + } + + /// The only parent of `id` on the page. + fn parent(&mut self, id: ExGuid) -> Result { + match self.page()?.parents.get(&id).map(Vec::as_slice) { + Some([parent]) => Ok(*parent), + _ => Err(OpError::StructureChanged("Page content must have one parent").into()), + } + } + + /// The container `id` lies in, outline groups passed over. + fn container(&mut self, id: ExGuid) -> Result { + let mut at = self.parent(id)?; + while matches!(self.page()?.view.nodes[&at].kind, Kind::OutlineGroup) { + at = self.parent(at)?; + } + Ok(at) + } + + /// The text object a paragraph holds. + fn text_of(&mut self, paragraph: ExGuid) -> Result { + let page = self.page()?; + let node = &page.view.nodes[¶graph]; + match node.content.as_slice() { + [text] if matches!(page.view.nodes[text].kind, Kind::RichText { .. }) => Ok(*text), + _ => Err(OpError::Unsupported("Select a paragraph holding text").into()), + } + } + + /// The absolute outline level of paragraph `id`; zero for an outline or cell. + fn level(&mut self, id: ExGuid) -> Result { + if !matches!(self.page()?.view.nodes[&id].kind, Kind::Paragraph { .. }) { + return Ok(0); + } + let container = self.container(id)?; + let depth = self.depth(container, id)?; + Ok(self.level(container)? + depth) + } + + fn depth(&mut self, container: ExGuid, id: ExGuid) -> Result { + levels::stored_depths(&self.page()?.view, container) + .into_iter() + .find(|(child, _)| *child == id) + .map(|(_, depth)| depth) + .ok_or_else(|| OpError::StructureChanged("A paragraph left its container").into()) + } + + /// Stores `container`'s children at the depths they have, `targets` overriding some. + fn regroup(&mut self, container: ExGuid, targets: &[(ExGuid, u32)]) -> Result<(), Failure> { + let page = self.page()?; + let current = levels::stored_depths(&page.view, container); + let is_cell = matches!(page.view.nodes[&container].kind, Kind::Cell { .. }); + let (children, depths): (Vec, Vec) = current + .iter() + .map(|(child, depth)| { + let target = targets + .iter() + .find(|(id, _)| id == child) + .map_or(*depth, |(_, depth)| *depth); + (*child, target) + }) + .unzip(); + if current.iter().map(|(_, depth)| *depth).eq(depths.iter().copied()) { + return Ok(()); + } + let (level, runs) = levels::runs(&children, &depths, is_cell)?; + self.write(|page| levels::regroup_changes(page, container, level, &runs)) + } + + fn apply(&mut self, op: &PageOp) -> Result<(), Failure> { + match op { + PageOp::Text { text, range, with } => { + self.target(*text)?; + if with.contains(['\0', '\n', '\u{fffc}']) { + return Err(OpError::Unsupported("Use ordinary paragraph text").into()); + } + self.write(|page| crate::edit::text_changes(page, *text, range.clone(), with)) + } + PageOp::Format { + text, + range, + set, + clear, + } => { + self.target(*text)?; + self.write(|page| { + crate::formatting::format_changes(page, *text, range.clone(), set, clear) + }) + } + PageOp::Link { + text, + range, + target, + } => { + self.target(*text)?; + let page = self.page()?; + let current = crate::page::text_of(&page.view, &page.parents, *text)?; + for op in link_ops(¤t, *text, range.clone(), target.as_deref())? { + self.apply(&op)?; + } + Ok(()) + } + PageOp::Equation { text, math } => { + self.target(*text)?; + self.write(|page| content::equation_changes(page, *text, math)) + } + PageOp::Insert { + container, + before, + paragraphs, + } => { + self.target(*container)?; + self.insert(*container, *before, paragraphs) + } + PageOp::Split { + text, + at, + paragraph, + right, + lists, + } => { + self.target(*text)?; + for id in [*paragraph, *right].iter().chain(lists) { + self.free(*id)?; + } + let split = ParagraphSplit::new(*text, *at, self.author)?; + self.write(|page| split.changes_as(page, *paragraph, *right, lists)) + } + PageOp::Join { left, right } => { + self.target(*left)?; + self.target(*right)?; + let join = ParagraphJoin::new(*left, *right, self.author)?; + self.write(|page| join.changes(page)) + } + PageOp::Move { + object, + parent, + before, + } => { + self.target(*object)?; + self.move_to(*object, *parent, *before) + } + PageOp::Delete { object } => { + self.target(*object)?; + let page = self.page()?; + if matches!(page.view.nodes[object].kind, Kind::Paragraph { .. }) { + let container = self.container(*object)?; + if matches!(self.page()?.view.nodes[&container].kind, Kind::Cell { .. }) + && levels::flat_children(&self.page()?.view, container) == [*object] + { + return Err(OpError::Unsupported( + "A table cell keeps a paragraph; insert its replacement first", + ) + .into()); + } + } + let edit = TreeEdit::delete(*object, self.author)?; + self.write(|page| edit.changes(page)) + } + PageOp::Level { paragraph, level } => { + self.target(*paragraph)?; + let container = self.container(*paragraph)?; + let base = self.level(container)?; + let depth = level + .checked_sub(base) + .filter(|depth| *depth > 0) + .ok_or(OpError::Unsupported( + "A paragraph lies deeper than its parent", + ))?; + self.regroup(container, &[(*paragraph, depth)]) + } + PageOp::Outline { object, edit } => { + self.target(*object)?; + self.write(|page| edit.changes(page, *object)) + } + PageOp::Paragraph { + paragraph, + alignment, + rtl, + space_before, + space_after, + line_spacing, + language, + } => { + self.target(*paragraph)?; + let text = self.text_of(*paragraph)?; + let values = ParagraphFields { + alignment: *alignment, + rtl: *rtl, + space_before: *space_before, + space_after: *space_after, + line_spacing: *line_spacing, + language: *language, + } + .values()?; + if values.is_empty() { + return Ok(()); + } + self.write(|page| properties::paragraph_format_changes(page, text, &values)) + } + PageOp::Style { + paragraph, + style, + definition, + } => { + self.target(*paragraph)?; + let text = self.text_of(*paragraph)?; + self.write(|page| properties::style_changes(page, text, *style, Some(definition))) + } + PageOp::List { paragraph, lists } => { + self.target(*paragraph)?; + let page = self.page()?; + for (id, _) in lists { + if page + .parents + .get(id) + .is_some_and(|owners| owners.iter().any(|owner| owner != paragraph)) + { + return Err(OpError::Unsupported("A list node belongs to one paragraph").into()); + } + } + let nodes = lists + .iter() + .map(|(id, definition)| Ok((*id, properties::list_values(definition)?))) + .collect::, Error>>()?; + self.write(|page| properties::list_changes(page, *paragraph, &nodes)) + } + PageOp::Tags { + target, + tags, + definitions, + } => { + self.target(*target)?; + let mut entries = Vec::new(); + for tag in tags { + let definition = tag + .definition + .ok_or(OpError::Unsupported("A note tag names its definition"))?; + let model = definitions + .iter() + .find(|(id, _)| *id == definition) + .map(|(_, definition)| definition); + entries.push((definition, tag, model)); + } + self.write(|page| properties::tag_changes(page, *target, &entries)) + } + PageOp::Add { object, before } => self.add(object, *before), + PageOp::Picture { + picture, + layout, + alt, + } => { + self.target(*picture)?; + let page = self.page()?; + let node = &page.view.nodes[picture]; + if !matches!(node.kind, Kind::Image { .. }) { + return Err(OpError::Unsupported("Select a picture").into()); + } + let stored = Image::read(&page.view, *picture, node)?; + self.write(|page| { + content::picture_edit_changes( + page, + *picture, + (&stored.layout, &stored.alt), + layout, + alt, + ) + }) + } + PageOp::Attachment { + attachment, + filename, + source_path, + size, + } => { + self.target(*attachment)?; + let page = self.page()?; + let node = &page.view.nodes[attachment]; + if !matches!(node.kind, Kind::Attachment { .. }) { + return Err(OpError::Unsupported("Select an attachment").into()); + } + let stored = Attachment::read(&page.view, *attachment, node)?; + let edited = Attachment { + filename: filename.clone(), + source_path: source_path.clone(), + size: *size, + ..stored.clone() + }; + self.write(|page| content::attachment_edit_changes(page, &stored, &edited)) + } + PageOp::Strokes { ink, add, remove } => { + self.target(*ink)?; + let page = self.page()?; + let node = &page.view.nodes[ink]; + if !matches!(node.kind, Kind::Ink { .. }) { + return Err(OpError::Unsupported("Select ink").into()); + } + let stored = Ink::read(&page.view, *ink, node)?; + if !stored.groups.is_empty() { + return Err(OpError::Unsupported("Grouped ink keeps its strokes").into()); + } + for id in remove { + if !stored.strokes.iter().any(|stroke| stroke.id == *id) { + return Err(OpError::TargetUnavailable(*id).into()); + } + } + for stroke in add { + self.free(stroke.id)?; + } + let kept: Vec = stored + .strokes + .iter() + .map(|stroke| stroke.id) + .filter(|id| !remove.contains(id)) + .collect(); + let added: Vec<_> = add.iter().map(|stroke| (stroke.id, stroke)).collect(); + self.write(|page| content::strokes_changes(page, *ink, &kept, &added)) + } + PageOp::Table { table, edit } => { + self.target(*table)?; + self.table(*table, edit) + } + } + } + + /// Moves `object` before `before` in `parent`, or among the page's children. + fn move_to( + &mut self, + object: ExGuid, + parent: Option, + before: Option, + ) -> Result<(), Failure> { + let Some(parent) = parent else { + let [page] = self.page()?.pages[..] else { + return Err(OpError::Unsupported("Choose a page space holding one page").into()); + }; + if let Some(before) = before + && self.parent(before)? != page + { + return Err(OpError::StructureChanged("The anchor is not on the page").into()); + } + let edit = TreeEdit::move_to(object, page, before, self.author)?; + return self.write(|page| edit.changes(page)); + }; + self.target(parent)?; + let mut at = parent; + loop { + if at == object { + return Err(OpError::StructureChanged("A subtree cannot move inside itself").into()); + } + match self.page()?.parents.get(&at).map(Vec::as_slice) { + Some([up]) => at = *up, + _ => break, + } + } + let holder = match before { + Some(before) => { + self.target(before)?; + if self.container(before)? != parent { + return Err( + OpError::StructureChanged("The anchor is no child of the container").into(), + ); + } + self.parent(before)? + } + None => parent, + }; + let level = self.level(object)?; + let edit = TreeEdit::move_to(object, holder, before, self.author)?; + self.write(|page| edit.changes(page))?; + if matches!(self.page()?.view.nodes[&parent].kind, Kind::Cell { .. }) { + return Ok(()); + } + let base = self.level(parent)?; + let depth = if level > base { level - base } else { 1 }; + self.regroup(parent, &[(object, depth)]) + } + + /// Inserts paragraphs into `container` before `before` as `PageOp::Insert` does. + fn insert( + &mut self, + container: ExGuid, + before: Option, + paragraphs: &[PageParagraph], + ) -> Result<(), Failure> { + if paragraphs.is_empty() { + return Err(OpError::Unsupported("Insert at least one paragraph").into()); + } + let holder = match before { + Some(before) => { + self.target(before)?; + if self.container(before)? != container { + return Err( + OpError::StructureChanged("The anchor is no child of the container").into(), + ); + } + self.parent(before)? + } + None => container, + }; + let mut seen = BTreeSet::new(); + for paragraph in paragraphs { + bare(paragraph)?; + for id in super::model::identities(std::slice::from_ref(paragraph)) { + if !seen.insert(id) { + return Err(OpError::DuplicateIdentity(id).into()); + } + self.free(id)?; + } + if let Some(parent) = paragraph.parent + && parent != container + && !paragraphs.iter().any(|p| p.id == parent) + { + return Err(OpError::StructureChanged( + "An inserted paragraph's parent precedes it among them", + ) + .into()); + } + } + let base = self.level(container)?; + let mut targets: BTreeMap> = BTreeMap::new(); + for paragraph in paragraphs { + let (parent, anchor, holder) = match paragraph.parent.filter(|p| *p != container) { + Some(parent) => (parent, None, parent), + None => (container, before, holder), + }; + let parent_level = if parent == container { + base + } else { + paragraphs.iter().find(|p| p.id == parent).unwrap().level + }; + let depth = paragraph + .level + .checked_sub(parent_level) + .filter(|depth| *depth > 0) + .ok_or(OpError::Unsupported("A paragraph lies deeper than its parent"))?; + targets.entry(parent).or_default().push((paragraph.id, depth)); + self.paragraph(holder, anchor, paragraph, None)?; + } + for (parent, targets) in targets { + if !matches!(self.page()?.view.nodes[&parent].kind, Kind::Cell { .. }) { + self.regroup(parent, &targets)?; + } + } + Ok(()) + } + + /// Creates one paragraph with its content in `holder` before `anchor`; with `outline`, + /// as the first paragraph of that new outline at its position. + fn paragraph( + &mut self, + holder: ExGuid, + anchor: Option, + paragraph: &PageParagraph, + outline: Option<(ExGuid, f32, f32)>, + ) -> Result<(), Failure> { + let (text, text_id) = match ¶graph.content { + ParagraphContent::Text(text) => (text.text.text(), text.id), + _ => ( + "", + ExGuid { + guid: crate::write::fresh_guid()?, + n: 1, + }, + ), + }; + let equation = paragraph + .text() + .is_some_and(|text| crate::page::Math::is_equation(&text.text)); + let insertion = match outline { + Some((_, x, y)) => { + let page = self.page()?.pages.first().copied().unwrap_or_default(); + Insertion::outline(page, x, y, text, self.author)? + } + None => Insertion::paragraph(holder, anchor, text, self.author)?, + }; + let object = outline.map_or(paragraph.id, |(id, _, _)| id); + self.write(|page| insertion.changes_as(page, object, paragraph.id, text_id))?; + match ¶graph.content { + ParagraphContent::Text(target) => { + if equation { + return self.write(|page| content::equation_changes(page, text_id, &target.text)); + } + let page = self.page()?; + let stored = crate::page::text_of(&page.view, &page.parents, text_id)?; + let values = paragraph_fields(&stored, &target.text)?.values()?; + if !values.is_empty() { + self.write(|page| properties::paragraph_format_changes(page, text_id, &values))?; + } + let page = self.page()?; + let stored = crate::page::text_of(&page.view, &page.parents, text_id)?; + for (range, set, clear) in format_edits(&stored, &target.text, true)? { + self.write(|page| { + crate::formatting::format_changes(page, text_id, range, &set, &clear) + })?; + } + Ok(()) + } + ParagraphContent::Table(table) => { + super::table::validate_table(table)?; + let structure = Structure { + rows: table + .rows + .iter() + .map(|row| (row.id, row.cells.iter().map(|cell| cell.id).collect())) + .collect(), + new_rows: table.rows.iter().map(|row| row.id).collect(), + new_cells: table + .rows + .iter() + .flat_map(|row| &row.cells) + .map(|cell| (cell.id, (table::cell_indents(&cell.indents, None), cell.shading))) + .collect(), + columns: &table.columns, + borders: table.borders, + }; + self.write(|page| table::table_changes(page, table.id, Some(paragraph.id), &structure))?; + for cell in table.rows.iter().flat_map(|row| &row.cells) { + self.cell(cell)?; + } + Ok(()) + } + ParagraphContent::Image(image) => { + if image.layout.x.is_some() || image.layout.y.is_some() { + return Err(OpError::Unsupported("A paragraph picture has no position of its own").into()); + } + self.picture(image, Some(paragraph.id)) + } + ParagraphContent::Attachment(attachment) => { + let Some(bytes) = &attachment.bytes else { + return Err(OpError::Unsupported("A new attachment needs its payload").into()); + }; + let ids = AttachmentIds { + object: attachment.id, + file: fresh()?, + payload: crate::write::fresh_guid()?, + preview: match &attachment.preview { + Some(_) => Some((crate::write::fresh_guid()?, fresh()?)), + None => None, + }, + }; + let mut payloads: Vec<([u8; 16], &[u8])> = vec![(ids.payload, bytes)]; + if let (Some((payload, _)), Some(icon)) = (ids.preview, &attachment.preview) { + payloads.push((payload, icon)); + } + self.write_with(&payloads, |page| { + content::attachment_changes(page, attachment, &ids, paragraph.id) + }) + } + ParagraphContent::Ink(ink) => self.ink(ink, Some(paragraph.id)), + ParagraphContent::Unsupported(_) => { + Err(OpError::Unsupported("Unsupported content cannot be inserted").into()) + } + } + } + + /// Fills a new cell with its paragraphs. + fn cell(&mut self, cell: &TableCell) -> Result<(), Failure> { + if cell.paragraphs.is_empty() { + return Err(OpError::Unsupported("A new table cell needs a paragraph").into()); + } + self.insert(cell.id, None, &cell.paragraphs) + } + + fn picture(&mut self, image: &Image, holder: Option) -> Result<(), Failure> { + let Some(bytes) = &image.bytes else { + return Err(OpError::Unsupported("A new picture needs its payload").into()); + }; + let (file, payload) = (fresh()?, crate::write::fresh_guid()?); + let bytes: &[u8] = bytes; + self.write_with(&[(payload, bytes)], |page| { + content::picture_changes(page, image, image.id, file, payload, holder) + }) + } + + fn ink(&mut self, ink: &Ink, holder: Option) -> Result<(), Failure> { + for stroke in &ink.strokes { + self.free(stroke.id)?; + } + let data = fresh()?; + let strokes: Vec<_> = ink.strokes.iter().map(|stroke| (stroke.id, stroke)).collect(); + self.write(|page| content::ink_changes(page, ink, ink.id, data, &strokes, holder)) + } + + /// Adds a page object on top, then moves it before `before`. + fn add(&mut self, object: &PageObject, before: Option) -> Result<(), Failure> { + self.free(object.id())?; + match object { + PageObject::Outline(outline) => { + let (Some(x), Some(y)) = (outline.layout.x, outline.layout.y) else { + return Err(OpError::Unsupported("A new outline needs a position").into()); + }; + let [first, rest @ ..] = outline.paragraphs.as_slice() else { + return Err(OpError::Unsupported("A new outline needs a paragraph").into()); + }; + for paragraph in &outline.paragraphs { + bare(paragraph)?; + for id in super::model::identities(std::slice::from_ref(paragraph)) { + self.free(id)?; + } + } + if first.parent.is_some() || first.level != 1 { + return Err(OpError::Unsupported("An outline starts at its first level").into()); + } + self.paragraph(outline.id, None, first, Some((outline.id, x, y)))?; + // Later paragraphs follow the first, their levels as the outline gives them. + if !rest.is_empty() { + let mut targets = vec![(first.id, 1)]; + for paragraph in rest { + let parent = paragraph.parent.unwrap_or(outline.id); + let parent_level = rest + .iter() + .chain([first]) + .find(|p| p.id == parent) + .map_or(0, |p| p.level); + let depth = paragraph + .level + .checked_sub(parent_level) + .filter(|depth| *depth > 0) + .ok_or(OpError::Unsupported("A paragraph lies deeper than its parent"))?; + self.paragraph(parent, None, paragraph, None)?; + if parent == outline.id { + targets.push((paragraph.id, depth)); + } else { + self.regroup(parent, &[(paragraph.id, depth)])?; + } + } + self.regroup(outline.id, &targets)?; + } + // A new outline is 468 points wide unless it says otherwise. + if let Some(points) = outline.layout.max_width + && (points, outline.layout.width_set_by_user) != (468.0, None) + { + let edit = OutlineEdit::Width { + points, + user_set: outline.layout.width_set_by_user == Some(true), + }; + self.write(|page| edit.changes(page, outline.id))?; + } + if !outline.indents.is_empty() && outline.indents != content::NATIVE_INDENTS { + let indents = content::measurement_bytes(&outline.indents, 4)?; + let id = outline.id; + self.write(|page| { + let mut node = + crate::write::PropertyObject::from_object(&page.live.revision.objects[&id])?; + node.set(&[(0x1c001c12, &indents)])?; + Ok(BTreeMap::from([(id, node)])) + })?; + } + } + PageObject::Image(image) => { + if image.layout.x.is_none() || image.layout.y.is_none() { + return Err(OpError::Unsupported("A new page-level picture needs a position").into()); + } + self.picture(image, None)?; + } + PageObject::Ink(ink) => self.ink(ink, None)?, + PageObject::Title(_) | PageObject::Unsupported(_) => { + return Err(OpError::Unsupported("Titles and unsupported objects cannot be added").into()); + } + } + if before.is_some() { + self.move_to(object.id(), None, before)?; + } + Ok(()) + } + + fn table(&mut self, id: ExGuid, edit: &TableEdit) -> Result<(), Failure> { + let page = self.page()?; + let node = &page.view.nodes[&id]; + let Kind::Table { + widths, + locked, + borders, + .. + } = &node.kind + else { + return Err(OpError::Unsupported("Select a table").into()); + }; + let mut columns: Vec = widths + .iter() + .enumerate() + .map(|(i, width)| TableColumn { + width: *width, + locked: locked.get(i).copied().unwrap_or(false), + }) + .collect(); + let mut rows: Vec<(ExGuid, Vec)> = node + .children + .iter() + .map(|row| (*row, page.view.nodes[row].children.clone())) + .collect(); + let mut borders = *borders; + let template = rows + .first() + .and_then(|(_, cells)| cells.first()) + .and_then(|cell| match &page.view.nodes[cell].kind { + Kind::Cell { indents, .. } => Some(indents.clone()), + _ => None, + }); + let mut new_rows = BTreeSet::new(); + let mut new_cells: BTreeMap, Option)> = BTreeMap::new(); + let mut filled: Vec<&TableCell> = Vec::new(); + let new_cell = |cell: &TableCell| { + ( + table::cell_indents(&cell.indents, template.as_deref()).to_vec(), + cell.shading, + ) + }; + match edit { + TableEdit::Rows { before, rows: added } => { + let at = match before { + Some(before) => rows + .iter() + .position(|(row, _)| row == before) + .ok_or(OpError::StructureChanged("The anchor row is not in the table"))?, + None => rows.len(), + }; + for row in added { + if row.cells.len() != columns.len() { + return Err(OpError::Unsupported("Every table row has one cell per column").into()); + } + new_rows.insert(row.id); + for cell in &row.cells { + new_cells.insert(cell.id, new_cell(cell)); + filled.push(cell); + } + } + rows.splice( + at..at, + added + .iter() + .map(|row| (row.id, row.cells.iter().map(|cell| cell.id).collect())), + ); + } + TableEdit::Column { at, width, cells } => { + let at = *at as usize; + if at > columns.len() || cells.len() != rows.len() { + return Err(OpError::Unsupported("A new column has one cell per row").into()); + } + for ((_, row), cell) in rows.iter_mut().zip(cells) { + row.insert(at, cell.id); + new_cells.insert(cell.id, new_cell(cell)); + filled.push(cell); + } + columns.insert( + at, + TableColumn { + width: *width, + locked: false, + }, + ); + } + TableEdit::DeleteRow(row) => { + let before = rows.len(); + rows.retain(|(id, _)| id != row); + if rows.len() == before { + return Err(OpError::TargetUnavailable(*row).into()); + } + if rows.is_empty() { + return Err(OpError::Unsupported("A table keeps a row; delete the table instead").into()); + } + } + TableEdit::DeleteColumn(at) => { + let at = *at as usize; + if at >= columns.len() || columns.len() == 1 { + return Err(OpError::Unsupported("Choose a column of a table with several").into()); + } + columns.remove(at); + for (_, cells) in &mut rows { + cells.remove(at); + } + } + TableEdit::Columns(widths) => { + if widths.len() != columns.len() { + return Err(OpError::Unsupported("Give every column its width").into()); + } + columns = widths.clone(); + } + TableEdit::Borders(value) => borders = Some(*value), + TableEdit::Cell { + cell, + shading, + indents, + } => { + if !rows.iter().any(|(_, cells)| cells.contains(cell)) { + return Err(OpError::TargetUnavailable(*cell).into()); + } + let Kind::Cell { + shading: stored_shading, + indents: stored_indents, + } = &page.view.nodes[cell].kind + else { + return Err(OpError::Unsupported("Select a table cell").into()); + }; + let stored = (*stored_shading, stored_indents.clone()); + return self.write(|page| { + table::cell_changes(page, *cell, (stored.0, &stored.1), *shading, indents) + }); + } + } + for cell in &filled { + for id in std::iter::once(cell.id).chain(super::model::identities(&cell.paragraphs)) { + self.free(id)?; + } + } + for row in &new_rows { + self.free(*row)?; + } + if columns.iter().any(|c| !c.width.is_finite() || c.width < 36.0) { + return Err(OpError::Unsupported("Table columns are at least 36 points wide").into()); + } + let structure = Structure { + rows, + new_rows, + new_cells: new_cells + .iter() + .map(|(id, (indents, shading))| (*id, (indents.as_slice(), *shading))) + .collect(), + columns: &columns, + borders, + }; + self.write(|page| table::table_changes(page, id, None, &structure))?; + for cell in filled { + self.cell(cell)?; + } + Ok(()) + } +} + +fn fresh() -> Result { + Ok(ExGuid { + guid: crate::write::fresh_guid()?, + n: 1, + }) +} + +/// Requires what `Insert` leaves to other ops unset. +fn bare(paragraph: &PageParagraph) -> Result<(), OpError> { + let text_tags = paragraph.text().is_some_and(|text| !text.tags.is_empty()); + if paragraph.style.is_some() + || !paragraph.lists.is_empty() + || !paragraph.tags.is_empty() + || text_tags + || paragraph.collapsed + { + return Err(OpError::Unsupported( + "Set styles, lists, tags and collapse state with their own ops", + )); + } + if paragraph.media != Default::default() { + return Err(OpError::Unsupported( + "Recording annotations are made by OneNote while it records", + )); + } + match ¶graph.content { + ParagraphContent::Text(text) if text.date_field.is_some() => Err(OpError::Unsupported( + "New paragraphs contain plain text without fields", + )), + ParagraphContent::Table(table) => { + if !table.tags.is_empty() { + return Err(OpError::Unsupported("Set a table's tags with their own op")); + } + for paragraph in table.rows.iter().flat_map(|row| &row.cells).flat_map(|cell| &cell.paragraphs) + { + bare(paragraph)?; + } + Ok(()) + } + ParagraphContent::Attachment(attachment) if attachment.recording.is_some() => Err( + OpError::Unsupported("Recordings are captured by OneNote, not inserted"), + ), + _ => Ok(()), + } +} + +/// The field code OneNote stores before a hyperlink's label. +fn field_code(target: &str) -> Result { + if target.is_empty() || target.contains(['"', '\0', '\r', '\n', '\u{fddf}']) { + return Err(invalid("Choose a link target without quotes or line breaks")); + } + Ok(format!("\u{fddf}HYPERLINK \"{target}\"")) +} + +/// The text edits making `range` of `text` a hyperlink to `target`: the field code +/// inserted hidden before the label, both flagged as link runs as OneNote stores them. With +/// `None` or an existing link, the field code before the label is removed first. +pub(crate) fn link_ops( + text: &Paragraph, + id: ExGuid, + range: Range, + target: Option<&str>, +) -> Result, Error> { + if range.is_empty() { + return Err(invalid("A link needs a label")); + } + let at = |offset: u32| super::lower::format_in(text, offset); + let mut ops = Vec::new(); + let mut label = range.clone(); + // A field code is a hidden link run ending where the label starts. + let start = text.byte_offset(range.start)?; + let spans = text.spans(); + let code = spans.iter().enumerate().find_map(|(i, span)| { + let from = if i == 0 { 0 } else { spans[i - 1].end }; + (span.end == start + && span.format.hidden == Some(true) + && span.format.hyperlink == Some(true) + && text.text()[from..span.end].starts_with('\u{fddf}')) + .then_some(from..span.end) + }); + if let Some(code) = code { + let code = text.utf16_offset(code.start)?..text.utf16_offset(code.end)?; + let length = code.end - code.start; + ops.push(PageOp::Text { + text: id, + range: code.clone(), + with: String::new(), + }); + label = label.start - length..label.end - length; + } else if target.is_none() { + return Err(invalid("The range is no link")); + } + let base = at(range.start)?.clone(); + match target { + Some(target) => { + let code = field_code(target)?; + let length = code.encode_utf16().count() as u32; + ops.push(PageOp::Text { + text: id, + range: label.start..label.start, + with: code, + }); + let mut set = vec![ + TextAttribute::Hyperlink(true), + TextAttribute::HyperlinkLabel(true), + ]; + if base.hidden != Some(true) { + ops.push(PageOp::Format { + text: id, + range: label.start..label.start + length, + set: [set.clone(), vec![TextAttribute::Hidden(true)]].concat(), + clear: Vec::new(), + }); + } + set.retain(|attribute| match attribute { + TextAttribute::Hyperlink(_) => base.hyperlink != Some(true), + _ => base.hyperlink_label != Some(true), + }); + if !set.is_empty() || base.hidden == Some(true) { + ops.push(PageOp::Format { + text: id, + range: label.start + length..label.end + length, + set, + clear: Vec::new(), + }); + } + } + None => ops.push(PageOp::Format { + text: id, + range: label, + set: Vec::new(), + clear: vec![TextProperty::Hyperlink, TextProperty::HyperlinkLabel], + }), + } + ops.retain(|op| !matches!(op, PageOp::Format { set, clear, .. } if set.is_empty() && clear.is_empty())); + Ok(ops) +} diff --git a/crates/onestore/src/op/content.rs b/crates/onestore/src/op/content.rs new file mode 100644 index 0000000000000000000000000000000000000000..d4ff55ca870cfebf3f26828fee50e71387bc66fa --- /dev/null +++ b/crates/onestore/src/op/content.rs @@ -0,0 +1,746 @@ +//! Paragraph and page content other than text: pictures, attachments, ink and equations, +//! written as OneNote 2010 stores them. + +use super::Values; +use crate::{ + Error, ExGuid, + active::{ActivePage, Changes}, + document::{Format, Layout}, + page::{Attachment, Image, Ink, InkStroke, Paragraph}, + write::PropertyObject, +}; +use std::collections::BTreeMap; + +fn invalid(message: &'static str) -> Error { + Error { offset: 0, message } +} + +/// The `{GUID}` form a file-data declaration uses to name a payload in the file-data store. +fn payload_reference(guid: [u8; 16]) -> String { + let id = ExGuid { guid, n: 0 }.to_string(); + format!("{}", id.split(',').next().unwrap()) +} + +/// The compact child references a page object holds. +fn page_children(object: &PropertyObject) -> Result, Error> { + let properties = crate::PropertySets::parse(&object.bytes)?; + match properties.sets[0] + .iter() + .find(|p| p.id == 0x24001c20) + .map(|p| &p.value) + { + Some(crate::Value::References { compact_ids, .. }) => Ok(compact_ids.to_vec()), + None => Ok(Vec::new()), + _ => Err(invalid("The page has an invalid child list")), + } +} + +/// Makes `object` the content of paragraph `holder`, or a child of the page after its others. +fn hold( + active: &ActivePage<'_>, + changed: &mut Changes, + object: ExGuid, + holder: Option, + modified: &[u8; 4], +) -> Result<(), Error> { + let raw = &active.live.revision; + match holder { + Some(holder) => { + let mut paragraph = PropertyObject::from_object(&raw.objects[&holder])?; + let content = paragraph.reference(object)?; + paragraph.set(&[(0x24001c1f, &content), (0x14001d7a, modified)])?; + changed.insert(holder, paragraph); + } + None => { + let [page] = active.pages[..] else { + return Err(invalid("Choose an object space containing one active page")); + }; + let mut node = PropertyObject::from_object(&raw.objects[&page])?; + let mut children = page_children(&node)?; + children.extend(node.reference(object)?); + node.set(&[(0x24001c20, &children), (0x14001d7a, modified)])?; + changed.insert(page, node); + } + } + Ok(()) +} + +/// The character-style properties a span format sets; paragraph-level fields stay on the +/// paragraph style. +pub(crate) fn style_values(format: &Format) -> Values { + let mut values = Values::new(); + for (id, flag) in [ + (0x08001c04, format.bold), + (0x08001c05, format.italic), + (0x08001c06, format.underline), + (0x08001c07, format.strike), + (0x08001c08, format.superscript), + (0x08001c09, format.subscript), + (0x08001e16, format.hidden), + (0x08001e14, format.hyperlink), + (0x08001e19, format.hyperlink_label), + (0x08003401, format.math), + (0x08001e22, format.embedded_object), + ] { + if let Some(flag) = flag { + values.push((id | (u32::from(flag) << 31), Vec::new())); + } + } + if let Some(font) = &format.font { + values.push((0x1c001c0a, crate::create::string(font))); + } + if let Some(size) = format.font_size { + values.push(( + 0x10001c0b, + ((size * 2.0).round() as u16).to_le_bytes().to_vec(), + )); + } + if let Some(color) = format.color { + values.push((0x14001c0c, color.to_le_bytes().to_vec())); + } + if let Some(highlight) = format.highlight { + values.push((0x14001c0d, highlight.to_le_bytes().to_vec())); + } + if let Some(language) = format.language { + values.push((0x14001c3b, language.to_le_bytes().to_vec())); + } + values +} + +/// The indentation table, in points, OneNote gives each outline and table cell it creates. +pub(crate) const NATIVE_INDENTS: [f32; 4] = [18.0, 0.0, 27.0, 27.0]; + +/// A native measurement array: a count byte, `header - 1` reserved bytes, then each value +/// in half-inch units. +pub(crate) fn measurement_bytes(values: &[f32], header: usize) -> Result, Error> { + let count = u8::try_from(values.len()) + .map_err(|_| invalid("A measurement array exceeds the document range"))?; + let mut bytes = vec![0u8; header]; + bytes[0] = count; + for value in values { + if !value.is_finite() { + return Err(invalid("A measurement must be finite")); + } + bytes.extend_from_slice(&(value / 36.0).to_le_bytes()); + } + Ok(bytes) +} + +/// A stored picture keeps what `Picture` cannot change. +pub(crate) fn picture_fixed_fields(stored: &Image, image: &Image) -> Result<(), Error> { + if stored.id != image.id + || stored.bytes != image.bytes + || stored.size != image.size + || stored.background != image.background + { + return Err(invalid( + "A stored picture keeps its payload, intrinsic size and background state", + )); + } + Ok(()) +} + +/// Position and displayed-size properties that take `layout` from `stored`, and the ones +/// to remove. +fn layout_values(stored: &Layout, layout: &Layout) -> Result<(Values, Vec), Error> { + let mut values = Values::new(); + let mut removed = Vec::new(); + if (layout.x, layout.y) != (stored.x, stored.y) { + let (Some(x), Some(y)) = (layout.x, layout.y) else { + return Err(invalid("A picture position needs both coordinates")); + }; + if !(x.is_finite() && y.is_finite()) { + return Err(invalid("A picture position must be finite")); + } + values.push((0x14001c14, (x / 36.0).to_le_bytes().to_vec())); + values.push((0x14001c15, (y / 36.0).to_le_bytes().to_vec())); + } + if ( + layout.max_width, + layout.max_height, + layout.width_set_by_user, + ) != ( + stored.max_width, + stored.max_height, + stored.width_set_by_user, + ) { + match (layout.max_width, layout.max_height) { + (Some(width), Some(height)) => { + if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { + return Err(invalid("Picture size must be positive")); + } + values.push((0x14001c1b, (width / 36.0).to_le_bytes().to_vec())); + values.push((0x14001c1c, (height / 36.0).to_le_bytes().to_vec())); + let user_set = u32::from(layout.width_set_by_user == Some(true)); + values.push((0x08001cbd | (user_set << 31), Vec::new())); + } + (None, None) => removed.extend([0x14001c1b, 0x14001c1c, 0x08001cbd]), + _ => return Err(invalid("A picture size needs both dimensions")), + } + } + if layout.reserved_width != stored.reserved_width { + return Err(invalid("A picture has no reserved width")); + } + Ok((values, removed)) +} + +fn icon_size(values: &mut Values, size: Option<[f32; 2]>, message: &'static str) -> Result<(), Error> { + if let Some([width, height]) = size { + if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { + return Err(invalid(message)); + } + values.push((0x140034cd, (width / 36.0).to_le_bytes().to_vec())); + values.push((0x140034ce, (height / 36.0).to_le_bytes().to_vec())); + } + Ok(()) +} + +/// What OneNote stores for an inserted picture: a file-data object declaring payload +/// `payload` by identity and extension, and picture object `id` that paragraph `holder` +/// holds as content or the page lists as a child. The caller embeds the payload. +pub(crate) fn picture_changes( + active: &ActivePage<'_>, + image: &Image, + id: ExGuid, + file: ExGuid, + payload: [u8; 16], + holder: Option, +) -> Result { + let Some(bytes) = &image.bytes else { + return Err(invalid("A new picture needs its payload")); + }; + let extension = match bytes.as_ref() { + [0x89, b'P', b'N', b'G', ..] => ".png", + [0xff, 0xd8, 0xff, ..] => ".jpg", + [b'G', b'I', b'F', b'8', ..] => ".gif", + [b'B', b'M', ..] => ".bmp", + _ => return Err(invalid("Choose a PNG, JPEG, GIF or BMP picture")), + }; + let modified = crate::create::current_timestamps()?.0.to_le_bytes(); + let mut values: Values = vec![(0x14001d7a, modified.to_vec())]; + if let Some([width, height]) = image.size { + if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { + return Err(invalid("Picture size must be positive")); + } + values.push((0x140034cd, (width / 36.0).to_le_bytes().to_vec())); + values.push((0x140034ce, (height / 36.0).to_le_bytes().to_vec())); + } + values.extend(layout_values(&Default::default(), &image.layout)?.0); + if let Some(alt) = &image.alt { + values.push((0x1c001e58, crate::create::string(alt))); + } + if image.background { + values.push((0x08001d13 | (1 << 31), Vec::new())); + } + values.push((0x14001c3b, 0x409_u32.to_le_bytes().to_vec())); + values.push((0x08001d85, Vec::new())); + let mut changed = BTreeMap::new(); + changed.insert( + file, + PropertyObject::file(file, &payload_reference(payload), extension)?, + ); + let mut picture = PropertyObject { + jcid: 0x60011, + bytes: crate::create::properties(&values)?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), + }; + picture.reference(id)?; + let container = picture.reference(file)?; + picture.set(&[(0x20001c3f, &container)])?; + changed.insert(id, picture); + hold(active, &mut changed, id, holder, &modified)?; + Ok(changed) +} + +/// A moved, resized or described picture as OneNote stores one: the position, the layout +/// width and height with the user flag and the description on the picture object, leaving +/// the intrinsic size alone. +pub(crate) fn picture_edit_changes( + active: &ActivePage<'_>, + object: ExGuid, + stored: (&Layout, &Option), + layout: &Layout, + alt: &Option, +) -> Result { + let mut values: Values = vec![( + 0x14001d7a, + crate::create::current_timestamps()? + .0 + .to_le_bytes() + .to_vec(), + )]; + let (layout, mut removed) = layout_values(stored.0, layout)?; + values.extend(layout); + if alt != stored.1 { + match alt { + Some(alt) => values.push((0x1c001e58, crate::create::string(alt))), + None => removed.push(0x1c001e58), + } + } + let raw = &active.live.revision; + let mut picture = PropertyObject::from_object(&raw.objects[&object])?; + picture.remove(&removed)?; + let values: Vec<(u32, &[u8])> = values + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect(); + picture.set(&values)?; + Ok(BTreeMap::from([(object, picture)])) +} + +/// Identities of a new attachment's objects and payloads. +pub(crate) struct AttachmentIds { + pub object: ExGuid, + pub file: ExGuid, + pub payload: [u8; 16], + /// The preview icon's payload and file-data object, when it has one. + pub preview: Option<([u8; 16], ExGuid)>, +} + +/// What OneNote stores for an inserted file: an embedded-file container declaring the +/// payload and an attachment object naming the file, which paragraph `holder` holds as +/// content. The caller embeds the payloads. +pub(crate) fn attachment_changes( + active: &ActivePage<'_>, + attachment: &Attachment, + ids: &AttachmentIds, + holder: ExGuid, +) -> Result { + let name = attachment.filename.as_str(); + if name.is_empty() || name.contains(['\0', '/', '\\']) { + return Err(invalid( + "An attachment needs a file name without path separators", + )); + } + if attachment.recording.is_some() { + return Err(invalid("Recordings are captured by OneNote, not inserted")); + } + if attachment.bytes.is_none() { + return Err(invalid("A new attachment needs its payload")); + } + if attachment + .preview + .as_ref() + .is_some_and(|icon| !icon.starts_with(&[0x89, b'P', b'N', b'G'])) + { + return Err(invalid("An attachment preview is a PNG icon")); + } + let extension = name + .rfind('.') + .filter(|dot| *dot > 0) + .map(|dot| &name[dot..]) + .unwrap_or(""); + let modified = crate::create::current_timestamps()?.0.to_le_bytes(); + let mut values: Values = vec![(0x14001d7a, modified.to_vec())]; + icon_size(&mut values, attachment.size, "Attachment icon size must be positive")?; + values.push((0x14001c3b, 0x409_u32.to_le_bytes().to_vec())); + values.push((0x10001cfe, 0x409_u16.to_le_bytes().to_vec())); + values.push((0x1c001dcf, vec![0; 32])); + values.push(( + 0x1c001d61, + [16u32, 1, 0, 0, 0] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect(), + )); + values.push((0x14001c3e, 1u32.to_le_bytes().to_vec())); + values.push((0x14001c84, 1u32.to_le_bytes().to_vec())); + values.push((0x1c001c22, crate::create::string(name))); + values.push((0x1c001d9c, crate::create::string(name))); + if let Some(path) = &attachment.source_path { + values.push((0x1c001d9d, crate::create::string(path))); + } + let mut changed = BTreeMap::new(); + let mut file = PropertyObject::file(ids.file, &payload_reference(ids.payload), extension)?; + file.jcid = crate::write::EMBEDDED_FILE_JCID; + changed.insert(ids.file, file); + let mut node = PropertyObject { + jcid: 0x60035, + bytes: crate::create::properties(&values)?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, ids.object.guid)])), + }; + node.reference(ids.object)?; + let container = node.reference(ids.file)?; + node.set(&[(0x20001d9b, &container)])?; + if let Some((payload, icon)) = ids.preview { + changed.insert( + icon, + PropertyObject::file(icon, &payload_reference(payload), ".png")?, + ); + let reference = node.reference(icon)?; + node.set(&[(0x20001c3f, &reference)])?; + } + changed.insert(ids.object, node); + hold(active, &mut changed, ids.object, Some(holder), &modified)?; + Ok(changed) +} + +/// A stored attachment keeps its payload and preview; its shown name, recorded source +/// path and icon size change in place, as OneNote's rename does. +pub(crate) fn attachment_edit_changes( + active: &ActivePage<'_>, + stored: &Attachment, + attachment: &Attachment, +) -> Result { + let mut values: Values = vec![( + 0x14001d7a, + crate::create::current_timestamps()? + .0 + .to_le_bytes() + .to_vec(), + )]; + if attachment.recording != stored.recording { + return Err(invalid("A recording stays the recording OneNote captured")); + } + let name = attachment.filename.as_str(); + if name.is_empty() || name.contains(['\0', '/', '\\']) { + return Err(invalid( + "An attachment needs a file name without path separators", + )); + } + let mut removed = Vec::new(); + if attachment.filename != stored.filename { + let name = crate::create::string(&attachment.filename); + values.push((0x1c001c22, name.clone())); + values.push((0x1c001d9c, name)); + } + if attachment.source_path != stored.source_path { + match &attachment.source_path { + Some(path) => values.push((0x1c001d9d, crate::create::string(path))), + None => removed.push(0x1c001d9d), + } + } + if attachment.size != stored.size { + match attachment.size { + Some(_) => icon_size(&mut values, attachment.size, "Attachment icon size must be positive")?, + None => removed.extend([0x140034cd, 0x140034ce]), + } + } + let object = stored.id; + let raw = &active.live.revision; + let mut node = PropertyObject::from_object(&raw.objects[&object])?; + node.remove(&removed)?; + let values: Vec<(u32, &[u8])> = values + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect(); + node.set(&values)?; + Ok(BTreeMap::from([(object, node)])) +} + +/// Creates stroke objects (numbered from `first`) and one drawing-attribute object per +/// distinct pen; returns the compact references for the data node's stroke list. +fn write_strokes( + changed: &mut Changes, + data_object: &mut PropertyObject, + strokes: &[(ExGuid, &InkStroke)], + first: usize, + filetime: u64, +) -> Result, Error> { + let mut styles: Vec<(InkPen, ExGuid)> = Vec::new(); + let mut references = Vec::new(); + for (offset, (stroke_id, stroke)) in strokes.iter().enumerate() { + let pen = InkPen::of(stroke); + let style = match styles.iter().find(|(known, _)| *known == pen) { + Some((_, id)) => *id, + None => { + let id = ExGuid { + guid: crate::write::fresh_guid()?, + n: 1, + }; + let mut style = PropertyObject { + jcid: 0x120048, + bytes: crate::create::properties(&pen.values())?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), + }; + style.reference(id)?; + changed.insert(id, style); + styles.push((pen, id)); + id + } + }; + let mut object = PropertyObject { + jcid: 0x20047, + bytes: crate::create::properties(&stroke_values( + stroke, + (first + offset) as u32 + 1, + filetime, + )?)?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, stroke_id.guid)])), + }; + object.reference(*stroke_id)?; + let style_reference = object.reference(style)?; + object.set(&[(0x20003409, &style_reference)])?; + changed.insert(*stroke_id, object); + references.extend(data_object.reference(*stroke_id)?); + } + Ok(references) +} + +/// The drawing attributes OneNote shares between strokes drawn with the same pen. +#[derive(PartialEq)] +struct InkPen { + width: u32, + height: u32, + color: Option, + transparency: Option, + pen_tip: Option, +} + +impl InkPen { + fn of(stroke: &InkStroke) -> Self { + Self { + width: (stroke.width * 2540.0 / 72.0).to_bits(), + height: (stroke.height * 2540.0 / 72.0).to_bits(), + color: stroke.color, + transparency: stroke.transparency, + pen_tip: stroke.pen_tip, + } + } + + fn values(&self) -> Values { + let mut values: Values = vec![ + (0x1c00340a, crate::page::ink::DIMENSIONS.to_vec()), + (0x1400340c, self.height.to_le_bytes().to_vec()), + (0x1400340d, self.width.to_le_bytes().to_vec()), + ]; + if let Some(color) = self.color { + values.push((0x1400340f, color.to_le_bytes().to_vec())); + } + if let Some(transparency) = self.transparency { + values.push((0x0c003414, vec![transparency])); + } + if let Some(tip) = self.pen_tip { + values.push((0x0c003412, vec![tip])); + } + values + } +} + +fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result { + if stroke.points.is_empty() { + return Err(invalid("A stroke needs at least one point")); + } + if stroke + .points + .iter() + .any(|p| !p[0].is_finite() || !p[1].is_finite()) + || !(stroke.width.is_finite() && stroke.height.is_finite()) + || stroke.width <= 0.0 + || stroke.height <= 0.0 + { + return Err(invalid( + "Stroke points and pen size must be finite and positive", + )); + } + let left = stroke + .points + .iter() + .map(|p| p[0]) + .fold(f32::INFINITY, f32::min); + let top = stroke + .points + .iter() + .map(|p| p[1]) + .fold(f32::INFINITY, f32::min); + let mut origin = ((left - stroke.width / 2.0) / 36.0).to_le_bytes().to_vec(); + origin.extend_from_slice(&((top - stroke.height / 2.0) / 36.0).to_le_bytes()); + Ok(vec![ + (0x1c00340b, stroke.packet()), + (0x14003419, index.to_le_bytes().to_vec()), + (0x1000341b, 0x409_u16.to_le_bytes().to_vec()), + (0x0c00341c, vec![0]), + (0x1c00341a, crate::write::fresh_guid()?.to_vec()), + (0x1c00341d, filetime.to_le_bytes().to_vec()), + (0x1c00345b, origin), + ]) +} + +/// New ink as OneNote 2010 stores a drawing: container `id` that the page lists as a child +/// (or paragraph `holder` holds as content), its data node `data` listing the strokes, each +/// stroke's packet and half-inch origin, and one drawing-attribute object per distinct pen. +pub(crate) fn ink_changes( + active: &ActivePage<'_>, + ink: &Ink, + id: ExGuid, + data: ExGuid, + strokes: &[(ExGuid, &InkStroke)], + holder: Option, +) -> Result { + if !ink.groups.is_empty() { + return Err(invalid("New ink holds strokes, not nested groups")); + } + if ink.strokes.is_empty() { + return Err(invalid("New ink needs at least one stroke")); + } + if ink.layout != Default::default() { + return Err(invalid("Ink positions come from its strokes")); + } + let (modified, filetime) = crate::create::current_timestamps()?; + let modified = modified.to_le_bytes(); + let mut changed = BTreeMap::new(); + let mut data_object = PropertyObject { + jcid: 0x2003b, + bytes: crate::create::properties(&[])?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, data.guid)])), + }; + data_object.reference(data)?; + let references = write_strokes(&mut changed, &mut data_object, strokes, 0, filetime)?; + data_object.set(&[(0x24003416, &references)])?; + changed.insert(data, data_object); + let mut object = PropertyObject { + jcid: 0x60014, + bytes: crate::create::properties(&[ + (0x14001d7a, modified.to_vec()), + (0x14001d4e, 1u32.to_le_bytes().to_vec()), + ])?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), + }; + object.reference(id)?; + let data_reference = object.reference(data)?; + object.set(&[(0x20003415, &data_reference)])?; + changed.insert(id, object); + hold(active, &mut changed, id, holder, &modified)?; + Ok(changed) +} + +/// Rewrites a drawing's stroke list: `kept` strokes stay as they are (OneNote erases whole +/// strokes rather than editing them), the rest leave the list, `added` ones are created. +pub(crate) fn strokes_changes( + active: &ActivePage<'_>, + container: ExGuid, + kept: &[ExGuid], + added: &[(ExGuid, &InkStroke)], +) -> Result { + let data = match active + .view + .nodes + .get(&container) + .map(|node| &node.kind) + { + Some(crate::document::Kind::Ink { + data: Some(data), .. + }) => *data, + _ => return Err(invalid("Stored ink has no stroke data to rewrite")), + }; + let (modified, filetime) = crate::create::current_timestamps()?; + let modified = modified.to_le_bytes(); + let raw = &active.live.revision; + let mut changed = BTreeMap::new(); + let mut data_object = PropertyObject::from_object(&raw.objects[&data])?; + let mut references = Vec::new(); + for id in kept { + references.extend(data_object.reference(*id)?); + } + references.extend(write_strokes( + &mut changed, + &mut data_object, + added, + kept.len(), + filetime, + )?); + data_object.set(&[(0x24003416, &references)])?; + changed.insert(data, data_object); + let mut object = PropertyObject::from_object(&raw.objects[&container])?; + object.set(&[(0x14001d7a, &modified)])?; + changed.insert(container, object); + Ok(changed) +} + +/// An equation as OneNote stores one: the linear text, one run per span with a style +/// carrying the span's format, the run-data array naming each run's inline object, and +/// the math language marker on the text object. +pub(crate) fn equation_changes( + active: &ActivePage<'_>, + object: ExGuid, + text: &Paragraph, +) -> Result { + if text.text().contains('\u{fffc}') { + return Err(invalid("Equations cannot hold embedded objects")); + } + let encoded: Vec = text + .text() + .encode_utf16() + .chain([0]) + .flat_map(u16::to_le_bytes) + .collect(); + let mut ends = Vec::new(); + let mut styles: Vec = Vec::new(); + let mut sets = Vec::new(); + let all_math = text.spans().iter().all(|s| s.format.math == Some(true)); + for span in text.spans() { + ends.extend(text.utf16_offset(span.end)?.to_le_bytes()); + styles.push(style_values(&span.format)); + sets.push(match &span.format.math_object { + Some(object) => { + let mut set = vec![(0x1400344f, object.kind.to_le_bytes().to_vec())]; + if let Some(count) = object.arguments { + set.push((0x14003450, count.to_le_bytes().to_vec())); + } + if let Some(columns) = object.columns { + set.push((0x0c003451, vec![columns])); + } + for (id, symbol) in [0x10003453, 0x10003454, 0x10003455] + .into_iter() + .zip(&object.symbols) + { + let unit = u16::try_from(u32::from(*symbol)) + .map_err(|_| invalid("Math symbols are single UTF-16 units"))?; + set.push((id, unit.to_le_bytes().to_vec())); + } + set + } + None => Vec::new(), + }); + } + ends.truncate(ends.len() - 4); + let has_objects = sets.iter().any(|set| !set.is_empty()); + let modified = crate::create::current_timestamps()?.0.to_le_bytes(); + let raw = &active.live.revision; + let mut changed = BTreeMap::new(); + let mut target = PropertyObject::from_object(&raw.objects[&object])?; + let mut references = Vec::new(); + let mut created: Vec<(Values, ExGuid)> = Vec::new(); + for values in &styles { + let id = match created.iter().find(|(known, _)| known == values) { + Some((_, id)) => *id, + None => { + let id = ExGuid { + guid: crate::write::fresh_guid()?, + n: 1, + }; + let mut style = PropertyObject { + jcid: 0x12004d, + bytes: crate::create::properties(values)?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), + }; + style.reference(id)?; + changed.insert(id, style); + created.push((values.clone(), id)); + id + } + }; + references.extend(target.reference(id)?); + } + target.remove(&[0x1c003498, 0x40003499])?; + target.set(&[ + (0x1c001c22, &encoded), + (0x1c001e12, &ends), + (0x24001e13, &references), + (0x14001d7a, &modified), + ])?; + if has_objects { + target.set_sets(0x40003499, 0x44000811, &sets)?; + } + if all_math { + // The flags and language marker OneNote's equation editor leaves on every equation + // text object. + target.set(&[ + (0x10001cfe, &0x7f_u16.to_le_bytes()), + (0x14001c3e, &1u32.to_le_bytes()), + (0x14001c84, &1u32.to_le_bytes()), + ])?; + } + changed.insert(object, target); + Ok(changed) +} diff --git a/crates/onestore/src/op/levels.rs b/crates/onestore/src/op/levels.rs new file mode 100644 index 0000000000000000000000000000000000000000..4143bed29890b99348f68feec990d63a6e3f56c3 --- /dev/null +++ b/crates/onestore/src/op/levels.rs @@ -0,0 +1,143 @@ +//! Outline levels: OneNote stores a container's children at its child level and nests each +//! run of deeper paragraphs in an outline group adding the rest +//! (`evidence/structural-edits/xml/c6-first-tab.xml`, MS-ONE 2.2.22). + +use crate::{ + Error, ExGuid, + active::{ActivePage, Changes}, + document::{Kind, Revision}, + write::PropertyObject, +}; +use std::collections::BTreeMap; + +fn invalid(message: &'static str) -> Error { + Error { offset: 0, message } +} + +/// The children of `container` with its outline groups lifted into it, in order. +pub(crate) fn flat_children(view: &Revision<'_>, container: ExGuid) -> Vec { + fn flat(view: &Revision<'_>, id: ExGuid, out: &mut Vec) { + for child in &view.nodes[&id].children { + match view.nodes[child].kind { + Kind::OutlineGroup => flat(view, *child, out), + _ => out.push(*child), + } + } + } + let mut children = Vec::new(); + flat(view, container, &mut children); + children +} + +/// How much deeper than `container` each of its children lies, groups lifted. +pub(crate) fn stored_depths(view: &Revision<'_>, container: ExGuid) -> Vec<(ExGuid, u32)> { + fn walk(view: &Revision<'_>, id: ExGuid, base: u32, out: &mut Vec<(ExGuid, u32)>) { + let node = &view.nodes[&id]; + let depth = base + u32::from(node.child_level.unwrap_or(0)); + for child in &node.children { + match view.nodes[child].kind { + Kind::OutlineGroup => walk(view, *child, depth, out), + _ => out.push((*child, depth)), + } + } + } + let mut out = Vec::new(); + walk(view, container, 0, &mut out); + out +} + +/// Lifts the children of `container`'s outline groups into it. +pub(crate) fn ungroup_changes(active: &ActivePage<'_>, container: ExGuid) -> Result { + let children = flat_children(&active.view, container); + let parents = active.editable_parents(container)?; + let modified = crate::create::current_timestamps()?.0.to_le_bytes(); + let raw = &active.live.revision; + let mut holder = PropertyObject::from_object(&raw.objects[&container])?; + let mut references = Vec::new(); + for child in children { + references.extend(holder.reference(child)?); + } + holder.set(&[(0x24001c20, &references), (0x14001d7a, &modified)])?; + let mut changed = BTreeMap::from([(container, holder)]); + crate::formatting::touch_ancestors(raw, parents, container, &modified, &mut changed)?; + Ok(changed) +} + +/// Children in runs: one child at the container's child level, or a run sharing a deeper +/// depth. +pub(crate) type Runs = Vec<(u32, Vec)>; + +/// The child level of a container whose children lie `depths` deeper than it, and its +/// children in runs. +pub(crate) fn runs(children: &[ExGuid], depths: &[u32], is_cell: bool) -> Result<(u32, Runs), Error> { + let level = *depths + .iter() + .min() + .ok_or_else(|| invalid("A container needs a child"))?; + if !(1..=31).contains(&level) || depths.iter().any(|depth| *depth > level + 31) { + return Err(invalid( + "Outline levels lie 1 to 31 deeper than their parent", + )); + } + if is_cell && depths.iter().any(|depth| *depth != level) { + return Err(invalid("Table cells hold no outline groups")); + } + let mut runs: Runs = Vec::new(); + for (id, depth) in children.iter().zip(depths) { + match runs.last_mut() { + Some((last, ids)) if *last == *depth && *depth != level => ids.push(*id), + _ => runs.push((*depth, vec![*id])), + } + } + Ok((level, runs)) +} + +/// Stores `container`'s children as `runs` below child level `level`, each deeper run in a +/// new outline group. +pub(crate) fn regroup_changes( + active: &ActivePage<'_>, + container: ExGuid, + level: u32, + runs: &[(u32, Vec)], +) -> Result { + let parents = active.editable_parents(container)?; + let modified = crate::create::current_timestamps()?.0.to_le_bytes(); + let raw = &active.live.revision; + let mut holder = PropertyObject::from_object(&raw.objects[&container])?; + let mut changed = BTreeMap::new(); + let mut references = Vec::new(); + for (depth, ids) in runs { + if *depth == level { + references.extend(holder.reference(ids[0])?); + continue; + } + let id = ExGuid { + guid: crate::write::fresh_guid()?, + n: 1, + }; + let mut group = PropertyObject { + jcid: 0x60019, + bytes: crate::create::properties(&[ + (0x14001d7a, modified.to_vec()), + (0x0c001c03, vec![(depth - level) as u8]), + ])?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), + }; + let mut members = Vec::new(); + for member in ids { + members.extend(group.reference(*member)?); + } + group.set(&[(0x24001c20, &members)])?; + group.reference(id)?; + changed.insert(id, group); + references.extend(holder.reference(id)?); + } + holder.set(&[ + (0x24001c20, &references), + (0x0c001c03, &[level as u8]), + (0x14001d7a, &modified), + ])?; + changed.insert(container, holder); + crate::formatting::touch_ancestors(raw, parents, container, &modified, &mut changed)?; + Ok(changed) +} diff --git a/crates/onestore/src/op/lower.rs b/crates/onestore/src/op/lower.rs new file mode 100644 index 0000000000000000000000000000000000000000..207d77184efdcc5bb74d52be412be1e333f8cb9d --- /dev/null +++ b/crates/onestore/src/op/lower.rs @@ -0,0 +1,1778 @@ +//! The ops that turn one page model into another, computed from the models alone: each op +//! is interpreted on a prediction of the page (`model::apply`) so later ones see what earlier +//! ones leave, as the typed writers would store it. + +use super::{PageOp, TableEdit, TextProperty, Values, model}; +use crate::{ + Error, ExGuid, TextAttribute, + document::{Format, Kind, Tag}, + page::{ + Definition, Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, Table, + TableCell, TextObject, text::new_id, + }, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + ops::Range, +}; + +fn invalid(message: &'static str) -> Error { + Error { offset: 0, message } +} + +/// The ops that turn `before`, a page as stored, into `after`. +pub fn lower_page(before: &Page, after: &Page) -> Result, Error> { + let old = View::new(before, false)?; + let new = View::new(after, false)?; + Lowering::new(before.clone(), None).run(before, after, &old, &new) +} + +/// The ops that replace `before`, consecutive paragraphs of outline or table cell +/// `container` with their parents listed first or outside the range, by `after`. `next` +/// is the paragraph following the range; `definitions` holds the lists, tags and styles +/// the paragraphs reference. Only the paragraphs given are read. +pub fn lower( + container: ExGuid, + before: &[PageParagraph], + after: &[PageParagraph], + next: Option<&PageParagraph>, + definitions: &BTreeMap, +) -> Result, Error> { + let page = |paragraphs: &[PageParagraph]| Page { + title: String::new(), + identity: None, + created: None, + margin_origin: [0.0; 2], + objects: vec![PageObject::Outline(Outline { + id: container, + title: false, + min_width: None, + layout: Default::default(), + indents: Vec::new(), + paragraphs: paragraphs.to_vec(), + unsupported: Vec::new(), + })], + definitions: definitions.clone(), + }; + let (before, after) = (page(before), page(after)); + let old = View::new(&before, true)?; + let new = View::new(&after, true)?; + let tail = next.map(|next| (next.parent.unwrap_or(container), next.id)); + Lowering::new(before.clone(), tail).run(&before, &after, &old, &new) +} + +/// Direct children of every container, in model order, plus lookups by identity. +pub(crate) struct View<'a> { + pub page: &'a Page, + pub outlines: BTreeMap, + /// Outlines owned by a title object rather than the page. + pub title_outlines: BTreeSet, + pub paragraphs: BTreeMap, + pub children: BTreeMap>, + pub container: BTreeMap, + pub page_children: Vec, + /// Containers a partial model names as parents without holding them, in order. + pub external: Vec, + partial: bool, +} + +impl<'a> View<'a> { + /// `partial` accepts parents outside the model, as a range of paragraphs has. + pub(crate) fn new(page: &'a Page, partial: bool) -> Result { + let mut view = Self { + page, + outlines: BTreeMap::new(), + title_outlines: BTreeSet::new(), + paragraphs: BTreeMap::new(), + children: BTreeMap::new(), + container: BTreeMap::new(), + page_children: Vec::new(), + external: Vec::new(), + partial, + }; + for object in &page.objects { + view.page_children.push(object.id()); + match object { + PageObject::Outline(outline) => view.outline(outline)?, + PageObject::Title(title) => { + for outline in &title.outlines { + view.title_outlines.insert(outline.id); + view.outline(outline)?; + } + } + PageObject::Image(_) | PageObject::Ink(_) | PageObject::Unsupported(_) => {} + } + } + Ok(view) + } + + fn outline(&mut self, outline: &'a Outline) -> Result<(), Error> { + if self.outlines.insert(outline.id, outline).is_some() { + return Err(invalid("The page model repeats an outline identity")); + } + self.children.entry(outline.id).or_default(); + self.paragraphs_of(outline.id, &outline.paragraphs) + } + + fn paragraphs_of(&mut self, root: ExGuid, list: &'a [PageParagraph]) -> Result<(), Error> { + let listed: BTreeSet = if self.partial { + list.iter().map(|p| p.id).collect() + } else { + BTreeSet::new() + }; + for paragraph in list { + let container = paragraph.parent.unwrap_or(root); + if let Some(parent) = paragraph.parent + && !self.paragraphs.contains_key(&parent) + { + if !self.partial || listed.contains(&parent) { + return Err(invalid( + "A paragraph's parent must precede it in its container", + )); + } + if !self.external.contains(&parent) { + self.external.push(parent); + } + } + if self.paragraphs.insert(paragraph.id, paragraph).is_some() { + return Err(invalid("The page model repeats a paragraph identity")); + } + self.children + .entry(container) + .or_default() + .push(paragraph.id); + self.children.entry(paragraph.id).or_default(); + self.container.insert(paragraph.id, container); + if let ParagraphContent::Table(table) = ¶graph.content { + for row in &table.rows { + for cell in &row.cells { + if self.children.contains_key(&cell.id) { + return Err(invalid("The page model repeats a cell identity")); + } + self.children.entry(cell.id).or_default(); + self.paragraphs_of(cell.id, &cell.paragraphs)?; + } + } + } + } + Ok(()) + } + + pub(crate) fn text(&self, paragraph: ExGuid) -> Option<&'a TextObject> { + self.paragraphs.get(¶graph).and_then(|p| p.text()) + } + + /// How much deeper than `container` each of its children lies: their outline levels + /// less the paragraph's, or less nothing in an outline or cell. + pub(crate) fn depths(&self, container: ExGuid, children: &[ExGuid]) -> Option> { + let base = self.paragraphs.get(&container).map_or(0, |p| p.level); + children + .iter() + .map(|id| self.paragraphs.get(id)?.level.checked_sub(base)) + .collect() + } + + /// Each outline's paragraphs in document order, a table's cells following its paragraph. + pub(crate) fn documents(&self) -> impl Iterator> + '_ { + fn walk<'a>(list: &'a [PageParagraph], out: &mut Vec<&'a PageParagraph>) { + for paragraph in list { + out.push(paragraph); + if let ParagraphContent::Table(table) = ¶graph.content { + for cell in table.rows.iter().flat_map(|row| &row.cells) { + walk(&cell.paragraphs, out); + } + } + } + } + self.outlines.values().map(|outline| { + let mut out = Vec::new(); + walk(&outline.paragraphs, &mut out); + out + }) + } + + /// Every container in document order: outlines, external parents, paragraphs and cells. + fn containers(&self) -> Vec { + let mut containers = Vec::new(); + for object in &self.page.objects { + let outlines: Vec<&Outline> = match object { + PageObject::Outline(outline) => vec![outline], + PageObject::Title(title) => title.outlines.iter().collect(), + PageObject::Image(_) | PageObject::Ink(_) | PageObject::Unsupported(_) => { + Vec::new() + } + }; + for outline in outlines { + containers.push(outline.id); + containers.extend(&self.external); + collect_containers(&outline.paragraphs, &mut containers); + } + } + containers + } +} + +pub(crate) fn collect_containers(list: &[PageParagraph], out: &mut Vec) { + for paragraph in list { + out.push(paragraph.id); + if let ParagraphContent::Table(table) = ¶graph.content { + for row in &table.rows { + for cell in &row.cells { + out.push(cell.id); + collect_containers(&cell.paragraphs, out); + } + } + } + } +} + +/// What the writers keep as stored, checked before any op: titles, unsupported objects, +/// definitions other than lists and tags, outline roles, paragraph styles and formats, +/// recording links, fields and content types. +pub(crate) fn validate( + before: &Page, + after: &Page, + old: &View<'_>, + new: &View<'_>, +) -> Result<(), Error> { + if after.created != before.created || after.margin_origin != before.margin_origin { + return Err(invalid("Page creation time and margins cannot be edited")); + } + for (id, definition) in &after.definitions { + if before + .definitions + .get(id) + .is_some_and(|stored| stored != definition) + && !matches!( + definition.kind, + Kind::List { .. } | Kind::TagDefinition { .. } + ) + { + return Err(invalid("Style definitions cannot be edited")); + } + } + for paragraph in new.paragraphs.values() { + if let ParagraphContent::Table(table) = ¶graph.content { + super::table::validate_table(table)?; + } + } + let fixed = |page: &Page| -> Vec { + let mut fixed: Vec = page + .objects + .iter() + .filter_map(|object| match object { + PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")), + PageObject::Title(title) => Some(format!( + "{:?} {:?} {:?} {:?}", + title.id, + title.date, + title.layout, + title.outlines.iter().map(|o| o.id).collect::>() + )), + PageObject::Outline(_) | PageObject::Image(_) | PageObject::Ink(_) => None, + }) + .collect(); + fixed.sort(); + fixed + }; + if fixed(before) != fixed(after) { + return Err(invalid( + "Titles and unsupported objects cannot be edited through the page model", + )); + } + for (id, outline) in &new.outlines { + if outline.paragraphs.is_empty() { + return Err(invalid( + "An outline needs a paragraph; remove the outline instead", + )); + } + let Some(previous) = old.outlines.get(id) else { + continue; + }; + let same = outline.title == previous.title + && outline.min_width == previous.min_width + && outline.indents == previous.indents + && outline.unsupported == previous.unsupported + && (!new.title_outlines.contains(id) || outline.layout == previous.layout); + if !same { + return Err(invalid( + "Outline roles, indentation tables and title geometry cannot be edited", + )); + } + } + let owners: BTreeMap = old + .paragraphs + .values() + .filter_map(|owner| Some((owner.text()?.id, *owner))) + .collect(); + for (id, paragraph) in &new.paragraphs { + let Some(previous) = old.paragraphs.get(id) else { + continue; + }; + // A join moves a text object, with its style and recording link, into an emptied + // paragraph; one new to the page starts as a copy without the link. + let owner = paragraph.text().map(|text| owners.get(&text.id)); + let (style, media) = match owner { + Some(Some(owner)) => (owner.style, owner.media.clone()), + Some(None) => (paragraph.style, Default::default()), + None => (previous.style, previous.media.clone()), + }; + if paragraph.media != media { + return Err(invalid("Recording annotations cannot be edited")); + } + if paragraph.style != style || paragraph.format != previous.format { + return Err(invalid( + "Paragraph styles and paragraph formatting cannot be edited", + )); + } + match (¶graph.content, &previous.content) { + (ParagraphContent::Text(text), ParagraphContent::Text(previous)) => { + if text.date_field != previous.date_field { + return Err(invalid("Text fields cannot be edited")); + } + } + (ParagraphContent::Table(table), ParagraphContent::Table(previous)) => { + if table.id != previous.id + || table.layout != previous.layout + || table.tags != previous.tags + { + return Err(invalid("Table identity, layout and tags cannot be edited")); + } + let cells: BTreeMap = previous + .rows + .iter() + .flat_map(|row| &row.cells) + .map(|cell| (cell.id, cell)) + .collect(); + let unchanged_cells = table.rows.iter().flat_map(|row| &row.cells).all(|cell| { + cells.get(&cell.id).is_none_or(|before| { + cell.layout == before.layout && cell.unsupported == before.unsupported + }) + }); + if !unchanged_cells { + return Err(invalid("Cell layout cannot be edited")); + } + } + (ParagraphContent::Unsupported(a), ParagraphContent::Unsupported(b)) if a == b => {} + (ParagraphContent::Ink(a), ParagraphContent::Ink(b)) => { + if a.id != b.id { + return Err(invalid("Ink identity cannot change")); + } + } + (ParagraphContent::Image(a), ParagraphContent::Image(b)) => { + super::content::picture_fixed_fields(a, b)?; + if (a.layout.x, a.layout.y) != (b.layout.x, b.layout.y) { + return Err(invalid("A paragraph picture has no position of its own")); + } + } + (ParagraphContent::Attachment(a), ParagraphContent::Attachment(b)) => { + if a.id != b.id { + return Err(invalid("Attachment identity cannot change")); + } + } + _ => return Err(invalid("Paragraph content type cannot change")), + } + } + Ok(()) +} + +/// Identities that keep their stored position: a longest increasing run of survivors, +/// always including immovable ones. `after` may contain identities absent from `before`. +pub(crate) fn kept_set( + before: &[ExGuid], + after: &[ExGuid], + movable: impl Fn(ExGuid) -> bool, +) -> Result, Error> { + let position: BTreeMap = + after.iter().enumerate().map(|(i, id)| (*id, i)).collect(); + let sequence: Vec<(ExGuid, usize)> = before + .iter() + .filter_map(|id| position.get(id).map(|at| (*id, *at))) + .collect(); + let fixed: Vec = sequence + .iter() + .filter(|(id, _)| !movable(*id)) + .map(|(_, at)| *at) + .collect(); + if fixed.windows(2).any(|w| w[0] > w[1]) { + return Err(invalid( + "Images and unsupported objects cannot be reordered", + )); + } + if sequence.windows(2).all(|pair| pair[0].1 < pair[1].1) { + return Ok(sequence.into_iter().map(|(id, _)| id).collect()); + } + // Longest increasing subsequence over `after` positions, forced through immovable items. + let n = sequence.len(); + let mut best = vec![1usize; n]; + let mut previous = vec![usize::MAX; n]; + for i in 0..n { + let (id, at) = sequence[i]; + let mandatory_before = sequence[..i] + .iter() + .filter(|(other, _)| !movable(*other)) + .map(|(_, at)| *at) + .max(); + if movable(id) && mandatory_before.is_some_and(|m| m > at) { + best[i] = 0; + continue; + } + let mandatory_after = sequence[i + 1..] + .iter() + .filter(|(other, _)| !movable(*other)) + .map(|(_, at)| *at) + .min(); + if movable(id) && mandatory_after.is_some_and(|m| m < at) { + best[i] = 0; + continue; + } + for j in 0..i { + if best[j] > 0 && sequence[j].1 < at && best[j] + 1 > best[i] { + best[i] = best[j] + 1; + previous[i] = j; + } + } + } + let mut kept = BTreeSet::new(); + if let Some((mut i, _)) = best + .iter() + .enumerate() + .max_by_key(|(i, b)| (**b, usize::MAX - i)) + && best[i] > 0 + { + loop { + kept.insert(sequence[i].0); + if previous[i] == usize::MAX { + break; + } + i = previous[i]; + } + } + for (id, _) in &sequence { + if !movable(*id) && !kept.contains(id) { + return Err(invalid( + "Images and unsupported objects cannot be reordered", + )); + } + } + Ok(kept) +} + +/// The format of the span containing the UTF-16 position `at` (the following span at a +/// boundary, the last at the end), which text inserted there takes. +pub(crate) fn format_in(paragraph: &Paragraph, at: u32) -> Result<&Format, Error> { + let byte = paragraph.byte_offset(at)?; + let spans = paragraph.spans(); + let index = spans + .partition_point(|span| span.end <= byte) + .min(spans.len() - 1); + Ok(&spans[index].format) +} + +/// The smallest UTF-16 range whose replacement turns `before` into `after`. +pub(crate) fn text_edit(before: &str, after: &str) -> Result<(Range, String), Error> { + let prefix = before + .char_indices() + .zip(after.chars()) + .take_while(|((_, a), b)| a == b) + .map(|((i, a), _)| i + a.len_utf8()) + .last() + .unwrap_or(0); + let suffix = before[prefix..] + .chars() + .rev() + .zip(after[prefix..].chars().rev()) + .take_while(|(a, b)| a == b) + .map(|(a, _)| a.len_utf8()) + .sum::(); + let units = |s: &str| -> Result { + u32::try_from(s.encode_utf16().count()) + .map_err(|_| invalid("Text exceeds UTF-16 offset range")) + }; + let start = units(&before[..prefix])?; + let end = start + units(&before[prefix..before.len() - suffix])?; + Ok((start..end, after[prefix..after.len() - suffix].to_owned())) +} + +/// A character-format change over a range: attributes to set and properties to clear. +pub(crate) type FormatEdit = (Range, Vec, Vec); + +/// The format edits turning `stored`'s spans into `target`'s, which has the same text. A +/// `fresh` text object was created by this edit, so an unset language keeps the writer's. +pub(crate) fn format_edits( + stored: &Paragraph, + target: &Paragraph, + fresh: bool, +) -> Result, Error> { + let mut boundaries = BTreeSet::new(); + for paragraph in [stored, target] { + for span in paragraph.spans() { + boundaries.insert(paragraph.utf16_offset(span.end)?); + } + } + boundaries.insert(0); + let boundaries: Vec = boundaries.into_iter().collect(); + let mut pending: Option = None; + let mut edits = Vec::new(); + for window in boundaries.windows(2) { + let (start, end) = (window[0], window[1]); + if start == end { + continue; + } + let (set, clear) = attributes(format_in(stored, start)?, format_in(target, start)?, fresh)?; + match &mut pending { + Some((range, previous_set, previous_clear)) + if *previous_set == set && *previous_clear == clear && range.end == start => + { + range.end = end; + } + _ => { + if let Some(edit) = pending.take() { + edits.push(edit); + } + pending = Some((start..end, set, clear)); + } + } + } + edits.extend(pending); + if target.text().is_empty() { + let (set, clear) = attributes(format_in(stored, 0)?, format_in(target, 0)?, fresh)?; + edits.push((0..0, set, clear)); + } + edits.retain(|(_, set, clear)| !set.is_empty() || !clear.is_empty()); + Ok(edits) +} + +/// The change turning `current` into `target`; unsupported differences are errors. A value the +/// target leaves unset is cleared from the run, as the model's formats are complete. +fn attributes( + current: &Format, + target: &Format, + fresh: bool, +) -> Result<(Vec, Vec), Error> { + let mut out = Vec::new(); + let mut cleared = Vec::new(); + macro_rules! boolean { + ($field:ident, $variant:ident) => { + match target.$field { + Some(value) if current.$field != Some(value) => { + out.push(TextAttribute::$variant(value)) + } + None if current.$field.is_some() => cleared.push(TextProperty::$variant), + _ => {} + } + }; + } + boolean!(bold, Bold); + boolean!(italic, Italic); + boolean!(underline, Underline); + boolean!(strike, Strike); + boolean!(superscript, Superscript); + boolean!(subscript, Subscript); + boolean!(hidden, Hidden); + boolean!(hyperlink, Hyperlink); + boolean!(hyperlink_label, HyperlinkLabel); + boolean!(math, Math); + macro_rules! value { + ($field:ident, $property:ident, $attribute:expr) => { + if current.$field != target.$field { + match &target.$field { + Some(value) => out.push($attribute(value)), + None => cleared.push(TextProperty::$property), + } + } + }; + } + let color = + |value: u32| (value != 0xff000000).then(|| value.to_le_bytes()[..3].try_into().unwrap()); + value!(font, Font, |font: &String| TextAttribute::Font(font.clone())); + value!(font_size, FontSize, |size: &f32| TextAttribute::FontSize( + *size + )); + value!(color, Color, |value: &u32| TextAttribute::Color(color( + *value + ))); + value!(highlight, Highlight, |value: &u32| { + TextAttribute::Highlight(color(*value)) + }); + if current.language != target.language { + match target.language { + Some(language) => out.push(TextAttribute::Language(language)), + None if fresh => {} + None => { + return Err(invalid("Inherited character formatting cannot be restored")); + } + } + } + // An absent value and its stored default are the same formatting. + let flag = |a: Option, b: Option| a.unwrap_or(false) == b.unwrap_or(false); + let points = |a: Option, b: Option| a.unwrap_or(0.0) == b.unwrap_or(0.0); + let same_rest = flag(current.embedded_object, target.embedded_object) + && current.alignment.unwrap_or(0) == target.alignment.unwrap_or(0) + && flag(current.rtl, target.rtl) + && points(current.space_before, target.space_before) + && points(current.space_after, target.space_after) + && points(current.line_spacing, target.line_spacing) + && points(current.list_spacing, target.list_spacing); + if !same_rest { + return Err(invalid( + "Fields and paragraph spacing cannot be edited through the page model", + )); + } + Ok((out, cleared)) +} + +/// Paragraph formatting a text object stores: alignment, direction, spacing and language. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct ParagraphFields { + pub alignment: Option, + pub rtl: Option, + pub space_before: Option, + pub space_after: Option, + pub line_spacing: Option, + pub language: Option, +} + +impl ParagraphFields { + pub(crate) fn values(&self) -> Result { + super::properties::paragraph_values( + self.alignment, + self.rtl, + self.space_before, + self.space_after, + self.line_spacing, + self.language, + ) + } +} + +/// The paragraph formatting `target` sets throughout that `stored` lacks somewhere. +pub(crate) fn paragraph_fields( + stored: &Paragraph, + target: &Paragraph, +) -> Result { + let first = format_in(target, 0)?; + macro_rules! field { + ($field:ident) => {{ + let value = first.$field.unwrap_or_default(); + (target + .spans() + .iter() + .all(|span| span.format.$field.unwrap_or_default() == value) + && stored + .spans() + .iter() + .any(|span| span.format.$field.unwrap_or_default() != value)) + .then_some(value) + }}; + } + Ok(ParagraphFields { + alignment: field!(alignment), + rtl: field!(rtl), + space_before: field!(space_before), + space_after: field!(space_after), + line_spacing: field!(line_spacing), + language: field!(language), + }) +} + +/// `paragraph_fields` encoded, as the page writer stores them. +pub(crate) fn paragraph_change(stored: &Paragraph, target: &Paragraph) -> Result { + paragraph_fields(stored, target)?.values() +} + +/// Whether two tag lists store the same tags. +pub(crate) fn same_tags(a: &[Tag], b: &[Tag]) -> bool { + let key = |t: &Tag| { + ( + t.definition, + t.action_type, + t.status, + t.created, + t.completed, + t.start, + t.due, + t.task_id, + ) + }; + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| key(x) == key(y)) +} + +/// Whether the writers can split or join this text: ordinary text without generated fields, +/// equations or embedded objects. +fn ordinary(text: &TextObject) -> bool { + text.date_field.is_none() + && !text.text.text().contains('\u{fffc}') + && text.text.spans().iter().all(|span| { + span.format.math != Some(true) && span.format.embedded_object != Some(true) + }) +} + +/// Whether a split at `offset` would divide a hyperlink, which the writer refuses. +fn divides_link(text: &Paragraph, offset: u32) -> Result { + let byte = text.byte_offset(offset)?; + let link = |span: &crate::page::text::Span| span.format.hyperlink == Some(true); + let spans = text.spans(); + let mut start = 0; + for (i, span) in spans.iter().enumerate() { + if start < byte && byte < span.end { + return Ok(link(span)); + } + if byte == span.end && byte > 0 && i + 1 < spans.len() { + let next = &spans[i + 1]; + return Ok(link(span) && link(next) && !text.text()[byte..].starts_with('\u{fddf}')); + } + start = span.end; + } + Ok(false) +} + +/// A paragraph as `Insert` and `Add` create it: structure and content, without the +/// properties their own ops set. +fn bare(paragraph: &PageParagraph) -> PageParagraph { + let mut stripped = paragraph.clone(); + stripped.style = None; + stripped.lists.clear(); + stripped.tags.clear(); + stripped.collapsed = false; + match &mut stripped.content { + ParagraphContent::Text(text) => text.tags.clear(), + ParagraphContent::Table(table) => { + for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { + cell.paragraphs = cell.paragraphs.iter().map(bare).collect(); + } + } + _ => {} + } + stripped +} + +fn bare_cells(cells: &[TableCell]) -> Vec { + cells + .iter() + .map(|cell| TableCell { + paragraphs: cell.paragraphs.iter().map(bare).collect(), + ..cell.clone() + }) + .collect() +} + +struct Lowering { + ops: Vec, + /// The page as the ops so far leave it. + current: Page, + /// The paragraph after a partial model's range: its container and identity. + tail: Option<(ExGuid, ExGuid)>, + /// Paragraphs and text objects the ops so far created. + created: BTreeSet, +} + +impl Lowering { + fn new(current: Page, tail: Option<(ExGuid, ExGuid)>) -> Self { + Self { + ops: Vec::new(), + current, + tail, + created: BTreeSet::new(), + } + } + + fn emit(&mut self, op: PageOp) -> Result<(), Error> { + model::apply(&mut self.current, &op)?; + self.ops.push(op); + Ok(()) + } + + /// Whether the page, as the ops so far leave it, holds `id` as a paragraph, text, + /// list node or table part. + fn holds(&self, id: ExGuid) -> bool { + model::holds(&self.current, id) + } + + fn run( + mut self, + before: &Page, + after: &Page, + old: &View<'_>, + new: &View<'_>, + ) -> Result, Error> { + validate(before, after, old, new)?; + let mut placed: BTreeMap> = old.children.clone(); + let mut consumed = BTreeSet::new(); + self.split_and_join(old, new, &mut placed, &mut consumed)?; + self.place_page(old, new)?; + self.place(old, new, &placed)?; + self.delete(old, new, &consumed)?; + self.levels(new)?; + self.equations(new)?; + self.text(new)?; + self.styles(after, new)?; + self.lists(after, new)?; + self.tags(after, new)?; + self.paragraph_formatting(new)?; + self.formatting(old, new)?; + self.layout(old, new)?; + Ok(self.ops) + } + + /// Splits and joins stored paragraphs as OneNote's Enter, Backspace and Delete do. + fn split_and_join( + &mut self, + old: &View<'_>, + new: &View<'_>, + placed: &mut BTreeMap>, + consumed: &mut BTreeSet, + ) -> Result<(), Error> { + // A new paragraph may be split from the stored paragraph nearest before it, whatever + // placement or pasted paragraphs the same edit adds between them. + let mut splits = Vec::new(); + for document in new.documents() { + let mut left = None; + for paragraph in document { + if old.paragraphs.contains_key(¶graph.id) { + left = paragraph.text().is_some().then_some(paragraph.id); + } else if let Some(left) = left { + let container = new.container[¶graph.id]; + let at = new.children[&container] + .iter() + .position(|id| *id == paragraph.id); + splits.push(((container, at), left, paragraph.id)); + } + } + } + splits.sort(); + let mut split = BTreeSet::new(); + for (_, left, right) in splits { + let (Some(previous), Some(after), Some(next)) = + (old.text(left), new.text(left), new.text(right)) + else { + continue; + }; + if split.contains(&left) + || after.id != previous.id + || new.paragraphs[&right].style != old.paragraphs[&left].style + || !ordinary(previous) + || old.title_outlines.contains(&root(old, left)) + { + continue; + } + let Ok(offset) = after.text.utf16_offset(after.text.text().len()) else { + continue; + }; + let length = previous.text.utf16_offset(previous.text.text().len())?; + // Splitting at the end only differs from appending a paragraph by the copied + // paragraph style; without one, an appended empty paragraph is an insertion. + if offset == length && new.paragraphs[&right].style.is_none() { + continue; + } + let (Ok(head), Ok(tail)) = ( + previous.text.slice(0..offset), + previous.text.slice(offset..length), + ) else { + continue; + }; + // An emptied side takes the writer's insertion style, so only its text must agree. + let same = |expected: &Paragraph, actual: &Paragraph| { + expected.text() == actual.text() + && (expected.text().is_empty() || expected == actual) + }; + if !same(&head, &after.text) + || !same(&tail, &next.text) + || divides_link(&previous.text, offset)? + || old.paragraphs[&left].lists.len() > 251 + { + continue; + } + // A text object a join moved into the paragraph keeps its identity there. + let right_text = if self.holds(next.id) { + new_id().map_err(|_| invalid("System random source failed"))? + } else { + next.id + }; + // The split copies the left paragraph's list nodes in order; a node the model + // shares with a stored paragraph stays that paragraph's. + let stored_lists = old.paragraphs[&left].lists.len(); + let model_lists = &new.paragraphs[&right].lists; + let mut lists = Vec::new(); + for i in 0..stored_lists { + lists.push(match model_lists.get(i) { + Some(list) if model_lists.len() == stored_lists && !self.holds(*list) => *list, + _ => new_id().map_err(|_| invalid("System random source failed"))?, + }); + } + self.emit(PageOp::Split { + text: previous.id, + at: offset, + paragraph: right, + right: right_text, + lists, + })?; + self.created.extend([right, right_text]); + split.insert(left); + // An earlier split may have carried the left paragraph to its tail. + let list = placed + .values_mut() + .find(|list| list.contains(&left)) + .unwrap(); + let at = list.iter().position(|id| *id == left).unwrap(); + list.insert(at + 1, right); + placed.insert(right, placed[&left].clone()); + placed.insert(left, Vec::new()); + } + for (container, children) in &old.children { + if !new.children.contains_key(container) { + continue; + } + for pair in children.windows(2) { + let [left, right] = [pair[0], pair[1]]; + let (Some(previous), Some(after), Some(removed)) = + (old.text(left), new.text(left), old.text(right)) + else { + continue; + }; + if new.paragraphs.contains_key(&right) + || !old.children[&left].is_empty() + || !old.children[&right].is_empty() + || removed.text.text().is_empty() + || !ordinary(previous) + || !ordinary(removed) + || old.title_outlines.contains(&root(old, left)) + { + continue; + } + let mut joined = previous.text.clone(); + if joined.append(removed.text.clone()).is_err() || joined != after.text { + continue; + } + let adopts_right = previous.text.text().is_empty(); + if after.id != if adopts_right { removed.id } else { previous.id } { + continue; + } + self.emit(PageOp::Join { + left: previous.id, + right: removed.id, + })?; + consumed.insert(right); + placed.get_mut(container).unwrap().retain(|id| *id != right); + placed.remove(&right); + } + } + Ok(()) + } + + /// Adds, changes and orders what the page holds directly: outlines, pictures and ink. + fn place_page(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { + let pictures = |page: &Page| -> BTreeMap { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Image(image) => Some((image.id, image.clone())), + _ => None, + }) + .collect() + }; + let stored = pictures(old.page); + for (id, image) in pictures(new.page) { + if let Some(previous) = stored.get(&id) + && *previous != image + { + super::content::picture_fixed_fields(previous, &image)?; + self.emit(PageOp::Picture { + picture: id, + layout: image.layout.clone(), + alt: image.alt.clone(), + })?; + } + } + let inks = |page: &Page| -> BTreeMap { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Ink(ink) => Some((ink.id, ink.clone())), + _ => None, + }) + .collect() + }; + let stored = inks(old.page); + for (id, ink) in inks(new.page) { + if let Some(previous) = stored.get(&id) + && *previous != ink + { + self.strokes(previous, &ink)?; + } + } + let title = |page: &Page, id: ExGuid| { + page.objects + .iter() + .any(|object| matches!(object, PageObject::Title(title) if title.id == id)) + }; + let survivors: Vec = old + .page_children + .iter() + .copied() + .filter(|id| { + !title(old.page, *id) && new.page.objects.iter().any(|object| object.id() == *id) + }) + .collect(); + let after: Vec<&PageObject> = new + .page + .objects + .iter() + .filter(|object| !title(new.page, object.id())) + .collect(); + let order: Vec = after.iter().map(|object| object.id()).collect(); + let kept = kept_set(&survivors, &order, |id| { + new.outlines.contains_key(&id) && !new.title_outlines.contains(&id) + })?; + let mut next = None; + for object in after.iter().rev() { + let id = object.id(); + let stored = old.page_children.contains(&id); + if !stored { + if new.title_outlines.contains(&id) { + return Err(invalid("Title outlines cannot be added")); + } + let object = match object { + PageObject::Outline(outline) => { + if outline.paragraphs.iter().any(|p| old.paragraphs.contains_key(&p.id)) { + return Err(invalid( + "A new outline holds new paragraphs; move stored ones after", + )); + } + self.created.extend(model::identities(&outline.paragraphs)); + PageObject::Outline(Outline { + paragraphs: outline.paragraphs.iter().map(bare).collect(), + ..(*outline).clone() + }) + } + PageObject::Image(_) | PageObject::Ink(_) => (*object).clone(), + PageObject::Title(_) | PageObject::Unsupported(_) => { + return Err(invalid( + "Titles and unsupported objects cannot be edited through the page model", + )); + } + }; + self.emit(PageOp::Add { + object, + before: next, + })?; + } else if !kept.contains(&id) { + self.emit(PageOp::Move { + object: id, + parent: None, + before: next, + })?; + } + next = Some(id); + } + Ok(()) + } + + /// Inserts new paragraphs and moves stored ones to the containers and order the model + /// gives them, then edits the stored tables and paragraph content. + fn place( + &mut self, + old: &View<'_>, + new: &View<'_>, + placed: &BTreeMap>, + ) -> Result<(), Error> { + let mut inserted: BTreeSet = BTreeSet::new(); + // New outlines and cells, whose paragraphs arrive with them. + let arriving: BTreeSet = new + .children + .keys() + .filter(|id| { + !old.children.contains_key(id) + && !new.paragraphs.contains_key(id) + && !new.external.contains(id) + }) + .copied() + .collect(); + for container in new.containers() { + if arriving.contains(&root(new, container)) { + continue; + } + let Some(children) = new.children.get(&container) else { + continue; + }; + let current: Vec = placed + .get(&container) + .map(|list| { + list.iter() + .copied() + .filter(|id| new.paragraphs.contains_key(id)) + .collect() + }) + .unwrap_or_default(); + let kept = kept_set(¤t, children, |_| true)?; + let tail = self + .tail + .filter(|(parent, _)| *parent == container) + .map(|(_, id)| id); + let mut next = tail; + let mut run: Vec = Vec::new(); + for id in children.iter().rev() { + let fresh = !old.paragraphs.contains_key(id) && !self.created.contains(id); + if fresh && !inserted.contains(id) { + run.push(*id); + continue; + } + self.insert(container, next, &mut run, new, &mut inserted)?; + if !kept.contains(id) && !inserted.contains(id) { + self.emit(PageOp::Move { + object: *id, + parent: Some(container), + before: next, + })?; + } + next = Some(*id); + } + self.insert(container, next, &mut run, new, &mut inserted)?; + } + self.tables(old, new)?; + self.content(old, new)?; + Ok(()) + } + + /// Inserts `run`, new siblings in reverse order, with their new descendants. + fn insert( + &mut self, + container: ExGuid, + before: Option, + run: &mut Vec, + new: &View<'_>, + inserted: &mut BTreeSet, + ) -> Result<(), Error> { + if run.is_empty() { + return Ok(()); + } + run.reverse(); + let mut paragraphs = Vec::new(); + let mut pending: Vec = run.drain(..).rev().collect(); + while let Some(id) = pending.pop() { + let paragraph = new.paragraphs[&id]; + paragraphs.push(bare(paragraph)); + inserted.insert(id); + self.created.insert(id); + if let Some(text) = paragraph.text() { + self.created.insert(text.id); + } + if let ParagraphContent::Table(table) = ¶graph.content { + for cell in table.rows.iter().flat_map(|row| &row.cells) { + self.created.extend(model::identities(&cell.paragraphs)); + } + } + // New children follow their parent; stored ones move there later. + pending.extend( + new.children[&id] + .iter() + .rev() + .filter(|child| !self.holds(**child) && !inserted.contains(child)), + ); + } + self.emit(PageOp::Insert { + container, + before, + paragraphs, + }) + } + + /// Row, column, width, border and cell edits of stored tables. + fn tables(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { + let stored: BTreeMap = old + .paragraphs + .values() + .filter_map(|p| match &p.content { + ParagraphContent::Table(table) => Some((table.id, table)), + _ => None, + }) + .collect(); + for paragraph in new.paragraphs.values() { + let ParagraphContent::Table(table) = ¶graph.content else { + continue; + }; + let Some(previous) = stored.get(&table.id) else { + continue; + }; + self.table(previous, table)?; + let cells: BTreeMap = previous + .rows + .iter() + .flat_map(|row| &row.cells) + .map(|cell| (cell.id, cell)) + .collect(); + for cell in table.rows.iter().flat_map(|row| &row.cells) { + let Some(before) = cells.get(&cell.id) else { + continue; + }; + if (cell.shading, &cell.indents) != (before.shading, &before.indents) { + self.emit(PageOp::Table { + table: table.id, + edit: TableEdit::Cell { + cell: cell.id, + shading: cell.shading, + indents: cell.indents.clone(), + }, + })?; + } + } + } + Ok(()) + } + + /// The row and column edits turning stored table `before` into `after`. + fn table(&mut self, before: &Table, after: &Table) -> Result<(), Error> { + let id = after.id; + let unsupported = || invalid("Table rows and cells keep their order; add or remove them"); + let old_rows: Vec = before.rows.iter().map(|row| row.id).collect(); + let new_rows: Vec = after.rows.iter().map(|row| row.id).collect(); + let kept_rows: Vec = old_rows + .iter() + .copied() + .filter(|row| new_rows.contains(row)) + .collect(); + if new_rows + .iter() + .filter(|row| old_rows.contains(row)) + .ne(kept_rows.iter()) + { + return Err(unsupported()); + } + // The column pattern every kept row shares: which stored cells stay, which are new. + let mut deleted: Option> = None; + let mut added: Option> = None; + for row in &kept_rows { + let old_cells: Vec = before.rows.iter().find(|r| r.id == *row).unwrap().cells + .iter() + .map(|cell| cell.id) + .collect(); + let new_cells: Vec = after.rows.iter().find(|r| r.id == *row).unwrap().cells + .iter() + .map(|cell| cell.id) + .collect(); + let gone: Vec = (0..old_cells.len() as u32) + .filter(|i| !new_cells.contains(&old_cells[*i as usize])) + .collect(); + let fresh: Vec = (0..new_cells.len() as u32) + .filter(|i| !old_cells.contains(&new_cells[*i as usize])) + .collect(); + if old_cells + .iter() + .filter(|cell| new_cells.contains(cell)) + .ne(new_cells.iter().filter(|cell| old_cells.contains(cell))) + || deleted.as_ref().is_some_and(|known| *known != gone) + || added.as_ref().is_some_and(|known| *known != fresh) + { + return Err(unsupported()); + } + deleted = Some(gone); + added = Some(fresh); + } + let (deleted, added) = (deleted.unwrap_or_default(), added.unwrap_or_default()); + if kept_rows.is_empty() && before.columns.len() != after.columns.len() { + return Err(unsupported()); + } + for row in &old_rows { + if !new_rows.contains(row) && !kept_rows.is_empty() { + self.emit(PageOp::Table { + table: id, + edit: TableEdit::DeleteRow(*row), + })?; + } + } + for at in deleted.iter().rev() { + self.emit(PageOp::Table { + table: id, + edit: TableEdit::DeleteColumn(*at), + })?; + } + for at in &added { + let cells = kept_rows + .iter() + .map(|row| { + let row = after.rows.iter().find(|r| r.id == *row).unwrap(); + bare_cells(&row.cells[*at as usize..=*at as usize]).remove(0) + }) + .collect::>(); + for cell in &cells { + self.created.extend(model::identities(&cell.paragraphs)); + } + self.emit(PageOp::Table { + table: id, + edit: TableEdit::Column { + at: *at, + width: after.columns[*at as usize].width, + cells, + }, + })?; + } + // New rows, each run before the stored row that follows it. + let mut run = Vec::new(); + let mut next = None; + for row in after.rows.iter().rev() { + if old_rows.contains(&row.id) { + self.rows(id, next, &mut run)?; + next = Some(row.id); + } else { + run.push(row.clone()); + } + } + self.rows(id, next, &mut run)?; + if kept_rows.is_empty() { + for row in &old_rows { + self.emit(PageOp::Table { + table: id, + edit: TableEdit::DeleteRow(*row), + })?; + } + } + let current = model::table(&self.current, id) + .ok_or_else(|| invalid("A table is missing after row edits"))?; + if current.columns != after.columns { + self.emit(PageOp::Table { + table: id, + edit: TableEdit::Columns(after.columns.clone()), + })?; + } + let current = model::table(&self.current, id).unwrap(); + if current.borders != after.borders + && let Some(borders) = after.borders + { + self.emit(PageOp::Table { + table: id, + edit: TableEdit::Borders(borders), + })?; + } + Ok(()) + } + + fn rows( + &mut self, + table: ExGuid, + before: Option, + run: &mut Vec, + ) -> Result<(), Error> { + if run.is_empty() { + return Ok(()); + } + run.reverse(); + let rows: Vec = run + .drain(..) + .map(|row| crate::page::TableRow { + id: row.id, + cells: bare_cells(&row.cells), + }) + .collect(); + for cell in rows.iter().flat_map(|row| &row.cells) { + self.created.extend(model::identities(&cell.paragraphs)); + } + self.emit(PageOp::Table { + table, + edit: TableEdit::Rows { before, rows }, + }) + } + + /// Edits of stored pictures, ink and attachments that paragraphs hold. + fn content(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { + for (id, paragraph) in &new.paragraphs { + let Some(previous) = old.paragraphs.get(id) else { + continue; + }; + match (&previous.content, ¶graph.content) { + (ParagraphContent::Image(stored), ParagraphContent::Image(image)) + if stored != image => + { + self.emit(PageOp::Picture { + picture: image.id, + layout: image.layout.clone(), + alt: image.alt.clone(), + })?; + } + (ParagraphContent::Ink(stored), ParagraphContent::Ink(ink)) if stored != ink => { + self.strokes(stored, ink)?; + } + (ParagraphContent::Attachment(stored), ParagraphContent::Attachment(attachment)) + if stored != attachment => + { + self.emit(PageOp::Attachment { + attachment: attachment.id, + filename: attachment.filename.clone(), + source_path: attachment.source_path.clone(), + size: attachment.size, + })?; + } + _ => {} + } + } + Ok(()) + } + + fn strokes(&mut self, stored: &crate::page::Ink, ink: &crate::page::Ink) -> Result<(), Error> { + if stored.layout != ink.layout || stored.groups != ink.groups { + return Err(invalid("Ink position and groups stay as stored")); + } + let mut add = Vec::new(); + for stroke in &ink.strokes { + match stored.strokes.iter().find(|s| s.id == stroke.id) { + Some(previous) if previous == stroke => {} + Some(_) => return Err(invalid("A stored stroke keeps its path and pen")), + None => add.push(stroke.clone()), + } + } + let remove: Vec = stored + .strokes + .iter() + .filter(|stroke| !ink.strokes.iter().any(|s| s.id == stroke.id)) + .map(|stroke| stroke.id) + .collect(); + if ink.strokes.iter().map(|s| s.id).ne(stored + .strokes + .iter() + .filter(|s| !remove.contains(&s.id)) + .map(|s| s.id) + .chain(add.iter().map(|s| s.id))) + { + return Err(invalid("Stored strokes keep their order; new ones follow")); + } + self.emit(PageOp::Strokes { + ink: ink.id, + add, + remove, + }) + } + + fn delete( + &mut self, + old: &View<'_>, + new: &View<'_>, + consumed: &BTreeSet, + ) -> Result<(), Error> { + let removed_outline = + |id: ExGuid| old.outlines.contains_key(&id) && !new.outlines.contains_key(&id); + for id in old.paragraphs.keys() { + if new.paragraphs.contains_key(id) || consumed.contains(id) { + continue; + } + let mut ancestor = old.container[id]; + let mut covered = false; + // A surviving ancestor is placed where the model says, carrying this paragraph along. + while !new.paragraphs.contains_key(&ancestor) { + if removed_outline(ancestor) + || (old.paragraphs.contains_key(&ancestor) && !consumed.contains(&ancestor)) + || (old.children.contains_key(&ancestor) + && !old.paragraphs.contains_key(&ancestor) + && !old.outlines.contains_key(&ancestor) + && !new.children.contains_key(&ancestor) + && !old.external.contains(&ancestor)) + { + covered = true; + break; + } + match old.container.get(&ancestor) { + Some(parent) => ancestor = *parent, + None => break, + } + } + if !covered { + self.emit(PageOp::Delete { object: *id })?; + } + } + let kept: BTreeSet = new.page.objects.iter().map(PageObject::id).collect(); + for object in &old.page.objects { + if matches!(object, PageObject::Image(_) | PageObject::Ink(_)) + && !kept.contains(&object.id()) + { + self.emit(PageOp::Delete { + object: object.id(), + })?; + } + } + for id in old.outlines.keys() { + if removed_outline(*id) { + if old.title_outlines.contains(id) { + return Err(invalid("Title outlines cannot be removed")); + } + self.emit(PageOp::Delete { object: *id })?; + } + } + Ok(()) + } + + /// Gives paragraphs the levels the model does, parents first. + fn levels(&mut self, new: &View<'_>) -> Result<(), Error> { + for document in new.documents() { + for paragraph in document { + let current = model::paragraph(&self.current, paragraph.id) + .ok_or_else(|| invalid("A paragraph is missing after structural edits"))?; + if current.level != paragraph.level { + self.emit(PageOp::Level { + paragraph: paragraph.id, + level: paragraph.level, + })?; + } + } + } + Ok(()) + } + + fn equations(&mut self, new: &View<'_>) -> Result<(), Error> { + for (id, paragraph) in &new.paragraphs { + let Some(text) = paragraph.text() else { + continue; + }; + let stored = model::paragraph(&self.current, *id).and_then(|p| p.text().cloned()); + // Deleting an equation leaves no math in the model, but its stored runs are still math. + if !crate::page::Math::is_equation(&text.text) + && !stored + .as_ref() + .is_some_and(|stored| crate::page::Math::is_equation(&stored.text)) + { + continue; + } + let stored = + stored.ok_or_else(|| invalid("An equation paragraph is missing after placement"))?; + if stored.text != text.text { + self.emit(PageOp::Equation { + text: stored.id, + math: text.text.clone(), + })?; + } + } + Ok(()) + } + + fn text(&mut self, new: &View<'_>) -> Result<(), Error> { + for (id, paragraph) in &new.paragraphs { + let Some(text) = paragraph.text() else { + continue; + }; + let Some(stored) = model::paragraph(&self.current, *id).and_then(|p| p.text().cloned()) + else { + return Err(invalid("A paragraph is missing after structural edits")); + }; + if stored.id != text.id { + return Err(invalid("Text object identities cannot change")); + } + if stored.text.text() == text.text.text() { + continue; + } + let (range, with) = text_edit(stored.text.text(), text.text.text())?; + self.emit(PageOp::Text { + text: stored.id, + range, + with, + })?; + } + Ok(()) + } + + fn styles(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> { + for (id, paragraph) in &new.paragraphs { + let Some(style) = paragraph.style else { + continue; + }; + let current = model::paragraph(&self.current, *id) + .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; + if current.style == Some(style) { + continue; + } + if paragraph.text().is_none() { + return Err(invalid("Only text paragraphs take a paragraph style")); + } + let definition = after + .definitions + .get(&style) + .ok_or_else(|| invalid("A paragraph references a missing style definition"))?; + self.emit(PageOp::Style { + paragraph: *id, + style, + definition: definition.clone(), + })?; + } + Ok(()) + } + + /// Gives each paragraph the list nodes its model references; one another paragraph + /// owns is copied, as native list nodes belong to one paragraph. + fn lists(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> { + for (id, paragraph) in &new.paragraphs { + let current = model::paragraph(&self.current, *id) + .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; + let same = paragraph.lists.len() == current.lists.len() + && paragraph.lists.iter().zip(¤t.lists).all(|(model, node)| { + model == node + && after.definitions.get(model) == self.current.definitions.get(node) + }); + if same { + continue; + } + let own = current.lists.clone(); + let mut lists = Vec::new(); + for list in ¶graph.lists { + let definition = after + .definitions + .get(list) + .ok_or_else(|| invalid("A paragraph references a missing list definition"))?; + let node = if own.contains(list) || !model::owns_list(&self.current, *list) { + *list + } else { + new_id().map_err(|_| invalid("System random source failed"))? + }; + lists.push((node, definition.clone())); + } + self.emit(PageOp::List { + paragraph: *id, + lists, + })?; + } + Ok(()) + } + + fn tags(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> { + for (id, paragraph) in &new.paragraphs { + let current = model::paragraph(&self.current, *id) + .ok_or_else(|| invalid("A paragraph is missing after text edits"))? + .clone(); + let mut targets = vec![(*id, ¶graph.tags, current.tags.clone())]; + if let (Some(text), Some(stored)) = (paragraph.text(), current.text()) { + targets.push((text.id, &text.tags, stored.tags.clone())); + } + for (target, tags, stored) in targets { + if same_tags(tags, &stored) { + continue; + } + let mut definitions = Vec::new(); + for tag in tags { + let definition = tag + .definition + .ok_or_else(|| invalid("A note tag names its definition"))?; + if let Some(model) = after.definitions.get(&definition) + && !definitions.iter().any(|(id, _)| *id == definition) + { + definitions.push((definition, model.clone())); + } + } + self.emit(PageOp::Tags { + target, + tags: tags.clone(), + definitions, + })?; + } + } + Ok(()) + } + + fn paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { + for (id, paragraph) in &new.paragraphs { + let Some(text) = paragraph.text() else { + continue; + }; + let stored = model::paragraph(&self.current, *id) + .and_then(|p| p.text().cloned()) + .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; + let fields = paragraph_fields(&stored.text, &text.text)?; + if fields == ParagraphFields::default() { + continue; + } + if text.date_field.is_some() { + return Err(invalid( + "Generated title fields cannot be formatted as ordinary text", + )); + } + self.emit(PageOp::Paragraph { + paragraph: *id, + alignment: fields.alignment, + rtl: fields.rtl, + space_before: fields.space_before, + space_after: fields.space_after, + line_spacing: fields.line_spacing, + language: fields.language, + })?; + } + Ok(()) + } + + fn formatting(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { + let stored_texts: BTreeSet = old + .paragraphs + .values() + .filter_map(|p| Some(p.text()?.id)) + .collect(); + for (id, paragraph) in &new.paragraphs { + let Some(text) = paragraph.text() else { + continue; + }; + let stored = model::paragraph(&self.current, *id) + .and_then(|p| p.text().cloned()) + .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; + if stored.text.text() != text.text.text() { + return Err(invalid("Text edits did not converge on the model")); + } + let fresh = !stored_texts.contains(&text.id); + for (range, set, clear) in format_edits(&stored.text, &text.text, fresh)? { + self.emit(PageOp::Format { + text: stored.id, + range, + set, + clear, + })?; + } + } + Ok(()) + } + + fn layout(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { + for (id, paragraph) in &new.paragraphs { + let current = model::paragraph(&self.current, *id) + .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; + if current.collapsed != paragraph.collapsed { + self.emit(PageOp::Outline { + object: *id, + edit: crate::OutlineEdit::Collapsed(paragraph.collapsed), + })?; + } + } + for (id, outline) in &new.outlines { + if new.title_outlines.contains(id) || !old.outlines.contains_key(id) { + continue; + } + let stored = model::outline(&self.current, *id) + .ok_or_else(|| invalid("An outline is missing after text edits"))?; + let (stored_position, stored_width) = ( + (stored.layout.x, stored.layout.y), + (stored.layout.max_width, stored.layout.width_set_by_user), + ); + if (outline.layout.x, outline.layout.y) != stored_position { + let (Some(x), Some(y)) = (outline.layout.x, outline.layout.y) else { + return Err(invalid("An outline position needs both coordinates")); + }; + self.emit(PageOp::Outline { + object: *id, + edit: crate::OutlineEdit::Position { x, y }, + })?; + } + if (outline.layout.max_width, outline.layout.width_set_by_user) != stored_width { + let Some(points) = outline.layout.max_width else { + return Err(invalid("An outline width cannot be removed")); + }; + self.emit(PageOp::Outline { + object: *id, + edit: crate::OutlineEdit::Width { + points, + user_set: outline.layout.width_set_by_user == Some(true), + }, + })?; + } + } + Ok(()) + } +} + +/// The outline or cell a paragraph of `view` lies in. +fn root(view: &View<'_>, paragraph: ExGuid) -> ExGuid { + let mut at = paragraph; + while let Some(parent) = view.container.get(&at) { + at = *parent; + } + at +} diff --git a/crates/onestore/src/op/mod.rs b/crates/onestore/src/op/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..fb9f498bcc3a1b9a5ad5e726702106a6d1763591 --- /dev/null +++ b/crates/onestore/src/op/mod.rs @@ -0,0 +1,265 @@ +//! Object-level edits of a section: what an editor emits, a queue stores and +//! `Section::apply` writes. Identities of new objects are the emitter's, so applying an edit +//! to another image of the section creates the same objects; UTF-16 code units measure text. + +use crate::{ + ExGuid, OutlineEdit, PageCreation, PageEdit, TextAttribute, + document::{Layout, Tag}, + page::{Definition, InkStroke, Page, PageObject, PageParagraph, Paragraph, TableCell, + TableColumn, TableRow}, +}; +use serde::{Deserialize, Serialize}; +use std::ops::Range; + +mod apply; +pub(crate) mod content; +pub(crate) mod levels; +pub(crate) mod lower; +pub(crate) mod model; +pub(crate) mod properties; +pub(crate) mod table; +#[cfg(test)] +mod tests; + +pub use lower::{lower, lower_page}; + +/// Property identifiers with their encoded values. +pub(crate) type Values = Vec<(u32, Vec)>; + +/// One user action, applied whole or not at all. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Edit { + /// FILETIME when the action happened; the modification time of what it changes. + pub at: u64, + pub ops: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Op { + Page { space: ExGuid, op: PageOp }, + Section(SectionOp), +} + +/// A change to the page an object space holds. Targets are stored identities; paragraph +/// properties name the paragraph, text edits its text object. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum PageOp { + /// Replaces a range; inserted text takes the format of the run it lands in, the + /// following run at a boundary except at the end. + Text { + text: ExGuid, + range: Range, + with: String, + }, + /// Sets and clears character formatting over a range; an empty text's `0..0` is its + /// insertion format. Cleared properties are inherited again. + Format { + text: ExGuid, + range: Range, + set: Vec, + clear: Vec, + }, + /// Makes the range a hyperlink to `target`, its field code stored hidden before the + /// label, or with `None` removes the link around it, leaving the label as plain text. + Link { + text: ExGuid, + range: Range, + target: Option, + }, + /// Rewrites an equation's linear text and spans whole. + Equation { text: ExGuid, math: Paragraph }, + /// Inserts paragraphs before a direct child of `container`, or last. Children follow + /// their parents in `paragraphs`; levels are absolute. Text keeps its spans' formats; + /// lists, tags, styles and collapse state are set by their own ops. + Insert { + container: ExGuid, + before: Option, + paragraphs: Vec, + }, + /// Enter at `at`: the text left of it stays, the rest moves to a new paragraph `paragraph` + /// with text object `right` and a copy of each list node under `lists`, in order. + Split { + text: ExGuid, + at: u32, + paragraph: ExGuid, + right: ExGuid, + lists: Vec, + }, + /// Appends the right text to the left one and removes the right paragraph; an empty + /// left text is replaced by the right text object. + Join { left: ExGuid, right: ExGuid }, + /// Moves a subtree before a direct child of `parent`, or last; `None` names the page, + /// whose children are outlines, pictures and ink. A paragraph keeps its level where it + /// lies deeper than its new parent. + Move { + object: ExGuid, + parent: Option, + before: Option, + }, + /// Removes a subtree; an emptied table cell receives an empty paragraph. + Delete { object: ExGuid }, + /// Sets a paragraph's absolute outline level, regrouping its container. + Level { paragraph: ExGuid, level: u32 }, + /// Outline position and width, or a paragraph's saved expansion state. + Outline { object: ExGuid, edit: OutlineEdit }, + /// Paragraph formatting stored on a paragraph's text; `None` leaves a value as it is. + Paragraph { + paragraph: ExGuid, + alignment: Option, + rtl: Option, + space_before: Option, + space_after: Option, + line_spacing: Option, + language: Option, + }, + /// References a paragraph style, created from its definition where the page lacks it. + Style { + paragraph: ExGuid, + style: ExGuid, + definition: Definition, + }, + /// Replaces a paragraph's list nodes; a node another paragraph owns is copied. + List { + paragraph: ExGuid, + lists: Vec<(ExGuid, Definition)>, + }, + /// Replaces the note tags of a paragraph, its text or a table; tag definitions the page + /// lacks are created from `definitions`. + Tags { + target: ExGuid, + tags: Vec, + definitions: Vec<(ExGuid, Definition)>, + }, + /// Adds an outline with its paragraphs, a picture with its payload, or ink, before a + /// page child or on top. + Add { + object: PageObject, + before: Option, + }, + /// A stored picture's position, displayed size and description. + Picture { + picture: ExGuid, + layout: Layout, + alt: Option, + }, + /// A stored attachment's shown name, recorded source path and icon size. + Attachment { + attachment: ExGuid, + filename: String, + source_path: Option, + size: Option<[f32; 2]>, + }, + /// Adds and erases whole strokes of stored ink. + Strokes { + ink: ExGuid, + add: Vec, + remove: Vec, + }, + Table { table: ExGuid, edit: TableEdit }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum TableEdit { + /// New rows before a row, or last, each with one cell per column. + Rows { + before: Option, + rows: Vec, + }, + /// A new column at `at`: one cell per row, in row order. + Column { + at: u32, + width: f32, + cells: Vec, + }, + DeleteRow(ExGuid), + DeleteColumn(u32), + /// Every column's width and lock. + Columns(Vec), + Borders(bool), + /// A cell's shading (`None` clears it) and indentation table. + Cell { + cell: ExGuid, + shading: Option, + indents: Vec, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum SectionOp { + Create(PageCreation), + /// A page created with `creation` holding `page`'s content under the identities it + /// carries (`Page::copy` gives fresh ones); the created title stays. + Import { creation: PageCreation, page: Page }, + /// Page moves and indentation, in order. + Pages(Vec), + /// Removes pages permanently; their revisions stay stored. + Delete(Vec), +} + +/// A character property that `PageOp::Format` can clear so the text inherits it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TextProperty { + Bold, + Italic, + Underline, + Strike, + Superscript, + Subscript, + Hidden, + Hyperlink, + HyperlinkLabel, + Math, + Font, + FontSize, + Color, + Highlight, +} + +impl TextProperty { + pub(crate) fn id(self) -> u32 { + match self { + Self::Bold => 0x08001c04, + Self::Italic => 0x08001c05, + Self::Underline => 0x08001c06, + Self::Strike => 0x08001c07, + Self::Superscript => 0x08001c08, + Self::Subscript => 0x08001c09, + Self::Hidden => 0x08001e16, + Self::Hyperlink => 0x08001e14, + Self::HyperlinkLabel => 0x08001e19, + Self::Math => 0x08003401, + Self::Font => 0x1c001c0a, + Self::FontSize => 0x10001c0b, + Self::Color => 0x14001c0c, + Self::Highlight => 0x14001c0d, + } + } +} + +/// Why `Section::apply` refused an edit; the section is as it was before it. +#[derive(Clone, Debug, PartialEq)] +pub enum OpError { + /// An object an op names is not reachable in its space's active revision. + TargetUnavailable(ExGuid), + /// A new object's identity is already reachable. + DuplicateIdentity(ExGuid), + /// An anchor is no direct child of its container, or a move would make a cycle. + StructureChanged(&'static str), + /// The writers cannot make this change to this content. + Unsupported(&'static str), + /// Writing failed after the section began changing; it must be reopened. + Failed(crate::Error), +} + +impl std::fmt::Display for OpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TargetUnavailable(id) => write!(f, "{id} is not on the page"), + Self::DuplicateIdentity(id) => write!(f, "{id} already exists"), + Self::StructureChanged(message) | Self::Unsupported(message) => f.write_str(message), + Self::Failed(error) => write!(f, "{error}"), + } + } +} + +impl std::error::Error for OpError {} diff --git a/crates/onestore/src/op/model.rs b/crates/onestore/src/op/model.rs new file mode 100644 index 0000000000000000000000000000000000000000..c6e0a7b86edaffe4e27a7a1e5d6da04a485f03ec --- /dev/null +++ b/crates/onestore/src/op/model.rs @@ -0,0 +1,1245 @@ +//! Ops interpreted on a page model: what reading the page back after `Section::apply` +//! yields, for the lowering's prediction and as the tests' oracle. + +use super::{PageOp, TableEdit, TextProperty, content::NATIVE_INDENTS, lower::format_in}; +use crate::{ + Error, ExGuid, OutlineEdit, TextAttribute, + document::{Format, Kind}, + page::{ + Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, Table, TextObject, + }, +}; +use std::collections::BTreeSet; + +fn invalid(message: &'static str) -> Error { + Error { offset: 0, message } +} + +/// A length in points as the store keeps it, in half inches. +fn stored(points: f32) -> f32 { + points / 36.0 * 36.0 +} + +/// Where a flat paragraph list lies: a page object, a title outline, then the table cells +/// on the way down, each as (paragraph index, row, cell). +#[derive(Clone, Debug)] +pub(crate) struct Path { + object: usize, + title_outline: Option, + cells: Vec<(usize, usize, usize)>, +} + +/// The lists of a page, each with its path and owner (outline or cell identity). +pub(crate) fn lists(page: &Page) -> Vec<(Path, ExGuid, &Vec)> { + fn cells<'p>( + path: &Path, + list: &'p [PageParagraph], + out: &mut Vec<(Path, ExGuid, &'p Vec)>, + ) { + for (index, paragraph) in list.iter().enumerate() { + if let ParagraphContent::Table(table) = ¶graph.content { + for (r, row) in table.rows.iter().enumerate() { + for (c, cell) in row.cells.iter().enumerate() { + let mut path = path.clone(); + path.cells.push((index, r, c)); + out.push((path.clone(), cell.id, &cell.paragraphs)); + cells(&path, &cell.paragraphs, out); + } + } + } + } + } + let mut out = Vec::new(); + for (object, value) in page.objects.iter().enumerate() { + let outlines: Vec<(Option, &Outline)> = match value { + PageObject::Outline(outline) => vec![(None, outline)], + PageObject::Title(title) => title.outlines.iter().enumerate().map(|(i, o)| (Some(i), o)).collect(), + _ => Vec::new(), + }; + for (title_outline, outline) in outlines { + let path = Path { + object, + title_outline, + cells: Vec::new(), + }; + out.push((path.clone(), outline.id, &outline.paragraphs)); + cells(&path, &outline.paragraphs, &mut out); + } + } + out +} + +pub(crate) fn list_at<'p>(page: &'p mut Page, path: &Path) -> &'p mut Vec { + let outline = match (&mut page.objects[path.object], path.title_outline) { + (PageObject::Outline(outline), None) => outline, + (PageObject::Title(title), Some(i)) => &mut title.outlines[i], + _ => unreachable!(), + }; + let mut list = &mut outline.paragraphs; + for (index, r, c) in &path.cells { + let ParagraphContent::Table(table) = &mut list[*index].content else { + unreachable!() + }; + list = &mut table.rows[*r].cells[*c].paragraphs; + } + list +} + +/// The list holding paragraph `id`, and its index there. +fn find(page: &Page, id: ExGuid) -> Option<(Path, usize)> { + lists(page).into_iter().find_map(|(path, _, list)| { + list.iter().position(|p| p.id == id).map(|index| (path, index)) + }) +} + +/// The list a container's children lie in: its own for an outline or cell, the one +/// holding it for a paragraph. +fn container(page: &Page, id: ExGuid) -> Option<(Path, Option)> { + lists(page) + .into_iter() + .find_map(|(path, owner, list)| { + if owner == id { + Some((path, None)) + } else { + list.iter() + .position(|p| p.id == id) + .map(|index| (path, Some(index))) + } + }) +} + +/// The entries of `list` in the subtree of the paragraph at `index`. +fn subtree(list: &[PageParagraph], index: usize) -> std::ops::Range { + let mut members = BTreeSet::from([list[index].id]); + let mut end = index + 1; + while end < list.len() && list[end].parent.is_some_and(|p| members.contains(&p)) { + members.insert(list[end].id); + end += 1; + } + index..end +} + +pub(crate) fn paragraph(page: &Page, id: ExGuid) -> Option<&PageParagraph> { + lists(page) + .into_iter() + .find_map(|(_, _, list)| list.iter().find(|p| p.id == id)) +} + +pub(crate) fn outline(page: &Page, id: ExGuid) -> Option<&Outline> { + page.objects.iter().find_map(|object| match object { + PageObject::Outline(outline) if outline.id == id => Some(outline), + _ => None, + }) +} + +pub(crate) fn table(page: &Page, id: ExGuid) -> Option<&Table> { + lists(page).into_iter().find_map(|(_, _, list)| { + list.iter().find_map(|p| match &p.content { + ParagraphContent::Table(table) if table.id == id => Some(table), + _ => None, + }) + }) +} + +/// Whether a paragraph of the page lists `list` among its list nodes. +pub(crate) fn owns_list(page: &Page, list: ExGuid) -> bool { + lists(page) + .into_iter() + .any(|(_, _, paragraphs)| paragraphs.iter().any(|p| p.lists.contains(&list))) +} + +/// Identities of paragraphs and what they hold, tables included. +pub(crate) fn identities(paragraphs: &[PageParagraph]) -> Vec { + let mut out = Vec::new(); + for paragraph in paragraphs { + out.push(paragraph.id); + match ¶graph.content { + ParagraphContent::Text(text) => out.push(text.id), + ParagraphContent::Table(table) => { + out.push(table.id); + for row in &table.rows { + out.push(row.id); + for cell in &row.cells { + out.push(cell.id); + out.extend(identities(&cell.paragraphs)); + } + } + } + ParagraphContent::Image(image) => out.push(image.id), + ParagraphContent::Attachment(attachment) => out.push(attachment.id), + ParagraphContent::Ink(ink) => { + out.push(ink.id); + out.extend(ink.strokes.iter().map(|s| s.id)); + } + ParagraphContent::Unsupported(unsupported) => out.push(unsupported.id), + } + out.extend(paragraph.lists.iter().copied()); + } + out +} + +/// Whether the page holds `id` as an object of the model. +pub(crate) fn holds(page: &Page, id: ExGuid) -> bool { + page.objects.iter().any(|object| { + object.id() == id + || match object { + PageObject::Outline(outline) => identities(&outline.paragraphs).contains(&id), + PageObject::Title(title) => title + .outlines + .iter() + .any(|o| o.id == id || identities(&o.paragraphs).contains(&id)), + PageObject::Ink(ink) => ink.strokes.iter().any(|s| s.id == id), + PageObject::Image(_) | PageObject::Unsupported(_) => false, + } + }) +} + +/// The paragraph holding text object `text`: its list and index. +fn text_holder(page: &Page, text: ExGuid) -> Option<(Path, usize)> { + lists(page).into_iter().find_map(|(path, _, list)| { + list.iter() + .position(|p| p.text().is_some_and(|t| t.id == text)) + .map(|index| (path, index)) + }) +} + +fn text_mut(page: &mut Page, text: ExGuid) -> Result<&mut TextObject, Error> { + let (path, index) = text_holder(page, text).ok_or_else(|| invalid("Text is not on the page"))?; + Ok(list_at(page, &path)[index].text_mut().unwrap()) +} + +fn paragraph_mut(page: &mut Page, id: ExGuid) -> Result<&mut PageParagraph, Error> { + let (path, index) = find(page, id).ok_or_else(|| invalid("Paragraph is not on the page"))?; + Ok(&mut list_at(page, &path)[index]) +} + +fn table_mut(page: &mut Page, id: ExGuid) -> Result<&mut Table, Error> { + let (path, index) = lists(page) + .into_iter() + .find_map(|(path, _, list)| { + list.iter() + .position(|p| matches!(&p.content, ParagraphContent::Table(t) if t.id == id)) + .map(|index| (path, index)) + }) + .ok_or_else(|| invalid("Table is not on the page"))?; + let ParagraphContent::Table(table) = &mut list_at(page, &path)[index].content else { + unreachable!() + }; + Ok(table) +} + +/// `text` with a range replaced as the text writer does: inserted text takes the format of +/// the run it lands in, and emptied text keeps its last run's. +pub(crate) fn replace_text( + text: &Paragraph, + range: std::ops::Range, + with: &str, +) -> Result { + let length = text.utf16_offset(text.text().len())?; + if range.start > range.end || range.end > length { + return Err(invalid("The edit range exceeds the text")); + } + // Replacing text with itself leaves its formatting as it is. + if text.text()[text.byte_offset(range.start)?..text.byte_offset(range.end)?] == *with { + return Ok(text.clone()); + } + let format = format_in(text, range.start)?.clone(); + let removed = range.end - range.start; + if length - removed == 0 && with.is_empty() { + return Ok(Paragraph::new(String::new(), text.spans().last().unwrap().format.clone())); + } + let mut result = text.clone(); + result.apply(crate::page::text::Edit { + range, + replacement: Paragraph::new(with.to_owned(), format), + })?; + Ok(result) +} + +/// `text` with every span taking `from`'s paragraph formatting, which the text object +/// stores for all its runs. +fn paragraph_formatted(text: &Paragraph, from: &Format) -> Paragraph { + let mut previous = 0; + Paragraph::from_runs(text.spans().iter().map(|span| { + let mut format = span.format.clone(); + format.alignment = from.alignment; + format.rtl = from.rtl; + format.space_before = from.space_before; + format.space_after = from.space_after; + format.line_spacing = from.line_spacing; + let run = (text.text()[previous..span.end].to_owned(), format); + previous = span.end; + run + })) +} + +fn set(format: &mut Format, attribute: &TextAttribute) { + let color = |value: &Option<[u8; 3]>| { + Some(value.map_or(0xff000000, |[r, g, b]| u32::from_le_bytes([r, g, b, 0]))) + }; + match attribute { + TextAttribute::Bold(v) => format.bold = Some(*v), + TextAttribute::Italic(v) => format.italic = Some(*v), + TextAttribute::Underline(v) => format.underline = Some(*v), + TextAttribute::Strike(v) => format.strike = Some(*v), + TextAttribute::Superscript(v) => { + format.superscript = Some(*v); + if *v { + format.subscript = Some(false); + } + } + TextAttribute::Subscript(v) => { + format.subscript = Some(*v); + if *v { + format.superscript = Some(false); + } + } + TextAttribute::Font(font) => format.font = Some(font.clone()), + TextAttribute::FontSize(size) => format.font_size = Some(*size), + TextAttribute::Color(value) => format.color = color(value), + TextAttribute::Highlight(value) => format.highlight = color(value), + TextAttribute::Language(language) => format.language = Some(*language), + TextAttribute::Hidden(v) => format.hidden = Some(*v), + TextAttribute::Hyperlink(v) => format.hyperlink = Some(*v), + TextAttribute::HyperlinkLabel(v) => format.hyperlink_label = Some(*v), + TextAttribute::Math(v) => format.math = Some(*v), + } +} + +fn clear(format: &mut Format, property: TextProperty) { + match property { + TextProperty::Bold => format.bold = None, + TextProperty::Italic => format.italic = None, + TextProperty::Underline => format.underline = None, + TextProperty::Strike => format.strike = None, + TextProperty::Superscript => format.superscript = None, + TextProperty::Subscript => format.subscript = None, + TextProperty::Hidden => format.hidden = None, + TextProperty::Hyperlink => format.hyperlink = None, + TextProperty::HyperlinkLabel => format.hyperlink_label = None, + TextProperty::Math => format.math = None, + TextProperty::Font => format.font = None, + TextProperty::FontSize => format.font_size = None, + TextProperty::Color => format.color = None, + TextProperty::Highlight => format.highlight = None, + } +} + +/// `text` with a range reformatted as the format writer does; a reformatted run states its +/// language. +pub(crate) fn format_text( + text: &Paragraph, + range: std::ops::Range, + attributes: &[TextAttribute], + cleared: &[TextProperty], + style: &Format, +) -> Result { + let apply = |format: &Format| { + let mut format = format.clone(); + for attribute in attributes { + set(&mut format, attribute); + } + for property in cleared { + clear(&mut format, *property); + } + // A cleared property shows the paragraph style's value. + format = format.inherit(style); + if format.language.is_none() { + format.language = Some(0x409); + } + format + }; + if text.text().is_empty() { + return Ok(Paragraph::new(String::new(), apply(&text.spans()[0].format))); + } + let (start, end) = (text.byte_offset(range.start)?, text.byte_offset(range.end)?); + let mut runs = Vec::new(); + let mut previous = 0; + for span in text.spans() { + for (from, to, selected) in [ + (previous, start.clamp(previous, span.end), false), + (start.clamp(previous, span.end), end.clamp(previous, span.end), true), + (end.clamp(previous, span.end), span.end, false), + ] { + if from < to { + let format = if selected { apply(&span.format) } else { span.format.clone() }; + runs.push((text.text()[from..to].to_owned(), format)); + } + } + previous = span.end; + } + Ok(Paragraph::from_runs(runs)) +} + +/// A text as `Insert` stores it: an unset language is the writer's insertion language, and +/// paragraph formatting is stored only where every span shares a value other than the default. +fn inserted(text: &Paragraph) -> Paragraph { + let spans = text.spans(); + macro_rules! shared { + ($field:ident, $type:ty, $store:expr) => {{ + let first = spans[0].format.$field.unwrap_or_default(); + (spans.iter().all(|s| s.format.$field.unwrap_or_default() == first) + && first != <$type>::default()) + .then(|| $store(first)) + }}; + } + let alignment = shared!(alignment, u8, |v| v); + let rtl = shared!(rtl, bool, |v| v); + let space_before = shared!(space_before, f32, stored); + let space_after = shared!(space_after, f32, stored); + let line_spacing = shared!(line_spacing, f32, stored); + let mut previous = 0; + Paragraph::from_runs(spans.iter().map(|span| { + let mut format = span.format.clone(); + format.language.get_or_insert(0x409); + format.alignment = alignment; + format.rtl = rtl; + format.space_before = space_before; + format.space_after = space_after; + format.line_spacing = line_spacing; + let run = (text.text()[previous..span.end].to_owned(), format); + previous = span.end; + run + })) +} + +/// A stroke as the store keeps it: points on the HIMETRIC grid, pen size in HIMETRIC. +fn stroked(stroke: &crate::page::InkStroke) -> crate::page::InkStroke { + let himetric = |points: f32| points * 2540.0 / 72.0 * 72.0 / 2540.0; + crate::page::InkStroke { + points: stroke + .points + .iter() + .map(|[x, y]| [crate::page::ink::snap(*x), crate::page::ink::snap(*y)]) + .collect(), + width: himetric(stroke.width), + height: himetric(stroke.height), + ..stroke.clone() + } +} + +fn inked(ink: &crate::page::Ink) -> crate::page::Ink { + crate::page::Ink { + strokes: ink.strokes.iter().map(stroked).collect(), + ..ink.clone() + } +} + +/// Paragraphs as `Insert` stores them. +fn insertion(paragraphs: &[PageParagraph]) -> Vec { + paragraphs + .iter() + .map(|paragraph| { + let mut paragraph = paragraph.clone(); + match &mut paragraph.content { + ParagraphContent::Text(text) if !crate::page::Math::is_equation(&text.text) => { + text.text = inserted(&text.text); + } + // An equation is written whole, without paragraph formatting. + ParagraphContent::Text(text) => { + text.text = paragraph_formatted(&text.text, &Format::default()); + } + ParagraphContent::Table(table) => { + for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { + if cell.indents.is_empty() { + cell.indents = NATIVE_INDENTS.to_vec(); + } + cell.paragraphs = insertion(&cell.paragraphs); + } + } + ParagraphContent::Ink(ink) => *ink = inked(ink), + _ => {} + } + paragraph + }) + .collect() +} + +/// Inserts `paragraphs` (roots then descendants) into `container` before `before`. +fn insert( + page: &mut Page, + container: ExGuid, + before: Option, + paragraphs: Vec, +) -> Result<(), Error> { + let (path, owner) = + self::container(page, container).ok_or_else(|| invalid("The container is not on the page"))?; + let list = list_at(page, &path); + let at = match before { + Some(before) => list + .iter() + .position(|p| p.id == before) + .ok_or_else(|| invalid("The anchor is not on the page"))?, + None => match owner { + Some(index) => subtree(list, index).end, + None => list.len(), + }, + }; + list.splice(at..at, paragraphs); + Ok(()) +} + +/// The absolute level of the children of `container`. +fn child_level(page: &Page, container: ExGuid) -> u32 { + paragraph(page, container).map_or(0, |p| p.level) +} + +/// Shifts the levels of the subtree at `index` so its root lies at `level`. +fn relevel(list: &mut [PageParagraph], index: usize, level: u32) { + let range = subtree(list, index); + let delta = i64::from(level) - i64::from(list[index].level); + for paragraph in &mut list[range] { + paragraph.level = (i64::from(paragraph.level) + delta) as u32; + } +} + +/// The title a page's writers store after changing it: the title text's first line when +/// it has one, which only an edit of that text changes, else the first line of body text. +fn retitle(page: &mut Page, edited: Option) { + let line = |text: &str| text.trim_start().split('\r').next().unwrap_or_default().to_owned(); + match find_title_text(page) { + Some(title) => { + let text = text_of(page, title); + if line(&text).is_empty() { + automatic(page); + } else if edited == Some(title) { + page.title = line(&text); + } + } + None => automatic(page), + } +} + +fn automatic(page: &mut Page) { + let mut roots: Vec<&PageObject> = page + .objects + .iter() + .filter(|object| !matches!(object, PageObject::Title(_))) + .collect(); + roots.sort_by(|a, b| { + let (a, b) = (a.layout(), b.layout()); + a.y.unwrap_or(0.0) + .total_cmp(&b.y.unwrap_or(0.0)) + .then_with(|| a.x.unwrap_or(0.0).total_cmp(&b.x.unwrap_or(0.0))) + }); + fn first(list: &[PageParagraph]) -> Option<&str> { + list.iter().find_map(|paragraph| match ¶graph.content { + ParagraphContent::Text(text) => { + Some(crate::edit::automatic_title(text.text.text())).filter(|t| !t.is_empty()) + } + ParagraphContent::Table(table) => table + .rows + .iter() + .flat_map(|row| &row.cells) + .find_map(|cell| first(&cell.paragraphs)), + _ => None, + }) + } + let title = roots + .into_iter() + .find_map(|object| match object { + PageObject::Outline(outline) => first(&outline.paragraphs), + _ => None, + }) + .unwrap_or_default() + .to_owned(); + page.title = title; +} + +/// Applies `op` to `page` as reading the page back after `Section::apply` would show it. +pub(crate) fn apply(page: &mut Page, op: &PageOp) -> Result<(), Error> { + interpret(page, op)?; + // A page lists the definitions its paragraphs reference. + let mut referenced = BTreeSet::new(); + for (_, _, list) in lists(page) { + for paragraph in list { + referenced.extend(paragraph.lists.iter().copied()); + referenced.extend(paragraph.style); + let tags = paragraph.tags.iter().chain(match ¶graph.content { + ParagraphContent::Text(text) => text.tags.as_slice(), + ParagraphContent::Table(table) => table.tags.as_slice(), + _ => &[], + }); + referenced.extend(tags.filter_map(|tag| tag.definition)); + } + } + page.definitions.retain(|id, _| referenced.contains(id)); + Ok(()) +} + +fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { + match op { + PageOp::Text { text, range, with } => { + let object = text_mut(page, *text)?; + object.text = replace_text(&object.text, range.clone(), with)?; + retitle(page, Some(*text)); + } + PageOp::Format { + text, + range, + set, + clear, + } => { + let (path, index) = + text_holder(page, *text).ok_or_else(|| invalid("Text is not on the page"))?; + let style = list_at(page, &path)[index] + .style + .and_then(|style| page.definitions.get(&style)) + .map(|definition| definition.format.clone()) + .unwrap_or_default(); + let object = text_mut(page, *text)?; + object.text = format_text(&object.text, range.clone(), set, clear, &style)?; + } + PageOp::Link { + text, + range, + target, + } => { + let current = text_mut(page, *text)?.text.clone(); + for op in super::apply::link_ops(¤t, *text, range.clone(), target.as_deref())? { + interpret(page, &op)?; + } + } + PageOp::Equation { text, math } => { + let object = text_mut(page, *text)?; + let previous = object.text.spans()[0].format.clone(); + object.text = paragraph_formatted(math, &previous); + } + PageOp::Insert { + container, + before, + paragraphs, + } => { + insert(page, *container, *before, insertion(paragraphs))?; + retitle(page, None); + } + PageOp::Split { + text, + at, + paragraph, + right, + lists, + } => { + let (path, index) = text_holder(page, *text).ok_or_else(|| invalid("Text is not on the page"))?; + let definitions: Vec<_> = { + let list = list_at(page, &path); + list[index].lists.clone() + }; + let copies: Vec<_> = definitions + .iter() + .zip(lists) + .map(|(old, new)| { + let mut definition = page.definitions.get(old).cloned(); + if let Some(crate::page::Definition { + kind: Kind::List { restart, .. }, + .. + }) = &mut definition + { + *restart = None; + } + (*new, definition) + }) + .collect(); + for (id, definition) in copies { + if let Some(definition) = definition { + page.definitions.insert(id, definition); + } + } + let list = list_at(page, &path); + let left = &mut list[index]; + let source = left.text().unwrap().text.clone(); + let length = source.utf16_offset(source.text().len())?; + let (head, tail) = (source.slice(0..*at)?, source.slice(*at..length)?); + let mut new = left.clone(); + left.text_mut().unwrap().text = head; + new.id = *paragraph; + new.lists = lists.clone(); + new.media = Default::default(); + new.content = ParagraphContent::Text(TextObject { + id: *right, + date_field: None, + text: tail, + tags: Vec::new(), + }); + let left_id = list[index].id; + for child in &mut list[index + 1..] { + if child.parent == Some(left_id) { + child.parent = Some(*paragraph); + } + } + list.insert(index + 1, new); + retitle(page, None); + } + PageOp::Join { left, right } => { + let (path, index) = text_holder(page, *right).ok_or_else(|| invalid("Text is not on the page"))?; + let (left_path, left_index) = + text_holder(page, *left).ok_or_else(|| invalid("Text is not on the page"))?; + let list = list_at(page, &path); + let removed = list[index].clone(); + let right_text = removed.text().unwrap().clone(); + let definitions = page.definitions.clone(); + let left_list = list_at(page, &left_path); + let left_style = left_list[left_index].style; + let adopts = left_list[left_index].text().unwrap().text.text().is_empty(); + if adopts { + // The text object brings its paragraph style and recording link. + left_list[left_index].style = removed.style; + left_list[left_index].media = removed.media.clone(); + } + let upper = left_list[left_index].text_mut().unwrap(); + if adopts { + upper.id = right_text.id; + upper.text = right_text.text; + } else { + // The lower text keeps its look under the upper paragraph's style: a flag or + // colour only that style states becomes false or automatic. + let differs = left_style != removed.style; + let left = left_style + .and_then(|style| definitions.get(&style)) + .map(|definition| definition.format.clone()) + .unwrap_or_default(); + let mut previous = 0; + let lower = Paragraph::from_runs(right_text.text.spans().iter().map(|span| { + let mut format = span.format.clone(); + if differs { + for (field, base) in [ + (&mut format.bold, left.bold), + (&mut format.italic, left.italic), + (&mut format.underline, left.underline), + (&mut format.strike, left.strike), + (&mut format.superscript, left.superscript), + (&mut format.subscript, left.subscript), + (&mut format.hidden, left.hidden), + (&mut format.hyperlink, left.hyperlink), + (&mut format.hyperlink_label, left.hyperlink_label), + (&mut format.math, left.math), + ] { + if field.is_none() && base.is_some() { + *field = Some(false); + } + } + for (field, base) in [ + (&mut format.color, left.color), + (&mut format.highlight, left.highlight), + ] { + if field.is_none() && base.is_some() { + *field = Some(0xff000000); + } + } + } + let run = (right_text.text.text()[previous..span.end].to_owned(), format); + previous = span.end; + run + })); + let paragraph = upper.text.spans()[0].format.clone(); + upper.text.append(lower)?; + upper.text = paragraph_formatted(&upper.text, ¶graph); + } + let left_id = left_list[left_index].id; + let list = list_at(page, &path); + // The right paragraph's children join the left paragraph's ancestor beside it, + // which is the outline group holding it where it lies deeper. + let mut stem = left_id; + while let Some(parent) = list.iter().find(|p| p.id == stem).and_then(|p| p.parent) { + if Some(parent) == removed.parent { + break; + } + stem = parent; + } + let range = subtree(list, index); + let stem_at = list.iter().position(|p| p.id == stem).unwrap(); + let stem_level = list[stem_at].level; + if range.len() > 1 && stem_level > removed.level { + // The group takes the right paragraph's child level for all its members. + let level = list[range.start + 1].level; + let mut member = stem_at; + loop { + relevel(list, member, level); + let Some(previous) = list[..member] + .iter() + .rposition(|p| p.parent == removed.parent) + .filter(|at| list[*at].level == stem_level) + else { + break; + }; + member = previous; + } + for child in &mut list[range.start + 1..range.end] { + if child.parent == Some(removed.id) { + child.parent = removed.parent; + } + } + } else { + let delta = i64::from(stem_level) - i64::from(removed.level); + for child in &mut list[range.start + 1..range.end] { + child.level = (i64::from(child.level) + delta) as u32; + if child.parent == Some(removed.id) { + child.parent = Some(stem); + } + } + } + list.remove(index); + retitle(page, None); + } + PageOp::Move { + object, + parent, + before, + } => { + let Some(parent) = parent else { + let from = page + .objects + .iter() + .position(|o| o.id() == *object) + .ok_or_else(|| invalid("The object is not on the page"))?; + let moved = page.objects.remove(from); + let at = page_position(page, *before)?; + page.objects.insert(at, moved); + retitle(page, None); + return Ok(()); + }; + let (path, index) = find(page, *object).ok_or_else(|| invalid("The paragraph is not on the page"))?; + let list = list_at(page, &path); + let mut moved: Vec = list.drain(subtree(list, index)).collect(); + let into_cell = table_cells(page).contains(parent); + let base = child_level(page, *parent); + let level = if into_cell { + let (path, _) = self::container(page, *parent).unwrap(); + list_at(page, &path) + .iter() + .find(|p| p.parent.is_none()) + .map_or(1, |p| p.level) + } else if moved[0].level > base { + moved[0].level + } else { + base + 1 + }; + moved[0].parent = paragraph(page, *parent).map(|_| *parent); + relevel(&mut moved, 0, level); + insert(page, *parent, *before, moved)?; + drop_emptied(page); + retitle(page, None); + } + PageOp::Delete { object } => { + if let Some(at) = page.objects.iter().position(|o| o.id() == *object) { + page.objects.remove(at); + } else { + let (path, index) = + find(page, *object).ok_or_else(|| invalid("The object is not on the page"))?; + let list = list_at(page, &path); + let range = subtree(list, index); + if !path.cells.is_empty() && range.len() == list.len() { + return Err(invalid( + "A table cell keeps a paragraph; insert its replacement first", + )); + } + list.drain(range); + } + drop_emptied(page); + retitle(page, None); + } + PageOp::Level { paragraph, level } => { + let (path, index) = find(page, *paragraph).ok_or_else(|| invalid("The paragraph is not on the page"))?; + relevel(list_at(page, &path), index, *level); + } + PageOp::Outline { object, edit } => match edit { + OutlineEdit::Collapsed(value) => paragraph_mut(page, *object)?.collapsed = *value, + OutlineEdit::Position { x, y } => { + let outline = outline_mut(page, *object)?; + outline.layout.x = Some(stored(*x)); + outline.layout.y = Some(stored(*y)); + retitle(page, None); + } + OutlineEdit::Width { points, user_set } => { + let outline = outline_mut(page, *object)?; + outline.layout.max_width = Some(stored(*points)); + outline.layout.width_set_by_user = Some(*user_set); + outline.layout.reserved_width = None; + } + }, + PageOp::Paragraph { + paragraph, + alignment, + rtl, + space_before, + space_after, + line_spacing, + language, + } => { + let text = &mut paragraph_mut(page, *paragraph)? + .text_mut() + .ok_or_else(|| invalid("Paragraph formatting belongs to text"))? + .text; + let mut previous = 0; + let runs: Vec<(String, Format)> = text + .spans() + .iter() + .map(|span| { + let mut format = span.format.clone(); + format.alignment = alignment.or(format.alignment); + format.rtl = rtl.or(format.rtl); + format.space_before = space_before.map(stored).or(format.space_before); + format.space_after = space_after.map(stored).or(format.space_after); + format.line_spacing = line_spacing.map(stored).or(format.line_spacing); + // A run's own language outranks the text's. + format.language = format.language.or(*language); + let run = (text.text()[previous..span.end].to_owned(), format); + previous = span.end; + run + }) + .collect(); + *text = Paragraph::from_runs(runs); + } + PageOp::Style { + paragraph, + style, + definition, + } => { + let old = paragraph_mut(page, *paragraph)? + .style + .and_then(|style| page.definitions.get(&style)) + .map(|definition| definition.format.clone()) + .unwrap_or_default(); + let style = define(page, *style, definition); + let new = page.definitions[&style].format.clone(); + let paragraph = paragraph_mut(page, *paragraph)?; + paragraph.style = Some(style); + if let Some(text) = paragraph.text_mut() { + let mut previous = 0; + let runs: Vec<(String, Format)> = text + .text + .spans() + .iter() + .map(|span| { + // What the old style gave, the new one gives instead. + let mut format = span.format.clone(); + macro_rules! restyle { + ($($field:ident),*) => {$( + if old.$field.is_some() && format.$field == old.$field { + format.$field = new.$field.clone(); + } else if format.$field.is_none() { + format.$field = new.$field.clone(); + } + )*}; + } + restyle!(bold, italic, underline, strike, superscript, subscript, hidden, + hyperlink, hyperlink_label, math, embedded_object, font, font_size, + color, highlight, language, alignment, rtl, space_before, space_after, + line_spacing, list_spacing, math_object); + let run = (text.text.text()[previous..span.end].to_owned(), format); + previous = span.end; + run + }) + .collect(); + text.text = Paragraph::from_runs(runs); + } + } + PageOp::List { paragraph, lists } => { + paragraph_mut(page, *paragraph)?.lists = lists.iter().map(|(id, _)| *id).collect(); + for (id, definition) in lists { + page.definitions.insert(*id, definition.clone()); + } + } + PageOp::Tags { + target, + tags, + definitions, + } => { + let mut tags = tags.clone(); + for tag in &mut tags { + if let Some(id) = &mut tag.definition + && let Some((_, definition)) = definitions.iter().find(|(d, _)| d == id) + { + *id = define(page, *id, definition); + } + } + if let Ok(paragraph) = paragraph_mut(page, *target) { + paragraph.tags = tags; + } else if let Ok(text) = text_mut(page, *target) { + text.tags = tags; + } else { + table_mut(page, *target)?.tags = tags; + } + } + PageOp::Add { object, before } => { + let object = match object { + PageObject::Outline(outline) => { + let mut outline = outline.clone(); + outline.paragraphs = insertion(&outline.paragraphs); + if outline.indents.is_empty() { + outline.indents = NATIVE_INDENTS.to_vec(); + } + let layout = &mut outline.layout; + layout.x = layout.x.map(stored); + layout.y = layout.y.map(stored); + match layout.max_width { + Some(points) if (points, layout.width_set_by_user) != (468.0, None) => { + layout.max_width = Some(stored(points)); + layout.width_set_by_user = Some(layout.width_set_by_user == Some(true)); + } + _ => { + layout.max_width = Some(468.0); + layout.width_set_by_user = None; + } + } + layout.reserved_width = None; + layout.max_height = Some(0.6_f32 * 36.0); + PageObject::Outline(outline) + } + PageObject::Ink(ink) => PageObject::Ink(inked(ink)), + object => object.clone(), + }; + let outline = matches!(object, PageObject::Outline(_)); + let at = page_position(page, *before)?; + page.objects.insert(at, object); + if outline { + retitle(page, None); + } + } + PageOp::Picture { + picture, + layout, + alt, + } => { + let image = image_mut(page, *picture)?; + image.layout = crate::document::Layout { + x: layout.x.map(stored), + y: layout.y.map(stored), + max_width: layout.max_width.map(stored), + max_height: layout.max_height.map(stored), + width_set_by_user: layout.max_width.map(|_| layout.width_set_by_user == Some(true)), + reserved_width: image.layout.reserved_width, + }; + image.alt = alt.clone(); + } + PageOp::Attachment { + attachment, + filename, + source_path, + size, + } => { + let (path, index) = lists(page) + .into_iter() + .find_map(|(path, _, list)| { + list.iter() + .position(|p| matches!(&p.content, ParagraphContent::Attachment(a) if a.id == *attachment)) + .map(|index| (path, index)) + }) + .ok_or_else(|| invalid("The attachment is not on the page"))?; + let ParagraphContent::Attachment(stored_attachment) = &mut list_at(page, &path)[index].content + else { + unreachable!() + }; + stored_attachment.filename = filename.clone(); + stored_attachment.source_path = source_path.clone(); + stored_attachment.size = size.map(|[w, h]| [stored(w), stored(h)]); + } + PageOp::Strokes { ink, add, remove } => { + let ink = ink_mut(page, *ink)?; + ink.strokes.retain(|stroke| !remove.contains(&stroke.id)); + ink.strokes.extend(add.iter().map(stroked)); + } + PageOp::Table { table, edit } => { + let table = table_mut(page, *table)?; + match edit { + TableEdit::Rows { before, rows } => { + let at = match before { + Some(before) => table + .rows + .iter() + .position(|r| r.id == *before) + .ok_or_else(|| invalid("The anchor row is not in the table"))?, + None => table.rows.len(), + }; + let mut rows = rows.clone(); + for cell in rows.iter_mut().flat_map(|row| &mut row.cells) { + if cell.indents.is_empty() { + cell.indents = table + .rows + .first() + .and_then(|r| r.cells.first()) + .map_or(NATIVE_INDENTS.to_vec(), |c| c.indents.clone()); + } + cell.paragraphs = insertion(&cell.paragraphs); + } + table.rows.splice(at..at, rows); + } + TableEdit::Column { at, width, cells } => { + let template = table + .rows + .first() + .and_then(|r| r.cells.first()) + .map(|c| c.indents.clone()); + for (row, cell) in table.rows.iter_mut().zip(cells) { + let mut cell = cell.clone(); + if cell.indents.is_empty() { + cell.indents = template.clone().unwrap_or(NATIVE_INDENTS.to_vec()); + } + cell.paragraphs = insertion(&cell.paragraphs); + row.cells.insert(*at as usize, cell); + } + table.columns.insert( + *at as usize, + crate::page::TableColumn { + width: stored(*width), + locked: false, + }, + ); + } + TableEdit::DeleteRow(row) => table.rows.retain(|r| r.id != *row), + TableEdit::DeleteColumn(at) => { + table.columns.remove(*at as usize); + for row in &mut table.rows { + row.cells.remove(*at as usize); + } + } + TableEdit::Columns(columns) => { + table.columns = columns + .iter() + .map(|c| crate::page::TableColumn { + width: stored(c.width), + locked: c.locked, + }) + .collect(); + } + TableEdit::Borders(borders) => table.borders = Some(*borders), + TableEdit::Cell { + cell, + shading, + indents, + } => { + let cell = table + .rows + .iter_mut() + .flat_map(|row| &mut row.cells) + .find(|c| c.id == *cell) + .ok_or_else(|| invalid("The cell is not in the table"))?; + cell.shading = *shading; + cell.indents = indents.iter().map(|v| stored(*v)).collect(); + } + } + } + } + Ok(()) +} + +/// Removes outlines a tree edit left without paragraphs, as the tree writer does. +fn drop_emptied(page: &mut Page) { + page.objects + .retain(|object| !matches!(object, PageObject::Outline(outline) if outline.paragraphs.is_empty())); +} + +/// The identity a read-only definition is stored under: an equal one the page holds, as +/// the writers share identical read-only objects, else `id`. +fn define(page: &mut Page, id: ExGuid, definition: &crate::page::Definition) -> ExGuid { + if page.definitions.contains_key(&id) { + return id; + } + if let Some((existing, _)) = page.definitions.iter().find(|(_, d)| *d == definition) { + return *existing; + } + page.definitions.insert(id, definition.clone()); + id +} + +/// Where a page object goes before `before`, or after the other children; titles, which +/// the page lists apart, read after them. +fn page_position(page: &Page, before: Option) -> Result { + match before { + Some(before) => page + .objects + .iter() + .position(|o| o.id() == before) + .ok_or_else(|| invalid("The anchor is not on the page")), + None => Ok(page + .objects + .iter() + .position(|o| matches!(o, PageObject::Title(_))) + .unwrap_or(page.objects.len())), + } +} + +fn table_cells(page: &Page) -> BTreeSet { + lists(page) + .into_iter() + .filter(|(path, _, _)| !path.cells.is_empty()) + .map(|(_, owner, _)| owner) + .collect() +} + +fn outline_mut(page: &mut Page, id: ExGuid) -> Result<&mut Outline, Error> { + page.objects + .iter_mut() + .find_map(|object| match object { + PageObject::Outline(outline) if outline.id == id => Some(outline), + _ => None, + }) + .ok_or_else(|| invalid("The outline is not on the page")) +} + +fn image_mut(page: &mut Page, id: ExGuid) -> Result<&mut crate::page::Image, Error> { + if let Some(at) = page.objects.iter().position(|o| o.id() == id) { + let PageObject::Image(image) = &mut page.objects[at] else { + return Err(invalid("Select a picture")); + }; + return Ok(image); + } + let (path, index) = lists(page) + .into_iter() + .find_map(|(path, _, list)| { + list.iter() + .position(|p| matches!(&p.content, ParagraphContent::Image(i) if i.id == id)) + .map(|index| (path, index)) + }) + .ok_or_else(|| invalid("The picture is not on the page"))?; + let ParagraphContent::Image(image) = &mut list_at(page, &path)[index].content else { + unreachable!() + }; + Ok(image) +} + +fn ink_mut(page: &mut Page, id: ExGuid) -> Result<&mut crate::page::Ink, Error> { + if let Some(at) = page.objects.iter().position(|o| o.id() == id) { + let PageObject::Ink(ink) = &mut page.objects[at] else { + return Err(invalid("Select ink")); + }; + return Ok(ink); + } + let (path, index) = lists(page) + .into_iter() + .find_map(|(path, _, list)| { + list.iter() + .position(|p| matches!(&p.content, ParagraphContent::Ink(i) if i.id == id)) + .map(|index| (path, index)) + }) + .ok_or_else(|| invalid("The ink is not on the page"))?; + let ParagraphContent::Ink(ink) = &mut list_at(page, &path)[index].content else { + unreachable!() + }; + Ok(ink) +} + +/// The text object of the page title, when it has one. +fn find_title_text(page: &Page) -> Option { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Title(title) => Some(title), + _ => None, + }) + .flat_map(|title| title.outlines.iter().filter(|o| Some(o.id) != title.date)) + .flat_map(|outline| &outline.paragraphs) + .filter_map(|p| p.text().filter(|t| t.date_field.is_none())) + .map(|t| t.id) + .next_back() +} + +fn text_of(page: &Page, text: ExGuid) -> String { + lists(page) + .into_iter() + .find_map(|(_, _, list)| { + list.iter() + .find_map(|p| p.text().filter(|t| t.id == text).map(|t| t.text.text().to_owned())) + }) + .unwrap_or_default() +} diff --git a/crates/onestore/src/op/properties.rs b/crates/onestore/src/op/properties.rs new file mode 100644 index 0000000000000000000000000000000000000000..b33a621ba2813791bd8ea194a187ac9824f46ef3 --- /dev/null +++ b/crates/onestore/src/op/properties.rs @@ -0,0 +1,376 @@ +//! Paragraph properties: list nodes, note tags, paragraph styles and paragraph formatting. + +use super::Values; +use crate::{ + Error, ExGuid, PropertySets, Value, + active::{ActivePage, Changes}, + document::{Kind, Tag}, + page::Definition, + write::PropertyObject, +}; +use std::collections::{BTreeMap, BTreeSet}; + +fn invalid(message: &'static str) -> Error { + Error { offset: 0, message } +} + +/// The properties of a list node carrying `definition`. +pub(crate) fn list_values(definition: &Definition) -> Result { + let Kind::List { + font, + format, + restart, + bullet, + } = &definition.kind + else { + return Err(invalid("A paragraph list must reference a list definition")); + }; + if bullet.is_some() && (format.is_none() || font.is_none()) { + return Err(invalid("A bullet definition names its glyph and font")); + } + let mut values: Values = Vec::new(); + if let Some(format) = format { + let units: Vec = format.encode_utf16().collect(); + let count = u16::try_from(units.len()) + .map_err(|_| invalid("List format exceeds the document range"))?; + let mut bytes = count.to_le_bytes().to_vec(); + bytes.extend(units.iter().flat_map(|unit| unit.to_le_bytes())); + values.push((0x1c001c1a, bytes)); + } + if let Some(font) = font { + values.push((0x1c001c52, crate::create::string(font))); + } + if let Some(restart) = restart { + values.push((0x14001cb7, restart.to_le_bytes().to_vec())); + } + if let Some(bullet) = bullet { + values.push((0x10001d0e, bullet.to_le_bytes().to_vec())); + // Every native bullet node carries this cleared flag alongside its index. + values.push((0x0c001cc0, vec![0])); + } + let style = &definition.format; + if let Some(font) = &style.font { + values.push((0x1c001c0a, crate::create::string(font))); + } + if let Some(size) = style.font_size { + let half = (size * 2.0).round(); + if !(0.0..=f32::from(u16::MAX)).contains(&half) { + return Err(invalid("List font size is outside the document range")); + } + values.push((0x10001c0b, (half as u16).to_le_bytes().to_vec())); + } + if let Some(color) = style.color { + values.push((0x14001c0c, color.to_le_bytes().to_vec())); + } + if let Some(language) = style.language { + values.push((0x14001c3b, language.to_le_bytes().to_vec())); + } + for (flag, id) in [(style.bold, 0x08001c04), (style.italic, 0x08001c05)] { + if let Some(flag) = flag { + values.push((id | (u32::from(flag) << 31), Vec::new())); + } + } + Ok(values) +} + +/// Gives `paragraph` the list nodes `nodes` names, rewriting stored ones to their values +/// and creating the rest. +pub(crate) fn list_changes( + active: &ActivePage<'_>, + paragraph: ExGuid, + nodes: &[(ExGuid, Values)], +) -> Result { + let parents = active.editable_parents(paragraph)?; + let modified = crate::create::current_timestamps()?.0.to_le_bytes(); + let raw = &active.live.revision; + let mut changed = BTreeMap::new(); + let mut target = PropertyObject::from_object(&raw.objects[¶graph])?; + let mut references = Vec::new(); + for (list, values) in nodes { + let mut node = match raw.objects.get(list) { + Some(existing) => { + if existing.jcid != 0x60012 { + return Err(invalid( + "A list definition identity belongs to another object", + )); + } + let mut node = PropertyObject::from_object(existing)?; + node.remove(&[ + 0x1c001c1a, 0x1c001c52, 0x14001cb7, 0x10001d0e, 0x0c001cc0, 0x1c001c0a, + 0x10001c0b, 0x14001c0c, 0x14001c3b, 0x08001c04, 0x08001c05, + ])?; + node + } + None => PropertyObject { + jcid: 0x60012, + bytes: crate::create::properties(&[])?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, list.guid)])), + }, + }; + node.set( + &values + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect::>(), + )?; + node.set(&[(0x14001d7a, &modified)])?; + node.reference(*list)?; + references.extend_from_slice(&target.reference(*list)?); + changed.insert(*list, node); + } + if references.is_empty() { + target.remove(&[0x24001c26])?; + } else { + target.set(&[(0x24001c26, &references)])?; + } + target.set(&[(0x14001d7a, &modified)])?; + changed.insert(paragraph, target); + crate::formatting::touch_ancestors(raw, parents, paragraph, &modified, &mut changed)?; + Ok(changed) +} + +/// The properties of a new tag definition object. +fn tag_definition_values(definition: &Definition) -> Result { + let Kind::TagDefinition { + label, + action_type, + shape, + color, + highlight, + } = &definition.kind + else { + return Err(invalid("A note tag must reference a tag definition")); + }; + let mut values: Values = vec![ + (0x0c003473, vec![0]), + (0x10003463, action_type.unwrap_or(0).to_le_bytes().to_vec()), + (0x10003464, shape.unwrap_or(0).to_le_bytes().to_vec()), + (0x14003467, 0u32.to_le_bytes().to_vec()), + ]; + if let Some(label) = label { + values.push((0x1c003468, crate::create::string(label))); + } + if let Some(color) = color { + values.push((0x14003466, color.to_le_bytes().to_vec())); + } + if let Some(highlight) = highlight { + values.push((0x14003465, highlight.to_le_bytes().to_vec())); + } + Ok(values) +} + +/// Replaces the note tags of `object`. Each entry names the stored identity of its tag's +/// definition and, where the tag does not carry its action type or the page lacks the +/// definition, the definition itself. +pub(crate) fn tag_changes( + active: &ActivePage<'_>, + object: ExGuid, + tags: &[(ExGuid, &Tag, Option<&Definition>)], +) -> Result { + let raw = &active.live.revision; + let mut definitions: Vec<(ExGuid, Option)> = Vec::new(); + let mut sets: Vec<(usize, Values)> = Vec::new(); + let mut action_types = BTreeSet::new(); + for (written, tag, definition) in tags { + let action_type = if tag.status & 4 != 0 { + tag.action_type + } else { + match definition.map(|d| &d.kind) { + Some(Kind::TagDefinition { action_type, .. }) => *action_type, + _ => return Err(invalid("A note tag must reference a tag definition")), + } + }; + if !action_types.insert(action_type.unwrap_or(0)) { + return Err(invalid("An element holds one note tag per action type")); + } + let index = match definitions.iter().position(|(id, _)| id == written) { + Some(index) => index, + None => { + let values = if raw.objects.contains_key(written) { + None + } else { + let definition = definition + .ok_or_else(|| invalid("A note tag references a missing tag definition"))?; + Some(tag_definition_values(definition)?) + }; + definitions.push((*written, values)); + definitions.len() - 1 + } + }; + let mut fields: Values = Vec::new(); + if let Some(action_type) = tag.action_type { + fields.push((0x10003463, action_type.to_le_bytes().to_vec())); + } + for (id, value) in [ + (0x1400346e, tag.created), + (0x1400346f, tag.completed), + (0x1400346a, tag.start), + (0x1400346b, tag.due), + ] { + if let Some(value) = value { + fields.push((id, value.to_le_bytes().to_vec())); + } + } + fields.push((0x10003470, tag.status.to_le_bytes().to_vec())); + if let Some(task) = tag.task_id { + fields.push((0x1c003469, task.to_vec())); + } + sets.push((index, fields)); + } + let parents = active.editable_parents(object)?; + let modified = crate::create::current_timestamps()?.0.to_le_bytes(); + let mut changed = BTreeMap::new(); + for (id, values) in &definitions { + let Some(values) = values else { + continue; + }; + let mut node = PropertyObject { + jcid: 0x120043, + bytes: crate::create::properties(values)?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), + }; + node.reference(*id)?; + changed.insert(*id, node); + } + let mut target = PropertyObject::from_object(&raw.objects[&object])?; + let mut encoded = Vec::new(); + for (index, fields) in &sets { + let reference = target.reference(definitions[*index].0)?; + let mut set = vec![(0x20003488, reference.to_vec())]; + set.extend(fields.iter().cloned()); + encoded.push(set); + } + target.set_sets(0x40003489, 0x44000811, &encoded)?; + target.set(&[(0x14001d7a, &modified)])?; + changed.insert(object, target); + crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?; + Ok(changed) +} + +/// Makes `text` reference paragraph style `style`, created from `definition` where the +/// page does not store it. +pub(crate) fn style_changes( + active: &ActivePage<'_>, + text: ExGuid, + style: ExGuid, + definition: Option<&Definition>, +) -> Result { + let raw = &active.live.revision; + let mut changed = BTreeMap::new(); + if !raw.objects.contains_key(&style) { + let definition = + definition.ok_or_else(|| invalid("A paragraph references a missing style definition"))?; + let Kind::Style { name } = &definition.kind else { + return Err(invalid("A paragraph style must be a style definition")); + }; + let format = &definition.format; + let mut values = super::content::style_values(format); + if let Some(name) = name { + values.push((0x1c00345a, crate::create::string(name))); + } + if let Some(alignment) = format.alignment { + values.push((0x0c003477, vec![alignment])); + } + for (property, value) in [ + (0x1400342e, format.space_before), + (0x1400342f, format.space_after), + (0x14003430, format.line_spacing), + ] { + if let Some(points) = value { + values.push((property, (points / 36.0).to_le_bytes().to_vec())); + } + } + let mut node = PropertyObject { + jcid: 0x12004d, + bytes: crate::create::properties(&values)?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, style.guid)])), + }; + node.reference(style)?; + changed.insert(style, node); + } + let mut target = PropertyObject::from_object(&raw.objects[&text])?; + let reference = target.reference(style)?; + target.set(&[(0x2000342c, &reference)])?; + changed.insert(text, target); + Ok(changed) +} + +/// The text-object properties of paragraph formatting, in the order they are written. +pub(crate) fn paragraph_values( + alignment: Option, + rtl: Option, + space_before: Option, + space_after: Option, + line_spacing: Option, + language: Option, +) -> Result { + let mut values = Values::new(); + if let Some(alignment) = alignment { + if alignment > 2 { + return Err(invalid("Paragraph alignment must be left, center or right")); + } + values.push((0x0c003477, vec![alignment])); + } + if let Some(rtl) = rtl { + values.push((0x08003476 | (u32::from(rtl) << 31), Vec::new())); + } + for (property, value) in [ + (0x1400342e, space_before), + (0x1400342f, space_after), + (0x14003430, line_spacing), + ] { + if let Some(points) = value { + let stored = points / 36.0; + if !stored.is_finite() || !(0.0..=27777.777).contains(&stored) { + return Err(invalid("Paragraph spacing is outside the document range")); + } + values.push((property, stored.to_le_bytes().to_vec())); + } + } + if let Some(language) = language { + values.push((0x14001c3b, language.to_le_bytes().to_vec())); + } + Ok(values) +} + +/// Writes paragraph formatting `values` on text object `object`; alignment also sets the +/// layout alignment fields OneNote keeps beside it. +pub(crate) fn paragraph_format_changes( + active: &ActivePage<'_>, + object: ExGuid, + values: &Values, +) -> Result { + let parents = active.editable_parents(object)?; + let modified = crate::create::current_timestamps()?.0.to_le_bytes(); + let raw = &active.live.revision; + let mut target = PropertyObject::from_object(&raw.objects[&object])?; + target.set( + &values + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect::>(), + )?; + if let Some((_, alignment)) = values.iter().find(|(id, _)| *id == 0x0c003477) { + for property in [0x14001c3e, 0x14001c84] { + let fields = PropertySets::parse(&target.bytes)?; + let previous = fields.sets[0] + .iter() + .find(|field| field.id == property) + .map(|field| match field.value { + Value::Bytes(bytes) => bytes + .try_into() + .map(u32::from_le_bytes) + .map_err(|_| invalid("Invalid paragraph layout alignment")), + _ => Err(invalid("Invalid paragraph layout alignment")), + }) + .transpose()? + .unwrap_or(0); + let value = (previous & !7) | (u32::from(alignment[0]) + 1); + target.set(&[(property, &value.to_le_bytes())])?; + } + } + target.set(&[(0x14001d7a, &modified)])?; + let mut changed = BTreeMap::from([(object, target)]); + crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?; + Ok(changed) +} diff --git a/crates/onestore/src/op/table.rs b/crates/onestore/src/op/table.rs new file mode 100644 index 0000000000000000000000000000000000000000..37678fee9457d3bfe8bdecc47130b6926f0c3da8 --- /dev/null +++ b/crates/onestore/src/op/table.rs @@ -0,0 +1,214 @@ +//! Tables as OneNote stores them: rows and cells are plain containers, a cell carries its +//! indentation table and the flags every native cell has, the table its column widths, +//! locks and border flag. + +use super::{ + Values, + content::{NATIVE_INDENTS, measurement_bytes}, +}; +use crate::{ + Error, ExGuid, + active::{ActivePage, Changes}, + page::{Table, TableColumn}, + write::PropertyObject, +}; +use std::collections::{BTreeMap, BTreeSet}; + +fn invalid(message: &'static str) -> Error { + Error { offset: 0, message } +} + +/// Every row carries one cell per column and widths are usable. +pub(crate) fn validate_table(table: &Table) -> Result<(), Error> { + if table.rows.is_empty() || table.columns.is_empty() { + return Err(invalid("A table needs at least one row and one column")); + } + if table + .columns + .iter() + .any(|c| !c.width.is_finite() || c.width < 36.0) + { + return Err(invalid("Table columns are at least 36 points wide")); + } + for row in &table.rows { + if row.cells.len() != table.columns.len() { + return Err(invalid("Every table row has one cell per column")); + } + for cell in &row.cells { + // An emptied cell keeps a replacement paragraph, as the tree writer provides. + for paragraph in &cell.paragraphs { + if let crate::page::ParagraphContent::Table(nested) = ¶graph.content { + validate_table(nested)?; + } + } + } + } + Ok(()) +} + +/// A table's rows and cells by stored identity, in order, and what new ones carry. +pub(crate) struct Structure<'t> { + pub rows: Vec<(ExGuid, Vec)>, + pub new_rows: BTreeSet, + /// The indentation table and shading of each new cell. + pub new_cells: BTreeMap)>, + pub columns: &'t [TableColumn], + pub borders: Option, +} + +/// The indentation table a new cell takes: its own, else the table's first cell's, else +/// the one OneNote gives new cells. +pub(crate) fn cell_indents<'t>(own: &'t [f32], template: Option<&'t [f32]>) -> &'t [f32] { + [own, template.unwrap_or_default(), &NATIVE_INDENTS] + .into_iter() + .find(|indents| !indents.is_empty()) + .unwrap() +} + +/// Writes table `table` as `structure`; with `holder`, creates it as that paragraph's content. +pub(crate) fn table_changes( + active: &ActivePage<'_>, + table: ExGuid, + holder: Option, + structure: &Structure<'_>, +) -> Result { + let modified = crate::create::current_timestamps()?.0.to_le_bytes(); + let mut created: Vec<(ExGuid, u32, Values)> = Vec::new(); + for (row, cells) in &structure.rows { + for cell in cells { + if let Some((indents, shading)) = structure.new_cells.get(cell) { + let mut values: Values = vec![ + (0x14001d7a, modified.to_vec()), + (0x0c001c13, vec![0]), + (0x0c001c03, vec![1]), + (0x88001c91, Vec::new()), + (0x1c001c12, measurement_bytes(indents, 4)?), + ]; + if let Some(shading) = shading { + values.push((0x14001e26, shading.to_le_bytes().to_vec())); + } + created.push((*cell, 0x60024, values)); + } + } + if structure.new_rows.contains(row) { + created.push((*row, 0x60023, vec![(0x14001d7a, modified.to_vec())])); + } + } + let columns = structure.columns; + let mut table_values: Values = vec![ + (0x14001d7a, modified.to_vec()), + ( + 0x14001d57, + (structure.rows.len() as u32).to_le_bytes().to_vec(), + ), + (0x14001d58, (columns.len() as u32).to_le_bytes().to_vec()), + ( + 0x1c001d66, + measurement_bytes(&columns.iter().map(|c| c.width).collect::>(), 1)?, + ), + ]; + let mut locks = vec![columns.len() as u8]; + locks.extend(vec![0u8; columns.len().div_ceil(8)]); + for (i, column) in columns.iter().enumerate() { + if column.locked { + locks[1 + i / 8] |= 1 << (i % 8); + } + } + table_values.push((0x1c001d7d, locks)); + table_values.push(( + 0x08001d5e | (u32::from(structure.borders.unwrap_or(true)) << 31), + Vec::new(), + )); + if holder.is_some() { + table_values.push((0x14001c3e, 1u32.to_le_bytes().to_vec())); + table_values.push((0x14001c84, 1u32.to_le_bytes().to_vec())); + created.push((table, 0x60022, Vec::new())); + } + let raw = &active.live.revision; + let mut changed: Changes = BTreeMap::new(); + for (id, jcid, values) in &created { + let mut node = PropertyObject { + jcid: *jcid, + bytes: crate::create::properties(values)?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), + }; + node.reference(*id)?; + changed.insert(*id, node); + } + let mut row_refs = Vec::new(); + for (row_id, cells) in &structure.rows { + let mut row = match changed.remove(row_id) { + Some(row) => row, + None => PropertyObject::from_object(&raw.objects[row_id])?, + }; + let mut cell_refs = Vec::new(); + for cell in cells { + cell_refs.extend_from_slice(&row.reference(*cell)?); + } + row.set(&[(0x24001c20, &cell_refs), (0x14001d7a, &modified)])?; + changed.insert(*row_id, row); + } + let mut table_object = match changed.remove(&table) { + Some(object) => object, + None => PropertyObject::from_object(&raw.objects[&table])?, + }; + for (row_id, _) in &structure.rows { + row_refs.extend_from_slice(&table_object.reference(*row_id)?); + } + table_object.remove(&[0x1c001d7d, 0x08001d5e])?; + table_object.set( + &table_values + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect::>(), + )?; + table_object.set(&[(0x24001c20, &row_refs)])?; + changed.insert(table, table_object); + if let Some(holder) = holder { + let mut object = PropertyObject::from_object(&raw.objects[&holder])?; + let reference = object.reference(table)?; + object.set(&[(0x24001c1f, &reference), (0x14001d7a, &modified)])?; + changed.insert(holder, object); + } + Ok(changed) +} + +/// Shading (the documented `CellShadingColor`, which OneNote 2010 stores but neither +/// renders nor accepts through its COM schema) and indents change in place on a cell. +pub(crate) fn cell_changes( + active: &ActivePage<'_>, + cell: ExGuid, + stored: (Option, &[f32]), + shading: Option, + indents: &[f32], +) -> Result { + let mut values: Values = vec![( + 0x14001d7a, + crate::create::current_timestamps()? + .0 + .to_le_bytes() + .to_vec(), + )]; + let mut removed = Vec::new(); + if shading != stored.0 { + match shading { + Some(shading) => values.push((0x14001e26, shading.to_le_bytes().to_vec())), + None => removed.push(0x14001e26), + } + } + if indents != stored.1 { + if indents.is_empty() { + return Err(invalid("A table cell needs an indentation table")); + } + values.push((0x1c001c12, measurement_bytes(indents, 4)?)); + } + let raw = &active.live.revision; + let mut object = PropertyObject::from_object(&raw.objects[&cell])?; + object.remove(&removed)?; + let values: Vec<(u32, &[u8])> = values + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect(); + object.set(&values)?; + Ok(BTreeMap::from([(cell, object)])) +} diff --git a/crates/onestore/src/op/tests.rs b/crates/onestore/src/op/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..0928cf607cc8a7963b7a502fb866267280495ecf --- /dev/null +++ b/crates/onestore/src/op/tests.rs @@ -0,0 +1,2150 @@ +//! Ops against the page-model writer: for random edits of corpus pages, lowering the edit +//! and applying the ops through a `Section` stores what `PreparedEdit::page` stores. + +use super::*; +use crate::{ + Arena, RevisionIndex, Section, Store, + document::{Document, Format, Kind, Layout}, + page::{ + Attachment, Definition, Image, Ink, InkStroke, Outline, PageObject, PageParagraph, + ParagraphContent, Table, TableCell, TableColumn, TableRow, TextObject, text::new_id, + }, + write::GUIDS, +}; +use std::collections::BTreeMap; + +const SOURCES: &[(&str, &[u8])] = &[ + ("outline-edit", include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one")), + ("paragraph-edit", include_bytes!("../../../../corpus/paragraph-edit/before/notebook/synthetic.one")), + ("outline-tree", include_bytes!("../../../../corpus/outline-edit/tree/before/notebook/synthetic.one")), + ("features", include_bytes!("../../../../corpus/m6/native-features-01/notebook/Features.one")), + ("tables", include_bytes!("../../../../corpus/table-edit/nested/cold/notebook/synthetic.one")), + ("table-controls", include_bytes!("../../../../corpus/m6/native-table-controls-01/notebook/synthetic.one")), + ("lists", include_bytes!("../../../../corpus/list-edit/cold/notebook/lists.one")), + ("tags", include_bytes!("../../../../corpus/tag-edit/cold/notebook/tags.one")), + ("pictures", include_bytes!("../../../../corpus/picture-edit/native-page-level/notebook/pictures.one")), + ("attachments", include_bytes!("../../../../corpus/attachment-edit/plain/cold/notebook/files.one")), + ("ink", include_bytes!("../../../../corpus/ink-edit/drawing/cold/notebook/ink.one")), + ("math", include_bytes!("../../../../corpus/math-edit/native-editor/notebook/links.one")), + ("links", include_bytes!("../../../../corpus/link-edit/native-links/notebook/links.one")), + ("paragraph-format", include_bytes!("../../../../corpus/paragraph-format/cold/notebook/synthetic.one")), + ("enter-probe", include_bytes!("../../../../evidence/structural-edits/probe-section/probe.one")), +]; + +/// FILETIME both writers take as the edit's time. +const AT: u64 = 133_700_000_000_000_000; + +pub(super) struct Rng(pub(super) u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 >> 33 + } + + fn coin(&mut self) -> bool { + self.next().is_multiple_of(2) + } + + fn pick(&mut self, count: usize) -> Option { + (count > 0).then(|| self.next() as usize % count) + } + + fn text(&mut self) -> String { + let words = ["", "a", "Two words", "東京 🦀", "é\u{301}", "longer text here", " "]; + words[self.next() as usize % words.len()].to_owned() + } +} + +pub(super) fn seeded(seed: u64, f: impl FnOnce() -> T) -> T { + let outer = GUIDS.replace(Some(seed)); + let result = f(); + GUIDS.set(outer); + result +} + +fn pages(image: &[u8]) -> Vec { + let store = Store::parse(image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| space) + .collect() +} + +fn read(image: &[u8], space: ExGuid) -> Page { + let store = Store::parse(image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + Page::from_space(&Document::parse(&index).unwrap(), space).unwrap() +} + +/// `page` with writer-chosen identities replaced in document order: list nodes, which a +/// copy for a second owner gets, and tag field indices. +pub(super) fn normalize(page: &Page) -> Page { + let mut page = page.clone(); + let mut lists = BTreeMap::new(); + let mut paths: Vec<_> = model::lists(&page).into_iter().map(|(path, _, _)| path).collect(); + paths.sort_by_key(|path| format!("{path:?}")); + for path in &paths { + for paragraph in model::list_at(&mut page, path).iter_mut() { + for list in &mut paragraph.lists { + let next = ExGuid { + guid: [0xee; 16], + n: 1 + lists.len() as u32, + }; + *list = *lists.entry(*list).or_insert(next); + } + for tag in paragraph.tags.iter_mut() { + tag.extra_set = 0; + } + if let Some(text) = paragraph.text_mut() { + for tag in &mut text.tags { + tag.extra_set = 0; + } + // An absent paragraph value and its default are the same formatting. + let mut previous = 0; + let runs: Vec<(String, Format)> = text + .text + .spans() + .iter() + .map(|span| { + let mut format = span.format.clone(); + format.alignment = format.alignment.filter(|v| *v != 0); + format.rtl = format.rtl.filter(|v| *v); + format.space_before = format.space_before.filter(|v| *v != 0.0); + format.space_after = format.space_after.filter(|v| *v != 0.0); + format.line_spacing = format.line_spacing.filter(|v| *v != 0.0); + let run = (text.text.text()[previous..span.end].to_owned(), format); + previous = span.end; + run + }) + .collect(); + text.text = Paragraph::from_runs(runs); + } + } + } + page.definitions = std::mem::take(&mut page.definitions) + .into_iter() + .filter_map(|(id, definition)| match definition.kind { + Kind::List { .. } => lists.get(&id).map(|id| (*id, definition)), + _ => Some((id, definition)), + }) + .collect(); + page +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum Family { + Text, + Format, + Insert, + Paste, + Delete, + Move, + Collapse, + Position, + Width, + NewOutline, + List, + Tag, + Split, + Join, + Indent, + Outdent, + Alignment, + Style, + TableRow, + TableColumn, + TableDelete, + TableWidth, + TableShading, + NewTable, + PagePicture, + PictureEdit, + ParagraphPicture, + Attachment, + AttachmentRename, + NewInk, + InkStrokes, + Link, + Equation, +} + +pub(super) const FAMILIES: [Family; 33] = [ + Family::Text, + Family::Format, + Family::Insert, + Family::Paste, + Family::Delete, + Family::Move, + Family::Collapse, + Family::Position, + Family::Width, + Family::NewOutline, + Family::List, + Family::Tag, + Family::Split, + Family::Join, + Family::Indent, + Family::Outdent, + Family::Alignment, + Family::Style, + Family::TableRow, + Family::TableColumn, + Family::TableDelete, + Family::TableWidth, + Family::TableShading, + Family::NewTable, + Family::PagePicture, + Family::PictureEdit, + Family::ParagraphPicture, + Family::Attachment, + Family::AttachmentRename, + Family::NewInk, + Family::InkStrokes, + Family::Link, + Family::Equation, +]; + +const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, + 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, + 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, + 0xcf, 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, + 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]; + +fn id() -> ExGuid { + new_id().unwrap() +} + +fn text_paragraph(text: &str, format: Format) -> PageParagraph { + PageParagraph { + id: id(), + parent: None, + level: 1, + style: None, + format: Format::default(), + content: ParagraphContent::Text(TextObject { + id: id(), + date_field: None, + text: Paragraph::new(text.to_owned(), format), + tags: Vec::new(), + }), + lists: Vec::new(), + tags: Vec::new(), + media: Default::default(), + collapsed: false, + } +} + +/// Paths of the page's editable lists: ordinary outlines and their cells. +fn editable(page: &Page) -> Vec { + let titles: Vec = page + .objects + .iter() + .filter_map(|o| match o { + PageObject::Title(title) => Some(title.outlines.iter().map(|o| o.id).collect::>()), + _ => None, + }) + .flatten() + .collect(); + model::lists(page) + .into_iter() + .filter(|(_, owner, list)| !titles.contains(owner) && !list.is_empty()) + .map(|(path, _, _)| path) + .collect() +} + +/// A text paragraph of an editable list: its list and index. +fn pick_text(page: &Page, rng: &mut Rng) -> Option<(model::Path, usize)> { + let candidates: Vec<(model::Path, usize)> = editable(page) + .into_iter() + .flat_map(|path| { + let list = model::lists(page) + .into_iter() + .find(|(p, _, _)| format!("{p:?}") == format!("{path:?}")) + .unwrap() + .2 + .clone(); + list.iter() + .enumerate() + .filter(|(_, p)| { + p.text().is_some_and(|t| { + t.date_field.is_none() && !crate::page::Math::is_equation(&t.text) + }) + }) + .map(|(i, _)| (path.clone(), i)) + .collect::>() + }) + .collect(); + candidates.get(rng.pick(candidates.len())?).cloned() +} + +fn outlines(page: &mut Page) -> Vec<&mut Outline> { + page.objects + .iter_mut() + .filter_map(|o| match o { + PageObject::Outline(outline) => Some(outline), + _ => None, + }) + .collect() +} + +fn subtree_end(list: &[PageParagraph], index: usize) -> usize { + let mut members = vec![list[index].id]; + let mut end = index + 1; + while end < list.len() && list[end].parent.is_some_and(|p| members.contains(&p)) { + members.push(list[end].id); + end += 1; + } + end +} + +fn tables(page: &mut Page) -> Vec<&mut Table> { + let mut out = Vec::new(); + for outline in outlines(page) { + for paragraph in &mut outline.paragraphs { + if let ParagraphContent::Table(table) = &mut paragraph.content { + out.push(table); + } + } + } + out +} + +fn new_cell(indents: &[f32]) -> TableCell { + TableCell { + id: id(), + layout: Layout::default(), + indents: indents.to_vec(), + shading: None, + paragraphs: vec![text_paragraph("cell", Format::default())], + unsupported: Vec::new(), + } +} + +/// Applies one random edit of `family` to `page`; false when the page offers none. +pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { + match family { + Family::Text | Family::Format | Family::Link => { + let Some((path, index)) = pick_text(page, rng) else { + return false; + }; + let text = &mut model::list_at(page, &path)[index].text_mut().unwrap().text; + let end = text.utf16_offset(text.text().len()).unwrap(); + let chars: Vec = text + .text() + .char_indices() + .map(|(i, _)| text.utf16_offset(i).unwrap()) + .chain([end]) + .collect(); + let a = chars[rng.pick(chars.len()).unwrap()]; + let b = chars[rng.pick(chars.len()).unwrap()]; + let range = a.min(b)..a.max(b); + match family { + Family::Text => { + let format = text.format_at(range.start).unwrap().clone(); + text.apply(crate::page::text::Edit { + range, + replacement: Paragraph::new(rng.text(), format), + }) + .is_ok() + } + Family::Format => { + if range.is_empty() { + return false; + } + let slice = text.slice(range.clone()).unwrap(); + let attribute = rng.next() % 6; + let mut from = 0; + let runs: Vec<(String, Format)> = slice + .spans() + .iter() + .map(|span| { + let mut format = span.format.clone(); + match attribute { + 0 => format.bold = Some(!format.bold.unwrap_or(false)), + 1 => format.italic = Some(!format.italic.unwrap_or(false)), + 2 => format.underline = Some(!format.underline.unwrap_or(false)), + 3 => format.font_size = Some(if format.font_size == Some(14.0) { 11.0 } else { 14.0 }), + 4 => format.color = Some(0x00ff), + _ => format.font = Some("Consolas".into()), + } + let run = (slice.text()[from..span.end].to_owned(), format); + from = span.end; + run + }) + .collect(); + text.apply(crate::page::text::Edit { + range, + replacement: Paragraph::from_runs(runs), + }) + .is_ok() + } + _ => { + if range.is_empty() + || text.spans().iter().any(|s| s.format.hyperlink == Some(true)) + { + return false; + } + let base = text.format_at(range.start).unwrap().clone(); + let label = text.slice(range.clone()).unwrap(); + let mut code = base.clone(); + code.hyperlink = Some(true); + code.hyperlink_label = Some(true); + code.hidden = Some(true); + let mut from = 0; + let mut runs = vec![( + "\u{fddf}HYPERLINK \"https://example.invalid/op\"".to_owned(), + code, + )]; + for span in label.spans() { + let mut format = span.format.clone(); + format.hyperlink = Some(true); + format.hyperlink_label = Some(true); + runs.push((label.text()[from..span.end].to_owned(), format)); + from = span.end; + } + text.apply(crate::page::text::Edit { + range, + replacement: Paragraph::from_runs(runs), + }) + .is_ok() + } + } + } + Family::Insert | Family::Paste | Family::Style => { + let paths = editable(page); + let Some(path) = paths.get(rng.pick(paths.len()).unwrap_or(0)).cloned() else { + return false; + }; + let style = (family == Family::Style).then(|| { + let existing = page.definitions.iter().find_map(|(id, d)| { + matches!(&d.kind, Kind::Style { name: Some(name) } if name == "Heading 2") + .then_some(*id) + }); + existing.unwrap_or_else(|| { + let id = id(); + page.definitions.insert( + id, + Definition { + kind: Kind::Style { + name: Some("Heading 2".into()), + }, + format: Format { + font: Some("Calibri".into()), + font_size: Some(13.0), + bold: Some(true), + color: Some(0x7d4f1e), + ..Default::default() + }, + }, + ); + id + }) + }); + let list = model::list_at(page, &path); + let template = list + .iter() + .find_map(|p| p.text().map(|t| t.text.format_at(0).unwrap().clone())) + .unwrap_or_default(); + let boundaries: Vec = (0..=list.len()) + .filter(|i| list.get(*i).is_none_or(|p| p.parent.is_none())) + .collect(); + let at = boundaries[rng.pick(boundaries.len()).unwrap()]; + let count = if family == Family::Paste { 2 + rng.next() as usize % 3 } else { 1 }; + let mut inserted = Vec::new(); + for i in 0..count { + let mut paragraph = text_paragraph(&rng.text(), template.clone()); + if let Some(style) = style { + paragraph.style = Some(style); + } + if i > 0 && rng.coin() { + let parent: &PageParagraph = &inserted[0]; + paragraph.parent = Some(parent.id); + paragraph.level = parent.level + 1; + } + inserted.push(paragraph); + } + // Children follow their parent; keep them after the first inserted paragraph. + inserted.sort_by_key(|p| p.parent.is_some()); + let cell_level = list.first().map_or(1, |p| p.level); + for paragraph in &mut inserted { + if paragraph.parent.is_none() { + paragraph.level = cell_level; + } + } + list.splice(at..at, inserted); + true + } + Family::Delete | Family::Move | Family::Collapse => { + let paths = editable(page); + let Some(path) = paths.get(rng.pick(paths.len()).unwrap_or(0)).cloned() else { + return false; + }; + let list = model::list_at(page, &path); + let top: Vec = (0..list.len()).filter(|i| list[*i].parent.is_none()).collect(); + match family { + Family::Delete => { + if top.len() < 2 { + return false; + } + let at = top[rng.pick(top.len()).unwrap()]; + let end = subtree_end(list, at); + list.drain(at..end); + true + } + Family::Move => { + if top.len() < 2 { + return false; + } + let from = top[rng.pick(top.len()).unwrap()]; + let end = subtree_end(list, from); + let block: Vec<_> = list.drain(from..end).collect(); + let boundaries: Vec = (0..=list.len()) + .filter(|i| list.get(*i).is_none_or(|p| p.parent.is_none())) + .collect(); + let to = boundaries[rng.pick(boundaries.len()).unwrap()]; + list.splice(to..to, block); + true + } + _ => { + let at = rng.pick(list.len()).unwrap(); + list[at].collapsed ^= true; + true + } + } + } + Family::Position | Family::Width => { + let mut outlines: Vec<&mut Outline> = outlines(page).into_iter().filter(|o| !o.title).collect(); + let Some(at) = rng.pick(outlines.len()) else { + return false; + }; + let outline = &mut outlines[at]; + if family == Family::Position { + outline.layout.x = Some((rng.next() % 200) as f32 * 1.5); + outline.layout.y = Some((rng.next() % 200) as f32 * 2.25); + } else { + outline.layout.max_width = Some(36.0 + (rng.next() % 200) as f32 * 2.0); + outline.layout.width_set_by_user = Some(rng.coin()); + } + true + } + Family::NewOutline => { + let at = page + .objects + .iter() + .position(|o| matches!(o, PageObject::Title(_))) + .unwrap_or(page.objects.len()); + let at = rng.pick(at + 1).unwrap(); + page.objects.insert( + at, + PageObject::Outline(Outline { + id: id(), + title: false, + min_width: None, + layout: Layout { + x: Some((rng.next() % 100) as f32 * 1.5), + y: Some((rng.next() % 100) as f32 * 2.25), + ..Default::default() + }, + indents: Vec::new(), + paragraphs: vec![text_paragraph(&rng.text(), Format::default())], + unsupported: Vec::new(), + }), + ); + true + } + Family::List | Family::Tag | Family::Alignment => { + let Some((path, index)) = pick_text(page, rng) else { + return false; + }; + match family { + Family::List => { + let paragraph = &model::list_at(page, &path)[index]; + if paragraph.lists.is_empty() { + let list = id(); + page.definitions.insert( + list, + Definition { + kind: Kind::List { + font: Some("Courier New".into()), + format: Some("\u{25cb}".into()), + restart: None, + bullet: Some(4), + }, + format: Format { + font_size: Some(11.0), + color: Some(0xff000000), + ..Default::default() + }, + }, + ); + model::list_at(page, &path)[index].lists = vec![list]; + } else { + model::list_at(page, &path)[index].lists.clear(); + } + } + Family::Tag => { + let definition = page + .definitions + .iter() + .find(|(_, d)| matches!(&d.kind, Kind::TagDefinition { label: Some(l), .. } if l == "Op task")) + .map(|(id, _)| *id) + .unwrap_or_else(|| { + let id = id(); + page.definitions.insert( + id, + Definition { + kind: Kind::TagDefinition { + label: Some("Op task".into()), + action_type: Some(0), + shape: Some(3), + color: None, + highlight: None, + }, + format: Default::default(), + }, + ); + id + }); + let status = (rng.next() % 2) as u16; + let text = model::list_at(page, &path)[index].text_mut().unwrap(); + if text.tags.is_empty() { + text.tags.push(crate::document::Tag { + definition: Some(definition), + action_type: None, + status, + created: Some(1_262_401_445), + completed: (status == 1).then_some(1_262_402_000), + start: None, + due: None, + task_id: None, + extra_set: 0, + }); + } else { + text.tags.clear(); + } + } + _ => { + let alignment = (rng.next() % 3) as u8; + let text = &mut model::list_at(page, &path)[index].text_mut().unwrap().text; + let mut from = 0; + let runs: Vec<(String, Format)> = text + .spans() + .iter() + .map(|span| { + let mut format = span.format.clone(); + format.alignment = Some(alignment); + let run = (text.text()[from..span.end].to_owned(), format); + from = span.end; + run + }) + .collect(); + *text = Paragraph::from_runs(runs); + } + } + true + } + Family::Split | Family::Join => { + let Some((path, index)) = pick_text(page, rng) else { + return false; + }; + let list = model::list_at(page, &path); + if family == Family::Split { + let paragraph = &list[index]; + let text = ¶graph.text().unwrap().text; + let end = text.utf16_offset(text.text().len()).unwrap(); + if text.spans().iter().any(|s| s.format.hyperlink == Some(true)) { + return false; + } + let offsets: Vec = text + .text() + .char_indices() + .map(|(i, _)| text.utf16_offset(i).unwrap()) + .chain([end]) + .collect(); + let at = offsets[rng.pick(offsets.len()).unwrap()]; + let (head, tail) = (text.slice(0..at).unwrap(), text.slice(at..end).unwrap()); + let mut right = paragraph.clone(); + right.id = id(); + right.tags.clear(); + right.media = Default::default(); + right.content = ParagraphContent::Text(TextObject { + id: id(), + date_field: None, + text: tail, + tags: Vec::new(), + }); + let left_id = paragraph.id; + list[index].text_mut().unwrap().text = head; + for child in &mut list[index + 1..] { + if child.parent == Some(left_id) { + child.parent = Some(right.id); + } + } + list.insert(index + 1, right); + true + } else { + let next = index + 1; + let Some(lower) = list.get(next) else { + return false; + }; + let upper = &list[index]; + if lower.parent != upper.parent + || lower.level != upper.level + || list.iter().any(|p| p.parent == Some(upper.id) || p.parent == Some(lower.id)) + { + return false; + } + let Some(lower_text) = lower.text().cloned() else { + return false; + }; + if lower_text.text.text().is_empty() || crate::page::Math::is_equation(&lower_text.text) { + return false; + } + let upper = list[index].text_mut().unwrap(); + if upper.text.text().is_empty() { + upper.id = lower_text.id; + upper.text = lower_text.text; + } else { + upper.text.append(lower_text.text).unwrap(); + } + list.remove(next); + true + } + } + Family::Indent | Family::Outdent => { + let paths = editable(page); + let Some(path) = paths.get(rng.pick(paths.len()).unwrap_or(0)).cloned() else { + return false; + }; + if !format!("{path:?}").contains("cells: []") { + return false; + } + let list = model::list_at(page, &path); + let Some(index) = rng.pick(list.len()) else { + return false; + }; + let end = subtree_end(list, index); + if family == Family::Indent { + // Tab: the paragraph becomes the last child of its previous sibling. + let parent = list[index].parent; + let Some(sibling) = list[..index].iter().rev().find(|p| p.parent == parent).map(|p| (p.id, p.level)) else { + return false; + }; + list[index].parent = Some(sibling.0); + let delta = sibling.1 + 1 - list[index].level; + if delta > 1 || list[index].level + 1 > 31 { + return false; + } + for paragraph in &mut list[index..end] { + paragraph.level += 1; + } + list[index].level = list[index].level.max(sibling.1 + 1); + } else { + let Some(parent) = list[index].parent else { + return false; + }; + // Shift-Tab of a last child: it follows its parent. + if list[end..].iter().any(|p| p.parent == Some(parent)) { + return false; + } + let grandparent = list.iter().find(|p| p.id == parent).unwrap().parent; + list[index].parent = grandparent; + for paragraph in &mut list[index..end] { + paragraph.level -= 1; + } + if list[index].level == 0 { + return false; + } + } + true + } + Family::TableRow + | Family::TableColumn + | Family::TableDelete + | Family::TableWidth + | Family::TableShading => { + let mut tables = tables(page); + let Some(at) = rng.pick(tables.len()) else { + return false; + }; + let table = &mut tables[at]; + let indents = table.rows[0].cells[0].indents.clone(); + match family { + Family::TableRow => { + let row = TableRow { + id: id(), + cells: table.columns.iter().map(|_| new_cell(&indents)).collect(), + }; + let at = rng.pick(table.rows.len() + 1).unwrap(); + table.rows.insert(at, row); + } + Family::TableColumn => { + let at = rng.pick(table.columns.len() + 1).unwrap(); + table.columns.insert(at, TableColumn { width: 72.0, locked: false }); + for row in &mut table.rows { + row.cells.insert(at, new_cell(&indents)); + } + } + Family::TableDelete => { + if rng.coin() && table.rows.len() > 1 { + let at = rng.pick(table.rows.len()).unwrap(); + table.rows.remove(at); + } else if table.columns.len() > 1 { + let at = rng.pick(table.columns.len()).unwrap(); + table.columns.remove(at); + for row in &mut table.rows { + row.cells.remove(at); + } + } else { + return false; + } + } + Family::TableWidth => { + let at = rng.pick(table.columns.len()).unwrap(); + table.columns[at].width = 40.0 + (rng.next() % 100) as f32; + table.columns[at].locked = rng.coin(); + } + _ => { + let cells: Vec<_> = table.rows.iter_mut().flat_map(|r| &mut r.cells).collect(); + let len = cells.len(); + let cell = cells.into_iter().nth(rng.pick(len).unwrap()).unwrap(); + cell.shading = if cell.shading.is_some() { None } else { Some(0x00ccff) }; + } + } + true + } + Family::NewTable | Family::ParagraphPicture | Family::Attachment | Family::Equation => { + let equation = page.objects.iter().find_map(|o| match o { + PageObject::Outline(outline) => outline.paragraphs.iter().find_map(|p| { + p.text().filter(|t| crate::page::Math::is_equation(&t.text)).map(|t| t.text.clone()) + }), + _ => None, + }); + let Some(outline) = outlines(page).into_iter().find(|o| !o.title) else { + return false; + }; + let mut paragraph = text_paragraph("", Format::default()); + paragraph.content = match family { + Family::NewTable => ParagraphContent::Table(Table { + id: id(), + columns: vec![TableColumn { width: 72.0, locked: false }; 2], + rows: (0..2) + .map(|_| TableRow { + id: id(), + cells: (0..2).map(|_| new_cell(&[])).collect(), + }) + .collect(), + borders: Some(true), + layout: Layout::default(), + tags: Vec::new(), + }), + Family::ParagraphPicture => ParagraphContent::Image(Image { + id: id(), + layout: Layout::default(), + size: Some([0.75, 0.75]), + bytes: Some(PNG.into()), + alt: Some("dot".into()), + background: false, + }), + Family::Attachment => ParagraphContent::Attachment(Attachment { + id: id(), + filename: "notes.txt".into(), + source_path: Some(r"C:\notes.txt".into()), + size: None, + bytes: Some(b"attached bytes".as_slice().into()), + preview: None, + recording: None, + }), + _ => { + let Some(equation) = equation else { + return false; + }; + ParagraphContent::Text(TextObject { + id: id(), + date_field: None, + text: equation, + tags: Vec::new(), + }) + } + }; + let boundaries: Vec = (0..=outline.paragraphs.len()) + .filter(|i| outline.paragraphs.get(*i).is_none_or(|p| p.parent.is_none())) + .collect(); + let at = boundaries[rng.pick(boundaries.len()).unwrap()]; + outline.paragraphs.insert(at, paragraph); + true + } + Family::PagePicture | Family::NewInk => { + let at = page + .objects + .iter() + .position(|o| matches!(o, PageObject::Title(_))) + .unwrap_or(page.objects.len()); + let object = if family == Family::PagePicture { + PageObject::Image(Image { + id: id(), + layout: Layout { + x: Some(100.0), + y: Some(200.0), + ..Default::default() + }, + size: Some([0.75, 0.75]), + bytes: Some(PNG.into()), + alt: None, + background: false, + }) + } else { + PageObject::Ink(Ink { + id: id(), + layout: Layout::default(), + strokes: vec![stroke(rng)], + groups: Vec::new(), + }) + }; + page.objects.insert(at, object); + true + } + Family::PictureEdit => { + let images: Vec<&mut Image> = page + .objects + .iter_mut() + .filter_map(|o| match o { + PageObject::Image(image) => Some(image), + _ => None, + }) + .collect(); + let len = images.len(); + let Some(image) = images.into_iter().nth(rng.pick(len).unwrap_or(0)) else { + return false; + }; + match rng.next() % 3 { + 0 => { + image.layout.x = Some((rng.next() % 300) as f32); + image.layout.y = Some((rng.next() % 300) as f32); + } + 1 => { + image.layout.max_width = Some(20.0 + (rng.next() % 100) as f32); + image.layout.max_height = Some(20.0 + (rng.next() % 100) as f32); + image.layout.width_set_by_user = Some(true); + } + _ => image.alt = Some(rng.text()), + } + true + } + Family::AttachmentRename => { + let mut found = false; + for outline in outlines(page) { + for paragraph in &mut outline.paragraphs { + if let ParagraphContent::Attachment(attachment) = &mut paragraph.content + && !found + && attachment.recording.is_none() + { + attachment.filename = format!("renamed {}.txt", rng.next() % 100); + found = true; + } + } + } + found + } + Family::InkStrokes => { + let inks: Vec<&mut Ink> = page + .objects + .iter_mut() + .filter_map(|o| match o { + PageObject::Ink(ink) if ink.groups.is_empty() => Some(ink), + _ => None, + }) + .collect(); + let len = inks.len(); + let Some(ink) = inks.into_iter().nth(rng.pick(len).unwrap_or(0)) else { + return false; + }; + if rng.coin() || ink.strokes.len() < 2 { + ink.strokes.push(stroke(rng)); + } else { + let at = rng.pick(ink.strokes.len()).unwrap(); + ink.strokes.remove(at); + } + true + } + } +} + +fn stroke(rng: &mut Rng) -> InkStroke { + let x = (rng.next() % 300) as f32; + InkStroke { + id: id(), + points: vec![[x, 100.0], [x + 10.0, 110.0], [x + 20.0, 105.0]], + width: 1.5, + height: 1.5, + color: Some(0x0000ff), + transparency: None, + pen_tip: None, + } +} + +/// How one differential case came out. +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] +pub(super) enum Outcome { + /// The edit made no change the writers store. + Unchanged, + /// Both refused the edit. + Refused, + /// The page writer refused; the ops were written. + OnlyOps, + /// Identical bytes. + Bytes, + /// Equal pages, both images valid, the op revision no larger. + Model, + /// The op revision declares more than the page writer's. + Larger, + Mismatch(String), +} + +/// Objects the space's active revision declares in `image`. +pub(super) fn declared(image: &[u8], space: ExGuid) -> usize { + let store = Store::parse(image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let rid = index.active(space).unwrap(); + index.spaces[&space].revisions[&rid] + .nodes + .iter() + .filter_map(|node| match node.reference { + Some(crate::Reference::NodeList(chunk)) if node.id == 0xb0 => Some(chunk), + _ => None, + }) + .map(|chunk| { + store + .lists + .values() + .find(|list| list.fragments.first().is_some_and(|f| f.offset == chunk.offset)) + .map_or(0, |list| { + list.nodes + .iter() + .filter(|n| { + matches!(n.id, 0x2d | 0x2e | 0x41 | 0x42 | 0xa4 | 0xa5 | 0xc4 | 0xc5 | 0x72 | 0x73) + }) + .count() + }) + }) + .sum() +} + +/// Writes `after` over page `space` of `source` both ways and compares the images. +pub(super) fn differential(source: &[u8], space: ExGuid, after: &Page, seed: u64) -> Outcome { + let before = read(source, space); + let oracle = seeded(seed, || { + crate::create::at(AT, || crate::PreparedEdit::page(source, space, after, "Author")) + }); + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).unwrap(); + let ops = lower_page(&before, after); + let written = ops.as_ref().ok().map(|ops| { + seeded(seed, || { + let edit = Edit { + at: AT, + ops: ops + .iter() + .map(|op| Op::Page { + space, + op: op.clone(), + }) + .collect(), + }; + section.apply("Author", &edit).map(|()| { + let predicted = { + let mut page = before.clone(); + for op in ops { + model::apply(&mut page, op).unwrap(); + } + page + }; + (section.seal().unwrap(), predicted) + }) + }) + }); + let image = section.image(); + match (oracle, written) { + (Err(_), None | Some(Err(_))) => Outcome::Refused, + (Err(_), Some(Ok(_))) => Outcome::OnlyOps, + (Ok(_), None) => Outcome::Mismatch(format!("lowering refused: {:?}", ops.unwrap_err())), + (Ok(_), Some(Err(error))) => Outcome::Mismatch(format!("apply refused: {error:?} for {:?}", ops.unwrap())), + (Ok(edit), Some(Ok((transaction, predicted)))) => { + let stored = normalize(&read(&image, space)); + if normalize(&predicted) != stored { + return Outcome::Mismatch(format!( + "the model predicts otherwise: {}", + first_difference(&stored, &normalize(&predicted)) + )); + } + let expected = edit.as_bytes(); + if expected == source && transaction.is_none() { + return Outcome::Unchanged; + } + if expected == image.as_slice() { + return Outcome::Bytes; + } + let store = Store::parse(&image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + if let Err(error) = index.validate_current() { + return Outcome::Mismatch(format!("invalid image: {error:?}")); + } + let (ours, theirs) = (normalize(&read(&image, space)), normalize(&read(expected, space))); + if ours != theirs { + return Outcome::Mismatch(first_difference(&theirs, &ours)); + } + if expected == source { + return Outcome::Model; + } + if declared(&image, space) > declared(expected, space) { + return Outcome::Larger; + } + Outcome::Model + } + } +} + +fn spans(text: &Paragraph) -> String { + let mut out = format!("{:?}:", text.text()); + for span in text.spans() { + let f = &span.format; + out.push_str(&format!( + " [{} b{:?} h{:?} l{:?}/{:?} c{:?} a{:?} s{:?} lang{:?} f{:?}]", + span.end, f.bold, f.hidden, f.hyperlink, f.hyperlink_label, f.color, f.alignment, f.space_before, f.language, f.font + )); + } + out +} + +pub(super) fn first_difference(expected: &Page, actual: &Page) -> String { + let texts = |page: &Page| -> Vec<(ExGuid, Paragraph)> { + model::lists(page) + .into_iter() + .flat_map(|(_, _, list)| list.iter()) + .filter_map(|p| p.text().map(|t| (p.id, t.text.clone()))) + .collect() + }; + for ((a_id, a), (b_id, b)) in texts(expected).iter().zip(texts(actual).iter()) { + if a_id == b_id && a != b { + return format!("\n expected {}\n actual {}", spans(a), spans(b)); + } + } + let (a, b) = (format!("{expected:?}"), format!("{actual:?}")); + let at = a + .bytes() + .zip(b.bytes()) + .position(|(x, y)| x != y) + .unwrap_or(a.len().min(b.len())); + let window = |s: &str| { + let start = s.floor_char_boundary(at.saturating_sub(300)); + let end = s.floor_char_boundary((at + 200).min(s.len())); + s[start..end].to_owned() + }; + format!("\n expected …{}\n actual …{}", window(&a), window(&b)) +} + +/// Every family on every corpus page it applies to, several seeds each. +#[test] +fn ops_store_what_the_page_writer_stores() { + let mut tally: BTreeMap<(Family, String), usize> = BTreeMap::new(); + let mut mismatches = Vec::new(); + let seeds = std::env::var("OP_SEEDS").map_or(3, |s| s.parse().unwrap()); + for (name, source) in SOURCES { + for (p, space) in pages(source).into_iter().enumerate().take(4) { + for family in FAMILIES { + for seed in 0..seeds { + let mut rng = Rng(seed * 7919 + p as u64 * 131 + family as u64); + let mut after = read(source, space); + GUIDS.set(Some(1 << 60 | seed << 32 | (family as u64) << 20)); + if !mutate(&mut after, &mut rng, family) { + continue; + } + GUIDS.set(None); + let outcome = differential(source, space, &after, 1 << 40 | seed); + let key = match &outcome { + Outcome::Mismatch(_) => "Mismatch".to_owned(), + other => format!("{other:?}"), + }; + *tally.entry((family, key)).or_default() += 1; + if let Outcome::Mismatch(detail) = outcome { + mismatches.push(format!("{name} page {p} {family:?} seed {seed}: {detail}")); + } + } + } + } + } + let mut report = String::new(); + for ((family, outcome), count) in &tally { + report.push_str(&format!("{family:?} {outcome} {count}\n")); + } + println!("{report}"); + for mismatch in mismatches.iter().take(8) { + println!("{mismatch}\n"); + } + assert!(mismatches.is_empty(), "{} mismatches", mismatches.len()); + assert!(!tally.keys().any(|(_, outcome)| outcome == "Larger")); +} + +#[test] +#[ignore] +fn debug_case() { + let (_, source) = SOURCES[5]; + let space = pages(source)[1]; + let family = Family::NewOutline; + let (seed, p) = (1u64, 1u64); + let mut rng = Rng(seed * 7919 + p * 131 + family as u64); + let mut after = read(source, space); + GUIDS.set(Some(1 << 60 | seed << 32 | (family as u64) << 20)); + assert!(mutate(&mut after, &mut rng, family)); + GUIDS.set(None); + let before = read(source, space); + let ops = lower_page(&before, &after).unwrap(); + let mut page = before.clone(); + for op in &ops { + model::apply(&mut page, op).unwrap(); + } + for object in &page.objects { + println!("{:?} {:?} {:?}", object.id(), object.layout(), match object { PageObject::Outline(o) => o.paragraphs.iter().filter_map(|p| p.text().map(|t| t.text.text().to_owned())).collect::>(), PageObject::Title(t) => t.outlines.iter().flat_map(|o| &o.paragraphs).filter_map(|p| p.text().map(|t| format!("title {:?}", t.text.text()))).collect(), _ => vec![] }); + } + println!("{:?} -> {:?}", before.title, page.title); +} + +/// Random ops drawn from a page model: targets the page holds, new identities fresh. +fn random_op(page: &Page, rng: &mut Rng) -> Option { + let texts: Vec<(ExGuid, ExGuid, Paragraph)> = model::lists(page) + .into_iter() + .flat_map(|(_, _, list)| list.iter()) + .filter_map(|p| { + let text = p.text()?; + (text.date_field.is_none() && !crate::page::Math::is_equation(&text.text)) + .then(|| (p.id, text.id, text.text.clone())) + }) + .collect(); + let (paragraph, text, content) = texts.get(rng.pick(texts.len())?)?.clone(); + let length = content.utf16_offset(content.text().len()).unwrap(); + let offsets: Vec = content + .text() + .char_indices() + .map(|(i, _)| content.utf16_offset(i).unwrap()) + .chain([length]) + .collect(); + let a = offsets[rng.pick(offsets.len()).unwrap()]; + let b = offsets[rng.pick(offsets.len()).unwrap()]; + let range = a.min(b)..a.max(b); + let holder = model::paragraph(page, paragraph).unwrap(); + let container = holder.parent.or_else(|| { + model::lists(page) + .into_iter() + .find(|(_, _, list)| list.iter().any(|p| p.id == paragraph)) + .map(|(_, owner, _)| owner) + })?; + let siblings: Vec = model::lists(page) + .into_iter() + .flat_map(|(_, _, list)| list.iter()) + .filter(|p| p.parent == holder.parent && p.id != paragraph) + .map(|p| p.id) + .collect(); + if rng.next().is_multiple_of(4) + && let Some(op) = random_object_op(page, rng) + { + return Some(op); + } + Some(match rng.next() % 16 { + 0 | 1 => PageOp::Text { + text, + range, + with: rng.text(), + }, + 2 => PageOp::Format { + text, + range, + set: vec![TextAttribute::Bold(rng.coin())], + clear: if rng.next().is_multiple_of(3) { vec![TextProperty::Italic] } else { Vec::new() }, + }, + 3 => PageOp::Split { + text, + at: a, + paragraph: id(), + right: id(), + lists: holder.lists.iter().map(|_| id()).collect(), + }, + 4 => { + let mut new = text_paragraph(&rng.text(), content.format_at(0).unwrap().clone()); + new.level = holder.level; + new.parent = holder.parent; + PageOp::Insert { + container, + before: (rng.coin()).then_some(paragraph), + paragraphs: vec![new], + } + } + 5 => PageOp::Delete { object: paragraph }, + 6 => PageOp::Move { + object: paragraph, + parent: Some(container), + before: siblings.get(rng.pick(siblings.len().max(1)).unwrap_or(0)).copied(), + }, + 7 => PageOp::Level { + paragraph, + level: (holder.level + 1).clamp(1, 4) - (rng.next() % 2) as u32, + }, + 8 => PageOp::Outline { + object: paragraph, + edit: crate::OutlineEdit::Collapsed(rng.coin()), + }, + 9 => PageOp::Paragraph { + paragraph, + alignment: Some((rng.next() % 3) as u8), + rtl: None, + space_before: Some(4.5), + space_after: None, + line_spacing: None, + language: None, + }, + 10 => { + let right = texts.iter().skip_while(|(p, _, _)| *p != paragraph).nth(1)?; + PageOp::Join { + left: text, + right: right.1, + } + } + 11 => PageOp::List { + paragraph, + lists: vec![( + id(), + Definition { + kind: Kind::List { + font: Some("Calibri".into()), + format: Some("\u{fffd}1.".into()), + restart: None, + bullet: None, + }, + format: Format::default(), + }, + )], + }, + 12 => { + let definition = id(); + PageOp::Tags { + target: if rng.coin() { paragraph } else { text }, + tags: vec![crate::document::Tag { + definition: Some(definition), + action_type: None, + status: 0, + created: Some(1_262_401_445), + completed: None, + start: None, + due: None, + task_id: None, + extra_set: 0, + }], + definitions: vec![( + definition, + Definition { + kind: Kind::TagDefinition { + label: Some("Random".into()), + action_type: Some(0), + shape: Some(3), + color: None, + highlight: None, + }, + format: Format::default(), + }, + )], + } + } + 13 => PageOp::Link { + text, + range, + target: rng.coin().then(|| "https://example.invalid/random".into()), + }, + 14 => PageOp::Add { + object: PageObject::Outline(Outline { + id: id(), + title: false, + min_width: None, + layout: Layout { + x: Some(36.0), + y: Some(300.0 + (rng.next() % 100) as f32), + ..Default::default() + }, + indents: Vec::new(), + paragraphs: vec![text_paragraph(&rng.text(), Format::default())], + unsupported: Vec::new(), + }), + before: None, + }, + _ => PageOp::Add { + object: PageObject::Ink(Ink { + id: id(), + layout: Layout::default(), + strokes: vec![stroke(rng)], + groups: Vec::new(), + }), + before: None, + }, + }) +} + +/// Random edits of tables, pictures, ink, outlines and styles the page holds. +fn random_object_op(page: &Page, rng: &mut Rng) -> Option { + let tables: Vec = model::lists(page) + .into_iter() + .flat_map(|(_, _, list)| list.iter()) + .filter_map(|p| match &p.content { + ParagraphContent::Table(table) => Some(table.clone()), + _ => None, + }) + .collect(); + let outlines: Vec<&Outline> = page + .objects + .iter() + .filter_map(|o| match o { + PageObject::Outline(outline) if !outline.title => Some(outline), + _ => None, + }) + .collect(); + let pictures: Vec<&Image> = page + .objects + .iter() + .filter_map(|o| match o { + PageObject::Image(image) => Some(image), + _ => None, + }) + .collect(); + let inks: Vec<&Ink> = page + .objects + .iter() + .filter_map(|o| match o { + PageObject::Ink(ink) if ink.groups.is_empty() => Some(ink), + _ => None, + }) + .collect(); + match rng.next() % 6 { + 0 | 1 => { + let table = tables.get(rng.pick(tables.len())?)?; + let indents = table.rows[0].cells[0].indents.clone(); + let cell = || TableCell { + paragraphs: vec![text_paragraph("cell", Format::default())], + ..new_cell(&indents) + }; + let edit = match rng.next() % 7 { + 0 => TableEdit::Rows { + before: table.rows.get(rng.pick(table.rows.len() + 1)?).map(|r| r.id), + rows: vec![TableRow { + id: id(), + cells: table.columns.iter().map(|_| cell()).collect(), + }], + }, + 1 => TableEdit::Column { + at: rng.pick(table.columns.len() + 1)? as u32, + width: 60.0, + cells: table.rows.iter().map(|_| cell()).collect(), + }, + 2 => TableEdit::DeleteRow(table.rows[rng.pick(table.rows.len())?].id), + 3 => TableEdit::DeleteColumn(rng.pick(table.columns.len())? as u32), + 4 => TableEdit::Columns( + table + .columns + .iter() + .map(|c| TableColumn { + width: c.width + 9.0, + locked: !c.locked, + }) + .collect(), + ), + 5 => TableEdit::Borders(rng.coin()), + _ => { + let cells: Vec<&TableCell> = table.rows.iter().flat_map(|r| &r.cells).collect(); + let cell = cells[rng.pick(cells.len())?]; + TableEdit::Cell { + cell: cell.id, + shading: Some(0x00ccff), + indents: vec![18.0, 0.0, 27.0, 27.0], + } + } + }; + Some(PageOp::Table { + table: table.id, + edit, + }) + } + 2 => { + let outline = outlines.get(rng.pick(outlines.len())?)?; + Some(PageOp::Outline { + object: outline.id, + edit: if rng.coin() { + crate::OutlineEdit::Position { + x: (rng.next() % 300) as f32, + y: (rng.next() % 300) as f32, + } + } else { + crate::OutlineEdit::Width { + points: 100.0 + (rng.next() % 300) as f32, + user_set: rng.coin(), + } + }, + }) + } + 3 => { + let picture = pictures.get(rng.pick(pictures.len())?)?; + Some(PageOp::Picture { + picture: picture.id, + layout: Layout { + x: Some((rng.next() % 300) as f32), + y: Some((rng.next() % 300) as f32), + max_width: Some(40.0), + max_height: Some(30.0), + width_set_by_user: Some(true), + reserved_width: picture.layout.reserved_width, + }, + alt: Some(rng.text()), + }) + } + 4 => { + let ink = inks.get(rng.pick(inks.len())?)?; + let remove = if ink.strokes.len() > 1 && rng.coin() { + vec![ink.strokes[rng.pick(ink.strokes.len())?].id] + } else { + Vec::new() + }; + Some(PageOp::Strokes { + ink: ink.id, + add: vec![stroke(rng)], + remove, + }) + } + _ => { + let paragraphs: Vec<&PageParagraph> = model::lists(page) + .into_iter() + .flat_map(|(_, _, list)| list.iter()) + .filter(|p| p.text().is_some()) + .collect(); + let paragraph = paragraphs.get(rng.pick(paragraphs.len())?)?; + Some(PageOp::Style { + paragraph: paragraph.id, + style: id(), + definition: Definition { + kind: Kind::Style { + name: Some("Heading 3".into()), + }, + format: Format { + bold: Some(true), + font_size: Some(12.0), + color: Some(0x7d4f1e), + ..Default::default() + }, + }, + }) + } + } +} + +/// Random edits through a section, sealed now and then: each applied edit reads back as the +/// model predicts, a refused one changes nothing, and reopening every sealed image gives +/// the same pages. +#[test] +fn random_ops_read_back_as_the_model_predicts() { + let steps = std::env::var("OP_STEPS").map_or(60, |s| s.parse().unwrap()); + let (mut applied, mut refused, mut unseen) = (0, 0, 0); + let mut mismatches: Vec = Vec::new(); + for (name, source) in SOURCES { + let spaces = pages(source); + for seed in 0..2u64 { + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).unwrap(); + let mut rng = Rng(seed * 104_729 + name.len() as u64); + for step in 0..steps { + let space = spaces[step % spaces.len().min(3)]; + let before = section.page(space).unwrap(); + let ops: Vec = (0..1 + rng.next() % 2) + .filter_map(|_| random_op(&before, &mut rng)) + .map(|op| Op::Page { space, op }) + .collect(); + if ops.is_empty() { + continue; + } + let edit = Edit { at: AT + step as u64, ops }; + // Text whose emptied final run keeps an insertion style the model cannot show. + let hidden: Vec = section + .active(space) + .unwrap() + .view + .nodes + .iter() + .filter(|(_, node)| { + matches!(&node.kind, Kind::RichText { text, runs, .. } + if !text.is_empty() && runs.len() > 1 && runs.last().is_some_and(|r| r.start == r.end)) + }) + .map(|(id, _)| *id) + .collect(); + match section.apply("Author", &edit) { + Ok(()) => { + applied += 1; + let mut predicted = before.clone(); + for op in &edit.ops { + let Op::Page { op, .. } = op else { unreachable!() }; + model::apply(&mut predicted, op).unwrap(); + } + let stored = section.page(space).unwrap(); + let touches_hidden = edit.ops.iter().any(|op| { + matches!(op, Op::Page { op: PageOp::Text { text, .. } | PageOp::Link { text, .. } | PageOp::Split { text, .. }, .. } if hidden.contains(text)) + }); + // A restyled run may state what its old style did, which the model + // cannot tell from inheriting it. + let restyles = edit.ops.iter().any(|op| { + matches!(op, Op::Page { op: PageOp::Style { paragraph, .. }, .. } + if model::paragraph(&before, *paragraph).is_some_and(|p| p.style.is_some())) + }); + if (touches_hidden || restyles) && normalize(&predicted) != normalize(&stored) { + unseen += 1; + continue; + } + if normalize(&predicted) != normalize(&stored) { + let kinds: Vec = edit + .ops + .iter() + .map(|op| match op { + Op::Page { op, .. } => format!("{op:?}").split([' ', '{']).next().unwrap().to_owned(), + Op::Section(_) => "Section".into(), + }) + .collect(); + if std::env::var("OP_DEBUG").is_ok() { + let tree = |page: &Page| -> String { + model::lists(page) + .into_iter() + .map(|(_, owner, list)| { + format!( + "{}: {}", + owner.n, + list.iter() + .map(|p| format!("{}<{:?}>@{}", p.id.n, p.parent.map(|p| p.n), p.level)) + .collect::>() + .join(" ") + ) + }) + .collect::>() + .join("\n") + }; + println!("ops {:?}\nbefore\n{}\nstored\n{}\npredicted\n{}", edit.ops, tree(&before), tree(&stored), tree(&predicted)); + } + if mismatches.len() < 6 { + println!( + "{name} seed {seed} step {step} {kinds:?}: {}", + first_difference(&normalize(&stored), &normalize(&predicted)) + ); + } + mismatches.push(kinds.join("+")); + } + } + Err(OpError::Failed(error)) => panic!("{name} step {step}: {error:?}"), + Err(_) => { + refused += 1; + assert!(section.page(space).unwrap() == before, "{name} step {step}: a refused edit changed the page"); + } + } + if step % 7 == 6 || step + 1 == steps { + section.seal().unwrap(); + let image = section.image(); + let reopened = Section::open(&arena, image).unwrap(); + for space in &spaces { + assert!(reopened.page(*space).unwrap() == section.page(*space).unwrap(), "{name} step {step}: reopening changed a page"); + } + } + } + } + } + println!("{applied} edits applied, {refused} refused, {unseen} restyled or typed into a hidden insertion style"); + let mut kinds = BTreeMap::new(); + for kind in &mismatches { + *kinds.entry(kind.clone()).or_insert(0) += 1; + } + println!("model mispredictions: {kinds:?}"); + assert!(mismatches.is_empty()); + assert!(applied > refused); +} + +/// Page creation, moves, indentation and removal through a section store what the image +/// writers store, and the section lists the pages the document does. +#[test] +fn section_ops_store_what_the_image_writers_store() { + let sources: Vec> = vec![ + crate::create_section("pages.one", "First", "Author").unwrap(), + include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one").to_vec(), + SOURCES[3].1.to_vec(), + ]; + let (mut bytes, mut total) = (0, 0); + for (n, source) in sources.iter().enumerate() { + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).unwrap(); + let mut image = source.clone(); + let mut rng = Rng(n as u64 + 17); + for step in 0..12u64 { + let spaces = pages(&image); + let op = match step % 4 { + 0 => SectionOp::Create( + seeded(1 << 50 | step << 20, || { + crate::PageCreation::new( + spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied().filter(|_| rng.coin()), + Some("Created"), + "Author", + ) + }) + .unwrap(), + ), + 1 => { + let Some(space) = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied() else { continue }; + let level = if spaces[0] == space { 1 } else { 1 + (rng.next() % 2) as u32 }; + SectionOp::Pages(vec![seeded(1 << 51 | step << 20, || crate::PageEdit::set_level(space, level)).unwrap()]) + } + 2 => { + let Some(space) = spaces.get(rng.pick(spaces.len()).unwrap_or(0)).copied() else { continue }; + SectionOp::Pages(vec![seeded(1 << 52 | step << 20, || crate::PageEdit::move_to(space, None, 1)).unwrap()]) + } + _ => { + if spaces.len() < 2 { + continue; + } + SectionOp::Delete(vec![spaces[rng.pick(spaces.len()).unwrap()]]) + } + }; + let expected = seeded(1 << 40 | step << 16, || { + crate::create::at(AT, || match &op { + SectionOp::Create(creation) => crate::PreparedEdit::create_page(&image, creation), + SectionOp::Pages(edits) => crate::PreparedEdit::pages(&image, edits), + SectionOp::Delete(spaces) => crate::PreparedEdit::delete_pages_permanently(&image, spaces), + SectionOp::Import { .. } => unreachable!(), + }) + .map(|edit| edit.as_bytes().to_vec()) + }); + let applied = seeded(1 << 40 | step << 16, || { + section + .apply("Author", &Edit { at: AT, ops: vec![Op::Section(op.clone())] }) + .map(|()| section.seal().unwrap()) + }); + match (expected, applied) { + (Err(_), Err(_)) => continue, + (Ok(expected), Ok(_)) => { + total += 1; + let written = section.image(); + if written == expected { + bytes += 1; + } else { + let store = Store::parse(&written).unwrap(); + RevisionIndex::parse(&store).unwrap().validate_current().unwrap(); + let spaces = pages(&expected); + assert_eq!(pages(&written), spaces, "{n} step {step} {op:?}"); + for space in spaces { + assert_eq!(read(&written, space), read(&expected, space), "{n} step {step}"); + } + } + image = expected; + let store = Store::parse(&image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let listed: Vec<(ExGuid, String, u32)> = document + .pages() + .unwrap() + .into_iter() + .map(|(space, page)| { + let (title, level) = Page::heading(document.active(space).unwrap(), page); + (space, title, level) + }) + .collect(); + assert_eq!(section.pages().unwrap(), listed, "{n} step {step}"); + } + (expected, applied) => panic!("{n} step {step} {op:?}: {:?} vs {:?}", expected.err(), applied.err()), + } + } + } + println!("{bytes} of {total} section edits byte-identical"); + assert!(total > 12); +} + +/// Importing a page stores what creating it and writing its copy through the page writer +/// does. +#[test] +fn an_imported_page_reads_as_the_page_writer_leaves_it() { + let target = crate::create_section("import.one", "First", "Author").unwrap(); + let mut imported = 0; + for (name, source) in SOURCES { + for space in pages(source).into_iter().take(2) { + let Ok(copy) = read(source, space).copy() else { + continue; + }; + let creation = crate::PageCreation::new(None, Some(©.title), "Author").unwrap(); + let created = crate::PreparedEdit::create_page(&target, &creation).unwrap(); + let created = created.as_bytes(); + let mut after = read(created, creation.space()); + after.objects.retain(|object| matches!(object, PageObject::Title(_))); + after.objects.extend( + copy.objects + .iter() + .filter(|object| !matches!(object, PageObject::Title(_))) + .cloned(), + ); + after.definitions = copy.definitions.clone(); + let expected = crate::create::at(AT, || { + crate::PreparedEdit::page(created, creation.space(), &after, "Author") + }); + let arena = Arena::default(); + let mut section = Section::open(&arena, target.clone()).unwrap(); + let applied = section.apply( + "Author", + &Edit { + at: AT, + ops: vec![Op::Section(SectionOp::Import { + creation: creation.clone(), + page: copy.clone(), + })], + }, + ); + match (expected, applied) { + (Err(_), Err(_)) => continue, + (Ok(expected), Ok(())) => { + section.seal().unwrap(); + let image = section.image(); + let store = Store::parse(&image).unwrap(); + RevisionIndex::parse(&store).unwrap().validate_current().unwrap(); + let (ours, theirs) = ( + normalize(&read(&image, creation.space())), + normalize(&read(expected.as_bytes(), creation.space())), + ); + assert!(ours == theirs, "{name}: {}", first_difference(&theirs, &ours)); + imported += 1; + } + (expected, applied) => panic!("{name}: {:?} vs {:?}", expected.err(), applied.err()), + } + } + } + assert!(imported > 10, "{imported} pages imported"); +} + +/// The ops for the changed paragraphs of one outline, lowered from them alone. +fn lower_range(before: &Page, after: &Page) -> Option, crate::Error>> { + if before.objects.len() != after.objects.len() { + return None; + } + let mut changed = None; + for (a, b) in before.objects.iter().zip(&after.objects) { + match (a, b) { + (PageObject::Outline(a), PageObject::Outline(b)) if a.id == b.id => { + if a.paragraphs != b.paragraphs { + if changed.is_some() || (Outline { paragraphs: Vec::new(), ..a.clone() }) != (Outline { paragraphs: Vec::new(), ..b.clone() }) { + return None; + } + changed = Some((a, b)); + } + } + (a, b) if a == b => {} + _ => return None, + } + } + let (a, b) = changed?; + let (a, b) = (&a.paragraphs, &b.paragraphs); + let prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count(); + let suffix = a[prefix..] + .iter() + .rev() + .zip(b[prefix..].iter().rev()) + .take_while(|(x, y)| x == y) + .count(); + let (mut end_a, mut end_b) = (a.len() - suffix, b.len() - suffix); + // A range holds the children of what it changes. + while end_a < a.len() { + let ids: Vec = a[prefix..end_a].iter().chain(&b[prefix..end_b]).map(|p| p.id).collect(); + if a[end_a].parent.is_some_and(|parent| ids.contains(&parent)) { + end_a += 1; + end_b += 1; + } else { + break; + } + } + let outline = match before.objects.iter().find(|o| matches!(o, PageObject::Outline(o) if o.paragraphs == *a)) { + Some(PageObject::Outline(outline)) => outline.id, + _ => return None, + }; + let mut definitions = before.definitions.clone(); + definitions.extend(after.definitions.clone()); + Some(lower(outline, &a[prefix..end_a], &b[prefix..end_b], b.get(end_b), &definitions)) +} + +/// Lowering the changed range of an outline stores what lowering the whole page does. +#[test] +fn a_range_lowers_as_its_page_does() { + let families = [ + Family::Text, Family::Format, Family::Insert, Family::Paste, Family::Delete, Family::Move, + Family::Collapse, Family::List, Family::Tag, Family::Split, Family::Join, Family::Indent, + Family::Outdent, Family::Alignment, Family::Style, Family::Link, Family::NewTable, + Family::ParagraphPicture, Family::Attachment, Family::Equation, + ]; + let (mut same, mut total, mut smaller) = (0, 0, 0); + let seeds = std::env::var("OP_SEEDS").map_or(3, |s| s.parse().unwrap()); + for (name, source) in SOURCES { + for (p, space) in pages(source).into_iter().enumerate().take(4) { + for family in families { + for seed in 0..seeds { + let mut rng = Rng(seed * 7919 + p as u64 * 131 + family as u64); + let before = read(source, space); + let mut after = before.clone(); + if !mutate(&mut after, &mut rng, family) { + continue; + } + let Some(ranged) = lower_range(&before, &after) else { + continue; + }; + let whole = lower_page(&before, &after); + let (Ok(ranged), Ok(whole)) = (ranged, whole) else { + continue; + }; + total += 1; + let write = |ops: &[PageOp]| { + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).unwrap(); + let edit = Edit { + at: AT, + ops: ops.iter().map(|op| Op::Page { space, op: op.clone() }).collect(), + }; + section.apply("Author", &edit).map(|()| normalize(§ion.page(space).unwrap())) + }; + let (ours, theirs) = (write(&ranged), write(&whole)); + match (ours, theirs) { + (Ok(ours), Ok(theirs)) => { + assert!(ours == theirs, "{name} page {p} {family:?} seed {seed}: {}", first_difference(&theirs, &ours)); + same += 1; + } + (Err(_), Err(_)) => same += 1, + (ours, theirs) => panic!("{name} page {p} {family:?} seed {seed}: {ours:?} vs {theirs:?}"), + } + let length = |ops: &[PageOp]| format!("{ops:?}").len(); + smaller += usize::from(length(&ranged) <= length(&whole)); + } + } + } + } + println!("{same} of {total} ranges store what their pages do; {smaller} lowered no larger"); + assert_eq!(same, total); + assert!(total > 100); +} + +/// One keystroke as ops on the 3000-paragraph probe page: +/// `SECTION_PROBE=path cargo test --release -p onestore --lib keystroke_ops -- --ignored --nocapture`. +#[test] +#[ignore] +fn keystroke_ops() { + use std::time::{Duration, Instant}; + fn median(mut samples: Vec) -> Duration { + samples.sort(); + samples[samples.len() / 2] + } + let path = std::env::var("SECTION_PROBE").unwrap_or("/tmp/probe3000.one".into()); + let image = std::fs::read(&path).unwrap(); + let space = pages(&image) + .into_iter() + .max_by_key(|space| read(&image, *space).objects.len() + format!("{:?}", read(&image, *space)).len()) + .unwrap(); + let page = read(&image, space); + let outline = page + .objects + .iter() + .filter_map(|o| match o { + PageObject::Outline(outline) => Some(outline), + _ => None, + }) + .max_by_key(|o| o.paragraphs.len()) + .unwrap(); + let middle = outline.paragraphs.len() / 2; + let paragraph = &outline.paragraphs[middle]; + let text = paragraph.text().unwrap().id; + println!("{path}: {} bytes, {} paragraphs in the outline", image.len(), outline.paragraphs.len()); + + // Emitting: a keystroke is one op; a generic range replacement lowers its paragraphs. + let mut edited = paragraph.clone(); + let format = edited.text().unwrap().text.format_at(0).unwrap().clone(); + edited + .text_mut() + .unwrap() + .text + .apply(crate::page::text::Edit { + range: 0..0, + replacement: Paragraph::new("x".into(), format), + }) + .unwrap(); + let mut samples = Vec::new(); + for _ in 0..200 { + let start = Instant::now(); + let ops = lower( + outline.id, + std::slice::from_ref(paragraph), + std::slice::from_ref(&edited), + outline.paragraphs.get(middle + 1), + &page.definitions, + ) + .unwrap(); + samples.push(start.elapsed()); + assert_eq!(ops.len(), 1); + } + println!("lower one paragraph: {:?}", median(samples)); + + let arena = Arena::default(); + let start = Instant::now(); + let mut section = Section::open(&arena, image.clone()).unwrap(); + println!("Section::open: {:?}", start.elapsed()); + let start = Instant::now(); + section.active(space).unwrap(); + println!("first edit opens the page: {:?}", start.elapsed()); + // Five keystrokes a second, each sealed: the outline's modification time ticks once a + // second, which re-declares its child list. + let (mut applies, mut seals, mut sizes) = (Vec::new(), Vec::new(), Vec::new()); + for keystroke in 0..300u32 { + let edit = Edit { + at: AT + u64::from(keystroke) * 2_000_000, + ops: vec![Op::Page { + space, + op: PageOp::Text { + text, + range: keystroke..keystroke, + with: "x".into(), + }, + }], + }; + let start = Instant::now(); + section.apply("Probe", &edit).unwrap(); + applies.push(start.elapsed()); + let start = Instant::now(); + let transaction = section.seal().unwrap().unwrap(); + seals.push(start.elapsed()); + sizes.push(( + transaction.append.len(), + transaction.patches.iter().map(|(_, bytes)| bytes.len()).sum::(), + )); + } + sizes.sort(); + println!( + "Section::apply(Text) {:?}, seal {:?}; appended {} bytes (max {}), patched {} bytes (median of 300)", + median(applies), + median(seals), + sizes[sizes.len() / 2].0, + sizes.last().unwrap().0, + sizes[sizes.len() / 2].1, + ); + let start = Instant::now(); + let reopened = Section::open(&arena, section.image()).unwrap(); + println!("reopen after 300 seals: {:?}", start.elapsed()); + assert_eq!(reopened.page(space).unwrap(), section.page(space).unwrap()); +} + +/// Writes a notebook for the native cold-open gate of the op path to `OP_GATE_EXPORT`: +/// corpus pages imported as ops, then edited by lowered page edits of every family, new +/// pages, an outline move on a page without a margin origin, page moves and a removal. +#[test] +#[ignore] +fn op_gate_candidate() { + let directory = std::path::PathBuf::from(std::env::var("OP_GATE_EXPORT").unwrap()); + std::fs::create_dir_all(&directory).unwrap(); + let source = crate::create_section("ops.one", "Ops gate", "Author").unwrap(); + let arena = Arena::default(); + let mut section = Section::open(&arena, source).unwrap(); + let at = std::cell::Cell::new(AT); + let next = || { + at.set(at.get() + 20_000_000); + at.get() + }; + let apply = |section: &mut Section<'_>, op: Op| { + let result = section.apply("Gate author", &Edit { at: next(), ops: vec![op] }); + section.seal().unwrap(); + result + }; + let mut imported = Vec::new(); + for (name, bytes) in SOURCES.iter().filter(|(name, _)| *name != "enter-probe") { + let Some(space) = pages(bytes).first().copied() else { continue }; + let page = read(bytes, space); + if page.objects.iter().any(|o| matches!(o, PageObject::Unsupported(_))) { + continue; + } + let Ok(copy) = page.copy() else { continue }; + let creation = crate::PageCreation::new(None, Some(&format!("Imported {name}")), "Gate author").unwrap(); + if apply(&mut section, Op::Section(SectionOp::Import { creation: creation.clone(), page: copy })).is_ok() { + imported.push(creation.space()); + } + } + let mut edits = 0; + for (n, space) in imported.iter().enumerate() { + for (k, family) in FAMILIES.iter().enumerate() { + if (k + n) % 4 != 0 { + continue; + } + let before = section.page(*space).unwrap(); + let mut after = before.clone(); + let mut rng = Rng((n * 100 + k) as u64); + if !mutate(&mut after, &mut rng, *family) { + continue; + } + // Edits inside a field code leave a field OneNote and this reader resolve apart, + // whichever writer stores them. + let fields = |page: &Page| -> Vec<(ExGuid, String)> { + model::lists(page) + .into_iter() + .flat_map(|(_, _, list)| list.iter()) + .filter_map(|p| p.text()) + .filter(|t| t.text.text().contains('\u{fddf}')) + .map(|t| (t.id, t.text.text().to_owned())) + .collect() + }; + let edited = fields(&before); + if fields(&after).iter().any(|(id, text)| edited.iter().any(|(e, old)| e == id && old != text)) { + continue; + } + if section.apply_page("Gate author", next(), *space, &after).is_ok() { + section.seal().unwrap(); + edits += 1; + } + } + } + // A created page: margins, an outline, and its first move adding the margin origin. + let creation = crate::PageCreation::new(None, Some("Created by ops"), "Gate author").unwrap(); + apply(&mut section, Op::Section(SectionOp::Create(creation.clone()))).unwrap(); + let outline = Outline { + id: id(), + title: false, + min_width: None, + layout: Layout { x: Some(36.0), y: Some(86.4), ..Default::default() }, + indents: Vec::new(), + paragraphs: vec![text_paragraph("Typed through ops", Format::default())], + unsupported: Vec::new(), + }; + let outline_id = outline.id; + let text = outline.paragraphs[0].text().unwrap().id; + let space = creation.space(); + let page = |op| Op::Page { space, op }; + apply(&mut section, page(PageOp::Add { object: PageObject::Outline(outline), before: None })).unwrap(); + apply(&mut section, page(PageOp::Outline { object: outline_id, edit: crate::OutlineEdit::Position { x: 72.0, y: 108.0 } })).unwrap(); + apply(&mut section, page(PageOp::Text { text, range: 5..5, with: " and sealed".into() })).unwrap(); + apply(&mut section, page(PageOp::Link { text, range: 0..5, target: Some("https://example.invalid/ops".into()) })).unwrap(); + let spaces: Vec = section.pages().unwrap().into_iter().map(|(s, _, _)| s).collect(); + apply(&mut section, Op::Section(SectionOp::Pages(vec![crate::PageEdit::move_to(space, Some(spaces[1]), 1).unwrap()]))).unwrap(); + apply(&mut section, Op::Section(SectionOp::Pages(vec![crate::PageEdit::set_level(spaces[2], 2).unwrap()]))).unwrap(); + apply(&mut section, Op::Section(SectionOp::Delete(vec![*spaces.last().unwrap()]))).unwrap(); + let image = section.image(); + let store = Store::parse(&image).unwrap(); + RevisionIndex::parse(&store).unwrap().validate_current().unwrap(); + let count = section.pages().unwrap().len(); + std::fs::write(directory.join("ops.one"), &image).unwrap(); + std::fs::write( + directory.join("Open Notebook.onetoc2"), + crate::create_table_of_contents("Open Notebook.onetoc2", &[("ops.one", store.header.file_id)]).unwrap(), + ) + .unwrap(); + std::fs::write(directory.join("expected-count.txt"), count.to_string()).unwrap(); + println!("{} imported pages, {edits} lowered edits, {count} pages", imported.len()); +} diff --git a/crates/onestore/src/outline.rs b/crates/onestore/src/outline.rs index 924a0a139e065d8a8cd95da742625e5ed65246c7..4f28658702161656c4bca2d135b7c5a88b66eccd 100644 --- a/crates/onestore/src/outline.rs +++ b/crates/onestore/src/outline.rs @@ -126,6 +126,17 @@ impl OutlineEdit { } } if matches!(self, Self::Position { .. }) { + // OneNote gives a page its margin origin when an outline on it first moves. + let page = changed.get_mut(page).unwrap(); + if !crate::PropertySets::parse(&page.bytes)?.sets[0] + .iter() + .any(|field| field.id == 0x14001d0f) + { + page.set(&[ + (0x14001d0f, &1.0_f32.to_le_bytes()), + (0x14001d10, &0.4_f32.to_le_bytes()), + ])?; + } update_title(active, view.clone(), &mut changed)?; } Ok(changed) diff --git a/crates/onestore/src/page/ink.rs b/crates/onestore/src/page/ink.rs index c947855a3541e84ef4cc2d6f9a02795c5f2449ed..e2f6016f637058089b07212fae51d2a306c212a4 100644 --- a/crates/onestore/src/page/ink.rs +++ b/crates/onestore/src/page/ink.rs @@ -50,7 +50,7 @@ pub struct InkStroke { } impl Ink { - pub(super) fn read( + pub(crate) fn read( revision: &Revision<'_>, id: ExGuid, node: &crate::document::Element<'_>, diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index 30ad19fe24af865fbf3591ee2d6430bf25c0b103..a7e3d827eb5965b6d4afc77774ac1189629cef8e 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -209,7 +209,7 @@ impl PartialEq for Attachment { } impl Attachment { - fn read( + pub(crate) fn read( revision: &Revision<'_>, id: ExGuid, node: &crate::document::Element<'_>, @@ -320,7 +320,7 @@ impl PartialEq for Image { } impl Image { - fn read( + pub(crate) fn read( revision: &Revision<'_>, id: ExGuid, node: &crate::document::Element<'_>, @@ -556,9 +556,11 @@ impl Page { })?)) }) .transpose()?, + // OneNote 2010 snaps to, and on the first move writes, (36, 14.4) when unset: 1.0 + // and 0.4 half inches, read as stored ones are. margin_origin: [ - margin_origin_x.unwrap_or(0.0), - margin_origin_y.unwrap_or(0.0), + margin_origin_x.unwrap_or(1.0_f32 * 36.0), + margin_origin_y.unwrap_or(0.4_f32 * 36.0), ], objects: Vec::new(), definitions: BTreeMap::new(), @@ -633,8 +635,8 @@ impl Page { }; let mut outline = Outline { id, - // OneNote flags the outline and its paragraph; `PageCreation` flags only - // the paragraph, and OneNote still shows that outline as the title. + // OneNote flags the outline and its paragraph, and still shows an outline + // whose paragraph alone is flagged as the title. title: title_text(node) || node .children @@ -682,6 +684,94 @@ impl Page { } } +/// The format `container` passes to its children, as reading its page inherits it. +fn inherited_by( + revision: &Revision<'_>, + parents: &BTreeMap>, + container: ExGuid, +) -> Result { + let invalid = |message| Error { offset: 0, message }; + let node = revision + .nodes + .get(&container) + .ok_or_else(|| invalid("Missing canvas outline object"))?; + let parent = || -> Result { + match parents.get(&container).map(Vec::as_slice) { + Some([parent]) => Ok(*parent), + _ => Err(invalid("Page content must have one parent")), + } + }; + Ok(match &node.kind { + Kind::Outline { .. } => node.format.clone(), + Kind::OutlineGroup => node + .format + .inherit(&inherited_by(revision, parents, parent()?)?), + Kind::Paragraph { + paragraph_style, .. + } => { + let style = match paragraph_style { + Some(id) => revision.nodes.get(id).map(|n| n.format.clone()).unwrap_or_default(), + None => Format::default(), + }; + node.format + .inherit(&style) + .inherit(&inherited_by(revision, parents, parent()?)?) + } + Kind::Cell { .. } => { + let row = parent()?; + let [table] = parents.get(&row).map(Vec::as_slice).unwrap_or_default() else { + return Err(invalid("A table row must have one table")); + }; + let [holder] = parents.get(table).map(Vec::as_slice).unwrap_or_default() else { + return Err(invalid("A table must have one paragraph")); + }; + node.format.inherit( + &revision.nodes[table] + .format + .inherit(&inherited_by(revision, parents, *holder)?), + ) + } + _ => Format::default(), + }) +} + +/// The text of rich-text object `text` as its page model shows it, read from its ancestors +/// alone. +pub(crate) fn text_of( + revision: &Revision<'_>, + parents: &BTreeMap>, + text: ExGuid, +) -> Result { + let invalid = |message| Error { offset: 0, message }; + let [paragraph] = parents.get(&text).map(Vec::as_slice).unwrap_or_default() else { + return Err(invalid("Select text belonging to one paragraph")); + }; + let format = inherited_by(revision, parents, *paragraph)?; + let node = revision + .nodes + .get(&text) + .ok_or_else(|| invalid("Missing text object"))?; + let Kind::RichText { + paragraph_style, .. + } = &node.kind + else { + return Err(invalid("Select a rich-text object")); + }; + let runs = revision.text_runs(text)?; + Ok(if runs.is_empty() { + let style = match paragraph_style { + Some(id) => revision.nodes.get(id).map(|n| n.format.clone()).unwrap_or_default(), + None => Format::default(), + }; + Paragraph::new(String::new(), node.format.inherit(&style).inherit(&format)) + } else { + Paragraph::from_runs( + runs.into_iter() + .map(|run| (run.text.to_owned(), run.format.inherit(&format))), + ) + }) +} + fn read_paragraphs( revision: &Revision<'_>, container: &Element<'_>, diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs index f34701c2d00e8d4346ca6263e4edc34e78ebe47f..7a1190d904d25ea88ade3b8888b213a679f24e1e 100644 --- a/crates/onestore/src/page/write.rs +++ b/crates/onestore/src/page/write.rs @@ -1,38 +1,29 @@ //! Publishes an edited page model by lowering the difference from the stored page onto //! the typed writers, whose revisions accumulate in memory, then squashing them into one -//! revision per space. +//! revision per space. `PreparedEdit::page` keeps it until the op path replaces it. -use super::{ - Attachment, Image, Ink, Math, Outline, Page, PageObject, PageParagraph, ParagraphContent, Table, -}; +use super::{Image, Ink, Outline, Page, PageObject, PageParagraph, ParagraphContent}; use crate::{ Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets, - RevisionIndex, Store, TextAttribute, TreeEdit, Value, + RevisionIndex, Store, TreeEdit, Value, active::{ActivePage, Changes}, - document::{Format, Kind, Tag}, + document::Kind, + op::{ + content::{self, AttachmentIds}, + levels, + lower::{View, collect_containers, kept_set, text_edit, validate}, + properties, + table::{self, Structure}, + }, write::{PropertyObject, RevisionEdit}, }; use bumpalo::Bump; -use std::{ - collections::{BTreeMap, BTreeSet}, - ops::Range, -}; - -/// Property identifiers with their encoded values. -type Values = Vec<(u32, Vec)>; +use std::collections::{BTreeMap, BTreeSet}; fn invalid(message: &'static str) -> Error { Error { offset: 0, message } } -/// A native measurement array: a count byte, `header - 1` reserved bytes, then each value -/// in inches. -/// The `{GUID}` form a file-data declaration uses to name a payload in the file-data store. -fn payload_reference(guid: [u8; 16]) -> String { - let id = ExGuid { guid, n: 0 }.to_string(); - format!("{}", id.split(',').next().unwrap()) -} - fn page_images(page: &Page) -> impl Iterator { page.objects.iter().filter_map(|object| match object { PageObject::Image(image) => Some((image.id, image)), @@ -47,292 +38,6 @@ fn page_ink(page: &Page) -> impl Iterator { }) } -/// The compact child references a page object holds. -fn page_children(object: &PropertyObject) -> Result, Error> { - let properties = crate::PropertySets::parse(&object.bytes)?; - match properties.sets[0] - .iter() - .find(|p| p.id == 0x24001c20) - .map(|p| &p.value) - { - Some(crate::Value::References { compact_ids, .. }) => Ok(compact_ids.to_vec()), - None => Ok(Vec::new()), - _ => Err(invalid("The page has an invalid child list")), - } -} - -/// Creates stroke objects (numbered from `first`) and one drawing-attribute object per -/// distinct pen; returns the compact references for the data node's stroke list. -fn write_strokes( - changed: &mut BTreeMap, - data_object: &mut PropertyObject, - strokes: &[(ExGuid, &super::InkStroke)], - first: usize, - filetime: u64, -) -> Result, Error> { - let mut styles: Vec<(InkPen, ExGuid)> = Vec::new(); - let mut references = Vec::new(); - for (offset, (stroke_id, stroke)) in strokes.iter().enumerate() { - let pen = InkPen::of(stroke); - let style = match styles.iter().find(|(known, _)| *known == pen) { - Some((_, id)) => *id, - None => { - let id = ExGuid { - guid: crate::write::fresh_guid()?, - n: 1, - }; - let mut style = PropertyObject { - jcid: 0x120048, - bytes: crate::create::properties(&pen.values())?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), - }; - style.reference(id)?; - changed.insert(id, style); - styles.push((pen, id)); - id - } - }; - let mut object = PropertyObject { - jcid: 0x20047, - bytes: crate::create::properties(&stroke_values( - stroke, - (first + offset) as u32 + 1, - filetime, - )?)?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, stroke_id.guid)])), - }; - object.reference(*stroke_id)?; - let style_reference = object.reference(style)?; - object.set(&[(0x20003409, &style_reference)])?; - changed.insert(*stroke_id, object); - references.extend(data_object.reference(*stroke_id)?); - } - Ok(references) -} - -/// The drawing attributes OneNote shares between strokes drawn with the same pen. -#[derive(PartialEq)] -struct InkPen { - width: u32, - height: u32, - color: Option, - transparency: Option, - pen_tip: Option, -} - -impl InkPen { - fn of(stroke: &super::InkStroke) -> Self { - Self { - width: (stroke.width * 2540.0 / 72.0).to_bits(), - height: (stroke.height * 2540.0 / 72.0).to_bits(), - color: stroke.color, - transparency: stroke.transparency, - pen_tip: stroke.pen_tip, - } - } - - fn values(&self) -> Values { - let mut values: Values = vec![ - (0x1c00340a, super::ink::DIMENSIONS.to_vec()), - (0x1400340c, self.height.to_le_bytes().to_vec()), - (0x1400340d, self.width.to_le_bytes().to_vec()), - ]; - if let Some(color) = self.color { - values.push((0x1400340f, color.to_le_bytes().to_vec())); - } - if let Some(transparency) = self.transparency { - values.push((0x0c003414, vec![transparency])); - } - if let Some(tip) = self.pen_tip { - values.push((0x0c003412, vec![tip])); - } - values - } -} - -fn stroke_values(stroke: &super::InkStroke, index: u32, filetime: u64) -> Result { - if stroke.points.is_empty() { - return Err(invalid("A stroke needs at least one point")); - } - if stroke - .points - .iter() - .any(|p| !p[0].is_finite() || !p[1].is_finite()) - || !(stroke.width.is_finite() && stroke.height.is_finite()) - || stroke.width <= 0.0 - || stroke.height <= 0.0 - { - return Err(invalid( - "Stroke points and pen size must be finite and positive", - )); - } - let left = stroke - .points - .iter() - .map(|p| p[0]) - .fold(f32::INFINITY, f32::min); - let top = stroke - .points - .iter() - .map(|p| p[1]) - .fold(f32::INFINITY, f32::min); - let mut origin = ((left - stroke.width / 2.0) / 36.0).to_le_bytes().to_vec(); - origin.extend_from_slice(&((top - stroke.height / 2.0) / 36.0).to_le_bytes()); - Ok(vec![ - (0x1c00340b, stroke.packet()), - (0x14003419, index.to_le_bytes().to_vec()), - (0x1000341b, 0x409_u16.to_le_bytes().to_vec()), - (0x0c00341c, vec![0]), - (0x1c00341a, crate::write::fresh_guid()?.to_vec()), - (0x1c00341d, filetime.to_le_bytes().to_vec()), - (0x1c00345b, origin), - ]) -} - -/// The character-style properties a span format sets; paragraph-level fields stay on the -/// paragraph style. -fn style_values(format: &crate::document::Format) -> Values { - let mut values = Values::new(); - for (id, flag) in [ - (0x08001c04, format.bold), - (0x08001c05, format.italic), - (0x08001c06, format.underline), - (0x08001c07, format.strike), - (0x08001c08, format.superscript), - (0x08001c09, format.subscript), - (0x08001e16, format.hidden), - (0x08001e14, format.hyperlink), - (0x08001e19, format.hyperlink_label), - (0x08003401, format.math), - (0x08001e22, format.embedded_object), - ] { - if let Some(flag) = flag { - values.push((id | (u32::from(flag) << 31), Vec::new())); - } - } - if let Some(font) = &format.font { - values.push((0x1c001c0a, crate::create::string(font))); - } - if let Some(size) = format.font_size { - values.push(( - 0x10001c0b, - ((size * 2.0).round() as u16).to_le_bytes().to_vec(), - )); - } - if let Some(color) = format.color { - values.push((0x14001c0c, color.to_le_bytes().to_vec())); - } - if let Some(highlight) = format.highlight { - values.push((0x14001c0d, highlight.to_le_bytes().to_vec())); - } - if let Some(language) = format.language { - values.push((0x14001c3b, language.to_le_bytes().to_vec())); - } - values -} - -fn picture_fixed_fields(stored: &Image, image: &Image) -> Result<(), Error> { - if stored.id != image.id - || stored.bytes != image.bytes - || stored.size != image.size - || stored.background != image.background - { - return Err(invalid( - "A stored picture keeps its payload, intrinsic size and background state", - )); - } - Ok(()) -} - -/// Position and displayed-size properties that take `layout` from `stored`, and the ones -/// to remove. -fn layout_values( - stored: &crate::document::Layout, - layout: &crate::document::Layout, -) -> Result<(Values, Vec), Error> { - let mut values = Values::new(); - let mut removed = Vec::new(); - if (layout.x, layout.y) != (stored.x, stored.y) { - let (Some(x), Some(y)) = (layout.x, layout.y) else { - return Err(invalid("A picture position needs both coordinates")); - }; - if !(x.is_finite() && y.is_finite()) { - return Err(invalid("A picture position must be finite")); - } - values.push((0x14001c14, (x / 36.0).to_le_bytes().to_vec())); - values.push((0x14001c15, (y / 36.0).to_le_bytes().to_vec())); - } - if ( - layout.max_width, - layout.max_height, - layout.width_set_by_user, - ) != ( - stored.max_width, - stored.max_height, - stored.width_set_by_user, - ) { - match (layout.max_width, layout.max_height) { - (Some(width), Some(height)) => { - if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { - return Err(invalid("Picture size must be positive")); - } - values.push((0x14001c1b, (width / 36.0).to_le_bytes().to_vec())); - values.push((0x14001c1c, (height / 36.0).to_le_bytes().to_vec())); - let user_set = u32::from(layout.width_set_by_user == Some(true)); - values.push((0x08001cbd | (user_set << 31), Vec::new())); - } - (None, None) => removed.extend([0x14001c1b, 0x14001c1c, 0x08001cbd]), - _ => return Err(invalid("A picture size needs both dimensions")), - } - } - if layout.reserved_width != stored.reserved_width { - return Err(invalid("A picture has no reserved width")); - } - Ok((values, removed)) -} - -fn measurement_bytes(values: &[f32], header: usize) -> Result, Error> { - let count = u8::try_from(values.len()) - .map_err(|_| invalid("A measurement array exceeds the document range"))?; - let mut bytes = vec![0u8; header]; - bytes[0] = count; - for value in values { - if !value.is_finite() { - return Err(invalid("A measurement must be finite")); - } - bytes.extend_from_slice(&(value / 36.0).to_le_bytes()); - } - Ok(bytes) -} - -/// Every row carries one cell per column and widths are usable. -fn validate_table(table: &Table) -> Result<(), Error> { - if table.rows.is_empty() || table.columns.is_empty() { - return Err(invalid("A table needs at least one row and one column")); - } - if table - .columns - .iter() - .any(|c| !c.width.is_finite() || c.width < 36.0) - { - return Err(invalid("Table columns are at least 36 points wide")); - } - for row in &table.rows { - if row.cells.len() != table.columns.len() { - return Err(invalid("Every table row has one cell per column")); - } - for cell in &row.cells { - // An emptied cell keeps a replacement paragraph, as the tree writer provides. - for paragraph in &cell.paragraphs { - if let ParagraphContent::Table(nested) = ¶graph.content { - validate_table(nested)?; - } - } - } - } - Ok(()) -} - pub(crate) fn write_page( source: &[u8], space: ExGuid, @@ -374,93 +79,6 @@ pub(crate) fn write_page( ) } -/// Direct children of every container, in model order, plus lookups by identity. -struct View<'a> { - page: &'a Page, - outlines: BTreeMap, - /// Outlines owned by a title object rather than the page. - title_outlines: BTreeSet, - paragraphs: BTreeMap, - children: BTreeMap>, - container: BTreeMap, - page_children: Vec, -} - -impl<'a> View<'a> { - fn new(page: &'a Page) -> Result { - let mut view = Self { - page, - outlines: BTreeMap::new(), - title_outlines: BTreeSet::new(), - paragraphs: BTreeMap::new(), - children: BTreeMap::new(), - container: BTreeMap::new(), - page_children: Vec::new(), - }; - for object in &page.objects { - view.page_children.push(object.id()); - match object { - PageObject::Outline(outline) => view.outline(outline)?, - PageObject::Title(title) => { - for outline in &title.outlines { - view.title_outlines.insert(outline.id); - view.outline(outline)?; - } - } - PageObject::Image(_) | PageObject::Ink(_) | PageObject::Unsupported(_) => {} - } - } - Ok(view) - } - - fn outline(&mut self, outline: &'a Outline) -> Result<(), Error> { - if self.outlines.insert(outline.id, outline).is_some() { - return Err(invalid("The page model repeats an outline identity")); - } - self.children.entry(outline.id).or_default(); - self.paragraphs_of(outline.id, &outline.paragraphs) - } - - fn paragraphs_of(&mut self, root: ExGuid, list: &'a [PageParagraph]) -> Result<(), Error> { - for paragraph in list { - let container = paragraph.parent.unwrap_or(root); - if paragraph - .parent - .is_some_and(|parent| !self.paragraphs.contains_key(&parent)) - { - return Err(invalid( - "A paragraph's parent must precede it in its container", - )); - } - if self.paragraphs.insert(paragraph.id, paragraph).is_some() { - return Err(invalid("The page model repeats a paragraph identity")); - } - self.children - .entry(container) - .or_default() - .push(paragraph.id); - self.children.entry(paragraph.id).or_default(); - self.container.insert(paragraph.id, container); - if let ParagraphContent::Table(table) = ¶graph.content { - for row in &table.rows { - for cell in &row.cells { - if self.children.contains_key(&cell.id) { - return Err(invalid("The page model repeats a cell identity")); - } - self.children.entry(cell.id).or_default(); - self.paragraphs_of(cell.id, &cell.paragraphs)?; - } - } - } - } - Ok(()) - } - - fn text(&self, paragraph: ExGuid) -> Option<&'a super::TextObject> { - self.paragraphs.get(¶graph).and_then(|p| p.text()) - } -} - struct Lowering<'a> { active: ActivePage<'a>, /// Holds the objects the typed writers store for as long as `active` reads them. @@ -521,30 +139,9 @@ impl<'a> Lowering<'a> { } fn run(&mut self, before: &Page, after: &Page) -> Result<(), Error> { - if after.created != before.created || after.margin_origin != before.margin_origin { - return Err(invalid("Page creation time and margins cannot be edited")); - } - for (id, definition) in &after.definitions { - if before - .definitions - .get(id) - .is_some_and(|stored| stored != definition) - && !matches!( - definition.kind, - Kind::List { .. } | Kind::TagDefinition { .. } - ) - { - return Err(invalid("Style definitions cannot be edited")); - } - } - let old = View::new(before)?; - let new = View::new(after)?; - for paragraph in new.paragraphs.values() { - if let ParagraphContent::Table(table) = ¶graph.content { - validate_table(table)?; - } - } - self.check_fixed_objects(before, after, &old, &new)?; + let old = View::new(before, false)?; + let new = View::new(after, false)?; + validate(before, after, &old, &new)?; for id in new.outlines.keys().chain(new.paragraphs.keys()) { if !old.outlines.contains_key(id) && !old.paragraphs.contains_key(id) @@ -567,9 +164,12 @@ impl<'a> Lowering<'a> { .collect(); self.insert_outlines(&old, &new, &mut placed, &mut page_order)?; let mut consumed = BTreeSet::new(); + self.ungroup(&old, &new)?; self.split_and_join(&old, &new, &mut placed, &mut consumed)?; self.place(&old, &new, &placed, &page_order)?; self.delete(&old, &new, &consumed)?; + self.edit_levels(&new)?; + self.edit_text_identities(&new)?; self.edit_equations(&new)?; self.edit_text(&new)?; self.edit_paragraph_styles(&old, &new, after)?; @@ -581,123 +181,6 @@ impl<'a> Lowering<'a> { Ok(()) } - fn check_fixed_objects( - &self, - before: &Page, - after: &Page, - old: &View<'_>, - new: &View<'_>, - ) -> Result<(), Error> { - let fixed = |page: &Page| -> Vec { - page.objects - .iter() - .filter_map(|object| match object { - PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")), - PageObject::Title(title) => Some(format!( - "{:?} {:?} {:?} {:?}", - title.id, - title.date, - title.layout, - title.outlines.iter().map(|o| o.id).collect::>() - )), - PageObject::Outline(_) | PageObject::Image(_) | PageObject::Ink(_) => None, - }) - .collect() - }; - let mut before_fixed = fixed(before); - let mut after_fixed = fixed(after); - before_fixed.sort(); - after_fixed.sort(); - if before_fixed != after_fixed { - return Err(invalid( - "Titles and unsupported objects cannot be edited through the page model", - )); - } - for (id, outline) in &new.outlines { - if outline.paragraphs.is_empty() { - return Err(invalid( - "An outline needs a paragraph; remove the outline instead", - )); - } - let Some(previous) = old.outlines.get(id) else { - continue; - }; - let same = outline.title == previous.title - && outline.min_width == previous.min_width - && outline.indents == previous.indents - && outline.unsupported == previous.unsupported - && (!new.title_outlines.contains(id) || outline.layout == previous.layout); - if !same { - return Err(invalid( - "Outline roles, indentation tables and title geometry cannot be edited", - )); - } - } - for (id, paragraph) in &new.paragraphs { - let Some(previous) = old.paragraphs.get(id) else { - continue; - }; - if paragraph.media != previous.media { - return Err(invalid("Recording annotations cannot be edited")); - } - let same = paragraph.style == previous.style && paragraph.format == previous.format; - if !same { - return Err(invalid( - "Paragraph styles and paragraph formatting cannot be edited", - )); - } - match (¶graph.content, &previous.content) { - (ParagraphContent::Text(text), ParagraphContent::Text(previous)) => { - if text.date_field != previous.date_field { - return Err(invalid("Text fields cannot be edited")); - } - } - (ParagraphContent::Table(table), ParagraphContent::Table(previous)) => { - if table.id != previous.id - || table.layout != previous.layout - || table.tags != previous.tags - { - return Err(invalid("Table identity, layout and tags cannot be edited")); - } - let unchanged_cells = - table.rows.iter().flat_map(|row| &row.cells).all(|cell| { - previous - .rows - .iter() - .flat_map(|row| &row.cells) - .find(|before| before.id == cell.id) - .is_none_or(|before| { - cell.layout == before.layout - && cell.unsupported == before.unsupported - }) - }); - if !unchanged_cells { - return Err(invalid("Cell layout cannot be edited")); - } - } - (ParagraphContent::Unsupported(a), ParagraphContent::Unsupported(b)) if a == b => {} - (ParagraphContent::Ink(a), ParagraphContent::Ink(b)) => { - if a.id != b.id { - return Err(invalid("Ink identity cannot change")); - } - } - (ParagraphContent::Image(a), ParagraphContent::Image(b)) => { - picture_fixed_fields(a, b)?; - if (a.layout.x, a.layout.y) != (b.layout.x, b.layout.y) { - return Err(invalid("A paragraph picture has no position of its own")); - } - } - (ParagraphContent::Attachment(a), ParagraphContent::Attachment(b)) => { - if a.id != b.id { - return Err(invalid("Attachment identity cannot change")); - } - } - _ => return Err(invalid("Paragraph content type cannot change")), - } - } - Ok(()) - } - fn insert_outlines( &mut self, old: &View<'_>, @@ -758,6 +241,10 @@ impl<'a> Lowering<'a> { Ok(()) } + /// Splits and joins stored paragraphs as OneNote's Enter, Backspace and Delete do. A new + /// styled paragraph that is no exact split starts as Enter at the end of the nearest stored + /// paragraph that allows one, taking its properties as OneNote's new paragraphs do; later + /// passes place it and give it its text. fn split_and_join( &mut self, old: &View<'_>, @@ -765,65 +252,98 @@ impl<'a> Lowering<'a> { placed: &mut BTreeMap>, consumed: &mut BTreeSet, ) -> Result<(), Error> { - for (container, children) in &new.children { - if !old.children.contains_key(container) { + // A new paragraph may be split from the stored paragraph nearest before it, whatever + // placement or pasted paragraphs the same save adds between them. + let mut splits = Vec::new(); + for document in new.documents() { + let mut left = None; + for paragraph in document { + if old.paragraphs.contains_key(¶graph.id) { + left = paragraph.text().is_some().then_some(paragraph.id); + } else if let Some(left) = left { + let container = new.container[¶graph.id]; + let at = new.children[&container] + .iter() + .position(|id| *id == paragraph.id); + splits.push(((container, at), left, paragraph.id)); + } + } + } + splits.sort(); + let mut split = BTreeSet::new(); + for (_, left, right) in splits { + let (Some(previous), Some(after), Some(next)) = + (old.text(left), new.text(left), new.text(right)) + else { + continue; + }; + if split.contains(&left) + || after.id != previous.id + || new.paragraphs[&right].style != old.paragraphs[&left].style + { continue; } - for pair in children.windows(2) { - let [left, right] = [pair[0], pair[1]]; - let (Some(previous), Some(after), Some(next)) = - (old.text(left), new.text(left), new.text(right)) - else { - continue; - }; - if old.paragraphs.contains_key(&right) - || old.container.get(&left) != Some(container) - || !new.children[&left].is_empty() - || new.children[&right] != old.children[&left] - || after.id != previous.id - || new.paragraphs[&right].style != old.paragraphs[&left].style - { - continue; - } - let Ok(offset) = after.text.utf16_offset(after.text.text().len()) else { - continue; - }; - // Splitting at the end only differs from appending a paragraph by the copied - // paragraph style; without one, an appended empty paragraph is an insertion. - if offset == previous.text.utf16_offset(previous.text.text().len())? - && new.paragraphs[&right].style.is_none() + let Ok(offset) = after.text.utf16_offset(after.text.text().len()) else { + continue; + }; + // Splitting at the end only differs from appending a paragraph by the copied + // paragraph style; without one, an appended empty paragraph is an insertion. + if offset == previous.text.utf16_offset(previous.text.text().len())? + && new.paragraphs[&right].style.is_none() + { + continue; + } + let Ok(head) = previous.text.slice(0..offset) else { + continue; + }; + let Ok(tail) = previous.text.slice( + offset + ..previous + .text + .utf16_offset(previous.text.text().len()) + .unwrap_or(0), + ) else { + continue; + }; + // An emptied side takes the writer's insertion style, so only its text must agree. + let same = |expected: &super::Paragraph, actual: &super::Paragraph| { + expected.text() == actual.text() + && (expected.text().is_empty() || expected == actual) + }; + if !same(&head, &after.text) || !same(&tail, &next.text) { + continue; + } + let edit = ParagraphSplit::new(previous.id, offset, self.author)?; + if self.split(&edit, left, right, old, new, placed)? { + split.insert(left); + } + } + for document in new.documents() { + for (at, paragraph) in document.iter().enumerate() { + if old.paragraphs.contains_key(¶graph.id) + || self.alias.contains_key(¶graph.id) + || paragraph.style.is_none() + || paragraph.text().is_none() { continue; } - let Ok(head) = previous.text.slice(0..offset) else { - continue; - }; - let Ok(tail) = previous.text.slice( - offset - ..previous - .text - .utf16_offset(previous.text.text().len()) - .unwrap_or(0), - ) else { - continue; + let stored = |candidate: &&&PageParagraph| { + old.paragraphs.contains_key(&candidate.id) && candidate.text().is_some() }; - // An emptied side takes the writer's insertion style, so only its text must agree. - let same = |expected: &super::Paragraph, actual: &super::Paragraph| { - expected.text() == actual.text() - && (expected.text().is_empty() || expected == actual) - }; - if !same(&head, &after.text) || !same(&tail, &next.text) { - continue; + let before = document[..at].iter().rev().filter(stored); + for template in before.chain(document[at + 1..].iter().filter(stored)) { + let text = self.active.view.nodes[&template.id].content[0]; + let Kind::RichText { text: content, .. } = &self.active.view.nodes[&text].kind + else { + continue; + }; + let end = u32::try_from(content.encode_utf16().count()) + .map_err(|_| invalid("Paragraph exceeds the UTF-16 offset range"))?; + let edit = ParagraphSplit::new(text, end, self.author)?; + if self.split(&edit, template.id, paragraph.id, old, new, placed)? { + break; + } } - let split = ParagraphSplit::new(previous.id, offset, self.author)?; - self.write(|active| split.changes(active))?; - self.alias.insert(right, split.object()); - self.alias.insert(next.id, split.text_object()); - let list = placed.get_mut(container).unwrap(); - let at = list.iter().position(|id| *id == left).unwrap(); - list.insert(at + 1, right); - placed.insert(right, placed[&left].clone()); - placed.insert(left, Vec::new()); } } for (container, children) in &old.children { @@ -856,12 +376,13 @@ impl<'a> Lowering<'a> { previous.id } { - return Err(invalid( - "A joined paragraph keeps the left text identity unless the left text was empty", - )); + continue; } let join = ParagraphJoin::new(previous.id, removed.id, self.author)?; - self.write(|active| join.changes(active))?; + let Ok(changes) = join.changes(&self.active) else { + continue; + }; + self.write(|_| Ok(changes))?; consumed.insert(right); placed.get_mut(container).unwrap().retain(|id| *id != right); placed.remove(&right); @@ -870,6 +391,50 @@ impl<'a> Lowering<'a> { Ok(()) } + /// Stores `edit` of model paragraph `left` as model paragraph `right`, unless the split + /// cannot be made there. + fn split( + &mut self, + edit: &ParagraphSplit, + left: ExGuid, + right: ExGuid, + old: &View<'_>, + new: &View<'_>, + placed: &mut BTreeMap>, + ) -> Result { + let Ok(changes) = edit.changes(&self.active) else { + return Ok(false); + }; + self.write(|_| Ok(changes))?; + self.alias.insert(right, edit.object()); + // A text object a join moved into the paragraph is taken over after placement. + if let Some(text) = new.text(right) + && !self.active.live.revision.objects.contains_key(&text.id) + { + self.alias.insert(text.id, edit.text_object()); + } + // The split copies the left paragraph's list nodes in order; a list the model shares + // with a stored paragraph stays that paragraph's. + if new.paragraphs[&right].lists.len() == old.paragraphs[&left].lists.len() { + for (n, list) in (5..).zip(&new.paragraphs[&right].lists) { + if !self.active.live.revision.objects.contains_key(list) { + let guid = edit.object().guid; + self.alias.insert(*list, ExGuid { guid, n }); + } + } + } + // An earlier split may have carried the left paragraph to its tail. + let list = placed + .values_mut() + .find(|list| list.contains(&left)) + .unwrap(); + let at = list.iter().position(|id| *id == left).unwrap(); + list.insert(at + 1, right); + placed.insert(right, placed[&left].clone()); + placed.insert(left, Vec::new()); + Ok(true) + } + fn place( &mut self, old: &View<'_>, @@ -957,9 +522,10 @@ impl<'a> Lowering<'a> { if pending.is_empty() { break; } - let (ready, waiting): (Vec, Vec) = pending - .into_iter() - .partition(|container| self.alias.contains_key(container)); + let (ready, waiting): (Vec, Vec) = + pending.into_iter().partition(|container| { + self.alias.contains_key(container) || old.children.contains_key(container) + }); if ready.is_empty() { return Err(invalid("Table content has no table to hold it")); } @@ -981,18 +547,12 @@ impl<'a> Lowering<'a> { let ParagraphContent::Attachment(attachment) = ¶graph.content else { continue; }; - let name = attachment.filename.as_str(); - if name.is_empty() || name.contains(['\0', '/', '\\']) { - return Err(invalid( - "An attachment needs a file name without path separators", - )); - } if let Some(previous) = old.paragraphs.get(paragraph_id) { let ParagraphContent::Attachment(stored) = &previous.content else { return Err(invalid("Paragraph content type cannot change")); }; if stored != attachment { - self.edit_attachment(stored, attachment)?; + self.write(|active| content::attachment_edit_changes(active, stored, attachment))?; } continue; } @@ -1002,89 +562,32 @@ impl<'a> Lowering<'a> { let Some(bytes) = &attachment.bytes else { return Err(invalid("A new attachment needs its payload")); }; - let extension = name - .rfind('.') - .filter(|dot| *dot > 0) - .map(|dot| &name[dot..]) - .unwrap_or(""); - let attachment_id = self.allocate(attachment.id)?; - let file_id = ExGuid { - guid: crate::write::fresh_guid()?, - n: 1, + let ids = AttachmentIds { + object: self.allocate(attachment.id)?, + file: ExGuid { + guid: crate::write::fresh_guid()?, + n: 1, + }, + payload: crate::write::fresh_guid()?, + preview: match &attachment.preview { + Some(_) => { + let payload = crate::write::fresh_guid()?; + let icon = ExGuid { + guid: crate::write::fresh_guid()?, + n: 1, + }; + Some((payload, icon)) + } + None => None, + }, }; - let payload_guid = crate::write::fresh_guid()?; - let reference = payload_reference(payload_guid); - let modified = crate::create::current_timestamps()?.0.to_le_bytes(); - let mut values: Values = vec![(0x14001d7a, modified.to_vec())]; - if let Some([width, height]) = attachment.size { - if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { - return Err(invalid("Attachment icon size must be positive")); - } - values.push((0x140034cd, (width / 36.0).to_le_bytes().to_vec())); - values.push((0x140034ce, (height / 36.0).to_le_bytes().to_vec())); - } - values.push((0x14001c3b, 0x409_u32.to_le_bytes().to_vec())); - values.push((0x10001cfe, 0x409_u16.to_le_bytes().to_vec())); - values.push((0x1c001dcf, vec![0; 32])); - values.push(( - 0x1c001d61, - [16u32, 1, 0, 0, 0] - .iter() - .flat_map(|v| v.to_le_bytes()) - .collect(), - )); - values.push((0x14001c3e, 1u32.to_le_bytes().to_vec())); - values.push((0x14001c84, 1u32.to_le_bytes().to_vec())); - values.push((0x1c001c22, crate::create::string(name))); - values.push((0x1c001d9c, crate::create::string(name))); - if let Some(path) = &attachment.source_path { - values.push((0x1c001d9d, crate::create::string(path))); + let mut payloads: Vec<([u8; 16], &[u8])> = vec![(ids.payload, bytes)]; + if let (Some((payload, _)), Some(icon)) = (ids.preview, &attachment.preview) { + payloads.push((payload, icon)); } let holder = self.id(*paragraph_id); - let mut payloads: Vec<([u8; 16], &[u8])> = vec![(payload_guid, bytes)]; - let mut preview = None; - if let Some(icon) = &attachment.preview { - if !icon.starts_with(&[0x89, b'P', b'N', b'G']) { - return Err(invalid("An attachment preview is a PNG icon")); - } - let guid = crate::write::fresh_guid()?; - payloads.push((guid, icon)); - preview = Some(( - ExGuid { - guid: crate::write::fresh_guid()?, - n: 1, - }, - payload_reference(guid), - )); - } self.write_with(&payloads, |active| { - let raw = &active.live.revision; - let mut changed = BTreeMap::new(); - let mut file = PropertyObject::file(file_id, &reference, extension)?; - file.jcid = 0x80036; - changed.insert(file_id, file); - let mut node = PropertyObject { - jcid: 0x60035, - bytes: crate::create::properties(&values)?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, attachment_id.guid)])), - }; - node.reference(attachment_id)?; - let container = node.reference(file_id)?; - node.set(&[(0x20001d9b, &container)])?; - if let Some((icon_id, icon_reference)) = &preview { - changed.insert( - *icon_id, - PropertyObject::file(*icon_id, icon_reference, ".png")?, - ); - let icon = node.reference(*icon_id)?; - node.set(&[(0x20001c3f, &icon)])?; - } - changed.insert(attachment_id, node); - let mut object = PropertyObject::from_object(&raw.objects[&holder])?; - let content = object.reference(attachment_id)?; - object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?; - changed.insert(holder, object); - Ok(changed) + content::attachment_changes(active, attachment, &ids, holder) })?; } Ok(()) @@ -1183,15 +686,6 @@ impl<'a> Lowering<'a> { /// child (or a paragraph holds as content), its data node listing stroke objects, each /// stroke's packet and half-inch origin, and one drawing-attribute object per distinct pen. fn insert_ink(&mut self, ink: &Ink, holder: Option) -> Result<(), Error> { - if !ink.groups.is_empty() { - return Err(invalid("New ink holds strokes, not nested groups")); - } - if ink.strokes.is_empty() { - return Err(invalid("New ink needs at least one stroke")); - } - if ink.layout != Default::default() { - return Err(invalid("Ink positions come from its strokes")); - } let container = self.allocate(ink.id)?; let data = ExGuid { guid: crate::write::fresh_guid()?, @@ -1201,50 +695,7 @@ impl<'a> Lowering<'a> { for stroke in &ink.strokes { strokes.push((self.allocate(stroke.id)?, stroke)); } - let (modified, filetime) = crate::create::current_timestamps()?; - let modified = modified.to_le_bytes(); - let page = self.page; - self.write(|active| { - let raw = &active.live.revision; - let mut changed = BTreeMap::new(); - let mut data_object = PropertyObject { - jcid: 0x2003b, - bytes: crate::create::properties(&[])?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, data.guid)])), - }; - data_object.reference(data)?; - let references = write_strokes(&mut changed, &mut data_object, &strokes, 0, filetime)?; - data_object.set(&[(0x24003416, &references)])?; - changed.insert(data, data_object); - let mut object = PropertyObject { - jcid: 0x60014, - bytes: crate::create::properties(&[ - (0x14001d7a, modified.to_vec()), - (0x14001d4e, 1u32.to_le_bytes().to_vec()), - ])?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, container.guid)])), - }; - object.reference(container)?; - let data_reference = object.reference(data)?; - object.set(&[(0x20003415, &data_reference)])?; - changed.insert(container, object); - match holder { - Some(holder) => { - let mut object = PropertyObject::from_object(&raw.objects[&holder])?; - let content = object.reference(container)?; - object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?; - changed.insert(holder, object); - } - None => { - let mut object = PropertyObject::from_object(&raw.objects[&page])?; - let mut children = page_children(&object)?; - children.extend(object.reference(container)?); - object.set(&[(0x24001c20, &children), (0x14001d7a, &modified)])?; - changed.insert(page, object); - } - } - Ok(changed) - }) + self.write(|active| content::ink_changes(active, ink, container, data, &strokes, holder)) } /// Rewrites a drawing's stroke list: stored strokes stay as they are (OneNote erases whole @@ -1266,42 +717,7 @@ impl<'a> Lowering<'a> { } } let container = self.id(ink.id); - let data = match self - .active - .view - .nodes - .get(&container) - .map(|node| &node.kind) - { - Some(crate::document::Kind::Ink { - data: Some(data), .. - }) => *data, - _ => return Err(invalid("Stored ink has no stroke data to rewrite")), - }; - let (modified, filetime) = crate::create::current_timestamps()?; - let modified = modified.to_le_bytes(); - self.write(|active| { - let raw = &active.live.revision; - let mut changed = BTreeMap::new(); - let mut data_object = PropertyObject::from_object(&raw.objects[&data])?; - let mut references = Vec::new(); - for id in &kept { - references.extend(data_object.reference(*id)?); - } - references.extend(write_strokes( - &mut changed, - &mut data_object, - &added, - kept.len(), - filetime, - )?); - data_object.set(&[(0x24003416, &references)])?; - changed.insert(data, data_object); - let mut object = PropertyObject::from_object(&raw.objects[&container])?; - object.set(&[(0x14001d7a, &modified)])?; - changed.insert(container, object); - Ok(changed) - }) + self.write(|active| content::strokes_changes(active, container, &kept, &added)) } /// Gives a new picture what OneNote stores for an inserted one: the payload embedded @@ -1312,158 +728,29 @@ impl<'a> Lowering<'a> { let Some(bytes) = &image.bytes else { return Err(invalid("A new picture needs its payload")); }; - let extension = match bytes.as_ref() { - [0x89, b'P', b'N', b'G', ..] => ".png", - [0xff, 0xd8, 0xff, ..] => ".jpg", - [b'G', b'I', b'F', b'8', ..] => ".gif", - [b'B', b'M', ..] => ".bmp", - _ => return Err(invalid("Choose a PNG, JPEG, GIF or BMP picture")), - }; let image_id = self.allocate(image.id)?; let file_id = ExGuid { guid: crate::write::fresh_guid()?, n: 1, }; let payload_guid = crate::write::fresh_guid()?; - let reference = payload_reference(payload_guid); - let modified = crate::create::current_timestamps()?.0.to_le_bytes(); - let mut values: Values = vec![(0x14001d7a, modified.to_vec())]; - if let Some([width, height]) = image.size { - if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { - return Err(invalid("Picture size must be positive")); - } - values.push((0x140034cd, (width / 36.0).to_le_bytes().to_vec())); - values.push((0x140034ce, (height / 36.0).to_le_bytes().to_vec())); - } - values.extend(layout_values(&Default::default(), &image.layout)?.0); - if let Some(alt) = &image.alt { - values.push((0x1c001e58, crate::create::string(alt))); - } - if image.background { - values.push((0x08001d13 | (1 << 31), Vec::new())); - } - values.push((0x08001d85, Vec::new())); - let page = self.page; let payload: &[u8] = bytes; self.write_with(&[(payload_guid, payload)], |active| { - let raw = &active.live.revision; - let mut changed = BTreeMap::new(); - let file = PropertyObject::file(file_id, &reference, extension)?; - changed.insert(file_id, file); - let mut picture = PropertyObject { - jcid: 0x60011, - bytes: crate::create::properties(&values)?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, image_id.guid)])), - }; - picture.reference(image_id)?; - let container = picture.reference(file_id)?; - picture.set(&[(0x20001c3f, &container)])?; - changed.insert(image_id, picture); - match holder { - Some(holder) => { - let mut object = PropertyObject::from_object(&raw.objects[&holder])?; - let content = object.reference(image_id)?; - object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?; - changed.insert(holder, object); - } - None => { - let mut object = PropertyObject::from_object(&raw.objects[&page])?; - let mut children = page_children(&object)?; - children.extend(object.reference(image_id)?); - object.set(&[(0x24001c20, &children), (0x14001d7a, &modified)])?; - changed.insert(page, object); - } - } - Ok(changed) - }) - } - - /// Writes a moved, resized or described picture the way OneNote stores one: the - /// position, the layout width and height with the user flag and the description on - /// the picture object, leaving the intrinsic size alone. - /// A stored attachment keeps its payload and preview; its shown name, recorded source - /// path and icon size change in place, as OneNote's rename does. - fn edit_attachment( - &mut self, - stored: &Attachment, - attachment: &Attachment, - ) -> Result<(), Error> { - let mut values: Values = vec![( - 0x14001d7a, - crate::create::current_timestamps()? - .0 - .to_le_bytes() - .to_vec(), - )]; - if attachment.recording != stored.recording { - return Err(invalid("A recording stays the recording OneNote captured")); - } - let mut removed = Vec::new(); - if attachment.filename != stored.filename { - let name = crate::create::string(&attachment.filename); - values.push((0x1c001c22, name.clone())); - values.push((0x1c001d9c, name)); - } - if attachment.source_path != stored.source_path { - match &attachment.source_path { - Some(path) => values.push((0x1c001d9d, crate::create::string(path))), - None => removed.push(0x1c001d9d), - } - } - if attachment.size != stored.size { - match attachment.size { - Some([width, height]) => { - if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { - return Err(invalid("Attachment icon size must be positive")); - } - values.push((0x140034cd, (width / 36.0).to_le_bytes().to_vec())); - values.push((0x140034ce, (height / 36.0).to_le_bytes().to_vec())); - } - None => removed.extend([0x140034cd, 0x140034ce]), - } - } - let object = self.id(attachment.id); - self.write(|active| { - let raw = &active.live.revision; - let mut node = PropertyObject::from_object(&raw.objects[&object])?; - node.remove(&removed)?; - let values: Vec<(u32, &[u8])> = values - .iter() - .map(|(id, bytes)| (*id, bytes.as_slice())) - .collect(); - node.set(&values)?; - Ok(BTreeMap::from([(object, node)])) + content::picture_changes(active, image, image_id, file_id, payload_guid, holder) }) } fn edit_image(&mut self, stored: &Image, image: &Image) -> Result<(), Error> { - picture_fixed_fields(stored, image)?; - let mut values: Values = vec![( - 0x14001d7a, - crate::create::current_timestamps()? - .0 - .to_le_bytes() - .to_vec(), - )]; - let (layout, mut removed) = layout_values(&stored.layout, &image.layout)?; - values.extend(layout); - if image.alt != stored.alt { - match &image.alt { - Some(alt) => values.push((0x1c001e58, crate::create::string(alt))), - None => removed.push(0x1c001e58), - } - } + content::picture_fixed_fields(stored, image)?; let object = self.id(image.id); self.write(|active| { - let raw = &active.live.revision; - let mut picture = PropertyObject::from_object(&raw.objects[&object])?; - picture.remove(&removed)?; - let values: Vec<(u32, &[u8])> = values - .iter() - .map(|(id, bytes)| (*id, bytes.as_slice())) - .collect(); - picture.set(&values)?; - Ok(BTreeMap::from([(object, picture)])) + content::picture_edit_changes( + active, + object, + (&stored.layout, &stored.alt), + &image.layout, + &image.alt, + ) }) } @@ -1528,7 +815,9 @@ impl<'a> Lowering<'a> { Insertion::paragraph(self.id(*container), anchor, text, self.author)?; self.write(|active| insertion.changes(active))?; self.alias.insert(*id, insertion.object()); - if let Some(text_id) = text_id { + if let Some(text_id) = + text_id.filter(|id| !self.active.live.revision.objects.contains_key(id)) + { self.alias.insert(text_id, insertion.text_object()); } } else if !kept.contains(id) { @@ -1542,12 +831,7 @@ impl<'a> Lowering<'a> { Ok(()) } - /// Creates tables, rows and cells the model added, rebuilds every table's row and cell - /// order to the model's, and writes the column widths, locks and border flag. Native - /// rows and cells are plain containers; a cell carries its indent array and the flags - /// every native cell has. - /// Shading (the documented `CellShadingColor`, which OneNote 2010 stores but neither - /// renders nor accepts through its COM schema) and indents change in place on a cell. + /// Changes the shading and indents of stored cells in place. fn edit_cells(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { let stored: BTreeMap = old .paragraphs @@ -1570,46 +854,25 @@ impl<'a> Lowering<'a> { if (cell.shading, &cell.indents) == (previous.shading, &previous.indents) { continue; } - let mut values: Values = vec![( - 0x14001d7a, - crate::create::current_timestamps()? - .0 - .to_le_bytes() - .to_vec(), - )]; - let mut removed = Vec::new(); - if cell.shading != previous.shading { - match cell.shading { - Some(shading) => values.push((0x14001e26, shading.to_le_bytes().to_vec())), - None => removed.push(0x14001e26), - } - } - if cell.indents != previous.indents { - if cell.indents.is_empty() { - removed.push(0x1c001c12); - } else { - values.push((0x1c001c12, measurement_bytes(&cell.indents, 4)?)); - } - } let object = self.id(cell.id); self.write(|active| { - let raw = &active.live.revision; - let mut object_properties = PropertyObject::from_object(&raw.objects[&object])?; - object_properties.remove(&removed)?; - let values: Vec<(u32, &[u8])> = values - .iter() - .map(|(id, bytes)| (*id, bytes.as_slice())) - .collect(); - object_properties.set(&values)?; - Ok(BTreeMap::from([(object, object_properties)])) + table::cell_changes( + active, + object, + (previous.shading, &previous.indents), + cell.shading, + &cell.indents, + ) })?; } } Ok(()) } + /// Creates tables, rows and cells the model added, rebuilds every table's row and cell + /// order to the model's, and writes the column widths, locks and border flag. fn edit_table_structure(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { - let old_tables: BTreeMap = old + let old_tables: BTreeMap = old .paragraphs .values() .filter_map(|p| match &p.content { @@ -1642,8 +905,6 @@ impl<'a> Lowering<'a> { if unchanged { continue; } - let modified = crate::create::current_timestamps()?.0.to_le_bytes(); - let mut created: Vec<(ExGuid, u32, Values)> = Vec::new(); let table_id = if previous.is_some() { self.id(table.id) } else { @@ -1662,13 +923,22 @@ impl<'a> Lowering<'a> { .unwrap_or_default(); let template = previous .and_then(|p| p.rows.first()) - .and_then(|r| r.cells.first()); - let mut rows = Vec::new(); + .and_then(|r| r.cells.first()) + .map(|cell| cell.indents.as_slice()); + let mut structure = Structure { + rows: Vec::new(), + new_rows: BTreeSet::new(), + new_cells: BTreeMap::new(), + columns: &table.columns, + borders: table.borders, + }; for row in &table.rows { let row_id = if existing_rows.contains(&row.id) { self.id(row.id) } else { - self.allocate(row.id)? + let written = self.allocate(row.id)?; + structure.new_rows.insert(written); + written }; let mut cells = Vec::new(); for cell in &row.cells { @@ -1676,116 +946,18 @@ impl<'a> Lowering<'a> { self.id(cell.id) } else { let written = self.allocate(cell.id)?; - let indents = if cell.indents.is_empty() { - template.map(|t| t.indents.clone()).unwrap_or_default() - } else { - cell.indents.clone() - }; - let mut values: Values = vec![ - (0x14001d7a, modified.to_vec()), - (0x0c001c13, vec![0]), - (0x0c001c03, vec![1]), - (0x88001c91, Vec::new()), - ]; - if !indents.is_empty() { - values.push((0x1c001c12, measurement_bytes(&indents, 4)?)); - } - if let Some(shading) = cell.shading { - values.push((0x14001e26, shading.to_le_bytes().to_vec())); - } - created.push((written, 0x60024, values)); + structure.new_cells.insert( + written, + (table::cell_indents(&cell.indents, template), cell.shading), + ); written }; cells.push(cell_id); } - if !existing_rows.contains(&row.id) { - created.push((row_id, 0x60023, vec![(0x14001d7a, modified.to_vec())])); - } - rows.push((row_id, cells)); + structure.rows.push((row_id, cells)); } - let mut table_values: Values = vec![ - (0x14001d7a, modified.to_vec()), - (0x14001d57, (table.rows.len() as u32).to_le_bytes().to_vec()), - ( - 0x14001d58, - (table.columns.len() as u32).to_le_bytes().to_vec(), - ), - ( - 0x1c001d66, - measurement_bytes( - &table.columns.iter().map(|c| c.width).collect::>(), - 1, - )?, - ), - ]; - let mut locks = vec![table.columns.len() as u8]; - locks.extend(vec![0u8; table.columns.len().div_ceil(8)]); - for (i, column) in table.columns.iter().enumerate() { - if column.locked { - locks[1 + i / 8] |= 1 << (i % 8); - } - } - table_values.push((0x1c001d7d, locks)); - table_values.push(( - 0x08001d5e | (u32::from(table.borders.unwrap_or(true)) << 31), - Vec::new(), - )); - if previous.is_none() { - table_values.push((0x14001c3e, 1u32.to_le_bytes().to_vec())); - table_values.push((0x14001c84, 1u32.to_le_bytes().to_vec())); - created.push((table_id, 0x60022, Vec::new())); - } - let holder = self.id(*paragraph_id); - let is_new = previous.is_none(); - self.write(|active| { - let raw = &active.live.revision; - let mut changed: BTreeMap = BTreeMap::new(); - for (id, jcid, values) in &created { - let mut node = PropertyObject { - jcid: *jcid, - bytes: crate::create::properties(values)?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), - }; - node.reference(*id)?; - changed.insert(*id, node); - } - let mut row_refs = Vec::new(); - for (row_id, cells) in &rows { - let mut row = match changed.remove(row_id) { - Some(row) => row, - None => PropertyObject::from_object(&raw.objects[row_id])?, - }; - let mut cell_refs = Vec::new(); - for cell in cells { - cell_refs.extend_from_slice(&row.reference(*cell)?); - } - row.set(&[(0x24001c20, &cell_refs), (0x14001d7a, &modified)])?; - changed.insert(*row_id, row); - } - let mut table_object = match changed.remove(&table_id) { - Some(object) => object, - None => PropertyObject::from_object(&raw.objects[&table_id])?, - }; - for (row_id, _) in &rows { - row_refs.extend_from_slice(&table_object.reference(*row_id)?); - } - table_object.remove(&[0x1c001d7d, 0x08001d5e])?; - table_object.set( - &table_values - .iter() - .map(|(id, bytes)| (*id, bytes.as_slice())) - .collect::>(), - )?; - table_object.set(&[(0x24001c20, &row_refs)])?; - changed.insert(table_id, table_object); - if is_new { - let mut object = PropertyObject::from_object(&raw.objects[&holder])?; - let reference = object.reference(table_id)?; - object.set(&[(0x24001c1f, &reference), (0x14001d7a, &modified)])?; - changed.insert(holder, object); - } - Ok(changed) - })?; + let holder = previous.is_none().then(|| self.id(*paragraph_id)); + self.write(|active| table::table_changes(active, table_id, holder, &structure))?; } Ok(()) } @@ -1804,11 +976,10 @@ impl<'a> Lowering<'a> { } let mut ancestor = old.container[id]; let mut covered = false; - loop { + // A surviving ancestor is placed where the model says, carrying this paragraph along. + while !new.paragraphs.contains_key(&ancestor) { if removed_outline(ancestor) - || (old.paragraphs.contains_key(&ancestor) - && !new.paragraphs.contains_key(&ancestor) - && !consumed.contains(&ancestor)) + || (old.paragraphs.contains_key(&ancestor) && !consumed.contains(&ancestor)) || (old.children.contains_key(&ancestor) && !old.paragraphs.contains_key(&ancestor) && !old.outlines.contains_key(&ancestor) @@ -1853,120 +1024,116 @@ impl<'a> Lowering<'a> { Ok(()) } - /// Rewrites every equation paragraph whose stored text object differs from the model, the - /// way OneNote stores an equation: the linear text, one run per span with a style - /// carrying the span's format, the run-data array naming each run's inline object, and - /// the math language marker on the text object. + /// Rewrites every equation paragraph whose stored text object differs from the model. fn edit_equations(&mut self, new: &View<'_>) -> Result<(), Error> { let current = self.current()?; - let current = View::new(¤t)?; + let current = View::new(¤t, false)?; for (id, paragraph) in &new.paragraphs { let Some(text) = paragraph.text() else { continue; }; - if !Math::is_equation(&text.text) { + let stored = current.text(self.id(*id)); + // Deleting an equation leaves no math in the model, but its stored runs are still math. + if !super::Math::is_equation(&text.text) + && !stored.is_some_and(|stored| super::Math::is_equation(&stored.text)) + { continue; } - let stored = current - .text(self.id(*id)) + let stored = stored .ok_or_else(|| invalid("An equation paragraph is missing after placement"))?; if stored.text == text.text { continue; } let object = stored.id; - if text.text.text().contains('\u{fffc}') { - return Err(invalid("Equations cannot hold embedded objects")); - } - let encoded: Vec = text - .text - .text() - .encode_utf16() - .chain([0]) - .flat_map(u16::to_le_bytes) - .collect(); - let mut ends = Vec::new(); - let mut styles: Vec = Vec::new(); - let mut sets = Vec::new(); - let all_math = text - .text - .spans() + self.write(|active| content::equation_changes(active, object, &text.text))?; + } + Ok(()) + } + + /// Lifts the paragraphs of outline groups into their container wherever this save changes + /// the container's children or their depths, for placement to see them; `edit_levels` + /// groups them again. + fn ungroup(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { + for (container, children) in &old.children { + let Some(after) = new.children.get(container) else { + continue; + }; + let grouped = self.active.view.nodes[container] + .children .iter() - .all(|s| s.format.math == Some(true)); - for span in text.text.spans() { - ends.extend(text.text.utf16_offset(span.end)?.to_le_bytes()); - styles.push(style_values(&span.format)); - sets.push(match &span.format.math_object { - Some(object) => { - let mut set = vec![(0x1400344f, object.kind.to_le_bytes().to_vec())]; - if let Some(count) = object.arguments { - set.push((0x14003450, count.to_le_bytes().to_vec())); - } - if let Some(columns) = object.columns { - set.push((0x0c003451, vec![columns])); - } - for (id, symbol) in [0x10003453, 0x10003454, 0x10003455] - .into_iter() - .zip(&object.symbols) - { - let unit = u16::try_from(u32::from(*symbol)) - .map_err(|_| invalid("Math symbols are single UTF-16 units"))?; - set.push((id, unit.to_le_bytes().to_vec())); - } - set - } - None => Vec::new(), - }); + .any(|id| matches!(self.active.view.nodes[id].kind, Kind::OutlineGroup)); + if !grouped + || (after == children + && old.depths(*container, children) == new.depths(*container, after)) + { + continue; } - ends.truncate(ends.len() - 4); - let has_objects = sets.iter().any(|set| !set.is_empty()); - let modified = crate::create::current_timestamps()?.0.to_le_bytes(); + let object = *container; + self.write(|active| levels::ungroup_changes(active, object))?; + } + Ok(()) + } + + /// Indents each container's children to the depths the model gives them. + fn edit_levels(&mut self, new: &View<'_>) -> Result<(), Error> { + let current = self.current()?; + let stored = View::new(¤t, false)?; + for (container, children) in &new.children { + let depths = new + .depths(*container, children) + .ok_or_else(|| invalid("A paragraph lies no deeper than its parent"))?; + let object = self.id(*container); + let written: Vec = children.iter().map(|id| self.id(*id)).collect(); + if children.is_empty() || stored.depths(object, &written) == Some(depths.clone()) { + continue; + } + let is_cell = + !new.paragraphs.contains_key(container) && !new.outlines.contains_key(container); + let (level, runs) = levels::runs(&written, &depths, is_cell)?; + self.write(|active| levels::regroup_changes(active, object, level, &runs))?; + } + Ok(()) + } + + /// Gives a paragraph the text object its model names when a split and a join in one save + /// moved text between paragraphs: a stored object is taken over, as OneNote moves a lower + /// paragraph's text into an emptied upper one; a new one starts as a copy of the text + /// the paragraph holds. + fn edit_text_identities(&mut self, new: &View<'_>) -> Result<(), Error> { + for (id, paragraph) in &new.paragraphs { + let Some(text) = paragraph.text() else { + continue; + }; + let object = self.id(*id); + let stored = self.active.view.nodes[&object].content[0]; + if stored == self.id(text.id) { + continue; + } + let target = if self.active.live.revision.objects.contains_key(&text.id) { + text.id + } else { + self.allocate(text.id)? + }; + let unstyled = paragraph.style.is_none(); self.write(|active| { + let parents = active.editable_parents(object)?; + let modified = crate::create::current_timestamps()?.0.to_le_bytes(); let raw = &active.live.revision; let mut changed = BTreeMap::new(); - let mut target = PropertyObject::from_object(&raw.objects[&object])?; - let mut references = Vec::new(); - let mut created: Vec<(Values, ExGuid)> = Vec::new(); - for values in &styles { - let id = match created.iter().find(|(known, _)| known == values) { - Some((_, id)) => *id, - None => { - let id = ExGuid { - guid: crate::write::fresh_guid()?, - n: 1, - }; - let mut style = PropertyObject { - jcid: 0x12004d, - bytes: crate::create::properties(values)?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), - }; - style.reference(id)?; - changed.insert(id, style); - created.push((values.clone(), id)); - id - } - }; - references.extend(target.reference(id)?); + if !raw.objects.contains_key(&target) { + let mut copy = PropertyObject::from_object(&raw.objects[&stored])?; + copy.remove(&[0x1c001c98, 0x14001c99])?; + if unstyled { + copy.remove(&[0x2000342c])?; + } + copy.reference(target)?; + changed.insert(target, copy); } - target.remove(&[0x1c003498, 0x40003499])?; - target.set(&[ - (0x1c001c22, &encoded), - (0x1c001e12, &ends), - (0x24001e13, &references), - (0x14001d7a, &modified), - ])?; - if has_objects { - target.set_sets(0x40003499, 0x44000811, &sets)?; - } - if all_math { - // The flags and language marker OneNote's equation editor leaves on - // every equation text object. - target.set(&[ - (0x10001cfe, &0x7f_u16.to_le_bytes()), - (0x14001c3e, &1u32.to_le_bytes()), - (0x14001c84, &1u32.to_le_bytes()), - ])?; - } - changed.insert(object, target); + let mut holder = PropertyObject::from_object(&raw.objects[&object])?; + let reference = holder.reference(target)?; + holder.set(&[(0x24001c1f, &reference), (0x14001d7a, &modified)])?; + changed.insert(object, holder); + crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?; Ok(changed) })?; } @@ -1975,7 +1142,7 @@ impl<'a> Lowering<'a> { fn edit_text(&mut self, new: &View<'_>) -> Result<(), Error> { let current = self.current()?; - let current = View::new(¤t)?; + let current = View::new(¤t, false)?; for (id, paragraph) in &new.paragraphs { let Some(text) = paragraph.text() else { continue; @@ -2003,7 +1170,7 @@ impl<'a> Lowering<'a> { /// nodes belong to one paragraph, and a dropped reference leaves the node unreferenced. fn edit_lists(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> { let current = self.current()?; - let stored = View::new(¤t)?; + let stored = View::new(¤t, false)?; let mut owners: BTreeMap = BTreeMap::new(); for (id, paragraph) in &stored.paragraphs { for list in ¶graph.lists { @@ -2022,7 +1189,8 @@ impl<'a> Lowering<'a> { .iter() .zip(&previous.lists) .all(|(model, node)| { - after.definitions.get(model) == current.definitions.get(node) + self.id(*model) == *node + && after.definitions.get(model) == current.definitions.get(node) }); if same { continue; @@ -2032,12 +1200,14 @@ impl<'a> Lowering<'a> { // Writer-allocated identities keep a nonzero sequence number so the node's // compact identity is never the null identity; squash renames them to the // model's. A copy for a second owner keeps its allocated identity. - let node_id = match owners.get(list) { - Some(owner) if *owner == image_id => *list, + let node_id = match owners.get(&self.id(*list)) { + Some(owner) if *owner == image_id => self.id(*list), Some(_) => ExGuid { guid: crate::write::fresh_guid()?, n: 1, }, + // A node its paragraph let go of in this save serves the model again. + None if self.active.live.revision.objects.contains_key(list) => *list, None => { let written = ExGuid { guid: crate::write::fresh_guid()?, @@ -2051,113 +1221,10 @@ impl<'a> Lowering<'a> { .definitions .get(list) .ok_or_else(|| invalid("A paragraph references a missing list definition"))?; - let Kind::List { - font, - format, - restart, - bullet, - } = &definition.kind - else { - return Err(invalid("A paragraph list must reference a list definition")); - }; - if bullet.is_some() && (format.is_none() || font.is_none()) { - return Err(invalid("A bullet definition names its glyph and font")); - } - let mut values: Values = Vec::new(); - if let Some(format) = format { - let units: Vec = format.encode_utf16().collect(); - let count = u16::try_from(units.len()) - .map_err(|_| invalid("List format exceeds the document range"))?; - let mut bytes = count.to_le_bytes().to_vec(); - bytes.extend(units.iter().flat_map(|unit| unit.to_le_bytes())); - values.push((0x1c001c1a, bytes)); - } - if let Some(font) = font { - values.push((0x1c001c52, crate::create::string(font))); - } - if let Some(restart) = restart { - values.push((0x14001cb7, restart.to_le_bytes().to_vec())); - } - if let Some(bullet) = bullet { - values.push((0x10001d0e, bullet.to_le_bytes().to_vec())); - // Every native bullet node carries this cleared flag alongside its index. - values.push((0x0c001cc0, vec![0])); - } - let style = &definition.format; - if let Some(font) = &style.font { - values.push((0x1c001c0a, crate::create::string(font))); - } - if let Some(size) = style.font_size { - let half = (size * 2.0).round(); - if !(0.0..=f32::from(u16::MAX)).contains(&half) { - return Err(invalid("List font size is outside the document range")); - } - values.push((0x10001c0b, (half as u16).to_le_bytes().to_vec())); - } - if let Some(color) = style.color { - values.push((0x14001c0c, color.to_le_bytes().to_vec())); - } - if let Some(language) = style.language { - values.push((0x14001c3b, language.to_le_bytes().to_vec())); - } - for (flag, id) in [(style.bold, 0x08001c04), (style.italic, 0x08001c05)] { - if let Some(flag) = flag { - values.push((id | (u32::from(flag) << 31), Vec::new())); - } - } - nodes.push((node_id, values)); + nodes.push((node_id, properties::list_values(definition)?)); } let object = self.id(*id); - self.write(|active| { - let parents = active.editable_parents(object)?; - let modified = crate::create::current_timestamps()?.0.to_le_bytes(); - let raw = &active.live.revision; - let mut changed = BTreeMap::new(); - let mut target = PropertyObject::from_object(&raw.objects[&object])?; - let mut references = Vec::new(); - for (list, values) in &nodes { - let mut node = match raw.objects.get(list) { - Some(existing) => { - if existing.jcid != 0x60012 { - return Err(invalid( - "A list definition identity belongs to another object", - )); - } - let mut node = PropertyObject::from_object(existing)?; - node.remove(&[ - 0x1c001c1a, 0x1c001c52, 0x14001cb7, 0x10001d0e, 0x0c001cc0, - 0x1c001c0a, 0x10001c0b, 0x14001c0c, 0x14001c3b, 0x08001c04, - 0x08001c05, - ])?; - node - } - None => PropertyObject { - jcid: 0x60012, - bytes: crate::create::properties(&[])?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, list.guid)])), - }, - }; - node.set( - &values - .iter() - .map(|(id, bytes)| (*id, bytes.as_slice())) - .collect::>(), - )?; - node.set(&[(0x14001d7a, &modified)])?; - node.reference(*list)?; - references.extend_from_slice(&target.reference(*list)?); - changed.insert(*list, node); - } - if references.is_empty() { - target.remove(&[0x24001c26])?; - } else { - target.set(&[(0x24001c26, &references)])?; - } - target.set(&[(0x14001d7a, &modified)])?; - changed.insert(object, target); - crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?; - Ok(changed) - })?; + self.write(|active| properties::list_changes(active, object, &nodes))?; } Ok(()) } @@ -2166,23 +1233,8 @@ impl<'a> Lowering<'a> { /// the stored ones. A tag definition new to the section becomes a definition object /// carrying the model's identity after squash. fn edit_tags(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> { - fn same(a: &[Tag], b: &[Tag]) -> bool { - let key = |t: &Tag| { - ( - t.definition, - t.action_type, - t.status, - t.created, - t.completed, - t.start, - t.due, - t.task_id, - ) - }; - a.len() == b.len() && a.iter().zip(b).all(|(x, y)| key(x) == key(y)) - } let current = self.current()?; - let stored = View::new(¤t)?; + let stored = View::new(¤t, false)?; for (id, paragraph) in &new.paragraphs { let image_id = self.id(*id); let previous = stored @@ -2194,28 +1246,15 @@ impl<'a> Lowering<'a> { targets.push((self.id(text.id), &text.tags, &before.tags)); } for (object, tags, stored_tags) in targets { - if same(tags, stored_tags) { + if crate::op::lower::same_tags(tags, stored_tags) { continue; } - let mut definitions: Vec<(ExGuid, Option)> = Vec::new(); - let mut sets: Vec<(usize, Values)> = Vec::new(); - let mut action_types = BTreeSet::new(); + let mut entries = Vec::new(); for tag in tags { let definition = tag .definition .ok_or_else(|| invalid("A note tag names its definition"))?; - let action_type = if tag.status & 4 != 0 { - tag.action_type - } else { - match after.definitions.get(&definition).map(|d| &d.kind) { - Some(Kind::TagDefinition { action_type, .. }) => *action_type, - _ => return Err(invalid("A note tag must reference a tag definition")), - } - }; - if !action_types.insert(action_type.unwrap_or(0)) { - return Err(invalid("An element holds one note tag per action type")); - } - let known = current.definitions.contains_key(&definition) + let known = self.active.live.revision.objects.contains_key(&definition) || self.alias.contains_key(&definition); let written = if known { self.id(definition) @@ -2227,107 +1266,9 @@ impl<'a> Lowering<'a> { self.alias.insert(definition, allocated); allocated }; - let index = match definitions.iter().position(|(id, _)| *id == written) { - Some(index) => index, - None => { - let values = if known { - None - } else { - let Some(model) = after.definitions.get(&definition) else { - return Err(invalid( - "A note tag references a missing tag definition", - )); - }; - let Kind::TagDefinition { - label, - action_type, - shape, - color, - highlight, - } = &model.kind - else { - return Err(invalid( - "A note tag must reference a tag definition", - )); - }; - let mut values: Values = vec![ - (0x0c003473, vec![0]), - (0x10003463, action_type.unwrap_or(0).to_le_bytes().to_vec()), - (0x10003464, shape.unwrap_or(0).to_le_bytes().to_vec()), - (0x14003467, 0u32.to_le_bytes().to_vec()), - ]; - if let Some(label) = label { - values.push((0x1c003468, crate::create::string(label))); - } - if let Some(color) = color { - values.push((0x14003466, color.to_le_bytes().to_vec())); - } - if let Some(highlight) = highlight { - values.push((0x14003465, highlight.to_le_bytes().to_vec())); - } - Some(values) - }; - definitions.push((written, values)); - definitions.len() - 1 - } - }; - let mut fields: Values = Vec::new(); - if let Some(action_type) = tag.action_type { - fields.push((0x10003463, action_type.to_le_bytes().to_vec())); - } - for (id, value) in [ - (0x1400346e, tag.created), - (0x1400346f, tag.completed), - (0x1400346a, tag.start), - (0x1400346b, tag.due), - ] { - if let Some(value) = value { - fields.push((id, value.to_le_bytes().to_vec())); - } - } - fields.push((0x10003470, tag.status.to_le_bytes().to_vec())); - if let Some(task) = tag.task_id { - fields.push((0x1c003469, task.to_vec())); - } - sets.push((index, fields)); + entries.push((written, tag, after.definitions.get(&definition))); } - self.write(|active| { - let parents = active.editable_parents(object)?; - let modified = crate::create::current_timestamps()?.0.to_le_bytes(); - let raw = &active.live.revision; - let mut changed = BTreeMap::new(); - for (id, values) in &definitions { - let Some(values) = values else { - continue; - }; - let mut node = PropertyObject { - jcid: 0x120043, - bytes: crate::create::properties(values)?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), - }; - node.reference(*id)?; - changed.insert(*id, node); - } - let mut target = PropertyObject::from_object(&raw.objects[&object])?; - let mut encoded = Vec::new(); - for (index, fields) in &sets { - let reference = target.reference(definitions[*index].0)?; - let mut set = vec![(0x20003488, reference.to_vec())]; - set.extend(fields.iter().cloned()); - encoded.push(set); - } - target.set_sets(0x40003489, 0x44000811, &encoded)?; - target.set(&[(0x14001d7a, &modified)])?; - changed.insert(object, target); - crate::formatting::touch_ancestors( - raw, - parents, - object, - &modified, - &mut changed, - )?; - Ok(changed) - })?; + self.write(|active| properties::tag_changes(active, object, &entries))?; } } Ok(()) @@ -2342,7 +1283,6 @@ impl<'a> Lowering<'a> { new: &View<'_>, after: &Page, ) -> Result<(), Error> { - let current = self.current()?; for (id, paragraph) in &new.paragraphs { let Some(definition) = paragraph.style else { continue; @@ -2357,7 +1297,7 @@ impl<'a> Lowering<'a> { let Some(text) = paragraph.text() else { return Err(invalid("Only text paragraphs take a paragraph style")); }; - let known = current.definitions.contains_key(&definition) + let known = self.active.live.revision.objects.contains_key(&definition) || self.alias.contains_key(&definition); let style_id = if known { self.id(definition) @@ -2369,59 +1309,16 @@ impl<'a> Lowering<'a> { self.alias.insert(definition, allocated); allocated }; - let values = if known { - None - } else { - let Some(model) = after.definitions.get(&definition) else { - return Err(invalid("A paragraph references a missing style definition")); - }; - let Kind::Style { name } = &model.kind else { - return Err(invalid("A paragraph style must be a style definition")); - }; - let mut values = style_values(&model.format); - if let Some(name) = name { - values.push((0x1c00345a, crate::create::string(name))); - } - if let Some(alignment) = model.format.alignment { - values.push((0x0c003477, vec![alignment])); - } - for (property, value) in [ - (0x1400342e, model.format.space_before), - (0x1400342f, model.format.space_after), - (0x14003430, model.format.line_spacing), - ] { - if let Some(points) = value { - values.push((property, (points / 36.0).to_le_bytes().to_vec())); - } - } - Some(values) - }; let object = self.id(text.id); - self.write(|active| { - let raw = &active.live.revision; - let mut changed = BTreeMap::new(); - if let Some(values) = &values { - let mut node = PropertyObject { - jcid: 0x12004d, - bytes: crate::create::properties(values)?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, style_id.guid)])), - }; - node.reference(style_id)?; - changed.insert(style_id, node); - } - let mut target = PropertyObject::from_object(&raw.objects[&object])?; - let reference = target.reference(style_id)?; - target.set(&[(0x2000342c, &reference)])?; - changed.insert(object, target); - Ok(changed) - })?; + let definition = after.definitions.get(&definition); + self.write(|active| properties::style_changes(active, object, style_id, definition))?; } Ok(()) } fn edit_paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { let current = self.current()?; - let current = View::new(¤t)?; + let current = View::new(¤t, false)?; for (id, paragraph) in &new.paragraphs { let Some(text) = paragraph.text() else { continue; @@ -2429,51 +1326,7 @@ impl<'a> Lowering<'a> { let stored = current .text(self.id(*id)) .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; - let mut values: Vec<(u32, Vec)> = Vec::new(); - macro_rules! field { - ($field:ident, $value:ident, $encode:block) => { - let $value = format_in(&text.text, 0)?.$field.unwrap_or_default(); - if text - .text - .spans() - .iter() - .all(|span| span.format.$field.unwrap_or_default() == $value) - && stored - .text - .spans() - .iter() - .any(|span| span.format.$field.unwrap_or_default() != $value) - { - values.push($encode); - } - }; - } - field!(alignment, value, { - if value > 2 { - return Err(invalid("Paragraph alignment must be left, center or right")); - } - (0x0c003477, vec![value]) - }); - field!(rtl, value, { - (0x08003476 | (u32::from(value) << 31), Vec::new()) - }); - macro_rules! spacing { - ($field:ident, $property:expr) => { - field!($field, value, { - let stored = value / 36.0; - if !stored.is_finite() || !(0.0..=27777.777).contains(&stored) { - return Err(invalid("Paragraph spacing is outside the document range")); - } - ($property, stored.to_le_bytes().to_vec()) - }); - }; - } - spacing!(space_before, 0x1400342e); - spacing!(space_after, 0x1400342f); - spacing!(line_spacing, 0x14003430); - field!(language, value, { - (0x14001c3b, value.to_le_bytes().to_vec()) - }); + let values = crate::op::lower::paragraph_change(&stored.text, &text.text)?; if values.is_empty() { continue; } @@ -2483,48 +1336,14 @@ impl<'a> Lowering<'a> { )); } let object = stored.id; - self.write(|active| { - let parents = active.editable_parents(object)?; - let modified = crate::create::current_timestamps()?.0.to_le_bytes(); - let raw = &active.live.revision; - let mut target = PropertyObject::from_object(&raw.objects[&object])?; - target.set( - &values - .iter() - .map(|(id, bytes)| (*id, bytes.as_slice())) - .collect::>(), - )?; - if let Some((_, alignment)) = values.iter().find(|(id, _)| *id == 0x0c003477) { - for property in [0x14001c3e, 0x14001c84] { - let fields = PropertySets::parse(&target.bytes)?; - let previous = fields.sets[0] - .iter() - .find(|field| field.id == property) - .map(|field| match field.value { - Value::Bytes(bytes) => bytes - .try_into() - .map(u32::from_le_bytes) - .map_err(|_| invalid("Invalid paragraph layout alignment")), - _ => Err(invalid("Invalid paragraph layout alignment")), - }) - .transpose()? - .unwrap_or(0); - let value = (previous & !7) | (u32::from(alignment[0]) + 1); - target.set(&[(property, &value.to_le_bytes())])?; - } - } - target.set(&[(0x14001d7a, &modified)])?; - let mut changed = BTreeMap::from([(object, target)]); - crate::formatting::touch_ancestors(raw, parents, object, &modified, &mut changed)?; - Ok(changed) - })?; + self.write(|active| properties::paragraph_format_changes(active, object, &values))?; } Ok(()) } fn edit_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { let current = self.current()?; - let current = View::new(¤t)?; + let current = View::new(¤t, false)?; for (id, paragraph) in &new.paragraphs { let Some(text) = paragraph.text() else { continue; @@ -2536,56 +1355,10 @@ impl<'a> Lowering<'a> { return Err(invalid("Text edits did not converge on the model")); } let fresh = self.alias.contains_key(&text.id); - let mut boundaries = BTreeSet::new(); - for paragraph in [&stored.text, &text.text] { - for span in paragraph.spans() { - boundaries.insert(paragraph.utf16_offset(span.end)?); - } - } - boundaries.insert(0); - let boundaries: Vec = boundaries.into_iter().collect(); - let mut pending: Option<(Range, Vec)> = None; - let mut edits = Vec::new(); - for window in boundaries.windows(2) { - let (start, end) = (window[0], window[1]); - if start == end { - continue; - } - let attributes = attributes( - format_in(&stored.text, start)?, - format_in(&text.text, start)?, - fresh, - )?; - match &mut pending { - Some((range, previous)) if *previous == attributes && range.end == start => { - range.end = end; - } - _ => { - if let Some(edit) = pending.take() { - edits.push(edit); - } - pending = Some((start..end, attributes)); - } - } - } - edits.extend(pending); - if text.text.text().is_empty() { - let attributes = attributes( - format_in(&stored.text, 0)?, - format_in(&text.text, 0)?, - fresh, - )?; - if !attributes.is_empty() { - edits.push((0..0, attributes)); - } - } - for (range, attributes) in edits { - if attributes.is_empty() { - continue; - } + for (range, set, clear) in crate::op::lower::format_edits(&stored.text, &text.text, fresh)? { let object = stored.id; self.write(|active| { - crate::formatting::format_changes(active, object, range, &attributes) + crate::formatting::format_changes(active, object, range, &set, &clear) })?; } } @@ -2594,7 +1367,7 @@ impl<'a> Lowering<'a> { fn edit_layout(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { let current = self.current()?; - let current = View::new(¤t)?; + let current = View::new(¤t, false)?; for (id, paragraph) in &new.paragraphs { let stored = current .paragraphs @@ -2621,7 +1394,7 @@ impl<'a> Lowering<'a> { && !outline.indents.is_empty() && outline.indents != stored.indents { - let indents = measurement_bytes(&outline.indents, 4)?; + let indents = content::measurement_bytes(&outline.indents, 4)?; self.write(|active| { let raw = &active.live.revision; let mut node = PropertyObject::from_object(&raw.objects[&object])?; @@ -2654,217 +1427,6 @@ impl<'a> Lowering<'a> { } } -fn collect_containers(list: &[PageParagraph], out: &mut Vec) { - for paragraph in list { - out.push(paragraph.id); - if let ParagraphContent::Table(table) = ¶graph.content { - for row in &table.rows { - for cell in &row.cells { - out.push(cell.id); - collect_containers(&cell.paragraphs, out); - } - } - } - } -} - -/// Identities that keep their stored position: a longest increasing run of survivors, -/// always including immovable ones. `after` may contain identities absent from `before`. -fn kept_set( - before: &[ExGuid], - after: &[ExGuid], - movable: impl Fn(ExGuid) -> bool, -) -> Result, Error> { - let position: BTreeMap = - after.iter().enumerate().map(|(i, id)| (*id, i)).collect(); - let sequence: Vec<(ExGuid, usize)> = before - .iter() - .filter_map(|id| position.get(id).map(|at| (*id, *at))) - .collect(); - let fixed: Vec = sequence - .iter() - .filter(|(id, _)| !movable(*id)) - .map(|(_, at)| *at) - .collect(); - if fixed.windows(2).any(|w| w[0] > w[1]) { - return Err(invalid( - "Images and unsupported objects cannot be reordered", - )); - } - if sequence.windows(2).all(|pair| pair[0].1 < pair[1].1) { - return Ok(sequence.into_iter().map(|(id, _)| id).collect()); - } - // Longest increasing subsequence over `after` positions, forced through immovable items. - let n = sequence.len(); - let mut best = vec![1usize; n]; - let mut previous = vec![usize::MAX; n]; - for i in 0..n { - let (id, at) = sequence[i]; - let mandatory_before = sequence[..i] - .iter() - .filter(|(other, _)| !movable(*other)) - .map(|(_, at)| *at) - .max(); - if movable(id) && mandatory_before.is_some_and(|m| m > at) { - best[i] = 0; - continue; - } - let mandatory_after = sequence[i + 1..] - .iter() - .filter(|(other, _)| !movable(*other)) - .map(|(_, at)| *at) - .min(); - if movable(id) && mandatory_after.is_some_and(|m| m < at) { - best[i] = 0; - continue; - } - for j in 0..i { - if best[j] > 0 && sequence[j].1 < at && best[j] + 1 > best[i] { - best[i] = best[j] + 1; - previous[i] = j; - } - } - } - let mut kept = BTreeSet::new(); - if let Some((mut i, _)) = best - .iter() - .enumerate() - .max_by_key(|(i, b)| (**b, usize::MAX - i)) - && best[i] > 0 - { - loop { - kept.insert(sequence[i].0); - if previous[i] == usize::MAX { - break; - } - i = previous[i]; - } - } - for (id, _) in &sequence { - if !movable(*id) && !kept.contains(id) { - return Err(invalid( - "Images and unsupported objects cannot be reordered", - )); - } - } - Ok(kept) -} - -/// The format of the span containing the UTF-16 position `at` (the last span at the end). -fn format_in(paragraph: &super::Paragraph, at: u32) -> Result<&Format, Error> { - let byte = paragraph.byte_offset(at)?; - let spans = paragraph.spans(); - let index = spans - .partition_point(|span| span.end <= byte) - .min(spans.len() - 1); - Ok(&spans[index].format) -} - -/// The smallest UTF-16 range whose replacement turns `before` into `after`. -fn text_edit(before: &str, after: &str) -> Result<(Range, String), Error> { - let prefix = before - .char_indices() - .zip(after.chars()) - .take_while(|((_, a), b)| a == b) - .map(|((i, a), _)| i + a.len_utf8()) - .last() - .unwrap_or(0); - let suffix = before[prefix..] - .chars() - .rev() - .zip(after[prefix..].chars().rev()) - .take_while(|(a, b)| a == b) - .map(|(a, _)| a.len_utf8()) - .sum::(); - let units = |s: &str| -> Result { - u32::try_from(s.encode_utf16().count()) - .map_err(|_| invalid("Text exceeds UTF-16 offset range")) - }; - let start = units(&before[..prefix])?; - let end = start + units(&before[prefix..before.len() - suffix])?; - Ok((start..end, after[prefix..after.len() - suffix].to_owned())) -} - -/// Explicit attributes turning `current` into `target`; unsupported differences are errors. -/// A `fresh` text object was inserted by this edit, so an unspecified target value keeps -/// the insertion's default instead of demanding an inherited value the image cannot restore. -/// Language tags are retained as stored because the model has no way to author them. -fn attributes(current: &Format, target: &Format, fresh: bool) -> Result, Error> { - let mut out = Vec::new(); - let inherited = || invalid("Inherited character formatting cannot be restored"); - // An absent flag and an explicit false are the same formatting. - macro_rules! boolean { - ($field:ident, $variant:ident) => { - if current.$field.unwrap_or(false) != target.$field.unwrap_or(false) { - out.push(TextAttribute::$variant(target.$field.unwrap_or(false))); - } - }; - } - boolean!(bold, Bold); - boolean!(italic, Italic); - boolean!(underline, Underline); - boolean!(strike, Strike); - boolean!(superscript, Superscript); - boolean!(subscript, Subscript); - boolean!(hidden, Hidden); - boolean!(hyperlink, Hyperlink); - boolean!(hyperlink_label, HyperlinkLabel); - if current.font != target.font { - match &target.font { - Some(font) => out.push(TextAttribute::Font(font.clone())), - None if fresh => {} - None => return Err(inherited()), - } - } - if current.font_size != target.font_size { - match target.font_size { - Some(size) => out.push(TextAttribute::FontSize(size)), - None if fresh => {} - None => return Err(inherited()), - } - } - let color = - |value: u32| (value != 0xff000000).then(|| value.to_le_bytes()[..3].try_into().unwrap()); - if current.color != target.color { - match target.color { - Some(value) => out.push(TextAttribute::Color(color(value))), - None if fresh => {} - None => return Err(inherited()), - } - } - if current.highlight != target.highlight { - match target.highlight { - Some(value) => out.push(TextAttribute::Highlight(color(value))), - None if fresh => {} - None => return Err(inherited()), - } - } - if current.language != target.language { - match target.language { - Some(language) => out.push(TextAttribute::Language(language)), - None if fresh => {} - None => return Err(inherited()), - } - } - // An absent value and its stored default are the same formatting. - let flag = |a: Option, b: Option| a.unwrap_or(false) == b.unwrap_or(false); - let points = |a: Option, b: Option| a.unwrap_or(0.0) == b.unwrap_or(0.0); - let same_rest = flag(current.math, target.math) - && flag(current.embedded_object, target.embedded_object) - && current.alignment.unwrap_or(0) == target.alignment.unwrap_or(0) - && flag(current.rtl, target.rtl) - && points(current.space_before, target.space_before) - && points(current.space_after, target.space_after) - && points(current.line_spacing, target.line_spacing) - && points(current.list_spacing, target.list_spacing); - if !same_rest { - return Err(invalid( - "Fields and paragraph spacing cannot be edited through the page model", - )); - } - Ok(out) -} - /// Payload identities the file-data store of `store` declares, in order. pub(crate) fn declared_payloads(store: &Store<'_>) -> Vec<[u8; 16]> { store @@ -3002,7 +1564,10 @@ fn remap(object: &mut PropertyObject, rename: &BTreeMap) -> Resu #[cfg(test)] mod tests { use super::*; - use crate::page::{PageParagraph, Paragraph, TextObject, text::new_id}; + use crate::{ + document::Format, + page::{PageParagraph, Paragraph, TextObject, text::new_id}, + }; /// A new page in a new section and the page with `count` paragraphs added in an outline. fn generated(count: usize) -> (Vec, ExGuid, Page) { diff --git a/crates/onestore/src/pages.rs b/crates/onestore/src/pages.rs index c007c99357976a1389251c94f053376c530e53bd..dcb87cb8868234d9523e7fd08f093fa329a490b0 100644 --- a/crates/onestore/src/pages.rs +++ b/crates/onestore/src/pages.rs @@ -2,6 +2,7 @@ use crate::{ Error, ExGuid, create::{current_timestamps, properties, string}, document::{Document, FieldValue, Kind}, + op::content::{NATIVE_INDENTS, measurement_bytes}, write::{PropertyObject, RevisionEdit, fresh_guid, write_revisions}, }; use serde::{Deserialize, Serialize}; @@ -129,11 +130,101 @@ impl PageEdit { } } +/// What page edits read of one page space: its metadata element and the stored manifest +/// and metadata objects. +pub(crate) struct PageParts<'a> { + pub metadata: crate::document::Element<'a>, + pub manifest: (ExGuid, PropertyObject), + pub stored_metadata: (ExGuid, PropertyObject), +} + pub(crate) fn edit_pages( source: &[u8], edits: &[PageEdit], removals: &[ExGuid], ) -> Result, Error> { + write_revisions(source, |index| { + let document = Document::parse(index)?; + let pages: Vec = document.pages()?.into_iter().map(|(sid, _)| sid).collect(); + let root = document.active(document.root)?; + let raw = index.resolve_active(document.root)?; + let parts = |sid: ExGuid| -> Result, Error> { + let page = document.active(sid)?; + let revision = index.resolve_active(sid)?; + let metadata = page + .roots + .get(&2) + .and_then(|id| page.nodes.get(id)) + .ok_or_else(|| invalid("Page metadata is unavailable"))?; + let (manifest, stored) = (revision.roots[&1], revision.roots[&2]); + Ok(PageParts { + metadata: metadata.clone(), + manifest: (manifest, PropertyObject::from_object(&revision.objects[&manifest])?), + stored_metadata: (stored, PropertyObject::from_object(&revision.objects[&stored])?), + }) + }; + let Some(changes) = page_changes(document.root, root, &raw, &pages, parts, edits, removals)? else { + return Ok(BTreeMap::new()); + }; + Ok(changes + .into_iter() + .map(|(space, changes)| (space, RevisionEdit::Update(changes))) + .collect()) + }) +} + +/// `edit_pages` on a section kept open: the objects each changed space stores. +pub(crate) fn section_changes( + section: &mut crate::Section<'_>, + edits: &[PageEdit], + removals: &[ExGuid], +) -> Result, Error> { + let root = section.root(); + let (view, raw) = { + let page = section.active(root)?; + (page.view.clone(), page.live.revision.clone()) + }; + let section_id = *view + .roots + .get(&1) + .ok_or_else(|| invalid("Section root is unavailable"))?; + let pages: Vec = view.nodes[§ion_id] + .children + .iter() + .flat_map(|series| view.nodes[series].spaces.clone()) + .collect(); + let section = &*section; + let parts = |sid: ExGuid| -> Result, Error> { + let revision = section.revision(sid)?; + let (manifest, stored) = (revision.roots[&1], revision.roots[&2]); + Ok(PageParts { + metadata: crate::document::Element::parse_with( + &revision.objects[&stored], + crate::FileType::Section, + &mut |_| Err(invalid("Page metadata holds no payload")), + )?, + manifest: (manifest, PropertyObject::from_object(&revision.objects[&manifest])?), + stored_metadata: (stored, PropertyObject::from_object(&revision.objects[&stored])?), + }) + }; + Ok(page_changes(root, &view, &raw, &pages, parts, edits, removals)? + .unwrap_or_default() + .into_iter() + .filter(|(_, changes)| !changes.is_empty()) + .collect()) +} + +/// The objects page moves, indentation and removals change in each space; none when the +/// order and levels stay. +fn page_changes<'a>( + root: ExGuid, + view: &crate::document::Revision<'_>, + raw: &crate::ResolvedRevision<'_>, + pages: &[ExGuid], + parts: impl Fn(ExGuid) -> Result, Error>, + edits: &[PageEdit], + removals: &[ExGuid], +) -> Result>, Error> { if !edits.is_empty() && !removals.is_empty() { return Err(invalid( "Choose page placement or page removal for one operation", @@ -153,252 +244,242 @@ pub(crate) fn edit_pages( )); } } - write_revisions(source, |index| { - let document = Document::parse(index)?; - let pages = document.pages()?; - let view = document.active(document.root)?; - let section_id = view.roots[&1]; - let section_node = &view.nodes[§ion_id]; - if !matches!(section_node.kind, Kind::Section { .. }) - || section_node.extra[0] + let section_id = view.roots[&1]; + let section_node = &view.nodes[§ion_id]; + if !matches!(section_node.kind, Kind::Section { .. }) + || section_node.extra[0] + .iter() + .any(|field| field.id == 0x88001cde) + { + return Err(invalid("Choose an editable section for page editing")); + } + let mut order = Vec::new(); + let mut levels = BTreeMap::new(); + let mut page_guids = BTreeMap::new(); + let mut stored = BTreeMap::new(); + for sid in pages { + let page = parts(*sid)?; + let Kind::Metadata { level, .. } = page.metadata.kind else { + return Err(invalid("Choose ordinary pages with page metadata")); + }; + let level = level.unwrap_or(1); + if !(1..=3).contains(&level) || levels.insert(*sid, level).is_some() { + return Err(invalid( + "Each page space must contain one page with a valid level", + )); + } + if page_guids.insert(metadata_guid(&page.metadata)?, *sid).is_some() { + return Err(invalid("Pages must have distinct metadata identifiers")); + } + order.push(*sid); + stored.insert(*sid, page); + } + let original_order = order.clone(); + let original_levels = levels.clone(); + let mut series_by_head = BTreeMap::new(); + let mut copies = BTreeMap::new(); + let mut copy_ids = BTreeSet::new(); + for oid in §ion_node.children { + let series = &view.nodes[oid]; + let Some(head) = series.spaces.first() else { + return Err(invalid("A page series must contain at least one page")); + }; + if levels.get(head) != Some(&1) + || series.spaces[1..] .iter() - .any(|field| field.id == 0x88001cde) + .any(|sid| levels.get(sid).is_none_or(|level| *level == 1)) { - return Err(invalid("Choose an editable section for page editing")); - } - let mut order = Vec::new(); - let mut levels = BTreeMap::new(); - let mut page_guids = BTreeMap::new(); - for (sid, _) in &pages { - let page = document.active(*sid)?; - let Some(metadata) = page.roots.get(&2).and_then(|id| page.nodes.get(id)) else { - return Err(invalid("Page metadata is unavailable")); - }; - let Kind::Metadata { level, .. } = metadata.kind else { - return Err(invalid("Choose ordinary pages with page metadata")); - }; - let level = level.unwrap_or(1); - if !(1..=3).contains(&level) || levels.insert(*sid, level).is_some() { - return Err(invalid( - "Each page space must contain one page with a valid level", - )); - } - if page_guids.insert(metadata_guid(metadata)?, *sid).is_some() { - return Err(invalid("Pages must have distinct metadata identifiers")); - } - order.push(*sid); - } - let original_order = order.clone(); - let original_levels = levels.clone(); - let mut series_by_head = BTreeMap::new(); - let mut copies = BTreeMap::new(); - let mut copy_ids = BTreeSet::new(); - for oid in §ion_node.children { - let series = &view.nodes[oid]; - let Some(head) = series.spaces.first() else { - return Err(invalid("A page series must contain at least one page")); - }; - if levels.get(head) != Some(&1) - || series.spaces[1..] - .iter() - .any(|sid| levels.get(sid).is_none_or(|level| *level == 1)) - { - return Err(invalid( - "A page series must start with its only top-level page", - )); - } - if let Some(retained) = series.spaces.iter().find(|sid| !removed.contains(sid)) { - series_by_head.insert(*retained, *oid); - } - if let Some(field) = series.extra[0].iter().find(|field| field.id == 0x24003442) { - let FieldValue::Objects(ids) = &field.value else { - return Err(invalid("Page metadata copies must be object references")); - }; - if ids.len() != series.spaces.len() { - return Err(invalid("Page metadata copies must match their series")); - } - for id in ids { - if !copy_ids.insert(*id) - || !matches!( - view.nodes.get(id).map(|n| &n.kind), - Some(Kind::Metadata { .. }) - ) - { - return Err(invalid("Each page needs its own ordinary metadata copy")); - } - let sid = page_guids - .get(&metadata_guid(&view.nodes[id])?) - .filter(|sid| series.spaces.contains(sid)) - .ok_or_else(|| { - invalid("Metadata copy must identify a page in its series") - })?; - if copies.insert(*sid, *id).is_some() { - return Err(invalid("Each page needs its own ordinary metadata copy")); - } - } - } - } - for sid in &removed { - if !levels.contains_key(sid) { - return Err(invalid("The selected page is no longer in the section")); - } - } - order.retain(|sid| !removed.contains(sid)); - if !removed.is_empty() - && let Some(first) = order.first() - { - levels.insert(*first, 1); - } - for edit in edits { - let Some(at) = order.iter().position(|sid| *sid == edit.space) else { - return Err(invalid("The selected page is no longer in the section")); - }; - levels.insert(edit.space, edit.level); - if let PagePosition::Before(before) = edit.position { - order.remove(at); - let position = match before { - None => order.len(), - Some(before) => { - order.iter().position(|sid| *sid == before).ok_or_else(|| { - invalid("The movement anchor is no longer in the section") - })? - } - }; - order.insert(position, edit.space); - } - } - if order.first().is_some_and(|sid| levels[sid] != 1) { return Err(invalid( - "The section's first page must have indentation level 1", + "A page series must start with its only top-level page", )); } - if order == original_order && levels == original_levels { - return Ok(BTreeMap::new()); + if let Some(retained) = series.spaces.iter().find(|sid| !removed.contains(sid)) { + series_by_head.insert(*retained, *oid); } - let raw = index.resolve_active(document.root)?; + if let Some(field) = series.extra[0].iter().find(|field| field.id == 0x24003442) { + let FieldValue::Objects(ids) = &field.value else { + return Err(invalid("Page metadata copies must be object references")); + }; + if ids.len() != series.spaces.len() { + return Err(invalid("Page metadata copies must match their series")); + } + for id in ids { + if !copy_ids.insert(*id) + || !matches!( + view.nodes.get(id).map(|n| &n.kind), + Some(Kind::Metadata { .. }) + ) + { + return Err(invalid("Each page needs its own ordinary metadata copy")); + } + let sid = page_guids + .get(&metadata_guid(&view.nodes[id])?) + .filter(|sid| series.spaces.contains(sid)) + .ok_or_else(|| { + invalid("Metadata copy must identify a page in its series") + })?; + if copies.insert(*sid, *id).is_some() { + return Err(invalid("Each page needs its own ordinary metadata copy")); + } + } + } + } + for sid in &removed { + if !levels.contains_key(sid) { + return Err(invalid("The selected page is no longer in the section")); + } + } + order.retain(|sid| !removed.contains(sid)); + if !removed.is_empty() + && let Some(first) = order.first() + { + levels.insert(*first, 1); + } + for edit in edits { + let Some(at) = order.iter().position(|sid| *sid == edit.space) else { + return Err(invalid("The selected page is no longer in the section")); + }; + levels.insert(edit.space, edit.level); + if let PagePosition::Before(before) = edit.position { + order.remove(at); + let position = match before { + None => order.len(), + Some(before) => { + order.iter().position(|sid| *sid == before).ok_or_else(|| { + invalid("The movement anchor is no longer in the section") + })? + } + }; + order.insert(position, edit.space); + } + } + if order.first().is_some_and(|sid| levels[sid] != 1) { + return Err(invalid( + "The section's first page must have indentation level 1", + )); + } + if order == original_order && levels == original_levels { + return Ok(None); + } let mut groups: Vec> = Vec::new(); - for sid in order { - if levels[&sid] == 1 { - groups.push(Vec::new()); - } - groups.last_mut().unwrap().push(sid); + for sid in order { + if levels[&sid] == 1 { + groups.push(Vec::new()); } - let mut changes = BTreeMap::new(); - for sid in &removed { - let page = index.resolve_active(*sid)?; - let manifest_id = page.roots[&1]; - let mut manifest = PropertyObject::from_object(&page.objects[&manifest_id])?; - if manifest.jcid != 0x60037 { - return Err(invalid("Choose ordinary pages for removal")); - } - manifest.bytes = properties(&[])?; - let metadata_id = page.roots[&2]; - let mut metadata = PropertyObject::from_object(&page.objects[&metadata_id])?; - metadata.set(&[(0x88001de9, &[])])?; - changes.insert( - *sid, - RevisionEdit::Update(BTreeMap::from([ - (manifest_id, manifest), - (metadata_id, metadata), - ])), - ); + groups.last_mut().unwrap().push(sid); + } + let mut changes = BTreeMap::new(); + for sid in &removed { + let page = &stored[sid]; + let (manifest_id, mut manifest) = page.manifest.clone(); + if manifest.jcid != 0x60037 { + return Err(invalid("Choose ordinary pages for removal")); } - let mut replacements = BTreeMap::new(); - let mut children = Vec::new(); - for spaces in groups { - let head = spaces[0]; - let old_id = series_by_head.get(&head).copied(); - let (id, mut series) = if let Some(id) = old_id { - (id, PropertyObject::from_object(&raw.objects[&id])?) - } else { - let edit = selected - .get(&head) - .ok_or_else(|| invalid("A new series must start at an edited page"))?; - let id = ExGuid { - guid: edit.guid, - n: 1, - }; - if raw.objects.contains_key(&id) { - return Err(invalid("The new page-series identity already exists")); - } - ( - id, - PropertyObject { - jcid: 0x60008, - bytes: properties(&[(0x1c001c30, edit.guid.to_vec())])?, - global_ids: Arc::new(BTreeMap::from([(0, edit.guid)])), - }, - ) + manifest.bytes = properties(&[])?; + let (metadata_id, mut metadata) = page.stored_metadata.clone(); + metadata.set(&[(0x88001de9, &[])])?; + changes.insert( + *sid, + BTreeMap::from([(manifest_id, manifest), (metadata_id, metadata)]), + ); + } + let mut replacements = BTreeMap::new(); + let mut children = Vec::new(); + for spaces in groups { + let head = spaces[0]; + let old_id = series_by_head.get(&head).copied(); + let (id, mut series) = if let Some(id) = old_id { + (id, PropertyObject::from_object(&raw.objects[&id])?) + } else { + let edit = selected + .get(&head) + .ok_or_else(|| invalid("A new series must start at an edited page"))?; + let id = ExGuid { + guid: edit.guid, + n: 1, }; - children.push(id); - let membership_changed = old_id.is_none_or(|old| view.nodes[&old].spaces != spaces); - if !membership_changed - && !spaces.iter().any(|sid| { - levels[sid] != original_levels[sid] - || copies.get(sid).is_some_and(|id| { - matches!(view.nodes[id].kind, Kind::Metadata { level, .. } - if level.unwrap_or(1) != levels[sid]) - }) - }) - { - continue; + if raw.objects.contains_key(&id) { + return Err(invalid("The new page-series identity already exists")); } - let mut metadata_ids = Vec::new(); - for sid in &spaces { - let page = index.resolve_active(*sid)?; - let page_id = page.roots[&2]; - let original = PropertyObject::from_object(&page.objects[&page_id])?; - if old_id.is_none() && *sid == head { - series.copy_property(&original, 0x18001c65)?; - } - if levels[sid] != original_levels[sid] { - let mut metadata = PropertyObject::from_object(&page.objects[&page_id])?; - metadata.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; - changes.insert( - *sid, - RevisionEdit::Update(BTreeMap::from([(page_id, metadata)])), - ); - } - let copy_id = copies - .get(sid) - .copied() - .unwrap_or_else(|| metadata_id(*sid)); - let mut copy = match raw.objects.get(©_id) { - Some(object) => { - if object.jcid != 0x20030 - || (!copies.contains_key(sid) && copy_ids.contains(©_id)) - { - return Err(invalid("Page metadata identities overlap")); - } - PropertyObject::from_object(object)? + ( + id, + PropertyObject { + jcid: 0x60008, + bytes: properties(&[(0x1c001c30, edit.guid.to_vec())])?, + global_ids: Arc::new(BTreeMap::from([(0, edit.guid)])), + }, + ) + }; + children.push(id); + let membership_changed = old_id.is_none_or(|old| view.nodes[&old].spaces != spaces); + if !membership_changed + && !spaces.iter().any(|sid| { + levels[sid] != original_levels[sid] + || copies.get(sid).is_some_and(|id| { + matches!(view.nodes[id].kind, Kind::Metadata { level, .. } + if level.unwrap_or(1) != levels[sid]) + }) + }) + { + continue; + } + let mut metadata_ids = Vec::new(); + for sid in &spaces { + let (page_id, original) = stored[sid].stored_metadata.clone(); + if old_id.is_none() && *sid == head { + series.copy_property(&original, 0x18001c65)?; + } + if levels[sid] != original_levels[sid] { + let mut metadata = original.clone(); + metadata.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; + changes.insert(*sid, BTreeMap::from([(page_id, metadata)])); + } + let copy_id = copies + .get(sid) + .copied() + .unwrap_or_else(|| metadata_id(*sid)); + let mut copy = match raw.objects.get(©_id) { + Some(object) => { + if object.jcid != 0x20030 + || (!copies.contains_key(sid) && copy_ids.contains(©_id)) + { + return Err(invalid("Page metadata identities overlap")); } - None => original, - }; - copy.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; - copy.reference(copy_id)?; - if replacements.insert(copy_id, copy).is_some() { - return Err(invalid("Page metadata identities overlap")); - } - metadata_ids.push(copy_id); - } - if membership_changed || spaces.iter().any(|sid| !copies.contains_key(sid)) { - set_references(&mut series, 0x2c001d63, &spaces)?; - set_references(&mut series, 0x24003442, &metadata_ids)?; - if replacements.insert(id, series).is_some() { - return Err(invalid("Page-series and metadata identities overlap")); + PropertyObject::from_object(object)? } + None => original, + }; + copy.set(&[(0x14001dff, &levels[sid].to_le_bytes())])?; + copy.reference(copy_id)?; + if replacements.insert(copy_id, copy).is_some() { + return Err(invalid("Page metadata identities overlap")); } + metadata_ids.push(copy_id); } - if children != section_node.children { - let mut parent = PropertyObject::from_object(&raw.objects[§ion_id])?; - set_references(&mut parent, 0x24001c20, &children)?; - replacements.insert(section_id, parent); + if membership_changed || spaces.iter().any(|sid| !copies.contains_key(sid)) { + set_references(&mut series, 0x2c001d63, &spaces)?; + set_references(&mut series, 0x24003442, &metadata_ids)?; + if replacements.insert(id, series).is_some() { + return Err(invalid("Page-series and metadata identities overlap")); + } } - changes.insert(document.root, RevisionEdit::Update(replacements)); - Ok(changes) - }) + } + if children != section_node.children { + let mut parent = PropertyObject::from_object(&raw.objects[§ion_id])?; + set_references(&mut parent, 0x24001c20, &children)?; + replacements.insert(section_id, parent); + } + changes.insert(root, replacements); + Ok(Some(changes)) } +/// A page creation's changes to the root space, and the new space's roots and objects. +pub(crate) type Creation = ( + crate::active::Changes, + BTreeMap, + crate::active::Changes, +); + /// An empty top-level page with stable identities and creation time. /// Retain the intent across retries; an existing page identity rejects duplicate creation. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -487,6 +568,38 @@ impl PageCreation { let document = Document::parse(index)?; document.pages()?; let view = document.active(document.root)?; + let raw = index.resolve_active(document.root)?; + let (root, roots, objects) = self.creation(view, &raw)?; + Ok(BTreeMap::from([ + (document.root, RevisionEdit::Update(root)), + (self.space(), RevisionEdit::Create { roots, objects }), + ])) + }) + } + + /// `apply` on a section kept open: the root space's changes, and the new space's roots + /// and objects. + pub(crate) fn changes( + &self, + section: &mut crate::Section<'_>, + ) -> Result { + self.validate()?; + if section.revision(self.space()).is_ok() { + return Err(invalid( + "This page identity already exists; reconcile the original creation", + )); + } + let root = section.root(); + let page = section.active(root)?; + self.creation(&page.view, &page.live.revision) + } + + fn creation( + &self, + view: &crate::document::Revision<'_>, + raw: &crate::ResolvedRevision<'_>, + ) -> Result { + { let section_id = view.roots[&1]; let section_node = &view.nodes[§ion_id]; if !matches!(section_node.kind, Kind::Section { .. }) @@ -506,7 +619,6 @@ impl PageCreation { invalid("Insert before the first page of an existing series, or append") })?, }; - let raw = index.resolve_active(document.root)?; let id = |n| ExGuid { guid: self.guid, n }; let reference = |n: u32| n.to_le_bytes().to_vec(); let timestamp = ((u64::from(self.created) + 315532800 + 11644473600) * 10000000) @@ -533,11 +645,15 @@ impl PageCreation { (0x14001dff, reference(1)), (0x18001c65, timestamp.clone()), ]; - let mut page = vec![ - modified(), - (0x1c001d75, string(&self.author)), - (0x1c001d3c, string("")), - ]; + let mut page = [ + vec![ + modified(), + (0x1c001d75, string(&self.author)), + (0x1c001d3c, string("")), + ], + crate::create::page_margins(), + ] + .concat(); if self.title.is_some() { page.push((0x24001d5f, reference(13))); } @@ -556,6 +672,9 @@ impl PageCreation { (0x24001c20, reference(14)), (0x14001c14, 0_f32.to_le_bytes().to_vec()), (0x14001c15, 0_f32.to_le_bytes().to_vec()), + (0x14001c3e, reference(0x0009_000c)), + (0x14001c84, reference(0)), + (0x14001cf1, reference(0)), ], ), ( @@ -564,7 +683,18 @@ impl PageCreation { vec![ modified(), (0x24001c20, reference(15)), + (0x1c001c12, measurement_bytes(&NATIVE_INDENTS, 4)?), + (0x0c001c13, vec![0]), (0x0c001c03, vec![1]), + (0x14001c1c, 0.6_f32.to_le_bytes().to_vec()), + (0x88001cf9, vec![]), + (0x88001cb2, vec![]), + (0x88001cb4, vec![]), + (0x88001c91, vec![]), + (0x14001cec, 4.5_f32.to_le_bytes().to_vec()), + (0x88001cff, vec![]), + (0x14001c3e, reference(0)), + (0x14001c84, reference(0xc)), ], ), ( @@ -577,6 +707,7 @@ impl PageCreation { (0x20001d78, reference(17)), (0x20001d79, reference(17)), (0x14001d09, self.created.to_le_bytes().to_vec()), + (0x88001cb2, vec![]), (0x88001cb4, vec![]), ], ), @@ -627,24 +758,16 @@ impl PageCreation { references.extend_from_slice(&parent.reference(child)?); } parent.set(&[(0x24001c20, &references)])?; - Ok(BTreeMap::from([ - ( - document.root, - RevisionEdit::Update(BTreeMap::from([ - (section_id, parent), - (id(2), series), - (metadata_id, object(0x20030, metadata)?), - ])), - ), - ( - self.space(), - RevisionEdit::Create { - roots: BTreeMap::from([(1, id(10)), (2, id(11))]), - objects, - }, - ), - ])) - }) + Ok(( + BTreeMap::from([ + (section_id, parent), + (id(2), series), + (metadata_id, object(0x20030, metadata)?), + ]), + BTreeMap::from([(1, id(10)), (2, id(11))]), + objects, + )) + } } } diff --git a/crates/onestore/src/paragraph.rs b/crates/onestore/src/paragraph.rs index 630727c118a3802a6f59a75c43e7ebb94f4babd1..10430ec625669a99979faa780694bbd4c1e936c9 100644 --- a/crates/onestore/src/paragraph.rs +++ b/crates/onestore/src/paragraph.rs @@ -85,6 +85,34 @@ impl ParagraphSplit { } pub(crate) fn changes(&self, active: &ActivePage<'_>) -> Result { + let lists = (5..256) + .map(|n| ExGuid { + guid: self.guid, + n, + }) + .collect::>(); + let changes = self.changes_as(active, self.object(), self.text_object(), &lists)?; + let raw = &active.live.revision; + if changes + .keys() + .any(|id| id.guid == self.guid && raw.objects.contains_key(id)) + { + return Err(invalid( + "A split identity already exists; reconcile the existing edit", + )); + } + Ok(changes) + } + + /// `changes` creating the right paragraph `paragraph` with text `right`, and a copy of + /// the left paragraph's list nodes under `lists`, in order. + pub(crate) fn changes_as( + &self, + active: &ActivePage<'_>, + paragraph_id: ExGuid, + right_text: ExGuid, + list_ids: &[ExGuid], + ) -> Result { let [_] = active.pages.as_slice() else { return Err(invalid("Splitting requires a single active page")); }; @@ -136,10 +164,32 @@ impl ParagraphSplit { } let raw = &active.live.revision; let (text, runs) = ordinary_text(view, raw, self.text)?; + let resolved = view.text_runs(self.text)?; + let link = |at: usize| { + resolved + .get(at) + .is_some_and(|run| run.format.hyperlink == Some(true)) + }; + if let Some(after) = runs + .iter() + .position(|run| run.start < self.offset && self.offset < run.end) + { + if link(after) { + return Err(invalid("A split cannot divide a hyperlink")); + } + } else if let Some(after) = runs + .iter() + .position(|run| run.start == self.offset && self.offset > 0) + && link(after - 1) + && link(after) + && !resolved[after].text.starts_with('\u{fddf}') + { + return Err(invalid("A split cannot divide a hyperlink")); + } let node = &view.nodes[&self.text]; let length = u32::try_from(text.encode_utf16().count()) .map_err(|_| invalid("Paragraph exceeds the UTF-16 offset range"))?; - if self.offset > length || lists.len() > 251 { + if self.offset > length || lists.len() > 251 || lists.len() > list_ids.len() { return Err(invalid( "Choose a position within the paragraph and at most 251 list levels", )); @@ -153,9 +203,14 @@ impl ParagraphSplit { n: 4, }; let typing = runs.last().and_then(|run| run.format).unwrap_or(normal_id); + // Enter at the start leaves the emptied upper paragraph the first run's style, as + // OneNote does (`evidence/structural-edits/xml/c2s-*`). + let leading = runs.first().and_then(|run| run.format); let modified = current_timestamps()?.0.to_le_bytes(); let mut changed = BTreeMap::new(); - if (self.offset == 0 && !text.is_empty()) || runs.iter().any(|run| run.format.is_none()) { + if (self.offset == 0 && !text.is_empty() && leading.is_none()) + || runs.iter().any(|run| run.format.is_none()) + { changed.insert( normal_id, PropertyObject { @@ -169,15 +224,22 @@ impl ParagraphSplit { &raw.objects[&self.text], node, 0..self.offset, - if text.is_empty() { typing } else { normal_id }, + if text.is_empty() { + typing + } else if self.offset == 0 { + leading.unwrap_or(normal_id) + } else { + normal_id + }, )?; let mut suffix = fragment(&raw.objects[&self.text], node, self.offset..length, typing)?; prefix.set(&[(0x14001d7a, &modified)])?; - suffix.remove(&[0x40003489])?; + // Note tags and the recording link stay with the original text. + suffix.remove(&[0x40003489, 0x1c001c98, 0x14001c99])?; suffix.set(&[(0x14001d7a, &modified)])?; - suffix.reference(self.text_object())?; + suffix.reference(right_text)?; changed.insert(self.text, prefix); - changed.insert(self.text_object(), suffix); + changed.insert(right_text, suffix); changed.insert( author_id, PropertyObject { @@ -187,7 +249,8 @@ impl ParagraphSplit { }, ); let mut right = PropertyObject::from_object(&raw.objects[paragraph])?; - let content = right.reference(self.text_object())?; + right.reference(paragraph_id)?; + let content = right.reference(right_text)?; let author = right.reference(author_id)?; right.set(&[ (0x24001c1f, &content), @@ -202,10 +265,7 @@ impl ParagraphSplit { if !matches!(view.nodes[old].kind, Kind::List { .. }) { return Err(invalid("The paragraph list is unavailable")); } - let id = ExGuid { - guid: self.guid, - n: 5 + u32::try_from(i).unwrap(), - }; + let id = list_ids[i]; let mut list = PropertyObject::from_object(&raw.objects[old])?; list.remove(&[0x14001cb7])?; list.reference(id)?; @@ -214,7 +274,7 @@ impl ParagraphSplit { } right.set(&[(0x24001c26, &references)])?; } - changed.insert(self.object(), right); + changed.insert(paragraph_id, right); let mut original = PropertyObject::from_object(&raw.objects[paragraph])?; original.remove(&[0x24001c20])?; let author = original.reference(author_id)?; @@ -225,19 +285,11 @@ impl ParagraphSplit { for child in &view.nodes[parent].children { children.extend_from_slice(&parent_object.reference(*child)?); if child == paragraph { - children.extend_from_slice(&parent_object.reference(self.object())?); + children.extend_from_slice(&parent_object.reference(paragraph_id)?); } } parent_object.set(&[(0x24001c20, &children)])?; changed.insert(*parent, parent_object); - if changed - .keys() - .any(|id| id.guid == self.guid && raw.objects.contains_key(id)) - { - return Err(invalid( - "A split identity already exists; reconcile the existing edit", - )); - } for id in ancestors { let object = match changed.entry(id) { std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(), @@ -311,6 +363,9 @@ fn fragment( Ok(object) } +/// A text object's text and runs, unless it holds what a split or join cannot divide or +/// merge: generated title text, equations, embedded objects or per-run data. Hyperlink +/// fields are ordinary runs; a recording link belongs to the text object. fn ordinary_text<'a>( view: &'a crate::document::Revision<'_>, raw: &crate::ResolvedRevision<'_>, @@ -326,23 +381,15 @@ fn ordinary_text<'a>( else { return Err(invalid("Select ordinary paragraph text")); }; - if *boilerplate || !node.media_ids.is_empty() || node.media_time_ms.is_some() { - return Err(invalid( - "Generated or recording-linked text cannot be split or joined", - )); + if *boilerplate { + return Err(invalid("Generated text cannot be split or joined")); } for run in view.text_runs(id)? { - if [ - run.format.hidden, - run.format.hyperlink, - run.format.math, - run.format.embedded_object, - ] - .contains(&Some(true)) - || run.text.contains(['\u{fffc}', '\u{fddf}']) + if [run.format.math, run.format.embedded_object].contains(&Some(true)) + || run.text.contains('\u{fffc}') { return Err(invalid( - "This paragraph contains a field or embedded object that cannot be split or joined", + "This paragraph contains an equation or embedded object that cannot be split or joined", )); } } diff --git a/crates/onestore/src/section.rs b/crates/onestore/src/section.rs new file mode 100644 index 0000000000000000000000000000000000000000..8f405310127a520ff747ad28f7487d6ebb43dfea --- /dev/null +++ b/crates/onestore/src/section.rs @@ -0,0 +1,1235 @@ +//! A section file kept parsed across edits. Edits apply to its object spaces in memory and +//! seal into one transaction appending one revision per changed space. + +use crate::{ + Chunk, Error, ExGuid, FileType, ObjectData, Reference, ResolvedRevision, RevisionIndex, Stamp, + Store, Transaction, + active::{ActivePage, Changes, Files}, + document::Revision, + page::Page, + store::StoreState, + write::{ + Appending, LiveRevision, Sealing, Written, chain_depth, check_transaction, declared, + replacement, unchanged, + }, +}; +use bumpalo::Bump; +use std::collections::{BTreeMap, BTreeSet}; + +type Result = std::result::Result; + +/// Holds the bytes a `Section` reads: the image it opened and each transaction it sealed. +#[derive(Default)] +pub struct Arena(Bump); + +/// A section file as its image and the transactions sealed on it leave it. +pub struct Section<'a> { + arena: &'a Arena, + /// The opened image, then the bytes each sealed transaction appended, at their offsets. + segments: Vec<(u64, &'a [u8])>, + /// The in-place writes of the sealed transactions, in order. + patches: Vec<(u64, Vec)>, + state: StoreState, + /// Payloads the opened image embeds, by identity. + files: &'a [([u8; 16], Chunk)], + /// Payload identities the file-data store declares. + declared: BTreeSet<[u8; 16]>, + spaces: BTreeMap>, + /// Payloads applied changes embed, in order, until sealed. + payloads: Vec<([u8; 16], &'a [u8])>, + /// Whether an operation failed after it began changing state; the section must be + /// reopened. + broken: bool, + /// The root object space, which lists the pages. + root: ExGuid, + /// While an edit applies, the spaces it changed as they were before it. + undo: Option>, +} + +#[derive(Default)] +struct Undo<'a> { + /// `None` for a space the edit created. + spaces: BTreeMap>>, + payloads: usize, +} + +#[derive(Clone)] +struct Space<'a> { + /// The active revision the file stores; none before a new space's first seal. + rid: Option, + state: SpaceState<'a>, +} + +#[derive(Clone)] +enum SpaceState<'a> { + /// As the opened image stores it, until first edited. + Stored { + revision: ResolvedRevision<'a>, + depth: usize, + }, + Open(Box>), +} + +#[derive(Clone)] +struct Open<'a> { + /// The revision the file stores. + file: LiveRevision<'a>, + /// `file` with the changes applied since the last seal. + page: ActivePage<'a>, + /// Objects whose content, reachability or count changed since the last seal. + pending: BTreeSet, +} + +impl<'a> Section<'a> { + /// Parses and fully validates a section image. + pub fn open(arena: &'a Arena, image: Vec) -> Result { + let bytes: &'a [u8] = arena.0.alloc_slice_copy(&image); + drop(image); + let store = Store::parse(bytes)?; + if let Some(offset) = store.checksum_mismatches.first() { + return Err(Error { + offset: *offset, + message: "Cannot write a file with transaction checksum damage", + }); + } + if store.header.file_type != FileType::Section { + return Err(Error { + offset: 0, + message: "Choose a section file", + }); + } + let index = RevisionIndex::parse(&store)?; + index.validate_current()?; + let mut spaces = BTreeMap::new(); + for (id, space) in &index.spaces { + let Some(rid) = space.labels.get(&(ExGuid::default(), 1)).copied() else { + continue; + }; + let resolved = index.resolve(*id, rid)?; + let revision = ResolvedRevision { + roots: resolved.roots, + objects: resolved + .objects + .into_iter() + .map(|(id, object)| { + // The parse borrows `bytes` only as long as `store`; its slices lie + // in `bytes`, which the arena keeps. + let data = match object.data { + ObjectData::Properties(data) => { + ObjectData::Properties(within(bytes, data)) + } + ObjectData::Encrypted(data) => { + ObjectData::Encrypted(within(bytes, data)) + } + ObjectData::File { + reference, + extension, + } => ObjectData::File { + reference: within(bytes, reference), + extension: within(bytes, extension), + }, + }; + let object = crate::Object { + jcid: object.jcid, + reference_count: object.reference_count, + data, + global_ids: object.global_ids, + }; + (id, object) + }) + .collect(), + }; + spaces.insert( + *id, + Space { + rid: Some(rid), + state: SpaceState::Stored { + revision, + depth: chain_depth(&index, *id, rid), + }, + }, + ); + } + let mut files = Vec::new(); + for node in store.lists.values().flat_map(|list| &list.nodes) { + if node.id != 0x94 { + continue; + } + let (Some(guid), Some(Reference::Data(chunk))) = + (node.payload.first_chunk(), node.reference) + else { + return Err(Error { + offset: node.offset, + message: "File-data object lacks a data reference", + }); + }; + files.push((*guid, chunk)); + } + files.sort_unstable_by_key(|(guid, _)| *guid); + Ok(Self { + arena, + segments: vec![(0, bytes)], + patches: Vec::new(), + state: store.state()?, + declared: files.iter().map(|(guid, _)| *guid).collect(), + files: arena.0.alloc_slice_copy(&files), + spaces, + payloads: Vec::new(), + broken: false, + root: index.root, + undo: None, + }) + } + + /// The header and length of the image this section's sealed transactions leave. + pub fn stamp(&self) -> &Stamp { + &self.state.stamp + } + + /// The image this section's sealed transactions leave; for tests and seeding caches. + pub fn image(&self) -> Vec { + let mut image = self + .segments + .iter() + .flat_map(|(_, bytes)| *bytes) + .copied() + .collect::>(); + for (offset, bytes) in &self.patches { + let offset = *offset as usize; + image[offset..offset + bytes.len()].copy_from_slice(bytes); + } + image[..1024].copy_from_slice(&self.state.stamp.header); + image + } + + /// The page an object space holds, with the changes applied since the last seal. + pub fn page(&self, space: ExGuid) -> Result { + let stored = self.spaces.get(&space).ok_or(Error { + offset: 0, + message: "Object space has no active default revision", + })?; + match &stored.state { + SpaceState::Open(open) => one_page(&open.page.view, &open.page.pages), + SpaceState::Stored { revision, .. } => { + let files = self.files(); + let (view, _) = + Revision::parse(space, revision, FileType::Section, &mut |guid| files(guid))?; + one_page(&view, &crate::active::manifest_pages(&view)) + } + } + } + + /// Page spaces in section order with the title and outline level (1 at the top) the + /// page list shows, read from each page's metadata alone. + pub fn pages(&mut self) -> Result> { + let root = self.root; + let view = &self.active(root)?.view; + let section = view.roots.get(&1).and_then(|id| view.nodes.get(id)).ok_or(Error { + offset: 0, + message: "Section root is unavailable", + })?; + let spaces: Vec = section + .children + .iter() + .filter_map(|series| view.nodes.get(series)) + .flat_map(|series| series.spaces.clone()) + .collect(); + let element = |object: &crate::Object<'a>| { + crate::document::Element::parse_with(object, FileType::Section, &mut |_| { + Err(Error { + offset: 0, + message: "Page metadata holds no payload", + }) + }) + }; + let mut pages = Vec::new(); + for space in spaces { + let revision = self.revision(space)?; + let (mut title, mut level) = (None, 1); + if let Some(metadata) = revision.roots.get(&2).and_then(|id| revision.objects.get(id)) + && let crate::document::Kind::Metadata { + title: stored, + level: stored_level, + } = element(metadata)?.kind + { + title = stored; + level = stored_level.unwrap_or(1); + } + if title.is_none() { + let manifest = revision.roots.get(&1).and_then(|id| revision.objects.get(id)); + if let Some(manifest) = manifest + && let Some(page) = element(manifest)? + .content + .first() + .and_then(|id| revision.objects.get(id)) + && let crate::document::Kind::Page { + alternate_title, .. + } = element(page)?.kind + { + title = alternate_title; + } + } + pages.push((space, title.unwrap_or_default(), level)); + } + Ok(pages) + } + + /// Reads a payload of the opened image by identity. + fn files(&self) -> Files<'a> { + let (bytes, files) = (self.segments[0].1, self.files); + std::rc::Rc::new(move |guid| { + let start = files.partition_point(|(id, _)| *id < guid); + match files[start..] + .iter() + .take_while(|(id, _)| *id == guid) + .count() + { + 0 => Err(Error { + offset: 0, + message: "File-data object is not declared", + }), + 1 => { + let chunk = files[start].1; + let (offset, length) = (chunk.offset as usize, chunk.length as usize); + let blob = bytes.get(offset..offset + length).ok_or(Error { + offset, + message: "Chunk extends outside the data area", + })?; + crate::files::payload(blob, offset) + } + _ => Err(Error { + offset: 0, + message: "Duplicate file-data identity", + }), + } + }) + } + + fn usable(&self) -> Result<()> { + if self.broken { + return Err(Error { + offset: 0, + message: "A failed edit left the section to be reopened", + }); + } + Ok(()) + } + + /// The editable state of an object space, opened on first use. + pub(crate) fn active(&mut self, space: ExGuid) -> Result<&ActivePage<'a>> { + Ok(&self.editable(space)?.page) + } + + fn editable(&mut self, id: ExGuid) -> Result<&mut Open<'a>> { + let files = self.files(); + let space = self.spaces.get_mut(&id).ok_or(Error { + offset: 0, + message: "Object space has no active default revision", + })?; + if let SpaceState::Stored { revision, depth } = &space.state { + let file = LiveRevision::new(revision.clone(), *depth)?; + let page = ActivePage::open(id, file.clone(), FileType::Section, files)?; + space.state = SpaceState::Open(Box::new(Open { + file, + page, + pending: BTreeSet::new(), + })); + } + let SpaceState::Open(open) = &mut space.state else { + unreachable!() + }; + Ok(open) + } + + /// Stores a writer's objects and payloads in a space until the next seal; false when + /// it stores nothing. An error leaves the section to be reopened. + pub(crate) fn apply_changes( + &mut self, + space: ExGuid, + payloads: &[([u8; 16], &[u8])], + changes: Changes, + ) -> Result { + self.usable()?; + if let Some(undo) = &mut self.undo + && !undo.spaces.contains_key(&space) + { + undo.spaces.insert(space, self.spaces.get(&space).cloned()); + } + let arena = &self.arena.0; + self.broken = true; + let open = self.editable(space)?; + let embedded = open.page.payloads.len(); + let Some(touched) = open.page.store(arena, payloads, changes)? else { + self.broken = false; + return Ok(false); + }; + open.pending.extend(touched); + let embedded = open.page.payloads[embedded..].to_vec(); + self.payloads.extend(embedded); + self.broken = false; + Ok(true) + } + + /// Creates an object space holding `changes` under `roots` until the next seal, which + /// declares it. An error after validation leaves the section to be reopened. + pub(crate) fn create_space( + &mut self, + space: ExGuid, + roots: BTreeMap, + changes: Changes, + ) -> Result<()> { + self.usable()?; + if space.guid == [0; 16] || self.spaces.contains_key(&space) { + return Err(Error { + offset: 0, + message: "Choose a new object-space identity in a section file", + }); + } + if roots.is_empty() || changes.is_empty() { + return Err(Error { + offset: 0, + message: "New object space needs roots and objects", + }); + } + let file = LiveRevision::new( + ResolvedRevision { + roots, + objects: BTreeMap::new(), + }, + 0, + )?; + let mut work = file.clone(); + let changes = work.prepare(changes, true)?; + let mut objects = Vec::new(); + for (id, change) in changes { + let bytes = self.arena.0.alloc_slice_copy(&change.bytes); + objects.push((id, declared(change.jcid, bytes, change.global_ids)?)); + } + if let Some(undo) = &mut self.undo { + undo.spaces.entry(space).or_insert(None); + } + self.broken = true; + let pending = work.commit(objects)?.touched; + let all: Vec = work.revision.objects.keys().copied().collect(); + work.settle(all)?; + let page = ActivePage::open(space, work, FileType::Section, self.files())?; + self.spaces.insert( + space, + Space { + rid: None, + state: SpaceState::Open(Box::new(Open { + file, + page, + pending, + })), + }, + ); + self.broken = false; + Ok(()) + } + + /// The root object space, which lists the pages. + pub(crate) fn root(&self) -> ExGuid { + self.root + } + + /// The stored revision of a space, as edits since the last seal leave it. + pub(crate) fn revision(&self, space: ExGuid) -> Result<&ResolvedRevision<'a>> { + match self.spaces.get(&space).map(|space| &space.state) { + Some(SpaceState::Stored { revision, .. }) => Ok(revision), + Some(SpaceState::Open(open)) => Ok(&open.page.live.revision), + None => Err(Error { + offset: 0, + message: "Object space has no active default revision", + }), + } + } + + /// Runs `f`, returning the section to its state before it when `f` fails; `undo` + /// saves each space before `f` first changes it, which costs a copy of that space. + pub(crate) fn atomically( + &mut self, + undo: bool, + f: impl FnOnce(&mut Self) -> std::result::Result, + ) -> std::result::Result { + if undo { + self.undo = Some(Undo { + spaces: BTreeMap::new(), + payloads: self.payloads.len(), + }); + } + let result = f(self); + if let Some(undo) = self.undo.take() + && result.is_err() + { + for (id, space) in undo.spaces { + match space { + Some(space) => self.spaces.insert(id, space), + None => self.spaces.remove(&id), + }; + } + self.payloads.truncate(undo.payloads); + self.broken = false; + } + result + } + + /// Whether a failed operation left the section to be reopened. + pub(crate) fn broken(&self) -> bool { + self.broken + } + + /// Appends one revision per space changed since the last seal, and the payloads they + /// embed, as one transaction on `stamp`; none when nothing changed. An error leaves the + /// section to be reopened. + pub fn seal(&mut self) -> Result> { + self.usable()?; + self.broken = true; + let arena = &self.arena.0; + let mut appending = Appending::new(self.state.clone()); + let mut sealed = Vec::new(); + for (id, space) in &mut self.spaces { + let SpaceState::Open(open) = &mut space.state else { + continue; + }; + if open.pending.is_empty() { + continue; + } + let (work, file) = (&open.page.live, &mut open.file); + let mut replacements = BTreeMap::new(); + for object_id in &open.pending { + if !work.is_reachable(*object_id) { + continue; + } + let object = &work.revision.objects[object_id]; + if let Some(stored) = file.revision.objects.get(object_id) + && unchanged(stored, object)? + { + continue; + } + replacements.insert(*object_id, replacement(*object_id, object)?); + } + let replacements = file.prepare(replacements, true)?; + if replacements.is_empty() { + sealed.push((*id, None)); + continue; + } + let replaced: BTreeSet = replacements.keys().copied().collect(); + let created = replaced + .iter() + .filter(|id| !file.revision.objects.contains_key(id)) + .copied() + .collect(); + let mut objects = Vec::new(); + for (object_id, change) in replacements { + // The page holds these bytes already unless preparing remapped them. + let bytes = match work.revision.objects.get(&object_id).map(|o| o.data) { + Some(ObjectData::Properties(bytes)) if bytes == change.bytes => bytes, + _ => arena.alloc_slice_copy(&change.bytes), + }; + objects.push((object_id, declared(change.jcid, bytes, change.global_ids)?)); + } + let commit = file.commit(objects)?; + let (rid, stored) = appending.revision( + &Sealing { + space: *id, + previous: space.rid, + new_space: space.rid.is_none(), + label: (ExGuid::default(), 1), + live: file, + commit: &commit, + replaced: &replaced, + created: &created, + }, + &self.segments, + None, + None, + )?; + if commit.checkpoint { + let all: Vec = file.revision.objects.keys().copied().collect(); + file.settle(all)?; + } else { + file.settle(commit.changed.iter().copied())?; + } + sealed.push((*id, Some((rid, stored, commit)))); + } + let payloads: Vec<_> = self + .payloads + .iter() + .filter(|(guid, _)| !self.declared.contains(guid)) + .copied() + .collect(); + appending.payloads(&payloads, None)?; + #[cfg_attr(not(test), expect(unused_mut))] + let mut transaction = appending.finish()?; + #[cfg(test)] + if let (Some(tamper), Some((transaction, _))) = (tests::TAMPER.get(), &mut transaction) { + tamper(transaction); + } + if let Some((transaction, state)) = &transaction { + let mut written = Vec::new(); + for (id, revision) in &sealed { + let (Some((rid, _, commit)), Some(space)) = (revision, self.spaces.get(id)) else { + continue; + }; + let SpaceState::Open(open) = &space.state else { + unreachable!() + }; + written.push(Written { + space: *id, + rid: *rid, + previous: space.rid, + live: &open.file, + commit, + }); + } + check_transaction( + &self.state, + state, + transaction, + &self.segments, + &written, + &payloads, + )?; + let base = transaction.base.length; + let segment: &'a [u8] = arena.alloc_slice_copy(&transaction.append); + self.segments.push((base, segment)); + self.patches.extend(transaction.patches.iter().cloned()); + self.state = state.clone(); + self.declared.extend(payloads.iter().map(|(guid, _)| *guid)); + for (id, revision) in &sealed { + let Some((rid, stored, _)) = revision else { + continue; + }; + let space = self.spaces.get_mut(id).unwrap(); + space.rid = Some(*rid); + let SpaceState::Open(open) = &mut space.state else { + unreachable!() + }; + for (object_id, chunk) in stored { + let start = (chunk.offset - base) as usize; + open.file.revision.objects.get_mut(object_id).unwrap().data = + ObjectData::Properties(&segment[start..start + chunk.length as usize]); + } + } + } + for (id, revision) in sealed { + let SpaceState::Open(open) = &mut self.spaces.get_mut(&id).unwrap().state else { + unreachable!() + }; + let mut compare = std::mem::take(&mut open.pending); + if let Some((_, _, commit)) = revision { + if commit.checkpoint { + compare.extend(open.file.revision.objects.keys()); + } else { + compare.extend(commit.touched); + } + } + open.page.adopt(&open.file, compare)?; + } + self.payloads.clear(); + self.broken = false; + Ok(transaction.map(|(transaction, _)| transaction)) + } + + /// Applies a transaction sealed on this section's stamp, as reopening the image it + /// leaves would; nothing may be applied since the last seal. + pub fn replay(&mut self, transaction: &Transaction) -> Result<()> { + self.usable()?; + if !self.payloads.is_empty() + || self.spaces.values().any( + |space| matches!(&space.state, SpaceState::Open(open) if !open.pending.is_empty()), + ) + { + return Err(Error { + offset: 0, + message: "Seal applied changes before replaying a transaction", + }); + } + let mut image = self.image(); + transaction.apply(&mut image)?; + *self = Self::open(self.arena, image)?; + Ok(()) + } +} + +/// `part`, a slice of `image`, with the image's lifetime. +fn within<'a>(image: &'a [u8], part: &[u8]) -> &'a [u8] { + let start = part.as_ptr().addr() - image.as_ptr().addr(); + &image[start..start + part.len()] +} + +fn one_page(view: &Revision<'_>, pages: &[ExGuid]) -> Result { + let [page] = pages else { + return Err(Error { + offset: 0, + message: "Choose an object space containing one active page", + }); + }; + Page::from_revision(view, *page) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Insertion, active::tests::Writes, document::Document, write::GUIDS}; + + thread_local! { + /// Changes a transaction after it is built and before it is checked. + pub(super) static TAMPER: std::cell::Cell> = + const { std::cell::Cell::new(None) }; + } + + /// `f` with `fresh_guid` counting from `seed`. + fn seeded(seed: u64, f: impl FnOnce() -> T) -> T { + let outer = GUIDS.replace(Some(seed)); + let result = f(); + GUIDS.set(outer); + result + } + + /// The page spaces of an image, fullest first. + fn page_spaces(image: &[u8]) -> Vec { + let store = Store::parse(image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let mut spaces: Vec<_> = document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| (document.active(space).unwrap().nodes.len(), space)) + .collect(); + spaces.sort_by(|a, b| b.cmp(a)); + spaces.into_iter().map(|(_, space)| space).collect() + } + + /// Applies seeded batches of typed-writer changes to the pages of `source` through a + /// `Section` and through the writer that parses the image and squashes a batch into + /// one revision, requiring equal bytes after every seal. + fn differential( + source: &[u8], + seed: u64, + batches: usize, + largest: usize, + pages: usize, + ) -> usize { + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).unwrap(); + let spaces = page_spaces(source); + let mut image = source.to_vec(); + let mut writes = Writes(seed); + // New objects count from far above the identities builds draw. + GUIDS.set(Some(1 << 62 | seed << 40)); + let (mut sealed, mut checkpoints) = (0, 0); + for batch in 0..batches { + let space = spaces[batch % spaces.len().min(pages)]; + let store = Store::parse(&image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let bump = Bump::new(); + let mut legacy = ActivePage::parse(&index, space).unwrap(); + for _ in 0..1 + (writes.0 as usize >> 40) % largest { + let Ok(changes) = writes.next(section.active(space).unwrap()) else { + continue; + }; + let stored = legacy.write(&bump, &[], changes.clone()).unwrap(); + assert_eq!( + section.apply_changes(space, &[], changes).unwrap(), + stored, + "batch {batch}" + ); + } + let guids = (batch as u64 + 1) << 32; + let expected = seeded(guids, || { + crate::page::write::squash( + &index, + &[(space, &legacy.live.revision)], + &legacy.payloads, + &BTreeMap::new(), + None, + ) + .unwrap() + }); + let before = section.stamp().clone(); + let chains = depths(§ion); + let transaction = seeded(guids, || section.seal().unwrap()); + checkpoints += depths(§ion) + .iter() + .filter(|(id, depth)| chains.get(id).is_some_and(|before| before > depth)) + .count(); + let mut written = image.clone(); + if let Some(transaction) = &transaction { + assert_eq!(transaction.base, before); + transaction.apply(&mut written).unwrap(); + sealed += 1; + } + assert!(written == expected, "batch {batch}"); + assert_eq!(Stamp::of(&written).unwrap(), *section.stamp()); + drop(legacy); + drop(index); + drop(store); + image = expected; + if batch % 25 == 0 || batch + 1 == batches { + assert!(section.image() == image); + let store = Store::parse(&image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + let document = Document::parse(&index).unwrap(); + for space in &spaces { + assert_eq!( + section.page(*space).unwrap(), + Page::from_space(&document, *space).unwrap() + ); + } + } + } + GUIDS.set(None); + assert!(sealed * 3 > batches, "{sealed} of {batches} batches sealed"); + checkpoints + } + + /// Chain depths of the spaces a section has opened. + fn depths(section: &Section<'_>) -> BTreeMap { + section + .spaces + .iter() + .filter_map(|(id, space)| match &space.state { + SpaceState::Open(open) => Some((*id, open.file.depth)), + SpaceState::Stored { .. } => None, + }) + .collect() + } + + #[test] + fn seals_append_the_bytes_the_image_writer_does() { + let corpus = [ + include_bytes!( + "../../../corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one" + ) + .as_slice(), + include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one"), + include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one"), + include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one"), + ]; + for (seed, source) in corpus.iter().enumerate() { + differential(source, seed as u64, 60, 1, 2); + differential(source, seed as u64 + 100, 60, 5, 2); + } + let created = crate::create_section("model.one", "First", "Author").unwrap(); + differential(&created, 7, 60, 3, 1); + } + + #[test] + fn wide_batches_regroup_and_alias_as_the_image_writer_does() { + // These seeds make a seal alias read-only objects or group tables across writes + // differently from the writes themselves, so the pages adopt the sealed form. + let corpus = [ + include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one") + .as_slice(), + include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one"), + include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one"), + ]; + for seed in 4..=8 { + for source in corpus { + differential(source, seed, 30, 12, 3); + } + } + } + + #[test] + fn a_long_chain_checkpoints_as_the_image_writer_does() { + // Past 512 revisions of one space, so the chain checkpoints once. + let checkpoints = differential( + include_bytes!( + "../../../corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one" + ), + 11, + 560, + 2, + 1, + ); + assert_eq!(checkpoints, 1); + } + + #[test] + fn a_created_space_seals_as_the_image_writer_creates_it() { + let source = &crate::create_section("model.one", "First", "Author").unwrap(); + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + // A page without history, whose objects name no other space or context. + let (copied, revision, reachable) = page_spaces(source) + .into_iter() + .map(|space| { + let revision = index.resolve_active(space).unwrap(); + let reachable = revision.reachable().unwrap(); + (space, revision, reachable) + }) + .find(|(_, revision, reachable)| { + reachable.iter().all(|id| { + let references = revision.objects[id].references().unwrap(); + references.object_spaces.is_empty() && references.contexts.is_empty() + }) + }) + .unwrap(); + let objects: Changes = reachable + .iter() + .map(|id| (*id, replacement(*id, &revision.objects[id]).unwrap())) + .collect(); + let space = ExGuid { + guid: [7; 16], + n: 0, + }; + let expected = seeded(1, || { + crate::write::write_revisions(source, |_| { + Ok(BTreeMap::from([( + space, + crate::write::RevisionEdit::Create { + roots: revision.roots.clone(), + objects: objects.clone(), + }, + )])) + }) + .unwrap() + }); + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).unwrap(); + section + .create_space(space, revision.roots.clone(), objects) + .unwrap(); + assert_eq!(section.page(space).unwrap(), section.page(copied).unwrap()); + seeded(1, || section.seal().unwrap()).unwrap(); + assert!(section.image() == expected); + // The space's next revision depends on the one creating it. + let mut writes = Writes(5); + let mut image = expected; + let mut written = 0; + for batch in 0..6 { + let Ok(changes) = writes.next(section.active(space).unwrap()) else { + continue; + }; + written += 1; + section.apply_changes(space, &[], changes.clone()).unwrap(); + let guids = (batch + 2) << 32; + let store = Store::parse(&image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let bump = Bump::new(); + // The root lists no page in this space, so no document reaches it. + let rid = index.active(space).unwrap(); + let live = LiveRevision::new( + index.resolve(space, rid).unwrap(), + chain_depth(&index, space, rid), + ) + .unwrap(); + let store = &store; + let files: Files<'_> = std::rc::Rc::new(|guid| store.file_data(guid)); + let mut legacy = ActivePage::open(space, live, FileType::Section, files).unwrap(); + legacy.write(&bump, &[], changes).unwrap(); + let expected = seeded(guids, || { + let revision = &legacy.live.revision; + crate::page::write::squash( + &index, + &[(space, revision)], + &[], + &BTreeMap::new(), + None, + ) + .unwrap() + }); + drop(legacy); + drop(index); + image = expected; + seeded(guids, || section.seal().unwrap()); + assert!(section.image() == image, "batch {batch}"); + } + assert!(written > 2); + let reopened = Section::open(&arena, image).unwrap(); + assert_eq!(reopened.page(space).unwrap(), section.page(space).unwrap()); + } + + #[test] + fn payloads_embed_once_as_the_image_writer_embeds_them() { + let source = include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one"); + let space = page_spaces(source)[0]; + let payload = ([9; 16], b"payload bytes".as_slice()); + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).unwrap(); + let mut image = source.to_vec(); + for seed in [1_u64 << 32, 2 << 32] { + assert!( + section + .apply_changes(space, &[payload], Changes::new()) + .unwrap() + ); + let store = Store::parse(&image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let legacy = ActivePage::parse(&index, space).unwrap(); + let expected = seeded(seed, || { + let revision = &legacy.live.revision; + crate::page::write::squash( + &index, + &[(space, revision)], + &[payload], + &BTreeMap::new(), + None, + ) + .unwrap() + }); + let transaction = seeded(seed, || section.seal().unwrap()); + assert_eq!(transaction.is_some(), expected != image); + assert!(section.image() == expected); + drop(legacy); + drop(index); + drop(store); + image = expected; + } + let store = Store::parse(&image).unwrap(); + assert_eq!(store.file_data(payload.0).unwrap(), payload.1); + } + + #[test] + fn a_seal_checks_the_bytes_it_appends() { + fn utf16(text: &str) -> Vec { + text.encode_utf16().flat_map(u16::to_le_bytes).collect() + } + fn object_bytes(transaction: &mut Transaction) { + let text = utf16("Tampered text"); + let at = transaction + .append + .windows(text.len()) + .position(|w| w == text) + .unwrap(); + transaction.append[at] ^= 1; + } + fn link(transaction: &mut Transaction) { + let (_, bytes) = transaction + .patches + .iter_mut() + .find(|(_, b)| b.len() == 12) + .unwrap(); + bytes[0] ^= 8; + } + fn sentinel(transaction: &mut Transaction) { + let (_, bytes) = transaction.patches.last_mut().unwrap(); + *bytes.last_mut().unwrap() ^= 1; + } + fn header(transaction: &mut Transaction) { + transaction.header[96] ^= 2; + } + let source = include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one"); + let space = page_spaces(source)[0]; + let seal = |tamper: Option| { + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).unwrap(); + let page = section.active(space).unwrap(); + let changes = page + .view + .nodes + .iter() + .filter(|(id, node)| { + matches!(node.kind, crate::document::Kind::Outline { .. }) + && page.parents.contains_key(id) + }) + .find_map(|(outline, _)| { + Insertion::paragraph(*outline, None, "Tampered text", "Author") + .and_then(|insertion| insertion.changes(page)) + .ok() + }) + .unwrap(); + section.apply_changes(space, &[], changes).unwrap(); + TAMPER.set(tamper); + let sealed = section.seal(); + TAMPER.set(None); + let error = sealed.as_ref().err().map(|error| error.message); + assert_eq!(section.seal().is_err(), error.is_some()); + error + }; + assert_eq!(seal(None), None); + for tamper in [object_bytes, link, sentinel, header] { + assert!(seal(Some(tamper)).is_some()); + } + } + + #[test] + fn a_replayed_transaction_leaves_the_sealing_section() { + let source = include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one"); + let space = page_spaces(source)[0]; + let arena = Arena::default(); + let mut sealing = Section::open(&arena, source.to_vec()).unwrap(); + let mut replaying = Section::open(&arena, source.to_vec()).unwrap(); + let mut writes = Writes(3); + for _ in 0..8 { + for _ in 0..3 { + if let Ok(changes) = writes.next(sealing.active(space).unwrap()) { + sealing.apply_changes(space, &[], changes).unwrap(); + } + } + let Some(transaction) = sealing.seal().unwrap() else { + continue; + }; + let queued: Transaction = + serde_json::from_str(&serde_json::to_string(&transaction).unwrap()).unwrap(); + assert_eq!(queued, transaction); + replaying.replay(&queued).unwrap(); + assert_eq!(replaying.stamp(), sealing.stamp()); + assert!(replaying.image() == sealing.image()); + assert_eq!(replaying.page(space).unwrap(), sealing.page(space).unwrap()); + } + } +} + +#[cfg(test)] +mod probe { + use super::*; + use crate::{document::Kind, edit::text_changes}; + use std::time::{Duration, Instant}; + + fn median(mut samples: Vec) -> Duration { + samples.sort(); + samples[samples.len() / 2] + } + + /// The fullest page space of `image` and a rich-text object in its middle. + fn target(image: &[u8]) -> (ExGuid, ExGuid, usize) { + let store = Store::parse(image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = crate::document::Document::parse(&index).unwrap(); + let texts = |space: ExGuid| -> Vec { + document + .active(space) + .unwrap() + .nodes + .iter() + .filter(|(_, node)| { + matches!( + node.kind, + Kind::RichText { + boilerplate: false, + .. + } + ) + }) + .map(|(id, _)| *id) + .collect() + }; + let (space, _) = document + .pages() + .unwrap() + .into_iter() + .max_by_key(|(space, _)| texts(*space).len()) + .unwrap(); + let texts = texts(space); + (space, texts[texts.len() / 2], texts.len()) + } + + /// One-character edits of a large page through the image writer and through a + /// `Section`: `SECTION_PROBE=path cargo test --release -p onestore --lib probe -- --ignored --nocapture`. + #[test] + #[ignore] + fn keystrokes() { + let path = std::env::var("SECTION_PROBE").unwrap_or("/tmp/probe3000.one".into()); + let image = std::fs::read(&path).unwrap(); + let (space, text, count) = target(&image); + println!( + "{path}: {} bytes, {count} text objects on the page", + image.len() + ); + + let mut samples = Vec::new(); + let mut written = 0; + for _ in 0..9 { + let start = Instant::now(); + let edit = crate::PreparedEdit::text(&image, space, text, 0..0, "x").unwrap(); + samples.push(start.elapsed()); + let transaction = edit.transaction(); + written = transaction.append.len() + + transaction + .patches + .iter() + .map(|(_, bytes)| bytes.len()) + .sum::(); + } + println!( + "image writer, one keystroke: {:?}, {written} bytes written", + median(samples) + ); + + let before = { + let store = Store::parse(&image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + Page::from_space(&crate::document::Document::parse(&index).unwrap(), space).unwrap() + }; + let mut after = before.clone(); + let edited = after + .objects + .iter_mut() + .filter_map(|object| match object { + crate::page::PageObject::Outline(outline) => Some(outline), + _ => None, + }) + .flat_map(|outline| &mut outline.paragraphs) + .find_map(|paragraph| paragraph.text_mut().filter(|object| object.id == text)) + .unwrap(); + let format = edited.text.format_at(0).unwrap().clone(); + edited + .text + .apply(crate::page::text::Edit { + range: 0..0, + replacement: crate::page::Paragraph::new("x".into(), format), + }) + .unwrap(); + let mut samples = Vec::new(); + for _ in 0..9 { + let start = Instant::now(); + crate::PreparedEdit::page(&image, space, &after, "Probe").unwrap(); + samples.push(start.elapsed()); + } + println!("page-model writer, one keystroke: {:?}", median(samples)); + + let arena = Arena::default(); + let start = Instant::now(); + let mut section = Section::open(&arena, image.clone()).unwrap(); + println!("Section::open: {:?}", start.elapsed()); + let start = Instant::now(); + section.active(space).unwrap(); + println!("first edit opens the page: {:?}", start.elapsed()); + let (mut applies, mut seals, mut sizes) = (Vec::new(), Vec::new(), Vec::new()); + for keystroke in 0..200u32 { + let start = Instant::now(); + let changes = text_changes( + section.active(space).unwrap(), + text, + keystroke..keystroke, + "x", + ) + .unwrap(); + section.apply_changes(space, &[], changes).unwrap(); + applies.push(start.elapsed()); + let start = Instant::now(); + let transaction = section.seal().unwrap().unwrap(); + seals.push(start.elapsed()); + sizes.push(( + transaction.append.len(), + transaction + .patches + .iter() + .map(|(_, bytes)| bytes.len()) + .sum::(), + )); + } + println!( + "first seal: appended {} bytes, patched {} bytes", + sizes[0].0, sizes[0].1 + ); + sizes.sort(); + println!( + "Section, one keystroke: apply {:?}, seal {:?}; appended {:?} bytes, patched {:?} bytes (median)", + median(applies), + median(seals), + sizes[sizes.len() / 2].0, + sizes[sizes.len() / 2].1, + ); + let start = Instant::now(); + let reopened = Section::open(&arena, section.image()).unwrap(); + println!("reopen after 200 seals: {:?}", start.elapsed()); + assert_eq!(reopened.page(space).unwrap(), section.page(space).unwrap()); + } +} diff --git a/crates/onestore/src/snapshot.rs b/crates/onestore/src/snapshot.rs index 8e94529b98034aa99338a556bf53e6845058f4a6..9a0600be820c231451d5202100fadcca86d0872a 100644 --- a/crates/onestore/src/snapshot.rs +++ b/crates/onestore/src/snapshot.rs @@ -1,7 +1,7 @@ use crate::{RevisionIndex, Store}; use std::io; -fn read_exact( +pub(crate) fn read_exact( read: &mut impl FnMut(u64, &mut [u8]) -> io::Result, mut offset: u64, mut output: &mut [u8], diff --git a/crates/onestore/src/store.rs b/crates/onestore/src/store.rs index d97a32017fd30c110bb607a9c539aeb17bb22505..5c8f202448c47fc4af2954fef5cde4910a5756ec 100644 --- a/crates/onestore/src/store.rs +++ b/crates/onestore/src/store.rs @@ -219,6 +219,176 @@ pub struct NodeList<'a> { pub nodes: Vec>, } +/// A file-node list fragment (MS-ONESTORE 2.4.1) before its nodes are decoded. +pub(crate) struct Fragment<'a> { + pub id: u32, + pub sequence: u32, + pub next: Chunk, + body: Cursor<'a>, +} + +impl<'a> Fragment<'a> { + /// The fragment `bytes`, which start at file offset `offset`. + pub(crate) fn parse(bytes: &'a [u8], offset: usize) -> Result { + if bytes.len() < 36 { + return Err(Error { + offset, + message: "Truncated file-node fragment", + }); + } + let mut c = Cursor { bytes, offset }; + if u64::from_le_bytes(c.read()?) != 0xa4567ab1f5f7f4c4 { + return Err(Error { + offset, + message: "Incorrect file-node fragment signature", + }); + } + let id = u32::from_le_bytes(c.read()?); + let sequence = u32::from_le_bytes(c.read()?); + let end = offset + bytes.len(); + let mut tail = Cursor { + bytes: &bytes[bytes.len() - 20..], + offset: end - 20, + }; + let next = tail.chunk()?; + if u64::from_le_bytes(tail.read()?) != 0x8bc215c38233ba4b { + return Err(Error { + offset: end - 8, + message: "Incorrect file-node fragment footer", + }); + } + Ok(Self { + id, + sequence, + next, + body: Cursor { + bytes: &bytes[16..bytes.len() - 20], + offset: offset + 16, + }, + }) + } + + /// Decodes nodes into `nodes` until it holds `required` or the fragment ends; `data` + /// checks each data reference. + pub(crate) fn nodes( + self, + required: usize, + nodes: &mut Vec>, + data: impl Fn(Chunk) -> Result<()>, + ) -> Result<()> { + let mut c = self.body; + while nodes.len() < required && c.bytes.len() >= 4 { + let offset = c.offset; + let raw = u32::from_le_bytes(c.read()?); + let size = usize::try_from((raw >> 10) & 0x1fff).unwrap(); + if size < 4 { + return Err(Error { + offset, + message: "File node is shorter than its header", + }); + } + let id = u16::try_from(raw & 0x3ff).unwrap(); + let body = c.take(size - 4)?; + if id == 0xff { + break; + } + let mut fields = Cursor { + bytes: body, + offset: offset + 4, + }; + let reference = match (raw >> 27) & 0xf { + 0 => None, + base @ (1 | 2) => { + let (stp, nil, shift) = match (raw >> 23) & 3 { + 0 => (u64::from_le_bytes(fields.read()?), u64::MAX, 0), + 1 => ( + u64::from(u32::from_le_bytes(fields.read()?)), + u64::from(u32::MAX), + 0, + ), + 2 => ( + u64::from(u16::from_le_bytes(fields.read()?)), + u64::from(u16::MAX), + 3, + ), + 3 => ( + u64::from(u32::from_le_bytes(fields.read()?)), + u64::from(u32::MAX), + 3, + ), + _ => unreachable!(), + }; + let cb = match (raw >> 25) & 3 { + 0 => u64::from(u32::from_le_bytes(fields.read()?)), + 1 => u64::from_le_bytes(fields.read()?), + 2 => u64::from(fields.read::<1>()?[0]) * 8, + 3 => u64::from(u16::from_le_bytes(fields.read()?)) * 8, + _ => unreachable!(), + }; + let reference = Chunk { + offset: if cb == 0 && stp == nil { + u64::MAX + } else { + stp << shift + }, + length: cb, + }; + if base == 1 { + if !reference.absent() { + data(reference)?; + } + Some(Reference::Data(reference)) + } else { + Some(Reference::NodeList(reference)) + } + } + _ => { + return Err(Error { + offset, + message: "Unsupported file-node base type", + }); + } + }; + nodes.push(Node { + id, + offset, + payload: fields.bytes, + reference, + }); + } + Ok(()) + } +} + +/// Where a store's next transaction appends, as its committed transactions leave it. +#[derive(Clone, Debug)] +pub(crate) struct StoreState { + pub stamp: crate::Stamp, + pub file_type: FileType, + /// The last transaction-log fragment and the entry bytes it holds. + pub log: (Chunk, usize), + /// The log's checksum through its last entry. + pub log_crc: u32, + /// The highest file-node list identity the log names. + pub max_list: u32, + pub root: ListTail, + /// The file-data store list, once one exists. + pub files: Option, + /// Each object space's revision manifest list. + pub spaces: BTreeMap, +} + +/// The last fragment of a file-node list, where appending continues. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ListTail { + pub id: u32, + pub fragments: u32, + pub last: Chunk, + pub nodes: usize, + /// The file offset past the list's last node. + pub end: u64, +} + #[derive(Debug)] pub(crate) struct TransactionFragment<'a> { pub chunk: Chunk, @@ -357,26 +527,9 @@ impl<'a> Store<'a> { loop { let range = chunk.range(data)?; claim(&mut occupied, range.clone())?; - if range.len() < 36 { - return Err(Error { - offset: range.start, - message: "Truncated file-node fragment", - }); - } - let mut c = Cursor { - bytes: &data[range.clone()], - offset: range.start, - }; - if u64::from_le_bytes(c.read()?) != 0xa4567ab1f5f7f4c4 { - return Err(Error { - offset: range.start, - message: "Incorrect file-node fragment signature", - }); - } - let id = u32::from_le_bytes(c.read()?); - let sequence = u32::from_le_bytes(c.read()?); - if usize::try_from(sequence).ok() != Some(fragments.len()) - || list_id.is_some_and(|previous| id != previous) + let fragment = Fragment::parse(&data[range.clone()], range.start)?; + if usize::try_from(fragment.sequence).ok() != Some(fragments.len()) + || list_id.is_some_and(|previous| fragment.id != previous) { return Err(Error { offset: range.start + 8, @@ -384,13 +537,13 @@ impl<'a> Store<'a> { }); } if list_id.is_none() { - if !list_ids.insert(id) { + if !list_ids.insert(fragment.id) { return Err(Error { offset: range.start + 8, message: "Duplicate file-node list identity", }); } - required = usize::try_from(*counts.get(&id).ok_or(Error { + required = usize::try_from(*counts.get(&fragment.id).ok_or(Error { offset: range.start + 8, message: "File-node list is absent from the transaction log", })?) @@ -398,99 +551,12 @@ impl<'a> Store<'a> { offset: range.start + 8, message: "File-node count exceeds address space", })?; - list_id = Some(id); - } - let mut tail = Cursor { - bytes: &data[range.end - 20..range.end], - offset: range.end - 20, - }; - let next = tail.chunk()?; - if u64::from_le_bytes(tail.read()?) != 0x8bc215c38233ba4b { - return Err(Error { - offset: range.end - 8, - message: "Incorrect file-node fragment footer", - }); - } - c.bytes = &data[range.start + 16..range.end - 20]; - while nodes.len() < required && c.bytes.len() >= 4 { - let offset = c.offset; - let raw = u32::from_le_bytes(c.read()?); - let size = usize::try_from((raw >> 10) & 0x1fff).unwrap(); - if size < 4 { - return Err(Error { - offset, - message: "File node is shorter than its header", - }); - } - let id = u16::try_from(raw & 0x3ff).unwrap(); - let body = c.take(size - 4)?; - if id == 0xff { - break; - } - let mut fields = Cursor { - bytes: body, - offset: offset + 4, - }; - let reference = match (raw >> 27) & 0xf { - 0 => None, - base @ (1 | 2) => { - let (stp, nil, shift) = match (raw >> 23) & 3 { - 0 => (u64::from_le_bytes(fields.read()?), u64::MAX, 0), - 1 => ( - u64::from(u32::from_le_bytes(fields.read()?)), - u64::from(u32::MAX), - 0, - ), - 2 => ( - u64::from(u16::from_le_bytes(fields.read()?)), - u64::from(u16::MAX), - 3, - ), - 3 => ( - u64::from(u32::from_le_bytes(fields.read()?)), - u64::from(u32::MAX), - 3, - ), - _ => unreachable!(), - }; - let cb = match (raw >> 25) & 3 { - 0 => u64::from(u32::from_le_bytes(fields.read()?)), - 1 => u64::from_le_bytes(fields.read()?), - 2 => u64::from(fields.read::<1>()?[0]) * 8, - 3 => u64::from(u16::from_le_bytes(fields.read()?)) * 8, - _ => unreachable!(), - }; - let reference = Chunk { - offset: if cb == 0 && stp == nil { - u64::MAX - } else { - stp << shift - }, - length: cb, - }; - if base == 1 { - if !reference.absent() { - reference.range(data)?; - } - Some(Reference::Data(reference)) - } else { - Some(Reference::NodeList(reference)) - } - } - _ => { - return Err(Error { - offset, - message: "Unsupported file-node base type", - }); - } - }; - nodes.push(Node { - id, - offset, - payload: fields.bytes, - reference, - }); + list_id = Some(fragment.id); } + let next = fragment.next; + fragment.nodes(required, &mut nodes, |reference| { + reference.range(data).map(drop) + })?; fragments.push(chunk); if nodes.len() == required { break; @@ -517,6 +583,90 @@ impl<'a> Store<'a> { }) } + /// Where the next transaction appends. + pub(crate) fn state(&self) -> Result { + let file_type = self.header.file_type; + let mut log_crc = if file_type == FileType::Section { + u32::MAX + } else { + 0 + }; + for fragment in &self.transaction_fragments { + log_crc = transaction_crc( + log_crc, + fragment.entries, + file_type, + fragment.entries.len() + 20 > fragment.chunk.length as usize, + ); + } + let last = self.transaction_fragments.last().unwrap(); + let root = self.list(self.header.root)?; + let files = match root.nodes.iter().find(|node| node.id == 0x90) { + Some(node) => { + let Some(Reference::NodeList(chunk)) = node.reference else { + return Err(Error { + offset: node.offset, + message: "File-data store reference lacks a list", + }); + }; + Some(self.tail(chunk)?) + } + None => None, + }; + let mut spaces = BTreeMap::new(); + for node in root.nodes.iter().filter(|node| node.id == 8) { + let revisions = node + .referenced_list(self)? + .iter() + .rfind(|node| node.id == 0x10); + if let Some(Node { + reference: Some(Reference::NodeList(chunk)), + .. + }) = revisions + { + spaces.insert(node.fields(self).exguid()?, self.tail(*chunk)?); + } + } + Ok(StoreState { + stamp: crate::Stamp::of(self.data)?, + file_type, + log: (last.chunk, last.entries.len()), + log_crc, + max_list: self + .transaction_fragments + .iter() + .flat_map(|fragment| fragment.entries.chunks_exact(8)) + .map(|entry| u32::from_le_bytes(entry[..4].try_into().unwrap())) + .max() + .unwrap(), + root: self.tail(self.header.root)?, + files, + spaces, + }) + } + + fn tail(&self, chunk: Chunk) -> Result { + let list = self.list(chunk)?; + let last = *list.fragments.last().unwrap(); + let start = usize::try_from(last.offset).unwrap(); + let node = list.nodes.last().ok_or(Error { + offset: start, + message: "Cannot append to an empty file-node list", + })?; + let header = + u32::from_le_bytes(self.data[node.offset..node.offset + 4].try_into().unwrap()); + Ok(ListTail { + id: u32::from_le_bytes(self.data[start + 8..start + 12].try_into().unwrap()), + fragments: u32::try_from(list.fragments.len()).map_err(|_| Error { + offset: start, + message: "File-node fragment sequences are exhausted", + })?, + last, + nodes: list.nodes.len(), + end: (node.offset + usize::try_from((header >> 10) & 0x1fff).unwrap()) as u64, + }) + } + pub fn chunk_data(&self, chunk: Chunk) -> Result<&'a [u8]> { Ok(&self.data[chunk.range(self.data)?]) } diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs index 41a55ba0d2289a49bb9e6578771b877c22c28bc8..69f67f124e1242bb8fb39f3616abee8fecbca39b 100644 --- a/crates/onestore/src/write.rs +++ b/crates/onestore/src/write.rs @@ -1,7 +1,8 @@ use crate::{ - Chunk, Error, ExGuid, FileType, ObjectData, PropertySets, Reference, RevisionIndex, Store, - Value, - store::{crc, transaction_crc}, + Chunk, Error, ExGuid, FileType, Node, ObjectData, PropertySets, Reference, RevisionIndex, + Store, Value, + bytes::Cursor, + store::{Fragment, ListTail, StoreState, crc, transaction_crc}, }; use std::{ @@ -14,12 +15,25 @@ mod tests; type Result = std::result::Result; +#[cfg(test)] +thread_local! { + /// When set, `fresh_guid` counts from it instead of drawing randomness, so tests can + /// compare builds byte for byte. + pub(crate) static GUIDS: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + pub(crate) fn fresh_guid() -> Result<[u8; 16]> { let mut guid = [0; 16]; getrandom::fill(&mut guid).map_err(|_| Error { offset: 0, message: "System random source failed", })?; + #[cfg(test)] + if let Some(next) = GUIDS.get() { + GUIDS.set(Some(next + 1)); + guid[..8].copy_from_slice(&next.to_le_bytes()); + guid[8..].copy_from_slice(&0x5eed_u64.to_le_bytes()); + } guid[7] = (guid[7] & 0x0f) | 0x40; guid[8] = (guid[8] & 0x3f) | 0x80; Ok(guid) @@ -74,9 +88,14 @@ pub(crate) fn append(data: &mut Vec, bytes: &[u8]) -> Result { } pub(crate) fn append_list(data: &mut Vec, id: u32, nodes: &[Vec]) -> Result { + append(data, &fragment(id, 0, nodes)) +} + +/// The file-node list fragment `sequence` of list `id`, holding `nodes` and no successor. +fn fragment(id: u32, sequence: u32, nodes: &[Vec]) -> Vec { let mut bytes = 0xa4567ab1f5f7f4c4_u64.to_le_bytes().to_vec(); bytes.extend_from_slice(&id.to_le_bytes()); - bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(&sequence.to_le_bytes()); for node in nodes { bytes.extend_from_slice(node); } @@ -84,10 +103,10 @@ pub(crate) fn append_list(data: &mut Vec, id: u32, nodes: &[Vec]) -> Res bytes.extend_from_slice(&u64::MAX.to_le_bytes()); bytes.extend_from_slice(&0_u32.to_le_bytes()); bytes.extend_from_slice(&0x8bc215c38233ba4b_u64.to_le_bytes()); - append(data, &bytes) + bytes } -fn compact(id: ExGuid, table: &BTreeMap) -> Result<[u8; 4]> { +pub(crate) fn compact(id: ExGuid, table: &BTreeMap) -> Result<[u8; 4]> { let index = table .iter() .find_map(|(index, guid)| (*guid == id.guid).then_some(*index)) @@ -378,6 +397,56 @@ pub(crate) fn patched( Ok(changed) } +/// Whether `after` stores as `before` does: the same type and bytes, naming the same +/// identities. Stored tables keep only the entries an object names, so those decide. +pub(crate) fn unchanged(before: &crate::Object<'_>, after: &crate::Object<'_>) -> Result { + if before.jcid != after.jcid || before.data != after.data { + return Ok(false); + } + let entries = match after.data { + ObjectData::Properties(bytes) => table_entries(bytes)?, + _ => Vec::new(), + }; + Ok(entries + .iter() + .all(|entry| before.global_ids.get(entry) == after.global_ids.get(entry))) +} + +/// `object` as a replacement a revision stores under `id`. +pub(crate) fn replacement(id: ExGuid, object: &crate::Object<'_>) -> Result { + let mut replacement = match object.data { + ObjectData::Properties(_) => PropertyObject::from_object(object)?, + ObjectData::File { + reference, + extension, + } => { + let text = |bytes: &[u8]| { + String::from_utf16( + &bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(), + ) + .map_err(|_| Error { + offset: 0, + message: "Invalid UTF-16 file-data declaration", + }) + }; + let mut replacement = PropertyObject::file(id, &text(reference)?, &text(extension)?)?; + replacement.jcid = object.jcid; + replacement + } + ObjectData::Encrypted(_) => { + return Err(Error { + offset: 0, + message: "Page edits only produce property objects", + }); + } + }; + replacement.reference(id)?; + Ok(replacement) +} + /// The object type OneNote gives embedded picture payload declarations; embedded files /// use `EMBEDDED_FILE_JCID` with the same declaration shape. pub(crate) const FILE_DATA_JCID: u32 = 0x80039; @@ -784,6 +853,7 @@ fn used_entries<'o>( } /// A space's revision as appending a revision and reading it back leaves it. +#[derive(Clone)] pub(crate) struct LiveRevision<'a> { pub revision: crate::ResolvedRevision<'a>, /// Incoming references of each object reachable from the roots; a root counts once. @@ -792,7 +862,7 @@ pub(crate) struct LiveRevision<'a> { /// objects alias. readonly: BTreeMap>>, /// Revisions in the dependency chain, counted to 512; zero before the first. - depth: usize, + pub depth: usize, } impl<'a> LiveRevision<'a> { @@ -1230,39 +1300,6 @@ fn update( } } -fn append_fragment( - source: &[u8], - output: &mut Vec, - list: &crate::NodeList, - nodes: &[Vec], -) -> Result<(u32, usize)> { - let last_fragment = *list.fragments.last().unwrap(); - let list_start = usize::try_from(last_fragment.offset).unwrap(); - let list_id = u32::from_le_bytes(source[list_start + 8..list_start + 12].try_into().unwrap()); - let chunk = append_list(output, list_id, nodes)?; - let start = usize::try_from(chunk.offset).unwrap(); - let sequence = u32::try_from(list.fragments.len()).map_err(|_| Error { - offset: list_start, - message: "File-node fragment sequences are exhausted", - })?; - output[start + 12..start + 16].copy_from_slice(&sequence.to_le_bytes()); - let last_node = list.nodes.last().unwrap(); - let node_header = u32::from_le_bytes( - source[last_node.offset..last_node.offset + 4] - .try_into() - .unwrap(), - ); - let nodes_end = last_node.offset + usize::try_from((node_header >> 10) & 0x1fff).unwrap(); - let tail = list_start + usize::try_from(last_fragment.length).unwrap() - 20; - if tail - nodes_end >= 4 { - output[nodes_end..nodes_end + 4].copy_from_slice(&node(0xff, None, &[])?); - } - output[tail..tail + 8].copy_from_slice(&chunk.offset.to_le_bytes()); - output[tail + 8..tail + 12].copy_from_slice(&(chunk.length as u32).to_le_bytes()); - - Ok((list_id, list.nodes.len() + nodes.len())) -} - pub(crate) fn write_revisions( source: &[u8], edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, @@ -1372,36 +1409,7 @@ pub(crate) fn build_on( None => index.resolve(space, rid), }; let changes = edit(index)?; - // Room for the revision, so appending does not double the image. - let mut output = Vec::with_capacity(source.len() + source.len() / 16 + (1 << 16)); - output.extend_from_slice(source); - // Native files reserve 1 KiB per transaction-log fragment; a fragment that ends the file - // keeps that room before the first new chunk. - let tail = store.transaction_fragments.last().unwrap().chunk; - let reserved = usize::try_from(tail.offset + tail.length.max(1024) + 32).unwrap(); - if usize::try_from(tail.offset + tail.length) - .unwrap() - .next_multiple_of(8) - >= output.len() - { - output.resize(reserved, 0); - } - let mut maximum = store - .transaction_fragments - .iter() - .flat_map(|fragment| fragment.entries.chunks_exact(8)) - .map(|entry| u32::from_le_bytes(entry[..4].try_into().unwrap())) - .max() - .unwrap(); - let mut allocate_list = || { - maximum = maximum.checked_add(1).ok_or(Error { - offset: 0, - message: "File-node list identities are exhausted", - })?; - Ok::<_, Error>(maximum) - }; - let mut counts = Vec::new(); - let mut root_nodes = Vec::new(); + let mut appending = Appending::new(store.state()?); for (space, change) in changes { let new_space = matches!(change, RevisionEdit::Create { .. }); let current = (ExGuid::default(), 1_u32); @@ -1457,18 +1465,19 @@ pub(crate) fn build_on( ) } }; - let existing: BTreeSet = revision.objects.keys().copied().collect(); let depth = rid.map_or(0, |rid| chain_depth(index, space, rid)); let mut live = LiveRevision::new(revision, depth)?; let replacements = live.prepare(replacements, is_section)?; if replacements.is_empty() { continue; } - let Commit { - checkpoint, - changed, - .. - } = live.commit( + let replaced: BTreeSet = replacements.keys().copied().collect(); + let created = replaced + .iter() + .filter(|id| !live.revision.objects.contains_key(id)) + .copied() + .collect(); + let commit = live.commit( replacements .iter() .map(|(id, replacement)| { @@ -1480,12 +1489,224 @@ pub(crate) fn build_on( }) .collect::>>()?, )?; + // A dependent revision inherits its key, as OneNote writes it. + let key = if protection.is_some() && commit.checkpoint { + Some( + index + .spaces + .get(&space) + .and_then(|space| { + space.revisions.values().find_map(|revision| { + revision.nodes.first().filter(|node| node.id == 0x7c) + }) + }) + .ok_or(Error { + offset: 0, + message: "Protected revisions continue a protected object space", + })?, + ) + } else { + None + }; + appending.revision( + &Sealing { + space, + previous: rid, + new_space, + label, + live: &live, + commit: &commit, + replaced: &replaced, + created: &created, + }, + &[(0, source)], + protection, + key, + )?; + } + appending.payloads(payloads, protection)?; + Ok(match appending.finish()? { + Some((transaction, _)) => { + let mut output = source.to_vec(); + transaction.apply(&mut output)?; + output + } + None => source.to_vec(), + }) +} + +/// A space's revision as `LiveRevision::commit` left it, ready to append. +pub(crate) struct Sealing<'r, 'a> { + pub space: ExGuid, + /// The revision it depends on unless it is a checkpoint; none for a new space or label. + pub previous: Option, + pub new_space: bool, + /// The context and role it is current under. + pub label: (ExGuid, u32), + pub live: &'r LiveRevision<'a>, + pub commit: &'r Commit, + /// Objects whose bytes the revision stores rather than referencing stored bytes. + pub replaced: &'r BTreeSet, + /// Replaced objects the space did not hold before. + pub created: &'r BTreeSet, +} + +/// The chunk `bytes` occupies in a store whose bytes `segments` hold at their offsets. +fn located(segments: &[(u64, &[u8])], bytes: &[u8]) -> Result { + let address = bytes.as_ptr().addr(); + segments + .iter() + .find_map(|(offset, segment)| { + let start = address.checked_sub(segment.as_ptr().addr())?; + (start + bytes.len() <= segment.len()).then(|| Chunk { + offset: offset + start as u64, + length: bytes.len() as u64, + }) + }) + .ok_or(Error { + offset: 0, + message: "Stored object data lies outside the store", + }) +} + +/// A transaction under construction: bytes appended at the end of a store and patches +/// inside it, advancing `state` as its lists grow. +pub(crate) struct Appending { + pub state: StoreState, + base: crate::Stamp, + append: Vec, + patches: Vec<(u64, Vec)>, + /// File-node counts of the lists this transaction writes, as log entries. + counts: Vec<(u32, usize)>, + /// Nodes the root list gains: new object spaces and the file-data store. + root_nodes: Vec>, +} + +impl Appending { + pub(crate) fn new(state: StoreState) -> Self { + let base = state.stamp.clone(); + let mut append = Vec::with_capacity(1 << 16); + // Native files reserve 1 KiB per transaction-log fragment; a fragment that ends the + // file keeps that room before the first new chunk. + let (tail, _) = state.log; + if (tail.offset + tail.length).next_multiple_of(8) >= base.length { + let reserved = tail.offset + tail.length.max(1024) + 32; + append.resize(reserved.saturating_sub(base.length) as usize, 0); + } + Self { + state, + base, + append, + patches: Vec::new(), + counts: Vec::new(), + root_nodes: Vec::new(), + } + } + + fn end(&self) -> u64 { + self.base.length + self.append.len() as u64 + } + + fn append(&mut self, bytes: &[u8]) -> Result { + let length = u64::from(u32::try_from(bytes.len()).map_err(|_| Error { + offset: 0, + message: "Chunk exceeds the encoded length limit", + })?); + let padding = self.end().next_multiple_of(8) - self.end(); + self.append.resize(self.append.len() + padding as usize, 0); + let offset = self.end(); + self.append.extend_from_slice(bytes); + Ok(Chunk { offset, length }) + } + + /// Writes `bytes` at a file offset: into the appended bytes, or as a patch. + fn write(&mut self, offset: u64, bytes: &[u8]) { + match offset.checked_sub(self.base.length) { + Some(at) => { + let at = at as usize; + self.append[at..at + bytes.len()].copy_from_slice(bytes); + } + None => self.patches.push((offset, bytes.to_vec())), + } + } + + fn allocate_list(&mut self) -> Result { + self.state.max_list = self.state.max_list.checked_add(1).ok_or(Error { + offset: 0, + message: "File-node list identities are exhausted", + })?; + Ok(self.state.max_list) + } + + /// Appends `nodes` as a new list. + fn list(&mut self, nodes: &[Vec]) -> Result<(Chunk, ListTail)> { + let id = self.allocate_list()?; + let chunk = self.append(&fragment(id, 0, nodes))?; + self.counts.push((id, nodes.len())); + Ok(( + chunk, + ListTail { + id, + fragments: 1, + last: chunk, + nodes: nodes.len(), + end: chunk.offset + 16 + nodes.iter().map(Vec::len).sum::() as u64, + }, + )) + } + + /// Appends `nodes` to the list ending at `tail` as its next fragment. + fn extend(&mut self, tail: &mut ListTail, nodes: &[Vec]) -> Result<()> { + let chunk = self.append(&fragment(tail.id, tail.fragments, nodes))?; + let next = tail.last.offset + tail.last.length - 20; + if next - tail.end >= 4 { + self.write(tail.end, &node(0xff, None, &[])?); + } + let mut reference = chunk.offset.to_le_bytes().to_vec(); + reference.extend_from_slice(&(chunk.length as u32).to_le_bytes()); + self.write(next, &reference); + *tail = ListTail { + id: tail.id, + fragments: tail.fragments.checked_add(1).ok_or(Error { + offset: 0, + message: "File-node fragment sequences are exhausted", + })?, + last: chunk, + nodes: tail.nodes + nodes.len(), + end: chunk.offset + 16 + nodes.iter().map(Vec::len).sum::() as u64, + }; + self.counts.push((tail.id, tail.nodes)); + Ok(()) + } + + /// Appends a space's next revision: its manifest, object groups and new data. Returns the + /// revision's identity and where it stored each replaced object's bytes. + pub(crate) fn revision( + &mut self, + sealing: &Sealing<'_, '_>, + segments: &[(u64, &[u8])], + protection: Option<&dyn Protection>, + key: Option<&crate::Node<'_>>, + ) -> Result<(ExGuid, Vec<(ExGuid, Chunk)>)> { + let is_section = self.state.file_type == FileType::Section; + let Sealing { + space, + label, + live, + commit, + .. + } = *sealing; let revision = &live.revision; - let selected: Vec<_> = revision - .objects - .iter() - .filter(|(id, _)| checkpoint || changed.contains(id)) - .collect(); + let checkpoint = commit.checkpoint; + let selected: Vec<(&ExGuid, &crate::Object<'_>)> = if checkpoint { + revision.objects.iter().collect() + } else { + commit + .changed + .iter() + .map(|id| (id, &revision.objects[id])) + .collect() + }; // A table-of-contents manifest has one global id table (sections group objects, // each group with its own table); OneNote resolves every node against it. let toc_table = if is_section { @@ -1535,7 +1756,10 @@ pub(crate) fn build_on( if checkpoint { ExGuid::default() } else { - rid.unwrap() + sealing.previous.ok_or(Error { + offset: 0, + message: "A dependent revision needs the revision it follows", + })? } .encode(&mut start); if !is_section { @@ -1548,7 +1772,7 @@ pub(crate) fn build_on( label.0.encode(&mut start); } let mut manifest = Vec::new(); - if new_space { + if sealing.new_space { let mut payload = Vec::new(); space.encode(&mut payload); payload.extend_from_slice(&0_u32.to_le_bytes()); @@ -1563,23 +1787,14 @@ pub(crate) fn build_on( None, &start, )?); - // A dependent revision inherits its key, as OneNote writes it. if protection.is_some() && checkpoint { - let key = index - .spaces - .get(&space) - .and_then(|space| { - space - .revisions - .values() - .find_map(|revision| revision.nodes.first().filter(|node| node.id == 0x7c)) - }) - .ok_or(Error { - offset: 0, - message: "Protected revisions continue a protected object space", - })?; + let key = key.ok_or(Error { + offset: 0, + message: "Protected revisions continue a protected object space", + })?; manifest.push(node(0x7c, key.reference, key.payload)?); } + let mut stored = Vec::new(); for (table, objects) in groups { let mut payload = Vec::new(); let mut group = if is_section { @@ -1651,33 +1866,31 @@ pub(crate) fn build_on( } } } - append(&mut output, &mapped)? - } else if replacements.contains_key(&id) { - match protection { + self.append(&mapped)? + } else if sealing.replaced.contains(&id) { + let chunk = match protection { Some(protection) => { - append(&mut output, &protection.seal_property(bytes)?)? + self.append(&protection.seal_property(bytes)?)? } - None => append(&mut output, bytes)?, - } - } else { - let stored = match protection { - Some(protection) => protection.stored(bytes).ok_or(Error { - offset: 0, - message: "Protected object has no stored form", - })?, - None => bytes, + None => self.append(bytes)?, }; - Chunk { - offset: u64::try_from( - stored.as_ptr().addr() - source.as_ptr().addr(), - ) - .unwrap(), - length: u64::try_from(stored.len()).unwrap(), - } + stored.push((id, chunk)); + chunk + } else { + located( + segments, + match protection { + Some(protection) => protection.stored(bytes).ok_or(Error { + offset: 0, + message: "Protected object has no stored form", + })?, + None => bytes, + }, + )? }; // A table-of-contents object is declared when the revision is a // checkpoint or the object is new, and revised otherwise. - let declared = checkpoint || !existing.contains(&id); + let declared = checkpoint || sealing.created.contains(&id); if is_section { declaration.extend_from_slice(&object.jcid.to_le_bytes()); declaration.push(flags); @@ -1720,9 +1933,7 @@ pub(crate) fn build_on( } if is_section { group.push(node(0xb8, None, &[])?); - let group_id = allocate_list()?; - let chunk = append_list(&mut output, group_id, &group)?; - counts.push((group_id, group.len())); + let (chunk, _) = self.list(&group)?; manifest.push(node(0xb0, Some(Reference::NodeList(chunk)), &payload)?); let mut overrides = vec![0; 8]; overrides.extend_from_slice(&(!override_crc).to_le_bytes()); @@ -1751,198 +1962,575 @@ pub(crate) fn build_on( } } manifest.push(node(0x1c, None, &[])?); - if new_space { - let list_id = allocate_list()?; - let chunk = append_list(&mut output, list_id, &manifest)?; - counts.push((list_id, manifest.len())); + if sealing.new_space { + let (chunk, tail) = self.list(&manifest)?; let mut payload = Vec::new(); space.encode(&mut payload); - let nodes = vec![ + let (chunk, _) = self.list(&[ node(0xc, None, &payload)?, node(0x10, Some(Reference::NodeList(chunk)), &[])?, + ])?; + self.root_nodes + .push(node(8, Some(Reference::NodeList(chunk)), &payload)?); + self.state.spaces.insert(space, tail); + } else { + let mut tail = *self.state.spaces.get(&space).ok_or(Error { + offset: 0, + message: "Object space is absent from the root list", + })?; + self.extend(&mut tail, &manifest)?; + self.state.spaces.insert(space, tail); + } + Ok((new_rid, stored)) + } + + /// Embeds payloads as file-data store objects referenced from the root file node list + /// under their identities, as OneNote embeds pictures and attachments. + pub(crate) fn payloads( + &mut self, + payloads: &[([u8; 16], &[u8])], + protection: Option<&dyn Protection>, + ) -> Result<()> { + let mut data_nodes = Vec::new(); + for (guid, payload) in payloads { + if self.state.file_type != FileType::Section { + return Err(Error { + offset: 0, + message: "Embedded payloads require a section file", + }); + } + let mut blob = vec![ + 0xe7, 0x16, 0xe3, 0xbd, 0x65, 0x26, 0x11, 0x45, 0xa4, 0xc4, 0x8d, 0x4d, 0x0b, 0x7a, + 0x9e, 0xac, ]; - let list_id = allocate_list()?; - let chunk = append_list(&mut output, list_id, &nodes)?; - counts.push((list_id, nodes.len())); - root_nodes.push(node(8, Some(Reference::NodeList(chunk)), &payload)?); - } else { - let root = store.list(store.header.root)?; - let space_node = root - .nodes - .iter() - .find(|node| node.id == 8 && node.fields(store).exguid() == Ok(space)) - .ok_or(Error { - offset: 0, - message: "Object space is absent from the root list", - })?; - let space_list = space_node.referenced_list(store)?; - let revision_node = space_list.iter().rfind(|node| node.id == 0x10).unwrap(); - let Some(Reference::NodeList(manifest_reference)) = revision_node.reference else { - unreachable!() - }; - let revision_list = store.list(manifest_reference)?; - counts.push(append_fragment( - source, - &mut output, - revision_list, - &manifest, - )?); + let sealed = protection.map(|protection| protection.seal_file(payload)); + let payload = sealed.as_deref().unwrap_or(*payload); + blob.extend_from_slice(&(payload.len() as u64).to_le_bytes()); + blob.extend_from_slice(&[0; 12]); + blob.extend_from_slice(payload); + blob.resize(blob.len().next_multiple_of(8), 0); + blob.extend_from_slice(&[ + 0x22, 0xa7, 0xfb, 0x71, 0x79, 0x0f, 0x0b, 0x4a, 0xbb, 0x13, 0x89, 0x92, 0x56, 0x42, + 0x6b, 0x24, + ]); + let chunk = self.append(&blob)?; + data_nodes.push(node(0x94, Some(Reference::Data(chunk)), guid)?); } - } - let mut data_nodes = Vec::new(); - for (guid, payload) in payloads { - if !is_section { - return Err(Error { - offset: 0, - message: "Embedded payloads require a section file", - }); + if data_nodes.is_empty() { + return Ok(()); } - let mut blob = vec![ - 0xe7, 0x16, 0xe3, 0xbd, 0x65, 0x26, 0x11, 0x45, 0xa4, 0xc4, 0x8d, 0x4d, 0x0b, 0x7a, - 0x9e, 0xac, - ]; - let sealed = protection.map(|protection| protection.seal_file(payload)); - let payload = sealed.as_deref().unwrap_or(*payload); - blob.extend_from_slice(&(payload.len() as u64).to_le_bytes()); - blob.extend_from_slice(&[0; 12]); - blob.extend_from_slice(payload); - blob.resize(blob.len().next_multiple_of(8), 0); - blob.extend_from_slice(&[ - 0x22, 0xa7, 0xfb, 0x71, 0x79, 0x0f, 0x0b, 0x4a, 0xbb, 0x13, 0x89, 0x92, 0x56, 0x42, - 0x6b, 0x24, - ]); - let chunk = append(&mut output, &blob)?; - data_nodes.push(node(0x94, Some(Reference::Data(chunk)), guid)?); - } - if !data_nodes.is_empty() { // Payload declarations live in the file-data store list the root list references. - let root = store.list(store.header.root)?; - match root.nodes.iter().find(|node| node.id == 0x90) { - Some(reference) => { - let Some(Reference::NodeList(chunk)) = reference.reference else { - return Err(Error { - offset: reference.offset, - message: "File-data store reference lacks a list", - }); - }; - counts.push(append_fragment( - source, - &mut output, - store.list(chunk)?, - &data_nodes, - )?); + match self.state.files { + Some(mut tail) => { + self.extend(&mut tail, &data_nodes)?; + self.state.files = Some(tail); } None => { - let list_id = allocate_list()?; - let chunk = append_list(&mut output, list_id, &data_nodes)?; - counts.push((list_id, data_nodes.len())); - root_nodes.push(node(0x90, Some(Reference::NodeList(chunk)), &[])?); + let (chunk, tail) = self.list(&data_nodes)?; + self.state.files = Some(tail); + self.root_nodes + .push(node(0x90, Some(Reference::NodeList(chunk)), &[])?); } } - } - if !root_nodes.is_empty() { - counts.push(append_fragment( - source, - &mut output, - store.list(store.header.root)?, - &root_nodes, - )?); - } - if counts.is_empty() { - return Ok(source.to_vec()); + Ok(()) } - let transactions = store.header.transaction_count.checked_add(1).ok_or(Error { - offset: 96, - message: "Transaction counter is exhausted", - })?; - let changed_bytes = store.header.transaction_count ^ transactions; - let commit_byte = (31 - changed_bytes.leading_zeros()) / 8; - let ceiling = transactions | ((1_u32 << (commit_byte * 8)) - 1); - let mut entries = Vec::new(); - for (id, count) in counts { - entries.extend_from_slice(&id.to_le_bytes()); - entries.extend_from_slice( - &u32::try_from(count) - .map_err(|_| Error { - offset: 0, - message: "File-node count exceeds the format limit", - })? - .to_le_bytes(), - ); - } - let mut checksum = if is_section { u32::MAX } else { 0 }; - for fragment in &store.transaction_fragments { - checksum = transaction_crc( - checksum, - fragment.entries, - store.header.file_type, - fragment.entries.len() + 20 > fragment.chunk.length as usize, - ); - } - let last_log = store.transaction_fragments.last().unwrap(); - let mut crc_used = last_log.entries.len(); - let mut crc_capacity = (last_log.chunk.length as usize - 12) & !7; - let mut advance_crc = |checksum, entry: &[u8]| { - if crc_used == crc_capacity { - crc_used = 0; - crc_capacity = 1008; + /// The transaction committing what was appended, and the state it leaves; none when + /// nothing was. + pub(crate) fn finish(mut self) -> Result> { + if !self.root_nodes.is_empty() { + let nodes = std::mem::take(&mut self.root_nodes); + let mut root = self.state.root; + self.extend(&mut root, &nodes)?; + self.state.root = root; } - crc_used += 8; - transaction_crc( - checksum, - entry, - store.header.file_type, - crc_used == crc_capacity, - ) - }; - for entry in entries.chunks_exact(8) { - checksum = advance_crc(checksum, entry); - } - for _ in transactions..=ceiling { - let sentinel_crc = if is_section { !checksum } else { checksum }; - entries.extend_from_slice(&1_u32.to_le_bytes()); - entries.extend_from_slice(&sentinel_crc.to_le_bytes()); - checksum = advance_crc(checksum, &entries[entries.len() - 8..]); - } - let mut log_chunk = last_log.chunk; - let mut used = last_log.entries.len(); - let mut remaining = entries.as_slice(); - while !remaining.is_empty() { - let capacity = usize::try_from(log_chunk.length) - .unwrap() - .checked_sub(12) - .map(|size| size & !7) - .filter(|capacity| *capacity >= used) + if self.counts.is_empty() { + return Ok(None); + } + let file_type = self.state.file_type; + let is_section = file_type == FileType::Section; + let mut header = self.base.header; + let count = u32::from_le_bytes(header[96..100].try_into().unwrap()); + let transactions = count.checked_add(1).ok_or(Error { + offset: 96, + message: "Transaction counter is exhausted", + })?; + let changed_bytes = count ^ transactions; + let commit_byte = (31 - changed_bytes.leading_zeros()) / 8; + let ceiling = transactions | ((1_u32 << (commit_byte * 8)) - 1); + let mut entries = Vec::new(); + for (id, count) in std::mem::take(&mut self.counts) { + entries.extend_from_slice(&id.to_le_bytes()); + entries.extend_from_slice( + &u32::try_from(count) + .map_err(|_| Error { + offset: 0, + message: "File-node count exceeds the format limit", + })? + .to_le_bytes(), + ); + } + let (mut log_chunk, mut used) = self.state.log; + let mut checksum = self.state.log_crc; + let mut crc_used = used; + let mut crc_capacity = (log_chunk.length as usize - 12) & !7; + let mut advance_crc = |checksum, entry: &[u8]| { + if crc_used == crc_capacity { + crc_used = 0; + crc_capacity = 1008; + } + crc_used += 8; + transaction_crc(checksum, entry, file_type, crc_used == crc_capacity) + }; + for entry in entries.chunks_exact(8) { + checksum = advance_crc(checksum, entry); + } + // Past a counter carry, sentinels up to the ceiling follow the one that commits; the + // next transaction's entries overwrite them, as a reader counting sentinels expects. + let committed = entries.len() + 8; + let mut committed_crc = None; + for _ in transactions..=ceiling { + let sentinel_crc = if is_section { !checksum } else { checksum }; + entries.extend_from_slice(&1_u32.to_le_bytes()); + entries.extend_from_slice(&sentinel_crc.to_le_bytes()); + checksum = advance_crc(checksum, &entries[entries.len() - 8..]); + committed_crc.get_or_insert(checksum); + } + let mut log = self.state.log; + let mut remaining = entries.as_slice(); + while !remaining.is_empty() { + let capacity = usize::try_from(log_chunk.length) + .unwrap() + .checked_sub(12) + .map(|size| size & !7) + .filter(|capacity| *capacity >= used) + .ok_or(Error { + offset: usize::try_from(log_chunk.offset).unwrap(), + message: "Transaction fragment has no room for continuation", + })?; + let size = remaining.len().min(capacity - used); + self.write(log_chunk.offset + used as u64, &remaining[..size]); + let written = entries.len() - remaining.len(); + if (written + 1..=written + size).contains(&committed) { + log = (log_chunk, used + committed - written); + } + used += size; + remaining = &remaining[size..]; + if !remaining.is_empty() { + let mut fragment = vec![0; 1024]; + fragment[1008..1016].copy_from_slice(&u64::MAX.to_le_bytes()); + let next = self.append(&fragment)?; + let mut reference = next.offset.to_le_bytes().to_vec(); + reference.extend_from_slice(&(next.length as u32).to_le_bytes()); + self.write(log_chunk.offset + capacity as u64, &reference); + log_chunk = next; + used = 0; + } + } + let length = self.end(); + header[96..100].copy_from_slice(&transactions.to_le_bytes()); + header[196..204].copy_from_slice(&length.to_le_bytes()); + header[212..228].copy_from_slice(&fresh_guid()?); + header[236..252].copy_from_slice(&fresh_guid()?); + let generation = u64::from_le_bytes(header[228..236].try_into().unwrap()) + .checked_add(1) .ok_or(Error { - offset: usize::try_from(log_chunk.offset).unwrap(), - message: "Transaction fragment has no room for continuation", + offset: 228, + message: "File generation counter is exhausted", })?; - let offset = usize::try_from(log_chunk.offset).unwrap(); - let size = remaining.len().min(capacity - used); - output[offset + used..offset + used + size].copy_from_slice(&remaining[..size]); - remaining = &remaining[size..]; - if !remaining.is_empty() { - let mut fragment = vec![0; 1024]; - fragment[1008..1016].copy_from_slice(&u64::MAX.to_le_bytes()); - let next = append(&mut output, &fragment)?; - output[offset + capacity..offset + capacity + 8] - .copy_from_slice(&next.offset.to_le_bytes()); - output[offset + capacity + 8..offset + capacity + 12] - .copy_from_slice(&(next.length as u32).to_le_bytes()); - log_chunk = next; - used = 0; - } - } - output[96..100].copy_from_slice(&transactions.to_le_bytes()); - let length = u64::try_from(output.len()).unwrap(); - output[196..204].copy_from_slice(&length.to_le_bytes()); - output[212..228].copy_from_slice(&fresh_guid()?); - output[236..252].copy_from_slice(&fresh_guid()?); - let generation = store.header.generation.checked_add(1).ok_or(Error { - offset: 228, - message: "File generation counter is exhausted", - })?; - output[228..236].copy_from_slice(&generation.to_le_bytes()); - Ok(output) + header[228..236].copy_from_slice(&generation.to_le_bytes()); + self.state.stamp = crate::Stamp { header, length }; + self.state.log = log; + self.state.log_crc = committed_crc.unwrap(); + Ok(Some(( + crate::Transaction { + base: self.base, + append: self.append, + patches: self.patches, + header, + }, + self.state, + ))) + } +} + +/// The `required` nodes of fragment `sequence` of list `id`, which `bytes` at `at` hold and +/// which ends its list. +fn nodes( + bytes: &[u8], + at: Chunk, + id: u32, + sequence: u32, + required: usize, +) -> Result>> { + let fragment = Fragment::parse(bytes, at.offset as usize)?; + if fragment.id != id || fragment.sequence != sequence || !fragment.next.absent() { + return Err(Error { + offset: at.offset as usize, + message: "An appended fragment has the wrong list, sequence or successor", + }); + } + let mut nodes = Vec::new(); + fragment.nodes(required, &mut nodes, |_| Ok(()))?; + if nodes.len() != required { + return Err(Error { + offset: at.offset as usize, + message: "An appended fragment lacks logged nodes", + }); + } + Ok(nodes) +} + +/// A revision `Appending::revision` wrote, as `check_transaction` expects to read it back. +pub(crate) struct Written<'r, 'a> { + pub space: ExGuid, + pub rid: ExGuid, + pub previous: Option, + pub live: &'r LiveRevision<'a>, + pub commit: &'r Commit, +} + +/// Reads back what `transaction` appends to the section `before` describes, whose bytes +/// `segments` hold, and checks it stores `revisions` and `payloads` as `after` expects: +/// fragments link and decode to the logged counts under the logged checksum; each declared +/// object parses, names identities its group's table holds and objects reachable after its +/// revision, carries its incremental reference count and, if read-only, its MD5; roots stay +/// unless a space is new. Validates nothing the transaction leaves as it was. +pub(crate) fn check_transaction( + before: &StoreState, + after: &StoreState, + transaction: &crate::Transaction, + segments: &[(u64, &[u8])], + revisions: &[Written<'_, '_>], + payloads: &[([u8; 16], &[u8])], +) -> Result<()> { + let wrong = |message| Error { offset: 0, message }; + let base = transaction.base.length; + // A chunk's bytes with this transaction's writes: nothing read here is patched earlier. + let read = |chunk: Chunk| -> Result> { + let (start, end) = (chunk.offset, chunk.offset + chunk.length); + let mut bytes = if start >= base { + transaction + .append + .get((start - base) as usize..(end - base) as usize) + .ok_or(wrong("An appended chunk lies outside the transaction"))? + .to_vec() + } else { + let (offset, segment) = segments + .iter() + .rfind(|(offset, _)| *offset <= start) + .ok_or(wrong("A chunk lies outside the store"))?; + segment + .get((start - offset) as usize..(end - offset) as usize) + .ok_or(wrong("A chunk lies outside the store"))? + .to_vec() + }; + for (offset, patch) in &transaction.patches { + let (from, to) = (start.max(*offset), end.min(offset + patch.len() as u64)); + if from < to { + bytes[(from - start) as usize..(to - start) as usize] + .copy_from_slice(&patch[(from - offset) as usize..(to - offset) as usize]); + } + } + Ok(bytes) + }; + let header = &transaction.header; + let count = |header: &[u8; 1024]| u32::from_le_bytes(header[96..100].try_into().unwrap()); + if *header != after.stamp.header + || count(header) != count(&before.stamp.header) + 1 + || u64::from_le_bytes(header[196..204].try_into().unwrap()) != after.stamp.length + || after.stamp.length != base + transaction.append.len() as u64 + { + return Err(wrong("The transaction header does not commit its bytes")); + } + + // The log: entries from where it ended, through the sentinel that commits them. + let mut counts = BTreeMap::new(); + let (mut chunk, mut used) = before.log; + let mut checksum = before.log_crc; + loop { + let capacity = (chunk.length as usize - 12) & !7; + let bytes = read(chunk)?; + if used == capacity { + let next = u64::from_le_bytes(bytes[capacity..capacity + 8].try_into().unwrap()); + let length = u32::from_le_bytes(bytes[capacity + 8..capacity + 12].try_into().unwrap()); + (chunk, used) = ( + Chunk { + offset: next, + length: u64::from(length), + }, + 0, + ); + continue; + } + let entry = &bytes[used..used + 8]; + used += 8; + let id = u32::from_le_bytes(entry[..4].try_into().unwrap()); + let value = u32::from_le_bytes(entry[4..].try_into().unwrap()); + if id == 1 { + if value != !checksum { + return Err(wrong("The transaction log checksum does not match")); + } + checksum = transaction_crc(checksum, entry, FileType::Section, used == capacity); + break; + } + checksum = transaction_crc(checksum, entry, FileType::Section, used == capacity); + counts.insert(id, value as usize); + } + if (chunk, used) != after.log || checksum != after.log_crc { + return Err(wrong("The transaction log ends elsewhere than its state")); + } + + // Nodes a list gained: the fragment `after` ends with, linked from where `before` ended. + let gained = |before: Option<&ListTail>, after: &ListTail| -> Result> { + if counts.get(&after.id) != Some(&after.nodes) { + return Err(wrong("A list's nodes disagree with the transaction log")); + } + if let Some(before) = before { + let previous = read(before.last)?; + if Fragment::parse(&previous, before.last.offset as usize)?.next != after.last + || after.fragments != before.fragments + 1 + || after.id != before.id + { + return Err(wrong("An appended fragment is not linked to its list")); + } + } + read(after.last) + }; + for written in revisions { + let Written { + space, + rid, + live, + commit, + .. + } = *written; + let old = before.spaces.get(&space); + let new = after + .spaces + .get(&space) + .ok_or(wrong("A sealed space has no list"))?; + let bytes = gained(old, new)?; + let manifest = nodes( + &bytes, + new.last, + new.id, + old.map_or(0, |old| old.fragments), + new.nodes - old.map_or(0, |old| old.nodes), + )?; + let mut manifest = manifest.iter(); + let mut next = || { + manifest + .next() + .ok_or(wrong("A revision manifest is truncated")) + }; + let mut node = next()?; + if old.is_none() { + let mut c = Cursor { + bytes: node.payload, + offset: node.offset, + }; + if node.id != 0x14 || c.exguid()? != space { + return Err(wrong("A new space's revision list names another space")); + } + node = next()?; + } + let mut c = Cursor { + bytes: node.payload, + offset: node.offset, + }; + let dependency = if commit.checkpoint { + ExGuid::default() + } else { + written + .previous + .ok_or(wrong("A dependent revision follows none"))? + }; + if node.id != 0x1e + || c.exguid()? != rid + || c.exguid()? != dependency + || c.read::<4>()? != 1_u32.to_le_bytes() + || c.read::<2>()? != [0, 0] + { + return Err(wrong( + "A revision starts with the wrong identity or dependency", + )); + } + let mut declared = BTreeSet::new(); + let mut roots = BTreeMap::new(); + loop { + let node = next()?; + match node.id { + 0xb0 => { + let Some(Reference::NodeList(at)) = node.reference else { + return Err(wrong("An object group lacks its list")); + }; + let bytes = read(at)?; + let id = Fragment::parse(&bytes, at.offset as usize)?.id; + let required = *counts + .get(&id) + .ok_or(wrong("An object group is not logged"))?; + let group = nodes(&bytes, at, id, 0, required)?; + let mut table = BTreeMap::new(); + let mut override_crc = u32::MAX; + for item in &group { + let mut c = Cursor { + bytes: item.payload, + offset: item.offset, + }; + match item.id { + 0xb4 | 0x22 | 0x28 | 0xb8 => {} + 0x24 => { + table.insert(u32::from_le_bytes(c.read()?), c.read::<16>()?); + } + 0xa5 | 0xc5 | 0x73 => { + let id = c.compact(&table)?; + let object = live + .revision + .objects + .get(&id) + .ok_or(wrong("A revision declares an unknown object"))?; + let jcid = u32::from_le_bytes(c.read()?); + let flags = if item.id == 0x73 { + None + } else { + Some(c.read::<1>()?[0]) + }; + let count = u32::from_le_bytes(c.read()?); + if !declared.insert(id) + || jcid != object.jcid + || count != object.reference_count + { + return Err(wrong( + "A declaration disagrees with its object or its count", + )); + } + override_crc = + crc(override_crc, &count.to_le_bytes(), FileType::Section); + if item.id == 0x73 { + continue; + } + let Some(Reference::Data(at)) = item.reference else { + return Err(wrong("An object declaration lacks its data")); + }; + let data = read(at)?; + let stored = crate::Object { + jcid, + reference_count: count, + data: ObjectData::Properties(&data), + global_ids: Arc::new(table.clone()), + }; + let references = stored.references()?; + let expected = u8::from(!references.objects.is_empty()) + | (u8::from( + !references.object_spaces.is_empty() + || !references.contexts.is_empty(), + ) << 1); + if object.data != ObjectData::Properties(&data) + || flags != Some(expected) + || (item.id == 0xc5) != (jcid & 0x100000 != 0) + || (item.id == 0xc5 && c.read::<16>()? != md5::compute(&data).0) + { + return Err(wrong( + "An object's stored bytes differ from its revision", + )); + } + if live.is_reachable(id) + && !references + .objects + .iter() + .all(|target| live.is_reachable(*target)) + { + return Err(wrong( + "An object references one its revision cannot reach", + )); + } + } + _ => return Err(wrong("Unexpected node in an object group")), + } + } + let node = next()?; + let mut c = Cursor { + bytes: node.payload, + offset: node.offset, + }; + if node.id != 0x84 + || c.read::<8>()? != [0; 8] + || u32::from_le_bytes(c.read()?) != !override_crc + { + return Err(wrong("An object group's count overrides do not match")); + } + } + 0x5a => { + let mut c = Cursor { + bytes: node.payload, + offset: node.offset, + }; + let id = c.exguid()?; + roots.insert(u32::from_le_bytes(c.read()?), id); + } + 0x1c => break, + _ => return Err(wrong("Unexpected node in a revision manifest")), + } + } + let expected: BTreeSet = if commit.checkpoint { + live.revision.objects.keys().copied().collect() + } else { + commit.changed.clone() + }; + if declared != expected + || (commit.checkpoint && roots != live.revision.roots) + || (!commit.checkpoint && !roots.is_empty()) + { + return Err(wrong( + "A revision declares other objects or roots than it changed", + )); + } + } + + if after.root.nodes != before.root.nodes { + let bytes = gained(Some(&before.root), &after.root)?; + let added = nodes( + &bytes, + after.root.last, + after.root.id, + before.root.fragments, + after.root.nodes - before.root.nodes, + )?; + let created = revisions + .iter() + .filter(|written| !before.spaces.contains_key(&written.space)) + .count(); + let spaces = added.iter().filter(|node| node.id == 8).count(); + if spaces != created || added.iter().any(|node| !matches!(node.id, 8 | 0x90)) { + return Err(wrong( + "The root list gains other nodes than new spaces declare", + )); + } + } + if !payloads.is_empty() { + let files = after + .files + .as_ref() + .ok_or(wrong("Embedded payloads have no store"))?; + let bytes = gained(before.files.as_ref(), files)?; + let declared = nodes( + &bytes, + files.last, + files.id, + before.files.map_or(0, |files| files.fragments), + payloads.len(), + )?; + for ((guid, payload), node) in payloads.iter().zip(&declared) { + let Some(Reference::Data(at)) = node.reference else { + return Err(wrong("A payload declaration lacks its data")); + }; + let data = read(at)?; + let blob = crate::files::payload(&data, at.offset as usize); + if node.id != 0x94 || node.payload != guid || blob.ok() != Some(*payload) { + return Err(wrong("An embedded payload differs from its declaration")); + } + } + } + Ok(()) } diff --git a/crates/onestore/src/write/tests/multi.rs b/crates/onestore/src/write/tests/multi.rs index 73c89104d36bd36b25694610cdd1bdbc0901fa33..17ff70a0194c7b1cdcc6655af43640929d298f39 100644 --- a/crates/onestore/src/write/tests/multi.rs +++ b/crates/onestore/src/write/tests/multi.rs @@ -186,7 +186,9 @@ fn interrupted_multi_space_publication_never_exposes_a_partial_nesting() { write_limit, random: 1951, }; - crate::commit::commit_bytes(&mut complete, SOURCE, &written).unwrap(); + crate::commit::Transaction::between(SOURCE, &written) + .commit(&mut complete) + .unwrap(); assert_eq!(complete.durable, written); for fail_at in 1..=complete.operation { let mut interrupted = disk::Disk { @@ -197,7 +199,9 @@ fn interrupted_multi_space_publication_never_exposes_a_partial_nesting() { write_limit, random: 1951 + fail_at as u64, }; - crate::commit::commit_bytes(&mut interrupted, SOURCE, &written).unwrap_err(); + crate::commit::Transaction::between(SOURCE, &written) + .commit(&mut interrupted) + .unwrap_err(); let observed = current::current(&interrupted.durable); assert!( observed == old || observed == new, @@ -286,7 +290,9 @@ fn repeated_multi_space_edits_cross_counter_carries_and_checkpoint_each_space() write_limit: 4096, random: 1952, }; - crate::commit::commit_bytes(&mut complete, &source, &written).unwrap(); + crate::commit::Transaction::between(&source, &written) + .commit(&mut complete) + .unwrap(); for fail_at in 1..=complete.operation { let mut interrupted = disk::Disk { visible: source.clone(), @@ -296,7 +302,9 @@ fn repeated_multi_space_edits_cross_counter_carries_and_checkpoint_each_space() write_limit: 4096, random: 1952 + fail_at as u64, }; - crate::commit::commit_bytes(&mut interrupted, &source, &written).unwrap_err(); + crate::commit::Transaction::between(&source, &written) + .commit(&mut interrupted) + .unwrap_err(); let observed = current::current(&interrupted.durable); assert!(observed == old || observed == new, "{step}:{fail_at}"); } diff --git a/crates/onestore/tests/edit.rs b/crates/onestore/tests/edit.rs index 1f4a5c1fe718c41c7f73e4edd51aef21c19b08a0..bf22275a152972be7cada34f6e97b62f3fea4835 100644 --- a/crates/onestore/tests/edit.rs +++ b/crates/onestore/tests/edit.rs @@ -210,7 +210,7 @@ fn confirming_visible_text_requires_flush_without_another_revision() { } #[test] -fn confirmation_rejects_changed_or_truncated_physical_tail_before_any_write() { +fn confirmation_rejects_a_changed_header_or_length_before_any_write() { let (sid, oid) = target(SOURCE); let mut source = SOURCE.to_vec(); source.resize(3 * 1024 * 1024 + 131, 0); @@ -223,7 +223,7 @@ fn confirmation_rejects_changed_or_truncated_physical_tail_before_any_write() { if let trace::Event::Write(offset, bytes) = &disk.events[0] { panic!("Confirmation wrote {} bytes at {offset}", bytes.len()); } - for changed in [65535, 65536, 1048575, 1048576, 2097152, source.len() - 1] { + for changed in [96, 128, 212, 1023] { disk.bytes.clone_from(&source); disk.bytes[changed] ^= 1; disk.events.clear(); @@ -232,9 +232,18 @@ fn confirmation_rejects_changed_or_truncated_physical_tail_before_any_write() { assert_eq!(error.error.kind(), std::io::ErrorKind::ResourceBusy); assert!(disk.events.is_empty()); } + // Committed content changes only with the header, so the body is never compared. + for changed in [1024, 1048576, source.len() - 1] { + disk.bytes.clone_from(&source); + disk.bytes[changed] ^= 1; + disk.events.clear(); + onestore::commit_text(&mut disk, &source, sid, oid, 0..0, "").unwrap(); + assert!(matches!(disk.events[..], [trace::Event::Flush])); + } for length in [source.len() - 1, source.len() + 1] { disk.bytes.clone_from(&source); disk.bytes.resize(length, 0); + disk.events.clear(); let error = onestore::commit_text(&mut disk, &source, sid, oid, 0..0, "").unwrap_err(); assert_eq!(error.state, CommitState::NotCommitted); assert!(disk.events.is_empty()); diff --git a/crates/onestore/tests/page.rs b/crates/onestore/tests/page.rs index f2ecc7d5c779d76dbca635b7252f8a0a01fa7393..c11eec4e82b8c8301a6b26b79e919f52548dfbef 100644 --- a/crates/onestore/tests/page.rs +++ b/crates/onestore/tests/page.rs @@ -1,6 +1,7 @@ use onestore::{ ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store, document::{Document, Kind}, + page::{Page, PageObject}, }; #[path = "support/current.rs"] @@ -137,6 +138,78 @@ fn created_pages_support_title_edits_and_body_insertion() { } } +/// OneNote 2010 draws level-1 text 27 pt inside an outline whose table stops at entry 0. +#[test] +fn created_body_outlines_carry_the_indentation_table_onenote_writes() { + let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (space, page) = document.pages().unwrap()[0]; + let [first] = document.active(space).unwrap().nodes[&page].children[..] else { + panic!() + }; + let insertion = Insertion::outline(page, 72.0, 144.0, "Second", "Author").unwrap(); + let written = PreparedEdit::insert(&source, space, &insertion).unwrap(); + let store = Store::parse(written.as_bytes()).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let view = document.active(space).unwrap(); + for outline in [first, insertion.object()] { + let Kind::Outline { indents } = &view.nodes[&outline].kind else { + panic!() + }; + assert_eq!(indents, &[18.0, 0.0, 27.0, 27.0]); + } +} + +#[test] +fn a_created_title_outline_is_stored_and_read_as_onenote_stores_it() { + let source = onestore::create_section("pages.one", "Original", "Author").unwrap(); + let intent = PageCreation::new(None, Some("Title"), "Author").unwrap(); + let prepared = PreparedEdit::create_page(&source, &intent).unwrap(); + let store = Store::parse(prepared.as_bytes()).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let view = document.active(intent.space()).unwrap(); + let [title] = view.nodes[&intent.object()].structure[..] else { + panic!() + }; + let [outline] = view.nodes[&title].children[..] else { + panic!() + }; + let [paragraph] = view.nodes[&outline].children[..] else { + panic!() + }; + let Kind::Outline { indents } = &view.nodes[&outline].kind else { + panic!() + }; + assert_eq!(indents, &[18.0, 0.0, 27.0, 27.0]); + let flags = |id| -> Vec { + view.nodes[&id].extra[0] + .iter() + .map(|field| field.id) + .filter(|id| id >> 24 == 0x88) + .collect() + }; + assert_eq!( + flags(outline), + [0x88001cf9, 0x88001cb2, 0x88001cb4, 0x88001c91, 0x88001cff] + ); + assert_eq!(flags(paragraph), [0x88001cb2, 0x88001cb4]); + let page = Page::from_space(&document, intent.space()).unwrap(); + let [PageObject::Title(title)] = &page.objects[..] else { + panic!() + }; + let [outline] = &title.outlines[..] else { + panic!() + }; + assert!(outline.title); + assert_eq!(outline.indents, [18.0, 0.0, 27.0, 27.0]); + assert_eq!(outline.min_width, Some(162.0)); + assert_eq!(outline.layout.max_height, Some(21.6)); +} + #[test] fn invalid_page_intents_and_nonleading_anchors_reject_before_publication() { for title in ["a\nb", "\r", "\0", "\u{fffc}", "\u{fddf}"] { diff --git a/crates/onestore/tests/page_math.rs b/crates/onestore/tests/page_math.rs index be3249abf1cbe63c07a12e33ec5229c14dd8ebc0..e0787b8af3027dfe0f14351118f304ddade40ba3 100644 --- a/crates/onestore/tests/page_math.rs +++ b/crates/onestore/tests/page_math.rs @@ -328,3 +328,42 @@ fn equations_are_written_and_read_back() { .unwrap(); } } + +/// Backspacing through inline math leaves plain text in the model, but the stored text object +/// still holds the math runs, which the ordinary text edit refuses. +#[test] +fn a_paragraph_emptied_of_its_inline_math_saves() { + let store = Store::parse(NATIVE).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (space, before) = document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| (space, Page::from_space(&document, space).unwrap())) + .find(|(_, page)| page.title == "Equation controls") + .unwrap(); + let mut after = before.clone(); + let text = after + .objects + .iter_mut() + .filter_map(|object| match object { + PageObject::Outline(outline) => Some(outline), + _ => None, + }) + .flat_map(|outline| &mut outline.paragraphs) + .filter_map(|paragraph| match &mut paragraph.content { + onestore::page::ParagraphContent::Text(text) => Some(text), + _ => None, + }) + .find(|text| text.text.text() == "Inline 𝛼+𝛽") + .unwrap(); + let plain = text.text.spans()[0].format.clone(); + assert_ne!(plain.math, Some(true)); + text.text = onestore::page::Paragraph::new(String::new(), plain); + let written = onestore::PreparedEdit::page(NATIVE, space, &after, "Author").unwrap(); + let store = Store::parse(written.as_bytes()).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + assert_eq!(Page::from_space(&document, space).unwrap(), after); +} diff --git a/crates/onestore/tests/page_tables.rs b/crates/onestore/tests/page_tables.rs index b3072cda11f0ef358c7b07e7da9999a3ff7ed782..85a87143eaabcd75db21683f49bd1c500934529b 100644 --- a/crates/onestore/tests/page_tables.rs +++ b/crates/onestore/tests/page_tables.rs @@ -94,6 +94,13 @@ fn referenced(page: &Page) -> std::collections::BTreeSet { out } +/// A new cell without its own or a sibling's indentation table takes OneNote's. +fn with_native_indents(table: &mut Table) { + for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { + cell.indents = vec![18.0, 0.0, 27.0, 27.0]; + } +} + fn assert_same(written: &[u8], space: ExGuid, expected: &Page) -> Page { let stored = page_in(written, space); let mut expected = expected.clone(); @@ -224,6 +231,9 @@ fn cell_shading_and_indents_change_in_place() { assert_eq!(cell.shading, None); cell.shading = Some(0x0000ffff); let original_indents = cell.indents.clone(); + cell.indents.clear(); + assert!(PreparedEdit::page(TREES, space, &after, AUTHOR).is_err()); + let cell = &mut table_mut(&mut after).rows[0].cells[0]; cell.indents = vec![18.0, 0.0, 27.0, 27.0]; let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); let stored = assert_same(written.as_bytes(), space, &after); @@ -277,6 +287,15 @@ fn a_table_nests_inside_a_cell_and_is_removed_again() { .paragraphs .push(holder); let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); + let ParagraphContent::Table(nested) = &mut table_mut(&mut after).rows[0].cells[1] + .paragraphs + .last_mut() + .unwrap() + .content + else { + panic!() + }; + with_native_indents(nested); let stored = assert_same(written.as_bytes(), space, &after); if let Some(directory) = std::env::var_os("ONESTORE_NESTED_TABLE_EXPORT") { let directory = std::path::PathBuf::from(directory); @@ -376,6 +395,7 @@ fn a_new_table_is_created_on_a_fresh_page() { body_paragraphs(&mut after).push(holder); body_paragraphs(&mut after).push(cell_paragraph(&template, "After the table")); let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap(); + with_native_indents(table_mut(&mut after)); assert_same(written.as_bytes(), space, &after); if let Some(directory) = std::env::var_os("ONESTORE_TABLE_EXPORT") { let directory = std::path::PathBuf::from(directory); diff --git a/crates/onestore/tests/page_writer.rs b/crates/onestore/tests/page_writer.rs index b7efc8395d6c50d52f9df30c10cc0705733d36c3..01a19853c98863b7b2887875d3e09da08c2a3b83 100644 --- a/crates/onestore/tests/page_writer.rs +++ b/crates/onestore/tests/page_writer.rs @@ -621,8 +621,10 @@ fn unsupported_model_edits_are_rejected_before_writing() { assert!(PreparedEdit::page(TREES, space, &page, "a\0b").is_err()); } +/// Unset run values stay unset; only the language, which MS-ONE requires on every run, comes +/// from the insertion. #[test] -fn a_new_paragraph_with_unspecified_formatting_takes_the_insertion_default() { +fn a_new_paragraph_with_unspecified_formatting_rereads_with_only_the_required_language() { let (space, mut page) = page_by_title(OUTLINES, "Move leaf down"); let text = onestore::page::TextObject { id: new_id().unwrap(), @@ -662,7 +664,14 @@ fn a_new_paragraph_with_unspecified_formatting_takes_the_insertion_default() { .unwrap(); assert_eq!(read_back.text.text(), "Plain 東京"); let format = read_back.text.format_at(0).unwrap(); - assert!(format.font.is_some() && format.font_size.is_some()); + assert!(format.language.is_some()); + assert_eq!( + format, + &onestore::document::Format { + language: format.language, + ..Default::default() + } + ); assert_eq!( PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) .unwrap() diff --git a/crates/onestore/tests/paragraph.rs b/crates/onestore/tests/paragraph.rs index e2f754a9d7ea6eab32f97f177ea7c930be7a673c..03d0651e6459e3d38e82104853235443a4aee73b 100644 --- a/crates/onestore/tests/paragraph.rs +++ b/crates/onestore/tests/paragraph.rs @@ -117,12 +117,8 @@ fn joins_match_native_graphs_tags_and_inherited_character_styles() { let intent = ParagraphJoin::new(left, right, "Join author").unwrap(); let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); assert_eq!(intent, restored); - let edited = PreparedEdit::join(source, *sid, &restored); - if name == "Split before hyperlink" { - assert!(edited.is_err()); - continue; - } - let edited = edited.unwrap_or_else(|error| panic!("{name}: {error}")); + let edited = PreparedEdit::join(source, *sid, &restored) + .unwrap_or_else(|error| panic!("{name}: {error}")); let current_store = Store::parse(edited.as_bytes()).unwrap(); assert_eq!( current_store.header.transaction_count, @@ -296,6 +292,9 @@ fn splits_partition_native_paragraphs_at_every_scalar_boundary() { let store = Store::parse(SOURCE).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); + let native_store = Store::parse(JOIN_FIXTURES[0].0).unwrap(); + let native_index = RevisionIndex::parse(&native_store).unwrap(); + let native_document = Document::parse(&native_index).unwrap(); for case in manifest["cases"].as_array().unwrap() { let text: ExGuid = serde_json::from_value(case["original_text"].clone()).unwrap(); let paragraph: ExGuid = serde_json::from_value(case["original_paragraph"].clone()).unwrap(); @@ -326,8 +325,18 @@ fn splits_partition_native_paragraphs_at_every_scalar_boundary() { let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap(); assert_eq!(intent, restored); let edited = PreparedEdit::split(SOURCE, *sid, &restored); - if case["case"] == "Split before hyperlink" { - assert!(edited.is_err()); + // OneNote 2010 splits before or after a hyperlink and ignores Enter inside one. + let link = |at: usize| { + expected + .get(at) + .is_some_and(|(_, style)| style["hyperlink"] == true) + }; + if position > 0 + && link(position - 1) + && link(position) + && expected[position].0 != '\u{fddf}' + { + assert!(edited.is_err(), "{} at {offset}", case["case"]); continue; } let edited = @@ -380,6 +389,23 @@ fn splits_partition_native_paragraphs_at_every_scalar_boundary() { "{} suffix at {offset}", case["case"] ); + if case["offset_utf16"] == *offset { + let native = &native_document.spaces[sid]; + let native = &native.revisions[&native.contexts[&ExGuid::default()]]; + let new_text = serde_json::from_value(case["new_text"].clone()).unwrap(); + assert_eq!( + characters(after, text), + characters(native, text), + "{}", + case["case"] + ); + assert_eq!( + characters(after, intent.text_object()), + characters(native, new_text), + "{}", + case["case"] + ); + } let mut children = parent_node.children.clone(); children.insert( children.iter().position(|id| *id == paragraph).unwrap() + 1, @@ -511,9 +537,6 @@ fn export_native_paragraph_splits() { let mut source = SOURCE.to_vec(); let mut written = Vec::new(); for case in manifest["cases"].as_array().unwrap() { - if case["case"] == "Split before hyperlink" { - continue; - } let text: ExGuid = serde_json::from_value(case["original_text"].clone()).unwrap(); let store = Store::parse(&source).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); @@ -621,9 +644,6 @@ fn export_native_paragraph_joins() { let mut source = source.to_vec(); let mut manifest = Vec::new(); for (name, left, right) in cases { - if name == "Split before hyperlink" { - continue; - } let sid = *document .spaces .iter() diff --git a/crates/onestore/tests/transaction.rs b/crates/onestore/tests/transaction.rs new file mode 100644 index 0000000000000000000000000000000000000000..72108fe5cc11db75ac0e9b6c645f4693d9b16fde --- /dev/null +++ b/crates/onestore/tests/transaction.rs @@ -0,0 +1,107 @@ +use onestore::{ + CommitIo, CommitState, ExGuid, PreparedEdit, RevisionIndex, Stamp, Store, + document::{Document, Kind}, +}; +use std::io; + +/// Crosses the 65535 → 65536 counter carry on its next transaction. +const SOURCE: &[u8] = + include_bytes!("../../../corpus/append/round-01/tx-65535/notebook/synthetic.one"); + +fn text(source: &[u8]) -> (ExGuid, ExGuid) { + let store = Store::parse(source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + for (sid, space) in &document.spaces { + let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; + for (oid, node) in &revision.nodes { + if matches!(node.kind, Kind::RichText { .. }) { + return (*sid, *oid); + } + } + } + panic!("Missing text fixture") +} + +/// A file that records how many bytes a commit reads and writes. +struct Counted { + bytes: Vec, + read: usize, + written: usize, +} + +impl CommitIo for Counted { + fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result { + let offset = offset as usize; + let count = output.len().min(self.bytes.len().saturating_sub(offset)); + output[..count].copy_from_slice(&self.bytes[offset..offset + count]); + self.read += count; + Ok(count) + } + fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result { + let offset = offset as usize; + self.bytes + .resize(self.bytes.len().max(offset + bytes.len()), 0); + self.bytes[offset..offset + bytes.len()].copy_from_slice(bytes); + self.written += bytes.len(); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[test] +fn a_transaction_reads_the_header_and_writes_only_its_edit() { + let (sid, oid) = text(SOURCE); + let edit = PreparedEdit::text(SOURCE, sid, oid, 0..0, "é").unwrap(); + let transaction = edit.transaction(); + let mut image = SOURCE.to_vec(); + transaction.apply(&mut image).unwrap(); + assert_eq!(image, edit.as_bytes()); + let mut file = Counted { + bytes: SOURCE.to_vec(), + read: 0, + written: 0, + }; + transaction.commit(&mut file).unwrap(); + assert_eq!(file.bytes, edit.as_bytes()); + assert!(file.read <= 1024 + 2, "read {} bytes", file.read); + let appended = edit.as_bytes().len() - SOURCE.len(); + assert!( + file.written < appended + 2048, + "wrote {} bytes for {appended} appended", + file.written + ); + // The file has moved past the base: nothing is written, and applying needs the base. + let (read, written) = (file.read, file.written); + let error = transaction.commit(&mut file).unwrap_err(); + assert_eq!(error.state, CommitState::NotCommitted); + assert_eq!(error.error.kind(), io::ErrorKind::ResourceBusy); + assert_eq!(file.written, written); + assert!(file.read - read <= 1024); + assert!(transaction.apply(&mut image).is_err()); +} + +#[test] +fn stamps_name_the_committed_image() { + let (sid, oid) = text(SOURCE); + let edit = PreparedEdit::text(SOURCE, sid, oid, 0..0, "x").unwrap(); + let before = Stamp::of(SOURCE).unwrap(); + let after = Stamp::of(edit.as_bytes()).unwrap(); + assert_ne!(before, after); + assert_eq!(before.length, SOURCE.len() as u64); + // An unpublished tail changes the length, so a commit built without it is refused. + let mut tail = SOURCE.to_vec(); + tail.extend_from_slice(&[0; 8]); + assert_ne!(Stamp::of(&tail).unwrap(), before); + let mut file = Counted { + bytes: tail, + read: 0, + written: 0, + }; + let error = edit.commit(&mut file).unwrap_err(); + assert_eq!(error.error.kind(), io::ErrorKind::ResourceBusy); + assert_eq!(file.written, 0); + assert!(Stamp::of(&SOURCE[..1023]).is_err()); +} diff --git a/tools/canvas/README.md b/tools/canvas/README.md index db0ee386b666434520e478809fb35de51629fc13..38db69057cc63274e27bd39ce70373baeb5904d8 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -50,7 +50,7 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a `--notebook FOLDER` opens a notebook laid out as OneNote lays one out. The title bar shows the application's icon and the notebook's name; below it two rows of toolbar buttons follow the Home ribbon's Basic Text group, the first nine tags and zoom (only zoom acts yet). Readable top-level sections are tabs in the notebook's order, each leaning over the next at 45° with a faint highlight along its top, and the open one on top, casting a soft shadow and framing the page and the page tabs on the right in its colour, which shades slightly down the window; a tab that opens rises to meet the frame as its outline fades in. The frame's top corners and the page's corners are rounded concentric with the window's. The open page's tab is the page's colour and joins it with rounded inside corners; subpages are indented. The interface follows the system's light or dark appearance, and in the dark one the page's paper darkens and text left at OneNote's automatic colour turns light (except on a highlight), resolved when painting so nothing re-lays out; explicit colours, highlights, pictures and ink keep theirs, except that the background art of OneNote 2010's page templates, recognised by the picture's hash, paints from bundled vector recreations drawn for the paper. Sections take their stored colour's hue at a saturation and lightness chosen for each appearance. `--ui-font FONT_FILE_OR_FOLDER` sets the interface's typeface in place of the system's; fonts are not bundled. The section's colour is also the interface accent, easing to the next section's when another opens. The search box, New Page and the page-tab toggle sit above the page tabs. Command-F searches the page titles; Enter opens the first match and Escape clears it. A page that also changed on another computer shows Keep mine / Keep theirs above it. `--section FILE TITLE` opens one section the same way at that page. Section files must be regular files; discovery rejects symbolic links. -The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (opening pages or sections, page requests, saving) runs after the frame is painted and requests the frame that shows it. +The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads it on a thread of its own while the current page stays live; the newest read is laid out and replaces the page once the pictures it shows first are drawn, or after 200 ms. A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press`, `release`, `wheel DX DY`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH` and `appearance light|dark` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. @@ -66,7 +66,7 @@ The `draw` crate owns the renderer so the page and application chrome share one Glyph coverage uses quarter-pixel phases with the vertical sign converted to Swash's Y-up coordinates. The `draw` GPU readback test checks nine successive quarter-pixel translations by their linear-light ink centroids, then requires identical pixels on repaint, cache eviction and renderer reconstruction on the same device. The native caret experiment exposed the previous inverted vertical phase; correcting raster placement does not change line metrics or wrapping. -Image textures have a separate 64 MiB / 256-entry cache. Offscreen images do not upload or consume the active-frame budget; a frame whose visible images exceed that budget returns an error. Under pressure, eviction preserves every image needed by the current frame. PNG/JPEG decoding checks encoded size, dimensions and decoded output size, with a best-effort codec allocation limit. These limits do not bound all CPU buffers or driver allocations. Images use linear filtering of premultiplied, linear-light colors; glyphs retain their own sampling/blending policy. Decoded color values are currently interpreted as sRGB; ICC and orientation metadata are not transformed by this experiment. +Image textures have a separate 64 MiB / 256-entry cache. Offscreen images do not upload or consume the active-frame budget; a frame whose visible images exceed that budget returns an error. Under pressure, eviction preserves every image needed by the current frame, and a texture goes when every copy of its image has. PNG/JPEG decoding reads the size from the header first, checks encoded size and dimensions against a 256 MiB decoding limit with a best-effort codec allocation limit, and shrinks the picture to the size asked for, filtering premultiplied values in sRGB encoding. These limits do not bound all CPU buffers or driver allocations. Images use linear filtering of premultiplied, linear-light colors; glyphs retain their own sampling/blending policy. Decoded color values are currently interpreted as sRGB; ICC and orientation metadata are not transformed by this experiment. ## Note tags @@ -138,7 +138,7 @@ Input tracing logs Winit keyboard/IME, pointer, modifier and focus events, Acces This integration mode keeps imported objects read-only and opens a separate editable canvas to their left. The initial notes width defaults to 240 pt in this mode, and the reference starts 24 pt to its right. Existing text-file and width arguments also work. Typing, outline creation/movement, paste and undo operate on the temporary notes. The close guard protects those notes. Reference text is rendered but is not exposed through the host’s editable accessibility fields. -The host and offscreen renderer both use `canvas::gpu::page::PageScene`. The live scene retains decoded image assets and reads current geometry from `CanvasEditor`; the static reference scene owns its imported content. Layout and image decoding happen on construction; drawing reuses those caches. Scene coordinates stay in source document points, while the comparison example applies its captured margin normalization through the viewport. All three shared-scene outputs are pixel-identical to their preceding title-rendering captures. Decoded image residency is capped at 64 MiB per scene, in addition to the renderer’s GPU cache budget; encoded source payloads and temporary decoder allocations are separate. +The host and offscreen renderer both use `canvas::gpu::page::PageScene`. The live scene reads current geometry from `CanvasEditor`; the static reference scene owns its imported content. Layout happens on construction and drawing reuses it. Pictures only read their headers then: before each frame, `update_pictures` decodes those in view, and half a view around it, on the scene's picture thread at their shown size in device pixels (in half-power-of-two zoom steps, 150 ms after the zoom settles), and paints each once it lands; one that fails to decode paints as a placeholder. Pictures in view shrink together to fit 32 MiB, those around it decode with what room is left, and those unseen longest are let go past it, so no number or size of pictures fails a page. `settle` waits for the whole page, for offscreen drawing. The stored bytes stay shared with the page model and save unchanged. Scene coordinates stay in source document points, while the comparison example applies its captured margin normalization through the viewport. All three shared-scene outputs are pixel-identical to their preceding title-rendering captures. Decoded pictures are capped at 32 MiB per scene, in addition to the renderer’s GPU cache budget; encoded source payloads and temporary decoder allocations are separate. The host sleeps its caret timer while macOS reports the window occluded and requests a redraw when visibility returns. Input tracing includes draw attempts, surface occlusion/timeouts and submitted presents; submission is not a measurement of display completion. Startup failures propagate a nonzero exit code after the event loop exits. @@ -150,7 +150,7 @@ The host sleeps its caret timer while macOS reports the window occluded and requ `--page` opens supported body outlines and title text as temporary editable objects in their source positions. It accepts a section copy and exact page title, without the separate text-file/width arguments. Title text supports editing, wrapping, IME and undo, and remains present when empty. Date text follows its height; clicking the date or time opens the macOS date control and commits into the same undo history. Clicking a picture selects it with OneNote's dashed border, tint and eight handles. Dragging it moves it on the page grid (Option places it freely); edge handles stretch one axis and corner handles keep the aspect ratio, anchored at the opposite side. Background pictures pass clicks through. Moves and resizes share the undo history. Character editing, selection, IME and undo use the same outline model as new pages. Existing structured or tagged paragraphs accept in-paragraph edits; their structural split/join remains restricted. Newly created flat outlines support the full structural workflow. The app never writes the section file. -`PageScene::from_page` constructs the editor-owned page and decodes its image assets. Imported paint order stays with the editor; each text slot invokes the host’s outline painter. Movement reads the current model geometry without decoding images again. New outlines draw after the imported page. The close guard compares all object layouts, editable text and the creation timestamp with the opening state, including displacement that remains after a title is shortened. +`PageScene::from_page` constructs the editor-owned page and reads its pictures' headers. Imported paint order stays with the editor; each text slot invokes the host’s outline painter. Movement reads the current model geometry and keeps each picture's raster. New outlines draw after the imported page. The close guard compares all object layouts, editable text and the creation timestamp with the opening state, including displacement that remains after a title is shortened. The app's private integration test exercises the actual outline painter and accessibility consumer for each imported outline, then verifies text restoration through undo: diff --git a/tools/test_page_creation.py b/tools/test_page_creation.py index 2ebdc63791729cfa1b434736467d9572d8880d4c..d15a29c8c7b2ce6f907ba47e5fe6dd01bd6be05b 100644 --- a/tools/test_page_creation.py +++ b/tools/test_page_creation.py @@ -16,9 +16,11 @@ class PageCreationTest(unittest.TestCase): compare(fixture / 'candidate', fixture / 'cold') def test_created_pages_and_both_writers_survive_native_cold_reopens(self): - for source, capture in [('candidate', 'cold'), ('native/notebook', 'native/cold'), ('followup', 'followup/cold')]: + for source, capture in [('candidate', 'cold'), ('native/notebook', 'native/cold')]: with self.subTest(source=source): compare(FIXTURE / source, FIXTURE / capture) + # OneNote refreshes the renamed titles' stale section metadata copies. + self.assertEqual(compare(FIXTURE / 'followup', FIXTURE / 'followup/cold', refresh_metadata=True), 2) def test_duplicate_titles_keep_distinct_pages_through_native_and_rust_edits(self): with TemporaryDirectory() as temporary: diff --git a/tools/test_page_movement.py b/tools/test_page_movement.py index abd76229afe2e54dd77b53d2d5f089fa35ce46ea..e96da2467061c19fb1f4ab6256424cb981cd4471 100644 --- a/tools/test_page_movement.py +++ b/tools/test_page_movement.py @@ -111,7 +111,7 @@ class PageMovementTest(unittest.TestCase): with self.assertRaises(AssertionError): compare(fixture / 'notebook', fixture / 'cold') refreshed = compare(fixture / 'notebook', fixture / 'cold', - refresh_metadata_levels=phase == '02-promoted-parent') + refresh_metadata=phase == '02-promoted-parent') self.assertEqual(refreshed, int(phase == '02-promoted-parent')) output = temporary / phase subprocess.run([EXPORTER, fixture / 'notebook/Lifecycle.one', output], check=True) diff --git a/tools/verify_page_creation.py b/tools/verify_page_creation.py index 9813c07af8c3a947ecc610889053c49fa960a4ca..85d001a580e9e261181228516f10c0e86d2d260c 100644 --- a/tools/verify_page_creation.py +++ b/tools/verify_page_creation.py @@ -14,7 +14,7 @@ from document_model import EXPORTER, ordered_pages, view compare_document = runpy.run_path(str(Path(__file__).with_name('verify-document.py')))['compare'] -def compare(source, capture, *, refresh_metadata_levels=False): +def compare(source, capture, *, refresh_metadata=False): files = list(source.glob('*.one')) assert len(files) == 1 with TemporaryDirectory() as temporary: @@ -53,13 +53,12 @@ def compare(source, capture, *, refresh_metadata_levels=False): series = expected[series_id] existing = [p for p in series['extra'][0] if p['id'] == 0x24003442] added = [p for p in observed['nodes'][series_id]['extra'][0] if p['id'] == 0x24003442] - if existing and refresh_metadata_levels: + if existing and refresh_metadata: for copy in existing[0]['value']['Objects']: - cached = expected[copy]['kind'] - assert cached['type'] == 'Metadata' - level = metadata[copy][1]['kind']['level'] - refreshed += cached['level'] != level - cached['level'] = level + assert expected[copy]['kind']['type'] == 'Metadata' + current = metadata[copy][1]['kind'] + refreshed += expected[copy]['kind'] != current + expected[copy]['kind'] = current if existing or not added: continue copies = [] @@ -75,7 +74,7 @@ def compare(source, capture, *, refresh_metadata_levels=False): series['extra'][0].append(added[0]) assert original['roots'] == observed['roots'] assert expected == observed['nodes'], sid - print(f'Passed: {len(pages)} ordered pages and preserved active graphs; {filled} missing section metadata copies filled natively; {refreshed} section metadata levels refreshed') + print(f'Passed: {len(pages)} ordered pages and preserved active graphs; {filled} missing section metadata copies filled natively; {refreshed} section metadata copies refreshed') return refreshed