diff --git a/corpus/page-copy/README.md b/corpus/page-copy/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..552dac7255c896ae966fa26cf3cca14281e7b4b1
--- /dev/null
+++ b/corpus/page-copy/README.md
@@ -0,0 +1,23 @@
+# Pages copied between sections
+
+`candidate/copies.one` is the destination section
+`pages_copy_into_another_section_with_their_content_and_fresh_identities` in
+`crates/notebook/tests/copy.rs` builds: a section created in Rust into which
+`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
+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
+styles become quick-style objects the writer creates from the copied
+definitions (OneNote exports their font, size and colour through
+`QuickStyleDef`, not on the runs), and payloads travelled inside the queued
+intents.
+
+`cold/` is a fresh OneNote 2010 read with a screenshot per page
+(`read/page-NNN.png`). `tools/test_page_copy.py` checks the content oracle
+and the page set without a VM. Regenerate with `NOTEBOOK_COPY_EXPORT` set to
+a new directory while running the test, then cold-open with
+`tools/native_runner.py OUTPUT COLD --expected-pages 9 --collect-notebook
+--screenshots`.
diff --git a/corpus/page-copy/candidate/Open Notebook.onetoc2 b/corpus/page-copy/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..a36a8cfe863f1cc81b557f0e550596bc8fbec09e
Binary files /dev/null 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
new file mode 100644
index 0000000000000000000000000000000000000000..422e712334d03bd2fe0be18d542c64b7d4e390fd
Binary files /dev/null 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
new file mode 100644
index 0000000000000000000000000000000000000000..f11c82a4cb6cc2e8f3bdf52b5cdeaad4d5bb214e
--- /dev/null
+++ b/corpus/page-copy/candidate/expected-count.txt
@@ -0,0 +1 @@
+9
\ No newline at end of file
diff --git a/corpus/page-copy/cold/commands.jsonl b/corpus/page-copy/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..3a0610b9eb779e7824b149b6a37aa1a57106b8f3
--- /dev/null
+++ b/corpus/page-copy/cold/commands.jsonl
@@ -0,0 +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 -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
new file mode 100644
index 0000000000000000000000000000000000000000..653f42a08c4bbafd4176acd640a1cf65d8599acd
--- /dev/null
+++ b/corpus/page-copy/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-1c0f2cde", "hostname": "ONE-M6-1C0F2CDE"}
diff --git a/corpus/page-copy/cold/notebook/Open Notebook.onetoc2 b/corpus/page-copy/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..5dcccbbc5397a59febc04781691411c1558f55cd
Binary files /dev/null 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
new file mode 100644
index 0000000000000000000000000000000000000000..6e9e78e9f6128bec0d13051105131831423fdddd
Binary files /dev/null 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
new file mode 100644
index 0000000000000000000000000000000000000000..f11c82a4cb6cc2e8f3bdf52b5cdeaad4d5bb214e
--- /dev/null
+++ b/corpus/page-copy/cold/notebook/expected-count.txt
@@ -0,0 +1 @@
+9
\ No newline at end of file
diff --git a/corpus/page-copy/cold/read/b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6.attachment b/corpus/page-copy/cold/read/b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6.attachment
new file mode 100644
index 0000000000000000000000000000000000000000..ae0d23c6d54266cd83b053571137ccdab0adb80a
--- /dev/null
+++ b/corpus/page-copy/cold/read/b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6.attachment
@@ -0,0 +1 @@
+Rust wrote this attachment.
diff --git a/corpus/page-copy/cold/read/environment.json b/corpus/page-copy/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..6ea9ff8ffbc01e8f6a3fb5f8e64218af7e377771
--- /dev/null
+++ b/corpus/page-copy/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-1C0F2CDE",
+ "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
new file mode 100644
index 0000000000000000000000000000000000000000..7b1fc27006da7b1129846b23242d71bf4b7f32c5
--- /dev/null
+++ b/corpus/page-copy/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-copy/cold/read/page-000.navigation.json b/corpus/page-copy/cold/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..af6bcb2175c02024ff0ca87879c85b382404985b
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-000.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-000.png b/corpus/page-copy/cold/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..02d6115a96d4757b3ad42fbd05d979afc13b47dc
Binary files /dev/null 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
new file mode 100644
index 0000000000000000000000000000000000000000..387f50d6db528c1fff51c72dc2b01a7302e34cbc
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-000.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-001.navigation.json b/corpus/page-copy/cold/read/page-001.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..c1fbc1110d4cf29bc7a5645b4863263b05217e60
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-001.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-001.png b/corpus/page-copy/cold/read/page-001.png
new file mode 100644
index 0000000000000000000000000000000000000000..b675a81bdad3b23236acc08fe6962d7c7da92bc9
Binary files /dev/null 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
new file mode 100644
index 0000000000000000000000000000000000000000..da7467cc6a6d5bfbcb6e2590817a8bd893644c4c
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-copy/cold/read/page-002.navigation.json b/corpus/page-copy/cold/read/page-002.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..999c7b5056164cd4b506338d91c60949c9da068e
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-002.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Destination - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/page-copy/cold/read/page-002.png b/corpus/page-copy/cold/read/page-002.png
new file mode 100644
index 0000000000000000000000000000000000000000..ea4460e550b81df83f5e286dc74e072ccc1442b4
Binary files /dev/null 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
new file mode 100644
index 0000000000000000000000000000000000000000..1bc815c76cffe8c6fdccb81b56244a95968dd590
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-002.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-copy/cold/read/page-003.navigation.json b/corpus/page-copy/cold/read/page-003.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..a4ef2b1e701a5f5af5652851cf8d82fddf764464
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-003.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-003.png b/corpus/page-copy/cold/read/page-003.png
new file mode 100644
index 0000000000000000000000000000000000000000..ee9172d16d78966fff0c64aa3b7aa0b71986fff8
Binary files /dev/null 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
new file mode 100644
index 0000000000000000000000000000000000000000..5612591d2cfc83742383fa64df64e40bc2e09a73
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-003.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-copy/cold/read/page-004.navigation.json b/corpus/page-copy/cold/read/page-004.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..957beaafccbcba166fba87cf91fc907f33c74e81
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-004.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-004.png b/corpus/page-copy/cold/read/page-004.png
new file mode 100644
index 0000000000000000000000000000000000000000..10ea73a7f646dfc4f6c29e448f6fd5367de52e01
Binary files /dev/null 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
new file mode 100644
index 0000000000000000000000000000000000000000..274ea54698f2abb5b1a1c680ee67709f7118696e
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-004.xml
@@ -0,0 +1,7 @@
+
+AM8CHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3
+URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj
+RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln
+jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEgKFNtArpWcU+AsrjC8JNY
+RQo/QCNkARXUNSA7oAMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG
+AAAAABIAESAJY0kvgp5dSp0WwhnX6NwFCj9AI2QBFdQ1IDugAxVGAEAKP0Aib0YILvRYCoA=
diff --git a/corpus/page-copy/cold/read/page-005.navigation.json b/corpus/page-copy/cold/read/page-005.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..b65a0c422e83efcb89dd26be239dd1906202f810
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-005.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Before the picture - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/page-copy/cold/read/page-005.png b/corpus/page-copy/cold/read/page-005.png
new file mode 100644
index 0000000000000000000000000000000000000000..b730eaf99ccf248065df37a7accb51b8ed7053dd
Binary files /dev/null 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
new file mode 100644
index 0000000000000000000000000000000000000000..c0be888ecaa00b1e6e27f59dc6512d546bf9fe4e
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-005.xml
@@ -0,0 +1,4 @@
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
+AAAAAElFTkSuQmCC
+
diff --git a/corpus/page-copy/cold/read/page-006.navigation.json b/corpus/page-copy/cold/read/page-006.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..b2cc9630cdb428ace6cdb6faa135abf85f7b4fa0
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-006.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Before the file - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/page-copy/cold/read/page-006.png b/corpus/page-copy/cold/read/page-006.png
new file mode 100644
index 0000000000000000000000000000000000000000..282533cf9f734443d11842d2f564349456597c5c
Binary files /dev/null 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
new file mode 100644
index 0000000000000000000000000000000000000000..27f8353a0980b09fde759afa8b326b58b18a93dd
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-006.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-copy/cold/read/page-007.navigation.json b/corpus/page-copy/cold/read/page-007.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..b8578e6a2f723044d70c505e33406a480433361c
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-007.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Bullet item - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/page-copy/cold/read/page-007.png b/corpus/page-copy/cold/read/page-007.png
new file mode 100644
index 0000000000000000000000000000000000000000..8fc37be3754d9e2c39f1ec14951f3f3b1b2b564c
Binary files /dev/null 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
new file mode 100644
index 0000000000000000000000000000000000000000..85b70e7ca8d640d8f1773ce3a7780f27f15ca1e1
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-007.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-copy/cold/read/page-008.navigation.json b/corpus/page-copy/cold/read/page-008.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..002a1c45a5ba0121a28f35c11466c61b8a2aac13
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-008.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-008.png b/corpus/page-copy/cold/read/page-008.png
new file mode 100644
index 0000000000000000000000000000000000000000..a2cbfec475a139baf867c1be1489f82dd34c1b99
Binary files /dev/null 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
new file mode 100644
index 0000000000000000000000000000000000000000..a522ee4a546279702cc0f619f2da7359b30a4b39
--- /dev/null
+++ b/corpus/page-copy/cold/read/page-008.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-copy/cold/read/payloads.json b/corpus/page-copy/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..79a92aefc1e18c78724631a0f820955eaf174486
--- /dev/null
+++ b/corpus/page-copy/cold/read/payloads.json
@@ -0,0 +1,10 @@
+[
+ {
+ "sha256": "b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6",
+ "name": "notes 🦀.txt",
+ "object": "{03AD9394-425B-0B9C-0D27-CA384DFFCEBF}{1}{B0}",
+ "kind": "InsertedFile",
+ "page": "{C12C0ECF-DDF8-0A21-11BE-3074429542E0}{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
new file mode 100644
index 0000000000000000000000000000000000000000..0fe03e897fb74f1d8a74a56d4e661f9d2f85c1b8
--- /dev/null
+++ b/corpus/page-copy/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/m4/copy",
+ "expected_pages": 9,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/page-copy/cold/scripts/cold.ps1 b/corpus/page-copy/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/page-copy/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/page-copy/cold/scripts/read.ps1 b/corpus/page-copy/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/page-copy/cold/scripts/read.ps1
@@ -0,0 +1,142 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/page-copy/cold/source.json b/corpus/page-copy/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..a0c388417af2adb1b04c2d4e5bcceb80023c48b3
--- /dev/null
+++ b/corpus/page-copy/cold/source.json
@@ -0,0 +1,20 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1604,
+ "sha256": "bf856a7700054971543fb890257cba327dfd35ef6e5438a41483490c4e06407b",
+ "mtime_ns": 1789284797191547350
+ },
+ {
+ "path": "copies.one",
+ "bytes": 91008,
+ "sha256": "8f6c9b66379c4bf6730e281d8363737d88fbe1c521a62fd4d054f735b0505cc7",
+ "mtime_ns": 1789284797187906252
+ },
+ {
+ "path": "expected-count.txt",
+ "bytes": 1,
+ "sha256": "19581e27de7ced00ff1ce50b2047e7a567c76b1cbaebabe5ef03f7c3017bb5b7",
+ "mtime_ns": 1789284797191633183
+ }
+]
diff --git a/corpus/page-copy/cold/teardown.json b/corpus/page-copy/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/page-copy/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/crates/notebook/README.md b/crates/notebook/README.md
index 8d854ac6801ad9acfc7a82075f3c5ed11cc84a35..664c71c8f19212c1aaf68813e4ddbf0071fed6b0 100644
--- a/crates/notebook/README.md
+++ b/crates/notebook/README.md
@@ -347,6 +347,13 @@ With the optional `protected` feature, `Notebook::unlock(path, password)`
reads the pages of a `Locked` section for display; nothing is cached or
written, and a wrong password is `Error::Protected(PasswordMismatch)`.
+`Section::import_page(page, author)` copies a page, usually read from another
+section, to the end of this one as a page creation and a save queued like the
+user's own edits, under fresh identities (`Page::copy`); payloads travel with
+the model, so the copy publishes offline later like any save. Content outside
+the model refuses to copy. A move is an import here followed by
+`Section::delete_pages` there.
+
`Notebook::refresh` rereads the directory on the caller's schedule and reports
what another client changed as `Change`s keyed by file identity: a renamed or
moved section is `Moved`, not removed and added, and a deleted section moves
diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs
index e117740c1587bbb0aa10d6531f7f7c23c5fc42df..0585d68eede87273686bc8a68ac8778598447ab3 100644
--- a/crates/notebook/src/session.rs
+++ b/crates/notebook/src/session.rs
@@ -5,7 +5,8 @@ use crate::{
ConflictKind, EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover,
};
use onestore::{
- CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, page::Page,
+ CommitError, ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store, document::Document,
+ page::Page,
};
use std::{
collections::BTreeMap,
@@ -661,6 +662,53 @@ impl Section {
Ok(Page::from_space(&Document::parse(&index)?, space)?)
}
+ /// Copies a page (usually from another section) to the end of this section as a
+ /// creation and a save queued like the user's own edits, under fresh identities.
+ /// Returns the new page's space. Content outside the model refuses to copy.
+ pub fn import_page(&self, page: &Page, author: &str) -> Result {
+ let copy = page.copy()?;
+ let creation = PageCreation::new(None, Some(&page.title), author)?;
+ self.replica
+ .create_page(&self.replica.snapshot()?, &creation)?;
+ let space = creation.space();
+ loop {
+ // The worker may publish the creation, and so replace the working image,
+ // between reading it and saving against it.
+ let source = self.replica.snapshot()?;
+ let mut after = Page::from_space(
+ &Document::parse(&RevisionIndex::parse(&Store::parse(&source)?)?)?,
+ space,
+ )?;
+ after
+ .objects
+ .retain(|object| matches!(object, onestore::page::PageObject::Title(_)));
+ after.objects.extend(
+ copy.objects
+ .iter()
+ .filter(|object| !matches!(object, onestore::page::PageObject::Title(_)))
+ .cloned(),
+ );
+ after.definitions = copy.definitions.clone();
+ match self.replica.save(&source, space, &after, author) {
+ Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => continue,
+ result => result?,
+ };
+ break;
+ }
+ self.wake();
+ Ok(space)
+ }
+
+ /// Removes pages permanently, queued like the user's own edits (a move across
+ /// sections is `import_page` there, then this here).
+ pub fn delete_pages(&self, pages: &[ExGuid]) -> Result