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