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> { + let id = self + .replica + .delete_pages(&self.replica.snapshot()?, pages)?; + self.wake(); + Ok(id) + } + /// Saves an edited page. `before` is the model the edit started from; a stored page /// that differs from it means the section changed underneath the editor. pub fn save(&self, space: ExGuid, before: &Page, after: &Page, author: &str) -> Result { diff --git a/crates/notebook/tests/copy.rs b/crates/notebook/tests/copy.rs new file mode 100644 index 0000000000000000000000000000000000000000..cb66ba0e3968bab26e4db10fb87ed0db7a7827ce --- /dev/null +++ b/crates/notebook/tests/copy.rs @@ -0,0 +1,242 @@ +use notebook::{ + EditStatus, + session::{Event, Section}, +}; +use onestore::{ + RevisionIndex, Store, + document::Document, + page::{Page, PageObject}, +}; +use std::{ + collections::BTreeMap, + io::Write, + path::Path, + time::{Duration, Instant}, +}; + +/// 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 +/// and paragraph formatting. +const SOURCES: &[&str] = &[ + "attachment-edit/icon/candidate/files.one", + "table-edit/created/candidate/tables.one", + "picture-edit/inserted/candidate/pictures.one", + "ink-edit/drawing/candidate/ink.one", + "tag-edit/candidate/tags.one", + "list-edit/candidate/lists.one", + "math-edit/written/candidate/math.one", + "paragraph-format/candidate/synthetic.one", +]; + +fn pages(bytes: &[u8]) -> Vec { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| Page::from_space(&document, space).unwrap()) + .collect() +} + +/// Everything but identities and the title object: what a copy must preserve. Identities +/// are numbered by first appearance in the content, so definitions compare by use. +fn shape(page: &Page) -> String { + let mut page = page.clone(); + flatten(&mut page); + let mut value = serde_json::to_value(&page).unwrap(); + let mut names: BTreeMap = BTreeMap::new(); + fn name(names: &mut BTreeMap, text: &str) -> String { + let next = format!("id{}", names.len()); + names.entry(text.to_owned()).or_insert(next).clone() + } + fn scrub(value: &mut serde_json::Value, names: &mut BTreeMap) { + match value { + serde_json::Value::String(text) if text.parse::().is_ok() => { + *text = name(names, text); + } + serde_json::Value::Array(items) => { + for item in items { + scrub(item, names); + } + } + 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() + && *value != serde_json::Value::Bool(false) + && value.as_f64() != Some(0.0) + }); + let keys: Vec = fields.keys().cloned().collect(); + for key in keys { + let mut item = fields.remove(&key).unwrap(); + scrub(&mut item, names); + let key = if key.parse::().is_ok() { + name(names, &key) + } else { + key + }; + fields.insert(key, item); + } + } + _ => {} + } + } + let object = value.as_object_mut().unwrap(); + object.remove("identity"); + object.remove("created"); + object.remove("margin_origin"); + let objects = object["objects"].as_array_mut().unwrap(); + objects.retain(|object| object.get("Title").is_none()); + scrub(&mut object["objects"], &mut names); + // The destination page brings its own title style. + object["definitions"] + .as_object_mut() + .unwrap() + .retain(|_, definition| definition["kind"]["type"] != "Style"); + scrub(&mut object["definitions"], &mut names); + value.to_string() +} + +/// Moves paragraph-level formatting into each span, where the reader resolves it: the +/// writer keeps a copied style's formatting on the paragraph or the runs as it sees fit. +fn flatten(page: &mut Page) { + fn paragraphs(list: &mut [onestore::page::PageParagraph]) { + for paragraph in list { + let format = std::mem::take(&mut paragraph.format); + match &mut paragraph.content { + onestore::page::ParagraphContent::Text(text) => { + let mut at = 0; + let runs: Vec<(String, onestore::document::Format)> = text + .text + .spans() + .iter() + .map(|span| { + let piece = text.text.text()[at..span.end].to_owned(); + at = span.end; + (piece, span.format.inherit(&format)) + }) + .collect(); + text.text = onestore::page::Paragraph::from_runs(runs); + } + onestore::page::ParagraphContent::Table(table) => { + for row in &mut table.rows { + for cell in &mut row.cells { + paragraphs(&mut cell.paragraphs); + } + } + } + _ => {} + } + } + } + for object in &mut page.objects { + if let PageObject::Outline(outline) = object { + paragraphs(&mut outline.paragraphs); + } + } +} + +/// `NOTEBOOK_COPY_EXPORT` names a new directory receiving the destination section for a +/// cold reopen. +#[test] +fn pages_copy_into_another_section_with_their_content_and_fresh_identities() { + let temporary = tempfile::tempdir().unwrap(); + let file = temporary.path().join("copies.one"); + let cache = temporary.path().join("cache.sqlite"); + std::fs::write( + &file, + onestore::create_section("copies.one", "Destination", "Author").unwrap(), + ) + .unwrap(); + let section = Section::open(&file, &cache, || {}).unwrap(); + let mut expected = Vec::new(); + for source in SOURCES { + let bytes = std::fs::read( + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus")).join(source), + ) + .unwrap(); + for page in pages(&bytes) { + if page + .objects + .iter() + .any(|object| matches!(object, PageObject::Unsupported(_))) + { + continue; + } + let copy = page.copy().unwrap(); + assert_ne!(copy.identity, page.identity); + let space = section + .import_page(&page, "Copier") + .unwrap_or_else(|e| panic!("{source} {}: {e}", page.title)); + expected.push((space, page.title.clone(), shape(©))); + } + } + assert!(expected.len() >= 8, "{} pages", expected.len()); + let deadline = Instant::now() + Duration::from_secs(120); + while section + .queue() + .unwrap() + .iter() + .any(|edit| !matches!(edit.status, EditStatus::Published { .. })) + || !section.queue().unwrap().is_empty() + { + for event in section.events() { + if let Event::Failed(message) = event { + panic!("{message}"); + } + } + assert!( + Instant::now() < deadline, + "publication did not finish: {:?}", + section.queue().unwrap() + ); + std::thread::sleep(Duration::from_millis(50)); + } + let stored = std::fs::read(&file).unwrap(); + let written = pages(&stored); + assert_eq!(written.len(), expected.len() + 1); + for ((space, title, expected), page) in expected.iter().zip(&written[1..]) { + let _ = space; + assert_eq!(&page.title, title); + let stored = shape(page); + if &stored != expected { + let dump = std::env::temp_dir().join("copy-mismatch"); + std::fs::create_dir_all(&dump).unwrap(); + std::fs::write(dump.join("expected.json"), expected).unwrap(); + std::fs::write(dump.join("stored.json"), &stored).unwrap(); + panic!("{title}: copy differs, see {}", dump.display()); + } + } + section.delete_pages(&[expected.last().unwrap().0]).unwrap(); + let deadline = Instant::now() + Duration::from_secs(60); + while !section.queue().unwrap().is_empty() { + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(50)); + } + assert_eq!(pages(&std::fs::read(&file).unwrap()).len(), expected.len()); + section.close().unwrap(); + if let Some(directory) = std::env::var_os("NOTEBOOK_COPY_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir_all(&directory).unwrap(); + let bytes = std::fs::read(&file).unwrap(); + std::fs::File::create_new(directory.join("copies.one")) + .unwrap() + .write_all(&bytes) + .unwrap(); + let file_id = Store::parse(&bytes).unwrap().header.file_id; + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents("Open Notebook.onetoc2", &[("copies.one", file_id)]) + .unwrap(), + ) + .unwrap(); + std::fs::write( + directory.join("expected-count.txt"), + expected.len().to_string(), + ) + .unwrap(); + } +} diff --git a/crates/onestore/Cargo.toml b/crates/onestore/Cargo.toml index fbebf33c80c16582ae57c43426fdbd12eb2d9a42..ec6aee64f500852101e2052af696ab1a6f2c8cec 100644 --- a/crates/onestore/Cargo.toml +++ b/crates/onestore/Cargo.toml @@ -5,14 +5,15 @@ edition = "2024" publish = false [features] -protected = ["dep:aes", "dep:base64", "dep:cbc", "dep:roxmltree", "dep:sha1", "dep:subtle", "dep:zeroize"] +protected = ["dep:aes", "dep:cbc", "dep:roxmltree", "dep:sha1", "dep:subtle", "dep:zeroize"] [dependencies] +base64 = "0.22.1" +serde_json = "1.0.151" md5 = "0.8.1" getrandom = "0.4.3" serde = { version = "1.0.229", features = ["derive"] } aes = { version = "0.9.3", features = ["zeroize"], optional = true } -base64 = { version = "0.22.1", optional = true } cbc = { version = "0.2.1", features = ["zeroize"], optional = true } roxmltree = { version = "0.21.1", optional = true } sha1 = { version = "0.11.0", features = ["zeroize"], optional = true } @@ -23,4 +24,3 @@ zeroize = { version = "1.9.0", optional = true } nix = { version = "0.31.3", default-features = false, features = ["fs"] } [dev-dependencies] -serde_json = "1.0.151" diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 59b3843b71b8bc68bb4870da4cef7bec8d013357..b9e24095306984ddafa348fb359623f3f1b7205c 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -43,6 +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::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 | @@ -60,7 +61,7 @@ harness also accepts `--client-profile release`. | `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules | | `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | | `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | -| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks (external and, via `page::link`, to pages, paragraphs and sections), bullets and numbering, note tags, table rows, columns, cell shading and indents, nested tables, inserted pictures (in paragraphs or on the page) and attachments, picture position, size and description, a stored attachment's shown name, source path and icon size, ink drawings and handwriting (strokes added and erased), equations (built from `page::Math` trees), paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities | +| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks (external and, via `page::link`, to pages, paragraphs and sections), bullets and numbering, note tags, table rows, columns, cell shading and indents, nested tables, inserted pictures (in paragraphs or on the page) and attachments, picture position, size and description, a stored attachment's shown name, source path and icon size, paragraph styles on new paragraphs, ink drawings and handwriting (strokes added and erased), equations (built from `page::Math` trees), paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities | | `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically | | `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter | | `read_file` | Read a snapshot under whole-file exclusion | diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index ea7d612c5f944047551fad0f00ec8e6c38d6284d..60a02a614a96ae78f142ba79db4f706c2da316ac 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -18,6 +18,38 @@ pub mod text; pub use ink::{Ink, InkStroke}; pub use math::Math; pub(crate) mod write; + +/// Picture and attachment payloads travel with the model (queued intents replay them), +/// as base64 text. +mod payload { + use base64::Engine; + use std::sync::Arc; + + pub fn serialize( + bytes: &Option>, + serializer: S, + ) -> Result { + match bytes { + Some(bytes) => { + serializer.serialize_some(&base64::engine::general_purpose::STANDARD.encode(bytes)) + } + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D: serde::Deserializer<'de>>( + deserializer: D, + ) -> Result>, D::Error> { + let text: Option = serde::Deserialize::deserialize(deserializer)?; + text.map(|text| { + base64::engine::general_purpose::STANDARD + .decode(text) + .map(Arc::from) + .map_err(serde::de::Error::custom) + }) + .transpose() + } +} pub use text::Paragraph; /// The role of a title-outline paragraph that displays the page's creation date or time. @@ -140,9 +172,9 @@ pub struct Attachment { pub source_path: Option, /// The icon OneNote rendered for the file, when present. pub size: Option<[f32; 2]>, - #[serde(skip)] + #[serde(with = "payload")] pub bytes: Option>, - #[serde(skip)] + #[serde(with = "payload")] pub preview: Option>, } @@ -245,7 +277,7 @@ pub struct Image { pub layout: Layout, /// Displayed picture width and height in points. pub size: Option<[f32; 2]>, - #[serde(skip)] + #[serde(with = "payload")] pub bytes: Option>, pub alt: Option, pub background: bool, @@ -331,6 +363,67 @@ impl Page { Self::from_revision(revision, id) } + /// The page's content under fresh identities, for a copy into this or another section: + /// OneNote gives a copied page and every object on it new identities, and stored + /// links keep naming the original. Content outside the model has no copy, so an + /// `Unsupported` object is an error. + pub fn copy(&self) -> Result { + let invalid = |message| Error { offset: 0, message }; + let mut value = + serde_json::to_value(self).map_err(|_| invalid("Page is not serializable"))?; + let mut fresh: BTreeMap = BTreeMap::new(); + fn walk( + value: &mut serde_json::Value, + fresh: &mut BTreeMap, + ) -> Result<(), Error> { + match value { + serde_json::Value::String(text) if text.starts_with('{') => { + if text.parse::().is_ok() { + if !fresh.contains_key(text) { + fresh.insert(text.clone(), text::new_id()?.to_string()); + } + *text = fresh[text].clone(); + } + } + serde_json::Value::Array(items) => { + for item in items { + walk(item, fresh)?; + } + } + serde_json::Value::Object(fields) => { + if fields.contains_key("Unsupported") { + return Err(Error { + offset: 0, + message: "Content outside the page model cannot be copied", + }); + } + let keys: Vec = fields.keys().cloned().collect(); + for key in keys { + let mut item = fields.remove(&key).unwrap(); + walk(&mut item, fresh)?; + let key = match key.parse::() { + Ok(_) => { + if !fresh.contains_key(&key) { + fresh.insert(key.clone(), text::new_id()?.to_string()); + } + fresh[&key].clone() + } + Err(_) => key, + }; + fields.insert(key, item); + } + } + _ => {} + } + Ok(()) + } + walk(&mut value, &mut fresh)?; + let mut copy: Self = + serde_json::from_value(value).map_err(|_| invalid("Page copy does not deserialize"))?; + copy.identity = Some(crate::write::fresh_guid()?); + Ok(copy) + } + /// The notebook-management identity of the page in an active page revision, which /// internal links name. pub fn identity_of(revision: &Revision<'_>) -> Option<[u8; 16]> { diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs index b6b037a8e48b82f8bc4d61040df7ea73fb636cef..d7e2e92b96016f19c85c4411c88ec0d06a3d7a96 100644 --- a/crates/onestore/src/page/write.rs +++ b/crates/onestore/src/page/write.rs @@ -496,7 +496,10 @@ impl Lowering<'_> { return Err(invalid("Page creation time and margins cannot be edited")); } for (id, definition) in &after.definitions { - if before.definitions.get(id) != Some(definition) + if before + .definitions + .get(id) + .is_some_and(|stored| stored != definition) && !matches!( definition.kind, Kind::List { .. } | Kind::TagDefinition { .. } @@ -540,6 +543,7 @@ impl Lowering<'_> { self.delete(&old, &new, &consumed)?; self.edit_equations(&new)?; self.edit_text(&new)?; + self.edit_paragraph_styles(&old, &new, after)?; self.edit_lists(after, &new)?; self.edit_tags(after, &new)?; self.edit_paragraph_formatting(&new)?; @@ -685,10 +689,23 @@ impl Lowering<'_> { let Some(first) = new.children[id].first().copied() else { return Err(invalid("A new outline needs a paragraph")); }; - let text = new - .text(first) - .ok_or_else(|| invalid("A new outline starts with a text paragraph"))?; - let insertion = Insertion::outline(self.page, x, y, text.text.text(), self.author)?; + // A new outline starts as one text paragraph; other content replaces it in + // its own pass, as for a paragraph inserted into an existing outline. + let (text, text_id) = match &new.paragraphs[&first].content { + ParagraphContent::Text(text) if text.date_field.is_none() => { + (text.text.text(), Some(text.id)) + } + ParagraphContent::Table(_) + | ParagraphContent::Image(_) + | ParagraphContent::Attachment(_) + | ParagraphContent::Ink(_) => ("", None), + _ => { + return Err(invalid( + "A new outline starts with a paragraph the writer builds", + )); + } + }; + let insertion = Insertion::outline(self.page, x, y, text, self.author)?; let space = self.space; self.apply(|image| insertion.apply(image, space))?; let outline_id = insertion.object(); @@ -700,7 +717,9 @@ impl Lowering<'_> { n: 3, }, ); - self.alias.insert(text.id, insertion.text_object()); + if let Some(text_id) = text_id { + self.alias.insert(text_id, insertion.text_object()); + } placed.insert(*id, vec![first]); placed.insert(first, Vec::new()); page_order.push(*id); @@ -1428,9 +1447,16 @@ impl Lowering<'_> { let anchor = next.map(|n| self.id(n)); if !old.paragraphs.contains_key(id) && !self.alias.contains_key(id) { let paragraph = new.paragraphs[id]; - if paragraph.style.is_some() { + // A styled paragraph appended to a stored outline is a split whose + // precondition failed under a merge; it stays for review. A new outline + // takes styled paragraphs as built (a copied page). + let mut root = *container; + while let Some(parent) = new.container.get(&root) { + root = *parent; + } + if paragraph.style.is_some() && old.outlines.contains_key(&root) { return Err(invalid( - "New paragraphs contain plain text without fields or styles", + "New paragraphs in a stored outline contain plain text without styles", )); } // A new table starts as an empty text paragraph whose content the @@ -2285,6 +2311,93 @@ impl Lowering<'_> { Ok(()) } + /// A new paragraph, or one whose style changed, references its paragraph style from + /// its text object; a style the page has not stored yet is created from its + /// definition, as OneNote keeps quick styles. + fn edit_paragraph_styles( + &mut self, + old: &View<'_>, + new: &View<'_>, + after: &Page, + ) -> Result<(), Error> { + let current = self.current()?; + for (id, paragraph) in &new.paragraphs { + let Some(definition) = paragraph.style else { + continue; + }; + if old + .paragraphs + .get(id) + .is_some_and(|previous| previous.style == paragraph.style) + { + continue; + } + let Some(text) = paragraph.text() else { + return Err(invalid("Only text paragraphs take a paragraph style")); + }; + let known = current.definitions.contains_key(&definition) + || self.alias.contains_key(&definition); + let style_id = if known { + self.id(definition) + } else { + let allocated = ExGuid { + guid: crate::write::fresh_guid()?, + n: 1, + }; + self.alias.insert(definition, allocated); + allocated + }; + let values = if known { + None + } else { + let Some(model) = after.definitions.get(&definition) else { + return Err(invalid("A paragraph references a missing style definition")); + }; + let Kind::Style { name } = &model.kind else { + return Err(invalid("A paragraph style must be a style definition")); + }; + let mut values = style_values(&model.format); + if let Some(name) = name { + values.push((0x1c00345a, crate::create::string(name))); + } + if let Some(alignment) = model.format.alignment { + values.push((0x0c003477, vec![alignment])); + } + for (property, value) in [ + (0x1400342e, model.format.space_before), + (0x1400342f, model.format.space_after), + (0x14003430, model.format.line_spacing), + ] { + if let Some(points) = value { + values.push((property, (points / 36.0).to_le_bytes().to_vec())); + } + } + Some(values) + }; + let (space, object) = (self.space, self.id(text.id)); + self.apply(|image| { + crate::write::write_revision(image, space, |raw| { + let mut changed = BTreeMap::new(); + if let Some(values) = &values { + let mut node = PropertyObject { + jcid: 0x12004d, + bytes: crate::create::properties(values)?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, style_id.guid)])), + }; + node.reference(style_id)?; + changed.insert(style_id, node); + } + let mut target = PropertyObject::from_object(&raw.objects[&object])?; + let reference = target.reference(style_id)?; + target.set(&[(0x2000342c, &reference)])?; + changed.insert(object, target); + Ok(changed) + }) + })?; + } + Ok(()) + } + fn edit_paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { let current = self.current()?; let current = View::new(¤t)?; @@ -2492,6 +2605,21 @@ impl Lowering<'_> { .get(&self.id(*id)) .ok_or_else(|| invalid("An outline is missing after text edits"))?; let (space, object) = (self.space, self.id(*id)); + // A new outline takes the model's indentation table (a copied outline keeps + // its levels' offsets); a stored table stays as it is. + if !old.outlines.contains_key(id) + && !outline.indents.is_empty() + && outline.indents != stored.indents + { + let indents = measurement_bytes(&outline.indents, 4)?; + self.apply(|current| { + crate::write::write_revision(current, space, |raw| { + let mut node = PropertyObject::from_object(&raw.objects[&object])?; + node.set(&[(0x1c001c12, &indents)])?; + Ok(BTreeMap::from([(object, node)])) + }) + })?; + } if (outline.layout.x, outline.layout.y) != (stored.layout.x, stored.layout.y) { let (Some(x), Some(y)) = (outline.layout.x, outline.layout.y) else { return Err(invalid("An outline position needs both coordinates")); diff --git a/tools/test_page_copy.py b/tools/test_page_copy.py new file mode 100644 index 0000000000000000000000000000000000000000..3ca46466a3da7b16f5cd2bc11bc7664f5e0fad8f --- /dev/null +++ b/tools/test_page_copy.py @@ -0,0 +1,34 @@ +from pathlib import Path +import runpy +import shutil +from tempfile import TemporaryDirectory +import unittest +import xml.etree.ElementTree as ET + +from native_xml import ns + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / 'corpus/page-copy' +compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] + + +class PageCopyTest(unittest.TestCase): + def test_pages_copied_between_sections_read_natively(self): + with TemporaryDirectory() as temporary: + read = Path(temporary) / 'read' + shutil.copytree(FIXTURE / 'cold/read', read) + compare(FIXTURE / 'candidate', read) + pages = [ET.parse(path).getroot() for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml'))] + self.assertEqual(len(pages), int((FIXTURE / 'candidate/expected-count.txt').read_text())) + kinds = {tag for page in pages for tag in {node.tag.rsplit('}', 1)[-1] for node in page.iter()}} + self.assertTrue({'InsertedFile', 'Image', 'Table', 'InkDrawing', 'Tag', 'List', 'Bullet', 'Number'} <= kinds, kinds) + hierarchy = ET.parse(FIXTURE / 'cold/read/hierarchy.xml').getroot() + titles = [page.get('name') for page in hierarchy.iter('{%s}Page' % ns['one'])] + self.assertEqual(titles[0], 'Destination') + self.assertNotIn('Paragraph formatting', titles) + self.assertEqual(len(set(titles)), len(titles)) + self.assertEqual(len(set(titles)), len(titles)) + + +if __name__ == '__main__': + unittest.main()