diff --git a/corpus/page-copy/README.md b/corpus/page-copy/README.md index 552dac7255c896ae966fa26cf3cca14281e7b4b1..1ffec07d6f8948db18f0c8a7b57744734a5ecf97 100644 --- a/corpus/page-copy/README.md +++ b/corpus/page-copy/README.md @@ -6,7 +6,10 @@ `Section::import_page` copied one page from each of eight earlier rows (text with an attachment and its icon, a table, an inserted picture, a page-level ink drawing, note tags, bullets and numbering, equations, paragraph -formatting), each as a page creation and a save queued through the replica +formatting) and the `table-edit/nested` pages whose indent levels the model +can express (nested tables, cell subtrees, mixed languages; four outdent +pages need outline groups and refuse), each as a page creation and a save +queued through the replica and published to the file; the last copy (paragraph formatting) was then deleted permanently through `Section::delete_pages` (`expected-count.txt` is the page count left). Every object carries a fresh identity (`Page::copy`), paragraph diff --git a/corpus/page-copy/candidate/Open Notebook.onetoc2 b/corpus/page-copy/candidate/Open Notebook.onetoc2 index a36a8cfe863f1cc81b557f0e550596bc8fbec09e..2e0a511b1b92e0f64c80cce4d24bafaa4926ebc2 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 422e712334d03bd2fe0be18d542c64b7d4e390fd..27136800811f3d444cf209ea58b23fc9ceb5b548 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/candidate/expected-count.txt b/corpus/page-copy/candidate/expected-count.txt index f11c82a4cb6cc2e8f3bdf52b5cdeaad4d5bb214e..8e2afd342773582f9484b796cdc0b84736e8194e 100644 --- a/corpus/page-copy/candidate/expected-count.txt +++ b/corpus/page-copy/candidate/expected-count.txt @@ -1 +1 @@ -9 \ No newline at end of file +17 \ No newline at end of file diff --git a/corpus/page-copy/cold/commands.jsonl b/corpus/page-copy/cold/commands.jsonl index 3a0610b9eb779e7824b149b6a37aa1a57106b8f3..e40a10bd504057a5f4e5d179dad31cf1979234ff 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-1C0F2CDE -ExpectedPages 9 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 9 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-33218F72 -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 653f42a08c4bbafd4176acd640a1cf65d8599acd..ca88d7b8816422f6f811419548ca2fa34ba9c91d 100644 --- a/corpus/page-copy/cold/machine.json +++ b/corpus/page-copy/cold/machine.json @@ -1 +1 @@ -{"name": "m6-1c0f2cde", "hostname": "ONE-M6-1C0F2CDE"} +{"name": "m6-33218f72", "hostname": "ONE-M6-33218F72"} diff --git a/corpus/page-copy/cold/notebook/Open Notebook.onetoc2 b/corpus/page-copy/cold/notebook/Open Notebook.onetoc2 index 5dcccbbc5397a59febc04781691411c1558f55cd..6f08f3a38e387191947b0859f4057b56e058bca9 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 6e9e78e9f6128bec0d13051105131831423fdddd..677026c229b03b17a16d6710f12f4520b88a8d9e 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/notebook/expected-count.txt b/corpus/page-copy/cold/notebook/expected-count.txt index f11c82a4cb6cc2e8f3bdf52b5cdeaad4d5bb214e..8e2afd342773582f9484b796cdc0b84736e8194e 100644 --- a/corpus/page-copy/cold/notebook/expected-count.txt +++ b/corpus/page-copy/cold/notebook/expected-count.txt @@ -1 +1 @@ -9 \ No newline at end of file +17 \ No newline at end of file diff --git a/corpus/page-copy/cold/read/environment.json b/corpus/page-copy/cold/read/environment.json index 6ea9ff8ffbc01e8f6a3fb5f8e64218af7e377771..61111c96f38b218f064910d6656937eae099ee09 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-1C0F2CDE", + "hostname": "ONE-M6-33218F72", "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 7b1fc27006da7b1129846b23242d71bf4b7f32c5..461b41d26007b0c6cc8760aa9948081c52b8bd2d 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 af6bcb2175c02024ff0ca87879c85b382404985b..999c7b5056164cd4b506338d91c60949c9da068e 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": "Equations below - Microsoft OneNote", + "title": "Destination - 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 02d6115a96d4757b3ad42fbd05d979afc13b47dc..a605a262d190860e8b3a9726cbfcfd5fbfb5b59f 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 387f50d6db528c1fff51c72dc2b01a7302e34cbc..49df7d69df513a30feb5d15d2bf291f360c4d7ce 100644 --- a/corpus/page-copy/cold/read/page-000.xml +++ b/corpus/page-copy/cold/read/page-000.xml @@ -1,3 +1,2 @@  -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-001.navigation.json b/corpus/page-copy/cold/read/page-001.navigation.json index c1fbc1110d4cf29bc7a5645b4863263b05217e60..b65a0c422e83efcb89dd26be239dd1906202f810 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": "Open task - Microsoft OneNote", + "title": "Before the picture - 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 b675a81bdad3b23236acc08fe6962d7c7da92bc9..0885a25c4e0f1db206f23ea5bba63d1782f1be63 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 da7467cc6a6d5bfbcb6e2590817a8bd893644c4c..b9de3b9064ac76f8820e3271f3520c0dff320664 100644 --- a/corpus/page-copy/cold/read/page-001.xml +++ b/corpus/page-copy/cold/read/page-001.xml @@ -1,2 +1,4 @@  - +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w +AAAAAElFTkSuQmCC + diff --git a/corpus/page-copy/cold/read/page-002.navigation.json b/corpus/page-copy/cold/read/page-002.navigation.json index 999c7b5056164cd4b506338d91c60949c9da068e..666dae945adaeb59d3d5c742c7ba85778929706b 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": "Destination - Microsoft OneNote", + "title": "Indent bullet subtree - 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 ea4460e550b81df83f5e286dc74e072ccc1442b4..d47da50b865823a76a40efece90dbfabb810ff02 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 1bc815c76cffe8c6fdccb81b56244a95968dd590..6354dc7fa36927b684bb57898b615e62ba1e5b25 100644 --- a/corpus/page-copy/cold/read/page-002.xml +++ b/corpus/page-copy/cold/read/page-002.xml @@ -1,2 +1,5 @@  - +Target 🦀 é 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 a4ef2b1e701a5f5af5652851cf8d82fddf764464..b8578e6a2f723044d70c505e33406a480433361c 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": "Before the table - Microsoft OneNote", + "title": "Bullet item - 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 ee9172d16d78966fff0c64aa3b7aa0b71986fff8..1a87e98aaa70d8dd720d9744a2c7953450dc376b 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 5612591d2cfc83742383fa64df64e40bc2e09a73..6142d59085d2bc8e36d735444a9579e01f2131d4 100644 --- a/corpus/page-copy/cold/read/page-003.xml +++ b/corpus/page-copy/cold/read/page-003.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-copy/cold/read/page-004.navigation.json b/corpus/page-copy/cold/read/page-004.navigation.json index 957beaafccbcba166fba87cf91fc907f33c74e81..d2b59e91271a6fdcb0b16e590b860b1146332688 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": "Beside the drawing - Microsoft OneNote", + "title": "Delete sole cell paragraph - 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 10ea73a7f646dfc4f6c29e448f6fd5367de52e01..2c489864709ee0eeb32b0101dc1d70722ca81ad2 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 274ea54698f2abb5b1a1c680ee67709f7118696e..ee737109e45bbf1dc4d82e0773a0423ed635c0d2 100644 --- a/corpus/page-copy/cold/read/page-004.xml +++ b/corpus/page-copy/cold/read/page-004.xml @@ -1,7 +1,4 @@  -AM8CHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3 -URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj -RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln -jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEgKFNtArpWcU+AsrjC8JNY -RQo/QCNkARXUNSA7oAMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG -AAAAABIAESAJY0kvgp5dSp0WwhnX6NwFCj9AI2QBFdQ1IDugAxVGAEAKP0Aib0YILvRYCoA= +Target 🦀 é Link label]]> diff --git a/corpus/page-copy/cold/read/page-005.navigation.json b/corpus/page-copy/cold/read/page-005.navigation.json index b65a0c422e83efcb89dd26be239dd1906202f810..ab78af6f58f0491d903cbc8f945f3c70dbe06606 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": "Before the picture - Microsoft OneNote", + "title": "Indent first subtree - 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 b730eaf99ccf248065df37a7accb51b8ed7053dd..f56a82b5ad7e7c2eda2b38c788ed303a958ff14a 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 c0be888ecaa00b1e6e27f59dc6512d546bf9fe4e..1b0a3a46a89ab08addae397a1b46ccd921d412b0 100644 --- a/corpus/page-copy/cold/read/page-005.xml +++ b/corpus/page-copy/cold/read/page-005.xml @@ -1,4 +1,5 @@  -iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w -AAAAAElFTkSuQmCC - +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 b2cc9630cdb428ace6cdb6faa135abf85f7b4fa0..365ff367b3c95a498dea1ea074b6874814b85ca9 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": "Before the file - 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-006.png b/corpus/page-copy/cold/read/page-006.png index 282533cf9f734443d11842d2f564349456597c5c..bac1750ff3c53958251f4f579938b3599321161e 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 27f8353a0980b09fde759afa8b326b58b18a93dd..f59feb7588b8753d120af6dc3f45e8154150bf3a 100644 --- a/corpus/page-copy/cold/read/page-006.xml +++ b/corpus/page-copy/cold/read/page-006.xml @@ -1,2 +1,20 @@  - +Bold 🦀 italic é color 東京
+End
]]>
Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/page-copy/cold/read/page-007.navigation.json b/corpus/page-copy/cold/read/page-007.navigation.json index b8578e6a2f723044d70c505e33406a480433361c..b2cc9630cdb428ace6cdb6faa135abf85f7b4fa0 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": "Bullet item - Microsoft OneNote", + "title": "Before the file - 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 8fc37be3754d9e2c39f1ec14951f3f3b1b2b564c..99a934c548c4888326b8f41d8c37141d08076f84 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 85b70e7ca8d640d8f1773ce3a7780f27f15ca1e1..9192d32cf20c9d9ded0a6c2b0588f99e05ecad16 100644 --- a/corpus/page-copy/cold/read/page-007.xml +++ b/corpus/page-copy/cold/read/page-007.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/page-copy/cold/read/page-008.navigation.json b/corpus/page-copy/cold/read/page-008.navigation.json index 002a1c45a5ba0121a28f35c11466c61b8a2aac13..2ffaf3c40700297201f6a2e70966546ef83aa74a 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": "A notebook created in Rust. - Microsoft OneNote", + "title": "Delete numbered subtree - 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 a2cbfec475a139baf867c1be1489f82dd34c1b99..91bda24a040ce1679fad1b6040ec10295c2ffead 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 a522ee4a546279702cc0f619f2da7359b30a4b39..90ac110d26e3d331803da3aa3079fcb9db66e06e 100644 --- a/corpus/page-copy/cold/read/page-008.xml +++ b/corpus/page-copy/cold/read/page-008.xml @@ -1,2 +1,5 @@  - +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 new file mode 100644 index 0000000000000000000000000000000000000000..af6bcb2175c02024ff0ca87879c85b382404985b --- /dev/null +++ b/corpus/page-copy/cold/read/page-009.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Equations below - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-copy/cold/read/page-009.png b/corpus/page-copy/cold/read/page-009.png new file mode 100644 index 0000000000000000000000000000000000000000..e266bbc7f9161db9138f076034cd4c313b86f3c8 Binary files /dev/null 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 new file mode 100644 index 0000000000000000000000000000000000000000..68bf933e4a4e935aef6003424e4288f5b2a2e8b7 --- /dev/null +++ b/corpus/page-copy/cold/read/page-009.xml @@ -0,0 +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-010.navigation.json b/corpus/page-copy/cold/read/page-010.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..48f1601e8396f2c2f32ff0f85f3517904d41697d --- /dev/null +++ b/corpus/page-copy/cold/read/page-010.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Move numbered subtree down - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-copy/cold/read/page-010.png b/corpus/page-copy/cold/read/page-010.png new file mode 100644 index 0000000000000000000000000000000000000000..71beaba4366dda094b97c9cfd2cdb79d9129acad Binary files /dev/null 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 new file mode 100644 index 0000000000000000000000000000000000000000..d04b04070c8b520ded17357b000a518f77ee923e --- /dev/null +++ b/corpus/page-copy/cold/read/page-010.xml @@ -0,0 +1,5 @@ + +Target 🦀 é 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 new file mode 100644 index 0000000000000000000000000000000000000000..c1fbc1110d4cf29bc7a5645b4863263b05217e60 --- /dev/null +++ b/corpus/page-copy/cold/read/page-011.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Open task - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-copy/cold/read/page-011.png b/corpus/page-copy/cold/read/page-011.png new file mode 100644 index 0000000000000000000000000000000000000000..9ee0f609b9ac929e417c606961a2d6f8eec80812 Binary files /dev/null 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 new file mode 100644 index 0000000000000000000000000000000000000000..6e950472e7284ff586d2916677d6bf1b548dfd13 --- /dev/null +++ b/corpus/page-copy/cold/read/page-011.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-copy/cold/read/page-012.navigation.json b/corpus/page-copy/cold/read/page-012.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..c2e498f910f1c5412d881cfd7157bb6e0f1e816a --- /dev/null +++ b/corpus/page-copy/cold/read/page-012.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Move cell subtree down - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-copy/cold/read/page-012.png b/corpus/page-copy/cold/read/page-012.png new file mode 100644 index 0000000000000000000000000000000000000000..b2fee73e41f3e5063edfde0b4ce5729f1cd2e1fd Binary files /dev/null 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 new file mode 100644 index 0000000000000000000000000000000000000000..5c63c3d3e573ddd3c98d77c6d989534945615ca9 --- /dev/null +++ b/corpus/page-copy/cold/read/page-012.xml @@ -0,0 +1,5 @@ + +Target 🦀 é 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 new file mode 100644 index 0000000000000000000000000000000000000000..f3ecc2514cfb245e8d524aba87942ac24e90c62e --- /dev/null +++ b/corpus/page-copy/cold/read/page-013.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Delete cell subtree - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-copy/cold/read/page-013.png b/corpus/page-copy/cold/read/page-013.png new file mode 100644 index 0000000000000000000000000000000000000000..34a9d1aed316156cd1036fd21436173474513ab6 Binary files /dev/null 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 new file mode 100644 index 0000000000000000000000000000000000000000..3c369a5ea6d1986ae293922c03f5480b59d23204 --- /dev/null +++ b/corpus/page-copy/cold/read/page-013.xml @@ -0,0 +1,5 @@ + +Target 🦀 é Link label]]>Child]]> diff --git a/corpus/page-copy/cold/read/page-014.navigation.json b/corpus/page-copy/cold/read/page-014.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..957beaafccbcba166fba87cf91fc907f33c74e81 --- /dev/null +++ b/corpus/page-copy/cold/read/page-014.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Beside the drawing - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-copy/cold/read/page-014.png b/corpus/page-copy/cold/read/page-014.png new file mode 100644 index 0000000000000000000000000000000000000000..bc9f5393f0c343fc46c6d976d371d56b8a203575 Binary files /dev/null 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 new file mode 100644 index 0000000000000000000000000000000000000000..e95ed1faa233bb439d6da5264e105dbc1ab804e2 --- /dev/null +++ b/corpus/page-copy/cold/read/page-014.xml @@ -0,0 +1,7 @@ + +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 new file mode 100644 index 0000000000000000000000000000000000000000..002a1c45a5ba0121a28f35c11466c61b8a2aac13 --- /dev/null +++ b/corpus/page-copy/cold/read/page-015.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "A notebook created in Rust. - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-copy/cold/read/page-015.png b/corpus/page-copy/cold/read/page-015.png new file mode 100644 index 0000000000000000000000000000000000000000..69b55b80c0927d590660231aad9a0c7e8ff7150b Binary files /dev/null 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 new file mode 100644 index 0000000000000000000000000000000000000000..5d7053e3ce71263bbb7775a64c41fd4113d150cb --- /dev/null +++ b/corpus/page-copy/cold/read/page-015.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-copy/cold/read/page-016.navigation.json b/corpus/page-copy/cold/read/page-016.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a4ef2b1e701a5f5af5652851cf8d82fddf764464 --- /dev/null +++ b/corpus/page-copy/cold/read/page-016.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Before the table - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/page-copy/cold/read/page-016.png b/corpus/page-copy/cold/read/page-016.png new file mode 100644 index 0000000000000000000000000000000000000000..01d4090ee402d3cf14100273333c46b7a7ca5c88 Binary files /dev/null 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 new file mode 100644 index 0000000000000000000000000000000000000000..e1edcf257bd581a0cda25c3d15bf276998d6ad44 --- /dev/null +++ b/corpus/page-copy/cold/read/page-016.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/page-copy/cold/read/payloads.json b/corpus/page-copy/cold/read/payloads.json index 79a92aefc1e18c78724631a0f820955eaf174486..ae289407ebe7e1ff6123a19f938136bca64544f6 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": "{03AD9394-425B-0B9C-0D27-CA384DFFCEBF}{1}{B0}", + "object": "{9E89195D-B7EC-0AC4-34FD-F47D633F0533}{1}{B0}", "kind": "InsertedFile", - "page": "{C12C0ECF-DDF8-0A21-11BE-3074429542E0}{1}{B0}", + "page": "{62E12C49-D5FE-0850-17D5-B4DC95B736E9}{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 0fe03e897fb74f1d8a74a56d4e661f9d2f85c1b8..d7f1a4d003fadf4aea3e154c9553c35ce6a33bb3 100644 --- a/corpus/page-copy/cold/run.json +++ b/corpus/page-copy/cold/run.json @@ -1,6 +1,6 @@ { "notebook": "/private/tmp/m4/copy", - "expected_pages": 9, + "expected_pages": 17, "author": null, "author_timeout_seconds": 600, "inspect": false, diff --git a/corpus/page-copy/cold/source.json b/corpus/page-copy/cold/source.json index a0c388417af2adb1b04c2d4e5bcceb80023c48b3..d2a8ee9c0a4a977ae4ee6d04eb6d840cb771f300 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": "bf856a7700054971543fb890257cba327dfd35ef6e5438a41483490c4e06407b", - "mtime_ns": 1789284797191547350 + "sha256": "acb0747c250f7bebed1e90b18d5dbbf8c32731f9287571b1e732681921fd323a", + "mtime_ns": 1789287183577857848 }, { "path": "copies.one", - "bytes": 91008, - "sha256": "8f6c9b66379c4bf6730e281d8363737d88fbe1c521a62fd4d054f735b0505cc7", - "mtime_ns": 1789284797187906252 + "bytes": 185400, + "sha256": "dcd7c226d86477adc1ad2f8da8a408ffa2b993b372517ab8d464e5746a971aac", + "mtime_ns": 1789287183575141938 }, { "path": "expected-count.txt", - "bytes": 1, - "sha256": "19581e27de7ced00ff1ce50b2047e7a567c76b1cbaebabe5ef03f7c3017bb5b7", - "mtime_ns": 1789284797191633183 + "bytes": 2, + "sha256": "4523540f1504cd17100c4835e85b7eefd49911580f8efff0599a8f283be6b9e3", + "mtime_ns": 1789287183577930514 } ] diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 664c71c8f19212c1aaf68813e4ddbf0071fed6b0..7e97726caf7588ffed76fc134b87a6915de9d8ad 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -334,8 +334,11 @@ metadata; a deleted section moves into `OneNote_RecycleBin`, a group with its own TOC. Every created, renamed or moved file is placed with `onestore::place_file`, which sets the header's ancestor to the parent TOC's identity and the name CRC OneNote checks on open; a file without them is -re-identified and listed anew. These operate on the mounted directory; -the SMB transport has no create, rename or delete. +re-identified and listed anew. They run over `Storage`: a mounted directory +(`Notebook::open`) or an SMB share (`Notebook::open_smb` with a +`smb::Client`, which gained create, directory creation, rename, delete and +header placement under native writer coordination); `tools/test_smb_structure.py +VM OUTPUT` drives them against a disposable Samba lab VM. `Notebook::find_page(url)` resolves a stored internal link to a section path and page space by identity: the linked section first, then every readable diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 0585d68eede87273686bc8a68ac8778598447ab3..f926999b243f7e07192aebc2e3ed78d84cc9c8a5 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -66,9 +66,195 @@ fn entries(folder: &discover::Folder) -> Entries { out } -/// A notebook directory and the cache directory holding its section replicas. +/// Where a notebook's files live: a mounted directory or an SMB share. Paths are catalog +/// paths, `/`-separated and relative to the notebook root. +pub trait Storage: Send + Sync { + fn discover(&self, limits: discover::Limits) -> Result; + fn exists(&self, path: &str) -> bool; + fn read(&self, path: &str) -> Result>; + /// Creates a file holding `bytes`; an existing file is an error. + fn create(&self, path: &str, bytes: &[u8]) -> Result<()>; + fn create_directory(&self, path: &str) -> Result<()>; + /// Renames or moves a file or directory; an existing target is an error. + fn rename(&self, from: &str, to: &str) -> Result<()>; + /// Names a section or TOC file for its notebook, as `onestore::place`. + fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()>; + fn commit(&self, path: &str, edit: &PreparedEdit<'_>) -> Result<()>; + fn set_property( + &self, + path: &str, + source: &[u8], + space: ExGuid, + object: ExGuid, + property: u32, + value: &[u8], + ) -> Result<()>; +} + +/// A mounted notebook directory. +struct Directory(PathBuf); + +impl Directory { + fn path(&self, relative: &str) -> PathBuf { + if relative.is_empty() { + self.0.clone() + } else { + self.0.join(relative) + } + } +} + +impl Storage for Directory { + fn discover(&self, limits: discover::Limits) -> Result { + Ok(discover::discover( + &mut discover::Local::open(&self.0)?, + limits, + )?) + } + + fn exists(&self, path: &str) -> bool { + self.path(path).exists() + } + + fn read(&self, path: &str) -> Result> { + Ok(onestore::read_file(self.path(path))?) + } + + fn create(&self, path: &str, bytes: &[u8]) -> Result<()> { + std::fs::File::create_new(self.path(path))?.write_all(bytes)?; + Ok(()) + } + + fn create_directory(&self, path: &str) -> Result<()> { + Ok(std::fs::create_dir(self.path(path))?) + } + + fn rename(&self, from: &str, to: &str) -> Result<()> { + Ok(std::fs::rename(self.path(from), self.path(to))?) + } + + fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()> { + Ok(onestore::place_file(self.path(path), ancestor, name)?) + } + + fn commit(&self, path: &str, edit: &PreparedEdit<'_>) -> Result<()> { + Ok(edit.commit_file(self.path(path))?) + } + + fn set_property( + &self, + path: &str, + source: &[u8], + space: ExGuid, + object: ExGuid, + property: u32, + value: &[u8], + ) -> Result<()> { + Ok(onestore::commit_file_property( + self.path(path), + source, + space, + object, + property, + value, + )?) + } +} + +/// A notebook directory on an SMB share, reached through the native-compatible client. +#[cfg(feature = "smb")] +pub struct Share { + client: Arc, + root: String, +} + +#[cfg(feature = "smb")] +impl Share { + fn path(&self, relative: &str) -> String { + match (self.root.is_empty(), relative.is_empty()) { + (_, true) => self.root.clone(), + (true, false) => relative.to_owned(), + (false, false) => format!("{}/{relative}", self.root), + } + } +} + +#[cfg(feature = "smb")] +impl Storage for Share { + fn discover(&self, limits: discover::Limits) -> Result { + Ok(discover::discover( + &mut discover::Smb::new(&self.client, &self.root)?, + limits, + )?) + } + + fn exists(&self, path: &str) -> bool { + let (folder, name) = split(path); + self.client + .read_dir(&self.path(folder), 100_000) + .is_ok_and(|entries| entries.iter().any(|entry| entry.name == name)) + } + + fn read(&self, path: &str) -> Result> { + Ok(self + .client + .read_storage(&self.path(path), 256 * 1024 * 1024)?) + } + + fn create(&self, path: &str, bytes: &[u8]) -> Result<()> { + Ok(self.client.create(&self.path(path), bytes)?) + } + + fn create_directory(&self, path: &str) -> Result<()> { + Ok(self.client.create_directory(&self.path(path))?) + } + + fn rename(&self, from: &str, to: &str) -> Result<()> { + Ok(self.client.rename(&self.path(from), &self.path(to))?) + } + + fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()> { + Ok(self.client.place(&self.path(path), ancestor, name)?) + } + + fn commit(&self, path: &str, edit: &PreparedEdit<'_>) -> Result<()> { + Ok(self.client.commit_prepared(&self.path(path), edit)?) + } + + fn set_property( + &self, + path: &str, + source: &[u8], + space: ExGuid, + object: ExGuid, + property: u32, + value: &[u8], + ) -> Result<()> { + Ok(self.client.commit_property_bytes( + &self.path(path), + source, + space, + object, + property, + value, + )?) + } +} + +const LIMITS: discover::Limits = discover::Limits { + entries: 100_000, + bytes_per_file: 256 * 1024 * 1024, + depth: 64, +}; + +const TOC: &str = "Open Notebook.onetoc2"; +const RECYCLE_BIN: &str = "OneNote_RecycleBin"; + +/// A notebook's files and the cache directory holding its section replicas. pub struct Notebook { - root: PathBuf, + storage: Box, + /// The mounted directory, when sections open through local replicas. + root: Option, cache: PathBuf, catalog: discover::Folder, } @@ -76,17 +262,31 @@ pub struct Notebook { impl Notebook { pub fn open(root: impl AsRef, cache: impl AsRef) -> Result { let root = root.as_ref().canonicalize()?; + Self::with(Box::new(Directory(root.clone())), Some(root), cache) + } + + /// Opens the notebook at `root` on the share `client` is connected to. Sections open + /// through `Section::resume_smb` with the catalog's paths. + #[cfg(feature = "smb")] + pub fn open_smb( + client: Arc, + root: &str, + cache: impl AsRef, + ) -> Result { + let root = root.replace('\\', "/"); + Self::with(Box::new(Share { client, root }), None, cache) + } + + fn with( + storage: Box, + root: Option, + cache: impl AsRef, + ) -> Result { let cache = cache.as_ref().to_path_buf(); std::fs::create_dir_all(&cache)?; - let catalog = discover::discover( - &mut discover::Local::open(&root)?, - discover::Limits { - entries: 100_000, - bytes_per_file: 256 * 1024 * 1024, - depth: 64, - }, - )?; + let catalog = storage.discover(LIMITS)?; Ok(Self { + storage, root, cache, catalog, @@ -97,18 +297,11 @@ impl Notebook { &self.catalog } - /// Rereads the notebook directory and reports what changed since the last catalog, - /// keyed by file identity so a renamed or moved section stays the same section. A - /// failed read keeps the previous catalog: an unreachable notebook is not an empty one. + /// Rereads the notebook and reports what changed since the last catalog, keyed by + /// file identity so a renamed or moved section stays the same section. A failed read + /// keeps the previous catalog: an unreachable notebook is not an empty one. pub fn refresh(&mut self) -> Result> { - let catalog = discover::discover( - &mut discover::Local::open(&self.root)?, - discover::Limits { - entries: 100_000, - bytes_per_file: 256 * 1024 * 1024, - depth: 64, - }, - )?; + let catalog = self.storage.discover(LIMITS)?; let (before, before_orders) = entries(&self.catalog); let (after, after_orders) = entries(&catalog); let mut changes = Vec::new(); @@ -158,28 +351,27 @@ impl Notebook { Err(io::Error::from(io::ErrorKind::NotFound).into()) } - fn directory(&self, path: &str) -> Result { - let directory = if path.is_empty() { - self.root.clone() - } else { - self.root.join(path).canonicalize()? - }; - if !directory.starts_with(&self.root) || !directory.is_dir() { - return Err(io::Error::from(io::ErrorKind::PermissionDenied).into()); + /// A catalog section's path, refusing paths the catalog does not list. + fn section_path(&self, path: &str) -> Result<&discover::Section> { + let mut folders = vec![&self.catalog]; + while let Some(folder) = folders.pop() { + if let Some(section) = folder.sections.iter().find(|section| section.path == path) { + return Ok(section); + } + folders.extend(&folder.groups); } - Ok(directory) + Err(io::Error::from(io::ErrorKind::NotFound).into()) } /// A folder's TOC path and file identity, creating the TOC when the folder has none /// (OneNote names it `Open Notebook.onetoc2`). - fn toc(&self, folder: &str) -> Result<(PathBuf, [u8; 16])> { - let directory = self.directory(folder)?; + fn toc(&self, folder: &str) -> Result<(String, [u8; 16])> { match &self.folder(folder)?.toc { - Some(toc) => Ok((directory.join(&toc.filename), toc.file_id)), + Some(toc) => Ok((catalog_path(folder, &toc.filename), toc.file_id)), None => { - let path = directory.join("Open Notebook.onetoc2"); - let bytes = onestore::create_table_of_contents("Open Notebook.onetoc2", &[])?; - std::fs::File::create_new(&path)?.write_all(&bytes)?; + let path = catalog_path(folder, TOC); + let bytes = onestore::create_table_of_contents(TOC, &[])?; + self.storage.create(&path, &bytes)?; Ok((path, onestore::Store::parse(&bytes)?.header.file_id)) } } @@ -187,23 +379,21 @@ impl Notebook { fn edit_toc(&self, folder: &str, edits: &[onestore::TocEdit]) -> Result<()> { let (toc, _) = self.toc(folder)?; - let source = onestore::read_file(&toc)?; - PreparedEdit::table_of_contents(&source, edits)? - .commit_file(&toc) - .map_err(|error| error.error)?; - Ok(()) + let source = self.storage.read(&toc)?; + self.storage + .commit(&toc, &PreparedEdit::table_of_contents(&source, edits)?) } /// Creates `name.one` in `folder` with one empty page and lists it last in the folder's /// TOC, as OneNote creates a section. Returns the new catalog path. pub fn create_section(&mut self, folder: &str, name: &str, author: &str) -> Result { let filename = format!("{name}.one"); - let directory = self.directory(folder)?; + self.folder(folder)?; let (_, ancestor) = self.toc(folder)?; let bytes = onestore::create_section(&filename, "", author)?; - let path = directory.join(&filename); - std::fs::File::create_new(&path)?.write_all(&bytes)?; - onestore::place_file(&path, ancestor, &filename)?; + let path = catalog_path(folder, &filename); + self.storage.create(&path, &bytes)?; + self.storage.place(&path, ancestor, &filename)?; let identity = onestore::Store::parse(&bytes)?.header.file_id; self.edit_toc( folder, @@ -214,22 +404,22 @@ impl Notebook { }], )?; self.refresh()?; - Ok(catalog_path(folder, &filename)) + Ok(path) } /// Creates a section group: a folder with its own TOC, listed last in the parent's TOC. pub fn create_group(&mut self, folder: &str, name: &str) -> Result { - let directory = self.directory(folder)?; + self.folder(folder)?; if !component(name) { return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); } - let group = directory.join(name); let (_, ancestor) = self.toc(folder)?; - std::fs::create_dir(&group)?; - let bytes = onestore::create_table_of_contents("Open Notebook.onetoc2", &[])?; - let path = group.join("Open Notebook.onetoc2"); - std::fs::File::create_new(&path)?.write_all(&bytes)?; - onestore::place_file(&path, ancestor, name)?; + let group = catalog_path(folder, name); + self.storage.create_directory(&group)?; + let bytes = onestore::create_table_of_contents(TOC, &[])?; + let path = catalog_path(&group, TOC); + self.storage.create(&path, &bytes)?; + self.storage.place(&path, ancestor, name)?; let identity = onestore::Store::parse(&bytes)?.header.file_id; self.edit_toc( folder, @@ -240,48 +430,46 @@ impl Notebook { }], )?; self.refresh()?; - Ok(catalog_path(folder, name)) + Ok(group) } - /// Renames a section or section group: the file or folder on disk and its TOC entry. + /// Renames a section or section group: the file or folder and its TOC entry. pub fn rename(&mut self, path: &str, name: &str) -> Result { let (folder, entry) = split(path); - let directory = self.directory(folder)?; let (filename, identity) = self.entry(folder, entry)?; let target = if filename.to_ascii_lowercase().ends_with(".one") { format!("{name}.one") } else { name.to_owned() }; - if !component(&target) || directory.join(&target).exists() { + let renamed = catalog_path(folder, &target); + if !component(&target) || self.storage.exists(&renamed) { return Err(io::Error::from(io::ErrorKind::AlreadyExists).into()); } - std::fs::rename(directory.join(&filename), directory.join(&target))?; + self.storage + .rename(&catalog_path(folder, &filename), &renamed)?; let (_, ancestor) = self.toc(folder)?; let placed = if target.ends_with(".one") { - directory.join(&target) + renamed.clone() } else { - directory.join(&target).join("Open Notebook.onetoc2") + catalog_path(&renamed, TOC) }; - onestore::place_file(placed, ancestor, &target)?; + self.storage.place(&placed, ancestor, &target)?; self.edit_toc( folder, &[onestore::TocEdit::Rename { identity, - filename: target.clone(), + filename: target, }], )?; self.refresh()?; - Ok(catalog_path(folder, &target)) + Ok(renamed) } /// Sets a section's colour (COLORREF) in its own metadata, where OneNote keeps it. pub fn set_section_color(&mut self, path: &str, color: Option) -> Result<()> { - let file = self.root.join(path).canonicalize()?; - if !file.starts_with(&self.root) { - return Err(io::Error::from(io::ErrorKind::PermissionDenied).into()); - } - let source = onestore::read_file(&file)?; + let path = self.section_path(path)?.path.clone(); + let source = self.storage.read(&path)?; let store = onestore::Store::parse(&source)?; let index = onestore::RevisionIndex::parse(&store)?; let document = Document::parse(&index)?; @@ -290,15 +478,14 @@ impl Notebook { .roots .get(&2) .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?; - onestore::commit_file_property( - &file, + self.storage.set_property( + &path, &source, document.root, metadata, 0x14001cbe, &color.unwrap_or(0xffff_ffff).to_le_bytes(), - ) - .map_err(|error| error.error)?; + )?; self.refresh()?; Ok(()) } @@ -322,23 +509,23 @@ impl Notebook { /// the root TOC lists), and its own folder's TOC entry goes. pub fn delete(&mut self, path: &str) -> Result<()> { let (folder, entry) = split(path); - let directory = self.directory(folder)?; let (filename, identity) = self.entry(folder, entry)?; if !filename.to_ascii_lowercase().ends_with(".one") { return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); } - let bin = self.root.join("OneNote_RecycleBin"); - let bin_toc = bin.join("Open Notebook.onetoc2"); - if !bin_toc.exists() { - std::fs::create_dir_all(&bin)?; - let bytes = onestore::create_table_of_contents("Open Notebook.onetoc2", &[])?; - std::fs::File::create_new(&bin_toc)?.write_all(&bytes)?; - onestore::place_file(&bin_toc, self.toc("")?.1, "OneNote_RecycleBin")?; + let bin_toc = catalog_path(RECYCLE_BIN, TOC); + if !self.storage.exists(&bin_toc) { + if !self.storage.exists(RECYCLE_BIN) { + self.storage.create_directory(RECYCLE_BIN)?; + } + let bytes = onestore::create_table_of_contents(TOC, &[])?; + self.storage.create(&bin_toc, &bytes)?; + self.storage.place(&bin_toc, self.toc("")?.1, RECYCLE_BIN)?; let bin_identity = onestore::Store::parse(&bytes)?.header.file_id; self.edit_toc( "", &[onestore::TocEdit::Add { - filename: "OneNote_RecycleBin".into(), + filename: RECYCLE_BIN.into(), identity: bin_identity, group: true, }], @@ -346,28 +533,31 @@ impl Notebook { } let mut target = filename.clone(); let mut attempt = 1; - while bin.join(&target).exists() { + while self.storage.exists(&catalog_path(RECYCLE_BIN, &target)) { attempt += 1; let (stem, extension) = filename.rsplit_once('.').unwrap_or((&filename, "")); target = format!("{stem} ({attempt}).{extension}"); } - std::fs::rename(directory.join(&filename), bin.join(&target))?; - let source = onestore::read_file(&bin_toc)?; - onestore::place_file( - bin.join(&target), + let binned = catalog_path(RECYCLE_BIN, &target); + self.storage + .rename(&catalog_path(folder, &filename), &binned)?; + let source = self.storage.read(&bin_toc)?; + self.storage.place( + &binned, onestore::Store::parse(&source)?.header.file_id, &target, )?; - PreparedEdit::table_of_contents( - &source, - &[onestore::TocEdit::Add { - filename: target, - identity, - group: false, - }], - )? - .commit_file(&bin_toc) - .map_err(|error| error.error)?; + self.storage.commit( + &bin_toc, + &PreparedEdit::table_of_contents( + &source, + &[onestore::TocEdit::Add { + filename: target, + identity, + group: false, + }], + )?, + )?; self.edit_toc(folder, &[onestore::TocEdit::Remove { identity }])?; self.refresh().map(drop) } @@ -419,7 +609,7 @@ impl Notebook { .map(|section| (section.path.clone(), None))); }; for section in sections { - let bytes = onestore::read_file(self.root.join(§ion.path))?; + let bytes = self.storage.read(§ion.path)?; let store = Store::parse(&bytes)?; let index = RevisionIndex::parse(&store)?; let document = Document::parse(&index)?; @@ -436,11 +626,8 @@ impl Notebook { /// written, and the decoded buffers go when the pages have been built. #[cfg(feature = "protected")] pub fn unlock(&self, path: &str, password: &str) -> Result> { - let file = self.root.join(path).canonicalize()?; - if !file.starts_with(&self.root) { - return Err(io::Error::from(io::ErrorKind::PermissionDenied).into()); - } - let bytes = onestore::read_file(&file)?; + let path = self.section_path(path)?.path.clone(); + let bytes = self.storage.read(&path)?; let store = Store::parse(&bytes)?; let index = RevisionIndex::parse(&store)?; let unlocked = onestore::protected::UnlockedSection::open( @@ -456,20 +643,22 @@ impl Notebook { .collect() } - /// Opens a section by its catalog path. + /// Opens a section of a mounted notebook by its catalog path. pub fn section(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result
{ - let mut folders = vec![&self.catalog]; - while let Some(folder) = folders.pop() { - if folder.sections.iter().any(|section| section.path == path) { - let file = self.root.join(path).canonicalize()?; - if !file.starts_with(&self.root) { - return Err(io::Error::from(io::ErrorKind::PermissionDenied).into()); - } - return Section::open(file, &self.cache, notify); - } - folders.extend(&folder.groups); + let path = self.section_path(path)?.path.clone(); + let Some(root) = &self.root else { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Sections on a share open through Section::resume_smb", + ) + .into()); + }; + // A section replaced by a link out of the notebook is not the catalog's section. + let file = root.join(path).canonicalize()?; + if !file.starts_with(root) { + return Err(io::Error::from(io::ErrorKind::PermissionDenied).into()); } - Err(io::Error::from(io::ErrorKind::NotFound).into()) + Section::open(file, &self.cache, notify) } } diff --git a/crates/notebook/src/smb/mod.rs b/crates/notebook/src/smb/mod.rs index e8ccdff841bd0e37acf6d4c57eb1198a76d7c859..38f25a82d2b7e49af84cc8a8500713e0e54b2794 100644 --- a/crates/notebook/src/smb/mod.rs +++ b/crates/notebook/src/smb/mod.rs @@ -13,6 +13,7 @@ use smb2::{ lock::{LockElement, LockRequest, LockResponse}, query_info::{InfoType, QueryInfoRequest, QueryInfoResponse}, read::{ReadRequest, ReadResponse}, + set_info::{SetInfoRequest, SetInfoResponse}, write::{WriteRequest, WriteResponse}, }, pack::{Pack, ReadCursor, Unpack}, @@ -180,6 +181,23 @@ impl Client { } fn open_shared(&self, path: &str, write: bool, sharing: u32) -> io::Result> { + self.open_with( + path, + if write { 0xc0000000 } else { 0x80000000 }, + sharing, + CreateDisposition::FileOpen, + 0x42, + ) + } + + fn open_with( + &self, + path: &str, + access: u32, + sharing: u32, + disposition: CreateDisposition, + options: u32, + ) -> io::Result> { if path.is_empty() || path.contains('\0') || path.encode_utf16().count() > 32767 { return Err(io::ErrorKind::InvalidInput.into()); } @@ -188,11 +206,11 @@ impl Client { CreateRequest { requested_oplock_level: OplockLevel::None, impersonation_level: ImpersonationLevel::Impersonation, - desired_access: FileAccessMask::new(if write { 0xc0000000 } else { 0x80000000 }), + desired_access: FileAccessMask::new(access), file_attributes: 0, share_access: ShareAccess(sharing), - create_disposition: CreateDisposition::FileOpen, - create_options: 0x42, + create_disposition: disposition, + create_options: options, name: smb2::encode_path(&path.replace('\\', "/")), create_contexts: Vec::new(), }, @@ -203,6 +221,78 @@ impl Client { }) } + /// Creates a file holding `bytes`; an existing file is an error. + pub fn create(&self, path: &str, bytes: &[u8]) -> io::Result<()> { + let mut file = self.open_with(path, 0xc0000000, 0, CreateDisposition::FileCreate, 0x40)?; + let mut written = 0; + while written < bytes.len() { + let count = file.write_at(written as u64, &bytes[written..])?; + if count == 0 { + return Err(io::ErrorKind::WriteZero.into()); + } + written += count; + } + file.flush()?; + file.close() + } + + /// Creates a directory; an existing one is an error. + pub fn create_directory(&self, path: &str) -> io::Result<()> { + self.open_with(path, 0x80000000, 7, CreateDisposition::FileCreate, 0x1)? + .close() + } + + /// Renames or moves a file or directory within the share; an existing target is an + /// error. + pub fn rename(&self, from: &str, to: &str) -> io::Result<()> { + if to.is_empty() || to.contains('\0') { + return Err(io::ErrorKind::InvalidInput.into()); + } + let file = self.open_with( + from, + 0x00010000 | 0x80000000, + 7, + CreateDisposition::FileOpen, + 0, + )?; + let name: Vec = to + .replace('/', "\\") + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect(); + let mut buffer = vec![0; 16]; + buffer.extend_from_slice(&u32::try_from(name.len()).unwrap().to_le_bytes()); + buffer.extend_from_slice(&name); + let _: SetInfoResponse = self.request( + Command::SetInfo, + SetInfoRequest { + info_type: InfoType::File, + file_info_class: 10, + additional_information: 0, + file_id: file.id.ok_or(io::ErrorKind::InvalidInput)?, + buffer, + }, + )?; + file.close() + } + + /// Deletes a file or an empty directory. + pub fn delete(&self, path: &str) -> io::Result<()> { + self.open_with(path, 0x00010000, 7, CreateDisposition::FileOpen, 0x1000)? + .close() + } + + /// Names a section or TOC file for its notebook (`onestore::place`) under native + /// writer coordination. + pub fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<(), CommitError> { + self.commit(path, |file| { + onestore::place(file, ancestor, name).map_err(|error| CommitError { + state: CommitState::NotCommitted, + error, + }) + }) + } + /// Reads a bounded external payload while denying concurrent writes and deletion. /// Empty files succeed; limits, sharing contention and failed close return no payload. pub fn read_asset(&self, path: &str, limit: usize) -> io::Result> { diff --git a/crates/notebook/src/smb/tests.rs b/crates/notebook/src/smb/tests.rs index 94b3dbd9638288fc2ea2c0fc69b6a8a6f13a3f22..241f954afc19c64630113def6aa4d3dd370d0cba 100644 --- a/crates/notebook/src/smb/tests.rs +++ b/crates/notebook/src/smb/tests.rs @@ -505,3 +505,102 @@ fn read_limits_respect_negotiation_and_available_credits() { } } } + +/// Notebook structure over the share: sections and groups created, renamed, coloured, +/// ordered and deleted through `Notebook::open_smb`, seen again by a fresh discovery. +#[test] +#[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"] +fn live_structure() { + use crate::session::Notebook; + let client = std::sync::Arc::new(client()); + let root = format!("structure-{}", std::process::id()); + client.create_directory(&root).unwrap(); + let first = onestore::create_section("First.one", "First page", "Author").unwrap(); + client.create(&format!("{root}/First.one"), &first).unwrap(); + let first_id = onestore::Store::parse(&first).unwrap().header.file_id; + let toc = + onestore::create_table_of_contents("Open Notebook.onetoc2", &[("First.one", first_id)]) + .unwrap(); + client + .create(&format!("{root}/Open Notebook.onetoc2"), &toc) + .unwrap(); + let toc_id = onestore::Store::parse(&toc).unwrap().header.file_id; + client + .place(&format!("{root}/First.one"), toc_id, "First.one") + .unwrap(); + let cache = tempfile::tempdir().unwrap(); + let mut notebook = + Notebook::open_smb(std::sync::Arc::clone(&client), &root, cache.path()).unwrap(); + assert_eq!( + notebook.create_section("", "Second", "Author").unwrap(), + "Second.one" + ); + assert_eq!(notebook.create_group("", "Archive").unwrap(), "Archive"); + assert_eq!( + notebook + .create_section("Archive", "Inner", "Author") + .unwrap(), + "Archive/Inner.one" + ); + assert_eq!( + notebook.rename("Second.one", "Renamed").unwrap(), + "Renamed.one" + ); + assert_eq!(notebook.rename("Archive", "Kept").unwrap(), "Kept"); + notebook + .set_section_color("Renamed.one", Some(0x5ed7ff)) + .unwrap(); + notebook.reorder("", &["Kept", "Renamed.one"]).unwrap(); + notebook.delete("First.one").unwrap(); + let names = |folder: &crate::discover::Folder| { + ( + folder + .sections + .iter() + .map(|s| s.path.clone()) + .collect::>(), + folder + .groups + .iter() + .map(|g| g.path.clone()) + .collect::>(), + ) + }; + let fresh = Notebook::open_smb(std::sync::Arc::clone(&client), &root, cache.path()).unwrap(); + assert_eq!( + names(fresh.catalog()), + ( + vec!["Renamed.one".to_owned()], + vec!["Kept".to_owned(), "OneNote_RecycleBin".to_owned()] + ) + ); + assert_eq!( + names(&fresh.catalog().groups[0]), + (vec!["Kept/Inner.one".to_owned()], vec![]) + ); + assert_eq!( + names(&fresh.catalog().groups[1]), + (vec!["OneNote_RecycleBin/First.one".to_owned()], vec![]) + ); + assert!(fresh.catalog().toc.as_ref().unwrap().unresolved.is_empty()); + let renamed = client + .read_storage(&format!("{root}/Renamed.one"), 1 << 20) + .unwrap(); + let header = onestore::Store::parse(&renamed).unwrap().header; + assert_eq!(header.ancestor, toc_id); + assert_eq!(header.name_crc, 0x6108912a); + assert!(fresh.section("Renamed.one", || {}).is_err()); + for path in [ + "Renamed.one", + "Kept/Inner.one", + "Kept/Open Notebook.onetoc2", + "Kept", + "OneNote_RecycleBin/First.one", + "OneNote_RecycleBin/Open Notebook.onetoc2", + "OneNote_RecycleBin", + "Open Notebook.onetoc2", + ] { + client.delete(&format!("{root}/{path}")).unwrap(); + } + client.delete(&root).unwrap(); +} diff --git a/crates/notebook/tests/copy.rs b/crates/notebook/tests/copy.rs index cb66ba0e3968bab26e4db10fb87ed0db7a7827ce..09d26a61193b5a1c9cf9e8a2e587798103c8dccf 100644 --- a/crates/notebook/tests/copy.rs +++ b/crates/notebook/tests/copy.rs @@ -15,11 +15,12 @@ use std::{ }; /// Pages the writer authored for other rows: text with an attachment and its icon, a -/// table, an inserted picture, a page-level ink drawing, tags, lists, equations +/// table, nested tables with cell subtrees, an inserted picture, a page-level ink drawing, tags, lists, equations /// and paragraph formatting. const SOURCES: &[&str] = &[ "attachment-edit/icon/candidate/files.one", "table-edit/created/candidate/tables.one", + "table-edit/nested/candidate/synthetic.one", "picture-edit/inserted/candidate/pictures.one", "ink-edit/drawing/candidate/ink.one", "tag-edit/candidate/tags.one", @@ -64,8 +65,10 @@ fn shape(page: &Page) -> String { serde_json::Value::Object(fields) => { // The writer's canonical formatting: an unset flag is false and unset // spacing is zero. - fields.retain(|_, value| { - !value.is_null() + fields.retain(|key, value| { + // OneNote lays outlines out again on open. + key != "max_height" + && !value.is_null() && *value != serde_json::Value::Bool(false) && value.as_f64() != Some(0.0) }); @@ -153,6 +156,7 @@ fn pages_copy_into_another_section_with_their_content_and_fresh_identities() { .unwrap(); let section = Section::open(&file, &cache, || {}).unwrap(); let mut expected = Vec::new(); + let mut grouped = Vec::new(); for source in SOURCES { let bytes = std::fs::read( Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus")).join(source), @@ -166,7 +170,14 @@ fn pages_copy_into_another_section_with_their_content_and_fresh_identities() { { continue; } - let copy = page.copy().unwrap(); + let copy = match page.copy() { + Ok(copy) => copy, + Err(error) if error.message == "Outline groups cannot be copied" => { + grouped.push(page.title.clone()); + continue; + } + Err(error) => panic!("{source} {}: {error}", page.title), + }; assert_ne!(copy.identity, page.identity); let space = section .import_page(&page, "Copier") @@ -175,6 +186,16 @@ fn pages_copy_into_another_section_with_their_content_and_fresh_identities() { } } assert!(expected.len() >= 8, "{} pages", expected.len()); + // Outdenting leaves paragraphs at levels only an outline group carries. + assert_eq!( + grouped, + [ + "Outdent first group", + "Delete only grouped subtree", + "Delete unindented sibling after group", + "Outdent child across indentation gap" + ] + ); let deadline = Instant::now() + Duration::from_secs(120); while section .queue() diff --git a/crates/onestore/README.md b/crates/onestore/README.md index b9e24095306984ddafa348fb359623f3f1b7205c..da3bb0f4fe9c8c4926085cb917bb6654478355ef 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -43,7 +43,7 @@ harness also accepts `--client-profile release`. | `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes | | `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities | | `Document::active`, `Document::pages_in`, `Revision::parents`, `RevisionIndex::active` | Resolve the active revision, the pages of a space and parent links without repeating the lookups | -| `Page::copy` | The page's content under fresh identities for copying into another section, definitions and payloads included | +| `Page::copy` | The page's content under fresh identities for copying into another section, definitions and payloads included; indent levels only an outline group carries refuse | | `page::link::internal_link`, `page::link::parse_internal_link` | Build and read the `onenote:#…` URLs OneNote stores for links to sections, pages and paragraphs, by identity | | `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments, ink drawings and handwriting decoded to stroke polylines in page points) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` | | `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | @@ -53,7 +53,7 @@ harness also accepts `--client-profile release`. | `PreparedEdit::delete_pages_permanently` | Remove explicit pages and their section references atomically while retaining stored revisions | | `create_table_of_contents` | Create ordered section entries from filenames and file identities | | `TocEdit`, `PreparedEdit::table_of_contents` | Add, rename, colour, order and remove a table of contents' section and group entries as one revision | -| `place_file` | Name a file for its notebook as OneNote does on adoption (parent TOC identity and name CRC in the header), so OneNote keeps its identity | +| `place`, `place_file` | Name a file for its notebook as OneNote does on adoption (parent TOC identity and name CRC in the header), so OneNote keeps its identity, on any `CommitIo` or under the filesystem adapter | | `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references | | `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range across ordinary text runs; publish text, run boundaries and modification time together | | `Insertion`, `PreparedEdit::insert` | Insert paragraphs into editable containers or positioned outlines into a page, retaining intent identities across rebases | diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index 48e65c2cb419e6036caebd5331b64b23bc69f583..d78b98d3d3bd80940cb23420fb8e3c7cbb0908a2 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -74,23 +74,27 @@ impl Drop for FileIo { /// Places a file in its notebook the way OneNote does on adoption: the header's /// `guidAncestor` becomes the parent table of contents' file identity and `crcName` the CRC /// of `name` (a section's file name, a group's folder name). OneNote re-identifies a file -/// whose header disagrees with its location, which orphans its TOC entry. +/// whose header disagrees with its location, which orphans its TOC entry. The caller holds +/// OneNote-compatible exclusion on `io`. +pub fn place(io: &mut impl CommitIo, ancestor: [u8; 16], name: &str) -> io::Result<()> { + let mut header = [0; 1024]; + if io.read_at(0, &mut header)? != header.len() { + return Err(io::Error::from(ErrorKind::UnexpectedEof)); + } + crate::Header::parse(&header) + .map_err(|error| io::Error::new(ErrorKind::InvalidData, error.message))?; + let placement = crate::create::placement(ancestor, name); + if io.write_at(128, &placement)? != placement.len() { + return Err(io::Error::from(ErrorKind::WriteZero)); + } + io.flush() +} + +/// `place` under the conservative filesystem adapter's whole-file exclusion. #[cfg(any(unix, windows))] pub fn place_file(path: impl AsRef, ancestor: [u8; 16], name: &str) -> io::Result<()> { let mut io = FileIo::open(path, true)?; - let mut header = [0; 1024]; - let result = (|| { - if io.read_at(0, &mut header)? != header.len() { - return Err(io::Error::from(ErrorKind::UnexpectedEof)); - } - crate::Header::parse(&header) - .map_err(|error| io::Error::new(ErrorKind::InvalidData, error.message))?; - let placement = crate::create::placement(ancestor, name); - if io.write_at(128, &placement)? != placement.len() { - return Err(io::Error::from(ErrorKind::WriteZero)); - } - io.flush() - })(); + let result = place(&mut io, ancestor, name); let released = io.release(); result?; released diff --git a/crates/onestore/src/formatting.rs b/crates/onestore/src/formatting.rs index d6a4a78f1eac3b36792c1915e337005a88dcb2aa..a1bae1fb1b91785ba316abdf8bb6b9767d8da146 100644 --- a/crates/onestore/src/formatting.rs +++ b/crates/onestore/src/formatting.rs @@ -35,6 +35,8 @@ pub enum TextAttribute { Color(Option<[u8; 3]>), /// RGB, or None to clear highlighting. Highlight(Option<[u8; 3]>), + /// Windows language id of the run, such as 1033 for US English. + Language(u32), /// Hidden text, such as a hyperlink's field code. Hidden(bool), /// The run belongs to a hyperlink field. @@ -73,6 +75,7 @@ impl TextAttribute { } (0x10001c0b, ((*points * 2.0) as u16).to_le_bytes().to_vec()) } + Self::Language(language) => (0x14001c3b, language.to_le_bytes().to_vec()), Self::Color(color) | Self::Highlight(color) => ( if matches!(self, Self::Color(_)) { 0x14001c0c diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs index b40bd2dd500c157b342581fc669defb8ac4a6502..9b29539f9ac3689afdc5c1a223759d724b3fd388 100644 --- a/crates/onestore/src/lib.rs +++ b/crates/onestore/src/lib.rs @@ -27,7 +27,7 @@ mod write; pub use commit::{ CommitError, CommitIo, CommitState, PreparedEdit, commit_property_bytes, commit_text, - confirm_snapshot, + confirm_snapshot, place, }; #[cfg(any(unix, windows))] pub use commit::{ diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index 60a02a614a96ae78f142ba79db4f706c2da316ac..95bf245bd0f3cb5f25cffbdd6ada12e2be6091b7 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -421,6 +421,33 @@ impl Page { let mut copy: Self = serde_json::from_value(value).map_err(|_| invalid("Page copy does not deserialize"))?; copy.identity = Some(crate::write::fresh_guid()?); + // A paragraph's indent level follows its parent chain; a level beyond that comes + // from an outline group, which the model does not hold. + fn levels(paragraphs: &[PageParagraph]) -> bool { + let by_id: BTreeMap = paragraphs.iter().map(|p| (p.id, p.level)).collect(); + paragraphs.iter().all(|paragraph| { + let expected = match paragraph.parent { + Some(parent) => by_id.get(&parent).map_or(0, |level| level + 1), + None => 1, + }; + paragraph.level == expected + && match ¶graph.content { + ParagraphContent::Table(table) => table + .rows + .iter() + .flat_map(|row| &row.cells) + .all(|cell| levels(&cell.paragraphs)), + _ => true, + } + }) + } + let expressible = copy.objects.iter().all(|object| match object { + PageObject::Outline(outline) => levels(&outline.paragraphs), + _ => true, + }); + if !expressible { + return Err(invalid("Outline groups cannot be copied")); + } Ok(copy) } diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs index d7e2e92b96016f19c85c4411c88ec0d06a3d7a96..e6c8b8f1cea0bce230bf2c41621566de611ddb78 100644 --- a/crates/onestore/src/page/write.rs +++ b/crates/onestore/src/page/write.rs @@ -355,6 +355,7 @@ pub(crate) fn write_page( page: *page, author, alias: BTreeMap::new(), + built: BTreeSet::new(), }; lowering.run(&before, after, &raw.objects.keys().copied().collect())?; if lowering.image == source { @@ -457,6 +458,8 @@ struct Lowering<'a> { author: &'a str, /// Model identities of new objects mapped to the identities the typed writers allocated. alias: BTreeMap, + /// Tables whose structure this run has written. + built: BTreeSet, } impl Lowering<'_> { @@ -900,20 +903,48 @@ impl Lowering<'_> { collect_containers(&outline.paragraphs, &mut containers); } } - // Cells that do not exist yet receive their paragraphs once the table structure does. + // Cells that do not exist yet, and containers inside them, receive their + // paragraphs once the table structure does. + let new_cell = |id: &ExGuid| { + !(old.children.contains_key(id) + || new.paragraphs.contains_key(id) + || new.outlines.contains_key(id)) + }; let (existing, deferred): (Vec, Vec) = containers.into_iter().partition(|container| { - old.children.contains_key(container) - || new.paragraphs.contains_key(container) - || new.outlines.contains_key(container) + let mut at = *container; + loop { + if new_cell(&at) { + return false; + } + match new.container.get(&at) { + Some(parent) => at = *parent, + None => return true, + } + } }); self.place_containers(old, new, placed, &existing)?; - self.edit_table_structure(old, new)?; + // Each round builds the tables whose holders exist and fills their cells, which + // may hold further new tables. + let mut pending = deferred; + loop { + self.edit_table_structure(old, new)?; + if pending.is_empty() { + break; + } + let (ready, waiting): (Vec, Vec) = pending + .into_iter() + .partition(|container| self.alias.contains_key(container)); + if ready.is_empty() { + return Err(invalid("Table content has no table to hold it")); + } + self.place_containers(old, new, placed, &ready)?; + pending = waiting; + } self.edit_cells(old, new)?; self.edit_images(old, new)?; self.edit_ink_paragraphs(old, new)?; self.edit_attachments(old, new)?; - self.place_containers(old, new, placed, &deferred)?; Ok(()) } @@ -1577,6 +1608,13 @@ impl Lowering<'_> { continue; }; let previous = old_tables.get(&table.id).copied(); + // A table is built once its holder is placed, and only once. + if !(old.paragraphs.contains_key(paragraph_id) || self.alias.contains_key(paragraph_id)) + || self.built.contains(&table.id) + { + continue; + } + self.built.insert(table.id); let unchanged = previous.is_some_and(|previous| { previous.columns == table.columns && previous.borders == table.borders @@ -2450,6 +2488,9 @@ impl Lowering<'_> { spacing!(space_before, 0x1400342e); spacing!(space_after, 0x1400342f); spacing!(line_spacing, 0x14003430); + field!(language, value, { + (0x14001c3b, value.to_le_bytes().to_vec()) + }); if values.is_empty() { continue; } @@ -2827,6 +2868,13 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result 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); @@ -2840,7 +2888,7 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result