authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 22:55:29-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:20-07:00
log75e67fc117387e3301560b6a3ee21075f3487b71
tree80f39d3880472ba3eac1f33123f9a1e9d2a0df55
parent5b4f04b30a869396c509201367a1d5b296becf7e
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: notebook structure, change detection, link resolution, reviewed release of uncertain saves

Table of contents editing (`onestore::TocEdit`, `PreparedEdit::table_of_contents`) adds, renames, colours, orders and removes section and group entries as one revision, and `notebook::session::Notebook` creates sections and groups, renames, colours, reorders and deletes into `OneNote_RecycleBin` the way OneNote does. Two writer rules came out of the cold reads: OneNote re-identifies any file whose header ancestor is not the parent TOC's identity or whose name CRC is not its file or folder name (`onestore::place_file` sets both on create, rename and delete), and a TOC revision manifest carries one global id table, or OneNote applies a revised entry's order to the wrong entry. `Notebook::refresh` now reports what another client changed, keyed by file identity (`Change::{Added, Removed, Moved, Reordered}`), keeping the last catalog when the notebook is unreachable. `page::link::parse_internal_link` and `Notebook::find_page` resolve stored links by identity across renamed, moved and stale sections. A stored attachment's shown name, source path and icon size edit in place. With the `protected` feature, `Notebook::unlock` reads a locked section's pages. An uncertain publication is released by review, never replay: `Replica::release_attempt` (`Section::release`) exports the branch to a new archive, then either continues from a reviewed page under the same id or abandons the branch, whose ids report `EditStatus::Archived { archive }` (cache schema 13). No receipt is ever written for the uncertain attempt. Cold reads: `corpus/notebook-edit/{structured,deleted}` (written order, colours, recycle bin, stable identities), `corpus/notebook-edit/native-reorder` (COM cannot reorder), `corpus/attachment-edit/renamed`. Assisted-by: claude-fable-5.1

161 files changed, 3315 insertions(+), 79 deletions(-)

corpus/attachment-edit/README.md+12
...@@ -21,3 +21,15 @@ without a VM. Regenerate with `ONESTORE_ATTACHMENT_EXPORT` set to a directory...@@ -21,3 +21,15 @@ without a VM. Regenerate with `ONESTORE_ATTACHMENT_EXPORT` set to a directory
21while running the test (it writes `plain/` and `icon/`), then cold-open each21while running the test (it writes `plain/` and `icon/`), then cold-open each
22with `tools/native_runner.py OUTPUT/plain COLD --expected-pages 122with `tools/native_runner.py OUTPUT/plain COLD --expected-pages 1
23--collect-notebook`.23--collect-notebook`.
24
25## Renaming a stored attachment
26
27`renamed/candidate` is the writer's output for
28`attachments_need_a_file_name_and_stored_ones_are_renamed_in_place` in
29`crates/onestore/tests/page_attachments.rs`: the native attachment of
30`corpus/native/cold-05-06-attachment` with its shown name and recorded source
31path changed in place (properties `0x1c001c22`, `0x1c001d9c` and
32`0x1c001d9d` on the attachment node), payload and preview untouched.
33`renamed/cold` is its cold read: `preferredName` and `pathSource` carry the
34new values. Regenerate with `ONESTORE_ATTACHMENT_RENAME_EXPORT` and cold-open
35with `--expected-pages 1`.
corpus/attachment-edit/renamed/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/attachment-edit/renamed/candidate/Open Notebook.onetoc2 differ
corpus/attachment-edit/renamed/candidate/synthetic.one created
Binary files /dev/null and b/corpus/attachment-edit/renamed/candidate/synthetic.one differ
corpus/attachment-edit/renamed/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"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}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-0A713CC8 -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
3{"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}
corpus/attachment-edit/renamed/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-0a713cc8", "hostname": "ONE-M6-0A713CC8"}
corpus/attachment-edit/renamed/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/attachment-edit/renamed/cold/notebook/Open Notebook.onetoc2 differ
corpus/attachment-edit/renamed/cold/notebook/synthetic.one created
Binary files /dev/null and b/corpus/attachment-edit/renamed/cold/notebook/synthetic.one differ
corpus/attachment-edit/renamed/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment created+1
...@@ -0,0 +1 @@
1Fictitious attachment for native corpus.
\ No newline at end of file
corpus/attachment-edit/renamed/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-0A713CC8",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/attachment-edit/renamed/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{2F07CE94-92FA-4E6B-ACD7-9608AD89F288}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T05:42:11.000Z" color="#B49EDE"><one:Section name="synthetic" ID="{EB71CD4B-A45F-0FB9-2B77-16FC20C7FB58}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-09-13T05:42:11.000Z" color="#8AA8E4"><one:Page ID="{C7383ECD-2D94-4CA8-AC10-BE6DDCFFE747}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-09-05T05:06:54.000Z" lastModifiedTime="2026-09-13T05:42:11.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/attachment-edit/renamed/cold/read/page-000.xml created+8
...@@ -0,0 +1,8 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{C7383ECD-2D94-4CA8-AC10-BE6DDCFFE747}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-09-05T05:06:54.000Z" lastModifiedTime="2026-09-13T05:42:11.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-05T05:06:56.000Z" objectID="{A0F49671-66E1-4F1E-A3E2-32B623630226}{10}{B0}"><one:Position x="36.0" y="14.40000057220459" z="0"/><one:Size width="127.5136947631836" height="14.30000114440918"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:55.000Z" lastModifiedTime="2026-09-05T05:06:56.000Z" objectID="{A0F49671-66E1-4F1E-A3E2-32B623630226}{11}{B0}" alignment="left" quickStyleIndex="0" style="font-size:11.0pt;color:#1F4E79"><one:T><![CDATA[<span
3style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span
4style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span
5style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span
6style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed'
7lang=ar-SA>مرحبا</span>]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-05T05:06:57.000Z" objectID="{F6444A64-D5B4-4543-B954-B03E876FE66F}{10}{B0}"><one:Position x="144.0" y="96.0" z="1"/><one:Size width="167.0449523925781" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:57.000Z" lastModifiedTime="2026-09-05T05:06:57.000Z" objectID="{F6444A64-D5B4-4543-B954-B03E876FE66F}{11}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Fictitious positioned outline.]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-05T05:06:58.000Z" objectID="{CB6D22EB-332E-4CED-8F24-1BCA6B02AEE1}{10}{B0}"><one:Position x="144.0" y="192.0" z="2"/><one:Size width="72.0" height="0.750005722045898"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:58.000Z" lastModifiedTime="2026-09-05T05:06:58.000Z" objectID="{CB6D22EB-332E-4CED-8F24-1BCA6B02AEE1}{11}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA
8AAAASUVORK5CYII=</one:Data></one:Image></one:OE></one:OEChildren></one:Outline><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-13T05:40:22.000Z" objectID="{4E773A8B-F556-4F2B-AA89-7C345FD361D2}{10}{B0}"><one:Position x="264.0" y="192.0" z="3"/><one:Size width="72.00001525878906" height="63.0"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:59.000Z" lastModifiedTime="2026-09-13T05:40:22.000Z" objectID="{4E773A8B-F556-4F2B-AA89-7C345FD361D2}{11}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\458001d8-0f1e-451f-81cb-621828fc0b39.txt" pathSource="C:\inputs\renamed 🦀.txt" preferredName="renamed 🦀.txt"/></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/attachment-edit/renamed/cold/read/payloads.json created+10
...@@ -0,0 +1,10 @@
1[
2 {
3 "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7",
4 "name": "renamed 🦀.txt",
5 "object": "{4E773A8B-F556-4F2B-AA89-7C345FD361D2}{11}{B0}",
6 "kind": "InsertedFile",
7 "page": "{C7383ECD-2D94-4CA8-AC10-BE6DDCFFE747}{1}{B0}",
8 "bytes": 43
9 }
10]
\ No newline at end of file
corpus/attachment-edit/renamed/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/m4/attachment-rename",
3 "expected_pages": 1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/attachment-edit/renamed/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/attachment-edit/renamed/cold/scripts/read.ps1 created+142
...@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/attachment-edit/renamed/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1612,
5 "sha256": "e95a685134505ff745de97cb2795f289c23329966ed49eead8a2dd4f98b91571",
6 "mtime_ns": 1789278022646769677
7 },
8 {
9 "path": "synthetic.one",
10 "bytes": 21896,
11 "sha256": "ff9cba8456136873d2929bba7f2fa01931eb9aa69a1d5ebbaa61a669724e10e5",
12 "mtime_ns": 1789278022626746201
13 }
14]
corpus/attachment-edit/renamed/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/notebook-edit/README.md created+40
...@@ -0,0 +1,40 @@
1# Notebook structure
2
3`native-structure/` is OneNote 2010 building structure through the COM API on
4a Rust-created notebook (`tools/native/notebook-structure.ps1`): it created
5two sections and a section group with a section inside, renamed the notebook,
6reopened it, and deleted a section; `structure-*.xml` are the hierarchy reads
7after each step, `notebook-renamed/` the files before the delete. OneNote
8keeps the documented table of contents (MS-ONE 2.2.14): one root object with
9an entry array whose entries carry the file identity, order, filename and
10colour (`0xffffffff` for sections, absent for groups); a section's colour
11lives in its own metadata. Deleting moves the file into `OneNote_RecycleBin`,
12a group with its own TOC that the root lists. OneNote also names every file
13for its place in the header: `guidAncestor` is the parent TOC's file
14identity and `crcName` the CRC of the section file name or the group folder
15name. The Rust section `links.one`, created with a zero ancestor, was
16re-identified on open and listed a second time under its new identity.
17
18`native-reorder/` is the COM API asked to move the last root section first
19through `UpdateHierarchy` (`tools/native/notebook-reorder.ps1`): the
20in-session read shows the new order, but after `SyncHierarchy`, close and
21reopen the order is back and the TOC unchanged, so section order cannot be
22authored through COM; the order rows below are proved by cold reads of Rust
23candidates alone. Section display order is the entry's ordering number
24ascending, sections before groups, which the owner's own notebook confirms
25(array order there differs from the numbers, and OneNote lists by number).
26
27`structured/` is the notebook `crates/notebook/tests/structure.rs` builds
28through `notebook::session::Notebook` (create sections and a group, rename
29both, colour a section, order the root as group, renamed, first), placed the
30same way; `deleted/` is the same notebook after deleting a section. Each
31`cold/` is a fresh OneNote 2010 read: the hierarchy lists the entries in the
32written order with the written colours, the recycle bin carries
33`isRecycleBin`, and every file keeps the identity Rust wrote.
34Every TOC revision the writer appends carries one global id table; with one
35table per object group, as the section writer emits, OneNote applied a
36reordered entry's value to the wrong entry. `tools/test_notebook_edit.py`
37checks this without a VM. Regenerate with
38`NOTEBOOK_STRUCTURE_EXPORT` and `NOTEBOOK_STRUCTURE_EXPORT_DELETED` set to
39new directories while running the test, then cold-open each with
40`tools/native_runner.py OUTPUT COLD --expected-pages -1 --collect-notebook`.
corpus/notebook-edit/deleted/candidate/Kept/First.one created
Binary files /dev/null and b/corpus/notebook-edit/deleted/candidate/Kept/First.one differ
corpus/notebook-edit/deleted/candidate/Kept/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/deleted/candidate/Kept/Open Notebook.onetoc2 differ
corpus/notebook-edit/deleted/candidate/OneNote_RecycleBin/First.one created
Binary files /dev/null and b/corpus/notebook-edit/deleted/candidate/OneNote_RecycleBin/First.one differ
corpus/notebook-edit/deleted/candidate/OneNote_RecycleBin/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/deleted/candidate/OneNote_RecycleBin/Open Notebook.onetoc2 differ
corpus/notebook-edit/deleted/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/deleted/candidate/Open Notebook.onetoc2 differ
corpus/notebook-edit/deleted/candidate/Renamed.one created
Binary files /dev/null and b/corpus/notebook-edit/deleted/candidate/Renamed.one differ
corpus/notebook-edit/deleted/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"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}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-3574EFF5 -ExpectedPages -1", "exit": 0, "stdout": "Read 3 sections and 3 pages.\r\n", "stderr": "", "error": null}
3{"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}
corpus/notebook-edit/deleted/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-3574eff5", "hostname": "ONE-M6-3574EFF5"}
corpus/notebook-edit/deleted/cold/notebook/Kept/First.one created
Binary files /dev/null and b/corpus/notebook-edit/deleted/cold/notebook/Kept/First.one differ
corpus/notebook-edit/deleted/cold/notebook/Kept/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/deleted/cold/notebook/Kept/Open Notebook.onetoc2 differ
corpus/notebook-edit/deleted/cold/notebook/OneNote_RecycleBin/First.one created
Binary files /dev/null and b/corpus/notebook-edit/deleted/cold/notebook/OneNote_RecycleBin/First.one differ
corpus/notebook-edit/deleted/cold/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/deleted/cold/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 differ
corpus/notebook-edit/deleted/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/deleted/cold/notebook/Open Notebook.onetoc2 differ
corpus/notebook-edit/deleted/cold/notebook/Renamed.one created
Binary files /dev/null and b/corpus/notebook-edit/deleted/cold/notebook/Renamed.one differ
corpus/notebook-edit/deleted/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-3574EFF5",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/notebook-edit/deleted/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{CB695DC0-280E-4A3B-8871-191550D2A46E}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T06:03:23.000Z" color="#B49EDE"><one:Section name="Renamed" ID="{3FD5D445-5A2E-0E8A-2421-8994F06C297E}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Renamed.one" lastModifiedTime="2026-09-13T06:03:23.000Z" color="#FFD75E"><one:Page ID="{08A5723C-B967-0E63-31FF-53C4A0A13686}{1}{B0}" name="Untitled page" dateTime="2026-09-13T06:01:23.000Z" lastModifiedTime="2026-09-13T06:03:23.000Z" pageLevel="1"/></one:Section><one:SectionGroup name="Kept" ID="{8C8F2EE3-C6BF-464D-959E-768A8C57248B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Kept" lastModifiedTime="2026-09-13T06:03:23.000Z"><one:Section name="First" ID="{BDE645E0-43F4-0353-0868-02FD5CE2DCDB}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Kept\First.one" lastModifiedTime="2026-09-13T06:03:23.000Z" color="#8AA8E4"><one:Page ID="{493EEDB3-1C25-095F-0F8A-CB99F91B3C68}{1}{B0}" name="Untitled page" dateTime="2026-09-13T06:01:23.000Z" lastModifiedTime="2026-09-13T06:03:23.000Z" pageLevel="1"/></one:Section></one:SectionGroup><one:SectionGroup name="OneNote_RecycleBin" ID="{FEDA5EC6-5E86-476D-9288-4176441FD71F}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin" lastModifiedTime="2026-09-13T06:03:23.000Z" isRecycleBin="true"><one:Section name="First" ID="{39FEF965-4A1B-07A2-25C1-FE71A8D2EA02}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin\First.one" lastModifiedTime="2026-09-13T06:03:23.000Z" color="#8AA8E4" isInRecycleBin="true"><one:Page ID="{AA4A5C23-5588-0652-1B4E-D91899C4BFE3}{1}{B0}" name="First page" dateTime="2026-09-13T06:01:23.000Z" lastModifiedTime="2026-09-13T06:03:23.000Z" pageLevel="1" isInRecycleBin="true"/></one:Section></one:SectionGroup></one:Notebook>
corpus/notebook-edit/deleted/cold/read/page-000.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{08A5723C-B967-0E63-31FF-53C4A0A13686}{1}{B0}" name="Untitled page" dateTime="2026-09-13T06:01:23.000Z" lastModifiedTime="2026-09-13T06:03:23.000Z" pageLevel="1"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings></one:Page>
corpus/notebook-edit/deleted/cold/read/page-001.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{493EEDB3-1C25-095F-0F8A-CB99F91B3C68}{1}{B0}" name="Untitled page" dateTime="2026-09-13T06:01:23.000Z" lastModifiedTime="2026-09-13T06:03:23.000Z" pageLevel="1"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings></one:Page>
corpus/notebook-edit/deleted/cold/read/page-002.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{AA4A5C23-5588-0652-1B4E-D91899C4BFE3}{1}{B0}" name="First page" dateTime="2026-09-13T06:01:23.000Z" lastModifiedTime="2026-09-13T06:03:23.000Z" pageLevel="1" isInRecycleBin="true" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-13T06:01:23.000Z" objectID="{39FEF965-4A1B-07A2-25C1-FE71A8D2EA02}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="110.3364486694336" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-13T06:01:23.000Z" lastModifiedTime="2026-09-13T06:01:23.000Z" objectID="{39FEF965-4A1B-07A2-25C1-FE71A8D2EA02}{24}{B0}" alignment="left"><one:T><![CDATA[First page]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/notebook-edit/deleted/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/notebook-edit/deleted/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/m4/structure3-deleted",
3 "expected_pages": -1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/notebook-edit/deleted/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/notebook-edit/deleted/cold/scripts/read.ps1 created+142
...@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/notebook-edit/deleted/cold/source.json created+38
...@@ -0,0 +1,38 @@
1[
2 {
3 "path": "Kept/First.one",
4 "bytes": 3164,
5 "sha256": "61be453f86b7791923497293f74db57bc7f33b6e8d5fd38ad57631571475a1cf",
6 "mtime_ns": 1789279283845197267
7 },
8 {
9 "path": "Kept/Open Notebook.onetoc2",
10 "bytes": 4120,
11 "sha256": "7d734133a1019a00260296bfd143282a7411b667f68956874f7e7ffaa97518b8",
12 "mtime_ns": 1789279283861857142
13 },
14 {
15 "path": "OneNote_RecycleBin/First.one",
16 "bytes": 3212,
17 "sha256": "25d2946bbef7c546dc7391f848d43b7b7a4270cf8e0c35264e6939a1a083821c",
18 "mtime_ns": 1789279283804243010
19 },
20 {
21 "path": "OneNote_RecycleBin/Open Notebook.onetoc2",
22 "bytes": 3824,
23 "sha256": "8e3ad16bb0ed85c55577d0996a6a3f98df2537f82e3d1d48a35be40283cf4a40",
24 "mtime_ns": 1789279283820890719
25 },
26 {
27 "path": "Open Notebook.onetoc2",
28 "bytes": 6328,
29 "sha256": "0c10b7470bb74f2e17b3319707f2f422370fcbde05f09bd7c0e9dd088483c97e",
30 "mtime_ns": 1789279283837910881
31 },
32 {
33 "path": "Renamed.one",
34 "bytes": 5504,
35 "sha256": "9ee7bd23c1679ce5035c92adca0416730b149d11bedd1b4a7c181a0186df7007",
36 "mtime_ns": 1789279283726949659
37 }
38]
corpus/notebook-edit/deleted/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/notebook-edit/native-reorder/commands.jsonl created+4
...@@ -0,0 +1,4 @@
1{"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}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-42A0DA0E", "exit": 0, "stdout": "", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-42A0DA0E -ExpectedPages -1 -UseCurrentCache", "exit": 0, "stdout": "Read 4 sections and 1 pages.\r\n", "stderr": "", "error": null}
4{"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}
corpus/notebook-edit/native-reorder/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-42a0da0e", "hostname": "ONE-M6-42A0DA0E"}
corpus/notebook-edit/native-reorder/notebook-reordered/Group/Inner.one created
Binary files /dev/null and b/corpus/notebook-edit/native-reorder/notebook-reordered/Group/Inner.one differ
corpus/notebook-edit/native-reorder/notebook-reordered/Group/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/native-reorder/notebook-reordered/Group/Open Notebook.onetoc2 differ
corpus/notebook-edit/native-reorder/notebook-reordered/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/native-reorder/notebook-reordered/Open Notebook.onetoc2 differ
corpus/notebook-edit/native-reorder/notebook-reordered/Second.one created
Binary files /dev/null and b/corpus/notebook-edit/native-reorder/notebook-reordered/Second.one differ
corpus/notebook-edit/native-reorder/notebook-reordered/Third.one created
Binary files /dev/null and b/corpus/notebook-edit/native-reorder/notebook-reordered/Third.one differ
corpus/notebook-edit/native-reorder/notebook-reordered/links.one created
Binary files /dev/null and b/corpus/notebook-edit/native-reorder/notebook-reordered/links.one differ
corpus/notebook-edit/native-reorder/notebook/Group/Inner.one created
Binary files /dev/null and b/corpus/notebook-edit/native-reorder/notebook/Group/Inner.one differ
corpus/notebook-edit/native-reorder/notebook/Group/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/native-reorder/notebook/Group/Open Notebook.onetoc2 differ
corpus/notebook-edit/native-reorder/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/native-reorder/notebook/Open Notebook.onetoc2 differ
corpus/notebook-edit/native-reorder/notebook/Second.one created
Binary files /dev/null and b/corpus/notebook-edit/native-reorder/notebook/Second.one differ
corpus/notebook-edit/native-reorder/notebook/Third.one created
Binary files /dev/null and b/corpus/notebook-edit/native-reorder/notebook/Third.one differ
corpus/notebook-edit/native-reorder/notebook/links.one created
Binary files /dev/null and b/corpus/notebook-edit/native-reorder/notebook/links.one differ
corpus/notebook-edit/native-reorder/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-42A0DA0E",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/notebook-edit/native-reorder/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{6A5A3C83-5517-460C-8653-4A8B59A40DE1}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T05:44:04.000Z" color="#B49EDE"><one:Section name="links" ID="{1FC12F94-8B08-096A-0B7E-1BA453C702FC}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-13T05:44:03.000Z" color="#8AA8E4"><one:Page ID="{BECCE91D-E31F-0657-1DD5-3BC4AB248B07}{1}{B0}" name="Read about Rust ﷟HYPERLINK &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-13T05:44:03.000Z" pageLevel="1"/></one:Section><one:Section name="Second" ID="{6813183B-4CA3-0971-013C-8C9B95107A50}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Second.one" lastModifiedTime="2026-09-13T05:44:04.000Z" color="#91BAAE"/><one:Section name="Third" ID="{F0B865B0-68B9-0DF0-1C93-17629064AC3B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Third.one" lastModifiedTime="2026-09-13T05:44:04.000Z" color="#F6B078"/><one:SectionGroup name="Group" ID="{5EBCC405-92D4-4810-A126-9285EA1A255B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group" lastModifiedTime="2026-09-13T05:44:04.000Z"><one:Section name="Inner" ID="{02C840F3-D6D1-0AA0-2CE1-EABC0DBD7304}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group\Inner.one" lastModifiedTime="2026-09-13T05:44:04.000Z" color="#D5A4BB"/></one:SectionGroup></one:Notebook>
corpus/notebook-edit/native-reorder/read/page-000.xml created+3
...@@ -0,0 +1,3 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{BECCE91D-E31F-0657-1DD5-3BC4AB248B07}{1}{B0}" name="Read about Rust ﷟HYPERLINK &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-13T05:44:03.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-12T09:49:16.000Z" objectID="{1FC12F94-8B08-096A-0B7E-1BA453C702FC}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="198.3904571533203" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-12T09:49:16.000Z" objectID="{1FC12F94-8B08-096A-0B7E-1BA453C702FC}{24}{B0}" alignment="left"><one:T><![CDATA[Read about Rust <a
3href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/notebook-edit/native-reorder/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/notebook-edit/native-reorder/reorder-00-opened.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{AED2DDE0-9C00-4929-88F9-FC3D5D96F2A0}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T05:42:31.000Z" color="#B49EDE"><one:Section name="links" ID="{C068D143-7DC3-0169-2AEF-2F4764A85D01}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-13T05:42:31.000Z" color="#8AA8E4"><one:Page ID="{616517CA-15D4-0E54-3C44-0F279C4BD4FA}{1}{B0}" name="Read about Rust ﷟HYPERLINK &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-13T05:42:31.000Z" pageLevel="1"/></one:Section><one:Section name="Second" ID="{A91B1243-0DB7-00D1-15EB-CAAF34BF51E6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Second.one" lastModifiedTime="2026-09-13T05:42:31.000Z" color="#91BAAE"/><one:Section name="Third" ID="{BA6D49A5-660D-0191-0DD0-520473543E0D}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Third.one" lastModifiedTime="2026-09-13T05:42:31.000Z" color="#F6B078"/><one:SectionGroup name="Group" ID="{0C040C7E-A39C-4B9B-9AA3-73ACC8B1BE2C}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group" lastModifiedTime="2026-09-13T05:42:31.000Z"><one:Section name="Inner" ID="{DA3AE60F-1B35-0F5D-1E20-E4486308D1D6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group\Inner.one" lastModifiedTime="2026-09-13T05:42:31.000Z" color="#D5A4BB"/></one:SectionGroup></one:Notebook>
corpus/notebook-edit/native-reorder/reorder-01-update.xml created+1
...@@ -0,0 +1 @@
1<?xml version="1.0"?><one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{AED2DDE0-9C00-4929-88F9-FC3D5D96F2A0}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T05:42:31.000Z" color="#B49EDE"><one:Section name="Third" ID="{BA6D49A5-660D-0191-0DD0-520473543E0D}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Third.one" lastModifiedTime="2026-09-13T05:42:31.000Z" color="#F6B078" /><one:Section name="links" ID="{C068D143-7DC3-0169-2AEF-2F4764A85D01}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-13T05:42:31.000Z" color="#8AA8E4"><one:Page ID="{616517CA-15D4-0E54-3C44-0F279C4BD4FA}{1}{B0}" name="Read about Rust ﷟HYPERLINK &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-13T05:42:31.000Z" pageLevel="1" /></one:Section><one:Section name="Second" ID="{A91B1243-0DB7-00D1-15EB-CAAF34BF51E6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Second.one" lastModifiedTime="2026-09-13T05:42:31.000Z" color="#91BAAE" /><one:SectionGroup name="Group" ID="{0C040C7E-A39C-4B9B-9AA3-73ACC8B1BE2C}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group" lastModifiedTime="2026-09-13T05:42:31.000Z"><one:Section name="Inner" ID="{DA3AE60F-1B35-0F5D-1E20-E4486308D1D6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group\Inner.one" lastModifiedTime="2026-09-13T05:42:31.000Z" color="#D5A4BB" /></one:SectionGroup></one:Notebook>
\ No newline at end of file
corpus/notebook-edit/native-reorder/reorder-02-reordered.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{AED2DDE0-9C00-4929-88F9-FC3D5D96F2A0}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T05:42:35.000Z" color="#B49EDE"><one:Section name="Third" ID="{BA6D49A5-660D-0191-0DD0-520473543E0D}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Third.one" lastModifiedTime="2026-09-13T05:42:31.000Z" color="#F6B078"/><one:Section name="links" ID="{C068D143-7DC3-0169-2AEF-2F4764A85D01}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-13T05:42:31.000Z" color="#8AA8E4"><one:Page ID="{616517CA-15D4-0E54-3C44-0F279C4BD4FA}{1}{B0}" name="Read about Rust ﷟HYPERLINK &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-13T05:42:31.000Z" pageLevel="1"/></one:Section><one:Section name="Second" ID="{A91B1243-0DB7-00D1-15EB-CAAF34BF51E6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Second.one" lastModifiedTime="2026-09-13T05:42:31.000Z" color="#91BAAE"/><one:SectionGroup name="Group" ID="{0C040C7E-A39C-4B9B-9AA3-73ACC8B1BE2C}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group" lastModifiedTime="2026-09-13T05:42:31.000Z"><one:Section name="Inner" ID="{DA3AE60F-1B35-0F5D-1E20-E4486308D1D6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group\Inner.one" lastModifiedTime="2026-09-13T05:42:31.000Z" color="#D5A4BB"/></one:SectionGroup></one:Notebook>
corpus/notebook-edit/native-reorder/reorder-03-reopened.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{07F22284-C808-42BB-BB86-5F7E3B0C20FF}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T05:42:35.000Z" color="#B49EDE"><one:Section name="links" ID="{A8B9B591-FCDF-0998-2CD0-36488B10B0BA}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-13T05:42:35.000Z" color="#8AA8E4"><one:Page ID="{09B47318-94C8-06A5-3A7B-162873F33941}{1}{B0}" name="Read about Rust ﷟HYPERLINK &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-13T05:42:35.000Z" pageLevel="1"/></one:Section><one:Section name="Second" ID="{D4619FD1-63FA-0726-0C8B-1B67A986B20E}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Second.one" lastModifiedTime="2026-09-13T05:42:35.000Z" color="#91BAAE"/><one:Section name="Third" ID="{07F3B47B-F352-005D-1973-CC132CEF7E55}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Third.one" lastModifiedTime="2026-09-13T05:42:35.000Z" color="#F6B078"/><one:SectionGroup name="Group" ID="{07DB5FDA-E99D-4525-BA25-54FC6850660E}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group" lastModifiedTime="2026-09-13T05:42:35.000Z"><one:Section name="Inner" ID="{450FBDDC-CB1C-003A-1A9A-D1482478B9C0}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group\Inner.one" lastModifiedTime="2026-09-13T05:42:35.000Z" color="#D5A4BB"/></one:SectionGroup></one:Notebook>
corpus/notebook-edit/native-reorder/run.json created+19
...@@ -0,0 +1,19 @@
1{
2 "notebook": "/Users/clo/dev/one/corpus/notebook-edit/native-structure/notebook-renamed",
3 "expected_pages": -1,
4 "author": "tools/native/notebook-reorder.ps1",
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "author.ps1": "61594f0aec7a19bf3c5b2bdc478a98aed9d59637f33f7fde074cdc4567716e03",
16 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
17 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
18 }
19}
corpus/notebook-edit/native-reorder/scripts/author.ps1 created+50
...@@ -0,0 +1,50 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
6$app = New-Object -ComObject OneNote.Application
7$notebookId = ''
8function Read-Tree {
9 $deadline = [DateTime]::UtcNow.AddSeconds(60)
10 do {
11 $hierarchy = ''
12 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
13 [xml]$tree = $hierarchy
14 if (@($tree.SelectNodes('//*[local-name()="Section"]')).Count -ge 4) { return $hierarchy }
15 Start-Sleep -Milliseconds 500
16 } while ([DateTime]::UtcNow -lt $deadline)
17 throw 'The notebook did not finish loading.'
18}
19try {
20 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
21 $hierarchy = Read-Tree
22 [IO.File]::WriteAllText((Join-Path $Root 'reorder-00-opened.xml'), $hierarchy, [Text.Encoding]::UTF8)
23 [xml]$tree = $hierarchy
24 $manager = New-Object Xml.XmlNamespaceManager($tree.NameTable)
25 $manager.AddNamespace('one', $namespace)
26 $notebookNode = $tree.DocumentElement
27 $sections = @($notebookNode.SelectNodes('one:Section', $manager))
28 if ($sections.Count -lt 3) { throw "Expected three root sections, found $($sections.Count)." }
29 $last = $sections[$sections.Count - 1]
30 [void]$notebookNode.RemoveChild($last)
31 [void]$notebookNode.InsertBefore($last, $sections[0])
32 [IO.File]::WriteAllText((Join-Path $Root 'reorder-01-update.xml'), $tree.OuterXml, [Text.Encoding]::UTF8)
33 $app.UpdateHierarchy($tree.OuterXml, 1)
34 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
35 [IO.File]::WriteAllText((Join-Path $Root 'reorder-02-reordered.xml'), $hierarchy, [Text.Encoding]::UTF8)
36 $app.SyncHierarchy($notebookId)
37 $app.CloseNotebook($notebookId, $false)
38 $notebookId = ''
39 Copy-Item -Recurse (Join-Path $Root 'notebook') (Join-Path $Root 'notebook-reordered')
40 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
41 $hierarchy = Read-Tree
42 [IO.File]::WriteAllText((Join-Path $Root 'reorder-03-reopened.xml'), $hierarchy, [Text.Encoding]::UTF8)
43 $app.CloseNotebook($notebookId, $false)
44 $notebookId = ''
45} finally {
46 if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
47 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
48 [GC]::Collect()
49 [GC]::WaitForPendingFinalizers()
50}
corpus/notebook-edit/native-reorder/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/notebook-edit/native-reorder/scripts/read.ps1 created+142
...@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/notebook-edit/native-reorder/source.json created+38
...@@ -0,0 +1,38 @@
1[
2 {
3 "path": "Group/Inner.one",
4 "bytes": 4016,
5 "sha256": "7a482b9feafd8220bc9f8c60aca78b4710d64cdc233d7e4aff2d43214f30d26d",
6 "mtime_ns": 1789276348740792164
7 },
8 {
9 "path": "Group/Open Notebook.onetoc2",
10 "bytes": 3752,
11 "sha256": "5a81be6da6b559291303c74fede90e0d6f7f78b640c3bce9c1365edb0d99d1b4",
12 "mtime_ns": 1789276348740542872
13 },
14 {
15 "path": "Open Notebook.onetoc2",
16 "bytes": 4116,
17 "sha256": "bb8943cc70108ff95a393122f53fd06864db347aafe865f2cf4094ff202c3bbc",
18 "mtime_ns": 1789276348741057831
19 },
20 {
21 "path": "Second.one",
22 "bytes": 4016,
23 "sha256": "b1c37af371bb1d7b93950b6b73920d5080178156b7448f3aa30f45472f441ad5",
24 "mtime_ns": 1789276348741246832
25 },
26 {
27 "path": "Third.one",
28 "bytes": 4016,
29 "sha256": "6654509286c8c342844a6b716407cfb3dc1303aa8b9c7a0cfff606cc900aea33",
30 "mtime_ns": 1789276348739887203
31 },
32 {
33 "path": "links.one",
34 "bytes": 6128,
35 "sha256": "85d17a08e48b0fc0c6d49c4389c80c58910ff64ed2ffd464522523256d2db5dc",
36 "mtime_ns": 1789276348740094245
37 }
38]
corpus/notebook-edit/native-reorder/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/notebook-edit/native-structure/commands.jsonl created+4
...@@ -0,0 +1,4 @@
1{"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}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-266B13AF", "exit": 0, "stdout": "", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-266B13AF -ExpectedPages -1 -UseCurrentCache", "exit": 0, "stdout": "Read 5 sections and 1 pages.\r\n", "stderr": "", "error": null}
4{"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}
corpus/notebook-edit/native-structure/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-266b13af", "hostname": "ONE-M6-266B13AF"}
corpus/notebook-edit/native-structure/notebook-renamed/Group/Inner.one created
Binary files /dev/null and b/corpus/notebook-edit/native-structure/notebook-renamed/Group/Inner.one differ
corpus/notebook-edit/native-structure/notebook-renamed/Group/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/native-structure/notebook-renamed/Group/Open Notebook.onetoc2 differ
corpus/notebook-edit/native-structure/notebook-renamed/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/native-structure/notebook-renamed/Open Notebook.onetoc2 differ
corpus/notebook-edit/native-structure/notebook-renamed/Second.one created
Binary files /dev/null and b/corpus/notebook-edit/native-structure/notebook-renamed/Second.one differ
corpus/notebook-edit/native-structure/notebook-renamed/Third.one created
Binary files /dev/null and b/corpus/notebook-edit/native-structure/notebook-renamed/Third.one differ
corpus/notebook-edit/native-structure/notebook-renamed/links.one created
Binary files /dev/null and b/corpus/notebook-edit/native-structure/notebook-renamed/links.one differ
corpus/notebook-edit/native-structure/notebook/Group/Inner.one created
Binary files /dev/null and b/corpus/notebook-edit/native-structure/notebook/Group/Inner.one differ
corpus/notebook-edit/native-structure/notebook/Group/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/native-structure/notebook/Group/Open Notebook.onetoc2 differ
corpus/notebook-edit/native-structure/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one created
Binary files /dev/null and b/corpus/notebook-edit/native-structure/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one differ
corpus/notebook-edit/native-structure/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/native-structure/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 differ
corpus/notebook-edit/native-structure/notebook/OneNote_RecycleBin/Third.one created
Binary files /dev/null and b/corpus/notebook-edit/native-structure/notebook/OneNote_RecycleBin/Third.one differ
corpus/notebook-edit/native-structure/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/native-structure/notebook/Open Notebook.onetoc2 differ
corpus/notebook-edit/native-structure/notebook/Second.one created
Binary files /dev/null and b/corpus/notebook-edit/native-structure/notebook/Second.one differ
corpus/notebook-edit/native-structure/notebook/links.one created
Binary files /dev/null and b/corpus/notebook-edit/native-structure/notebook/links.one differ
corpus/notebook-edit/native-structure/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-266B13AF",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/notebook-edit/native-structure/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{7AD18B91-E3D1-4A1E-AAD3-E1F5A1135B78}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T05:10:57.000Z" color="#B49EDE"><one:Section name="links" ID="{76DB36ED-CEC3-077F-3574-D7128DF2706B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-13T05:10:57.000Z" color="#8AA8E4"><one:Page ID="{D7D6F064-A6D4-0842-23DF-F7727511F990}{1}{B0}" name="Read about Rust ﷟HYPERLINK &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-13T05:10:57.000Z" pageLevel="1"/></one:Section><one:Section name="Second" ID="{9563D0BB-D7D8-0D5D-1D73-A7567A5F0A90}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Second.one" lastModifiedTime="2026-09-13T05:10:57.000Z" color="#91BAAE"/><one:SectionGroup name="Group" ID="{63C23302-AD82-4825-9A1F-E04D605235D7}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group" lastModifiedTime="2026-09-13T05:10:57.000Z"><one:Section name="Inner" ID="{981C0B09-8E23-041F-2580-EA4465609F39}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group\Inner.one" lastModifiedTime="2026-09-13T05:10:57.000Z" color="#D5A4BB"/></one:SectionGroup><one:SectionGroup name="OneNote_RecycleBin" ID="{1A88D270-2622-4283-B58D-319813208E75}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin" lastModifiedTime="2026-09-13T05:10:57.000Z" isRecycleBin="true"><one:Section name="Deleted Pages" ID="{E4E8C840-A034-07BF-35D8-3933B6A8A03F}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin\OneNote_DeletedPages.one" lastModifiedTime="2026-09-13T05:10:57.000Z" color="#E1E1E1" isInRecycleBin="true" isDeletedPages="true"/><one:Section name="Third" ID="{703C6173-A9DA-00E4-27ED-1EFB7F7089C5}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin\Third.one" lastModifiedTime="2026-09-13T05:10:57.000Z" color="#F6B078" isInRecycleBin="true"/></one:SectionGroup></one:Notebook>
corpus/notebook-edit/native-structure/read/page-000.xml created+3
...@@ -0,0 +1,3 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D7D6F064-A6D4-0842-23DF-F7727511F990}{1}{B0}" name="Read about Rust ﷟HYPERLINK &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-13T05:10:57.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-12T09:49:16.000Z" objectID="{76DB36ED-CEC3-077F-3574-D7128DF2706B}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="198.3904571533203" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-12T09:49:16.000Z" objectID="{76DB36ED-CEC3-077F-3574-D7128DF2706B}{24}{B0}" alignment="left"><one:T><![CDATA[Read about Rust <a
3href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/notebook-edit/native-structure/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/notebook-edit/native-structure/run.json created+19
...@@ -0,0 +1,19 @@
1{
2 "notebook": "/Users/clo/dev/one/corpus/link-edit/candidate",
3 "expected_pages": -1,
4 "author": "tools/native/notebook-structure.ps1",
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "author.ps1": "1369d3e7ae7205370d11e9c6abc3fcf4dd9931e7c0da50b3b86f1e2945e1d4a6",
16 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
17 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
18 }
19}
corpus/notebook-edit/native-structure/scripts/author.ps1 created+71
...@@ -0,0 +1,71 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
6$app = New-Object -ComObject OneNote.Application
7$notebookId = ''
8$log = @()
9try {
10 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
11 $hierarchy = ''
12 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
13 [IO.File]::WriteAllText((Join-Path $Root 'structure-00-opened.xml'), $hierarchy, [Text.Encoding]::UTF8)
14 $second = ''
15 $app.OpenHierarchy('Second.one', $notebookId, [ref]$second, 3)
16 $third = ''
17 $app.OpenHierarchy('Third.one', $notebookId, [ref]$third, 3)
18 $group = ''
19 $app.OpenHierarchy('Group', $notebookId, [ref]$group, 2)
20 $inner = ''
21 $app.OpenHierarchy('Inner.one', $group, [ref]$inner, 3)
22 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
23 [IO.File]::WriteAllText((Join-Path $Root 'structure-01-created.xml'), $hierarchy, [Text.Encoding]::UTF8)
24 $app.SyncHierarchy($notebookId)
25 $app.CloseNotebook($notebookId, $false)
26 $notebookId = ''
27 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
28 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
29 [xml]$tree = $hierarchy
30 $manager = New-Object Xml.XmlNamespaceManager($tree.NameTable)
31 $manager.AddNamespace('one', $namespace)
32 $sections = @($tree.SelectNodes('//one:Section', $manager))
33 foreach ($section in $sections) {
34 switch ($section.GetAttribute('name')) {
35 'Second' { $section.SetAttribute('name', 'Renamed'); $section.SetAttribute('color', '#FFD75E') }
36 'Third' { $section.SetAttribute('color', '#B7C997') }
37 }
38 }
39 [IO.File]::WriteAllText((Join-Path $Root 'structure-02-update.xml'), $tree.OuterXml, [Text.Encoding]::UTF8)
40 $app.UpdateHierarchy($tree.OuterXml, 1)
41 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
42 [IO.File]::WriteAllText((Join-Path $Root 'structure-03-renamed.xml'), $hierarchy, [Text.Encoding]::UTF8)
43 $app.SyncHierarchy($notebookId)
44 $app.CloseNotebook($notebookId, $false)
45 $notebookId = ''
46 Copy-Item -Recurse (Join-Path $Root 'notebook') (Join-Path $Root 'notebook-renamed')
47 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
48 $deadline = [DateTime]::UtcNow.AddSeconds(60)
49 do {
50 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
51 [xml]$tree = $hierarchy
52 if (@($tree.SelectNodes('//*[local-name()="Section"]')).Count -ge 4) { break }
53 Start-Sleep -Milliseconds 500
54 } while ([DateTime]::UtcNow -lt $deadline)
55 $manager = New-Object Xml.XmlNamespaceManager($tree.NameTable)
56 $manager.AddNamespace('one', $namespace)
57 [IO.File]::WriteAllText((Join-Path $Root 'structure-03b-reopened.xml'), $hierarchy, [Text.Encoding]::UTF8)
58 $doomed = @($tree.SelectNodes('//*[local-name()="Section"]') | Where-Object { $_.GetAttribute('name') -eq 'Third' })
59 if ($doomed.Count -ne 1) { throw "Expected one section named Third, found $($doomed.Count)." }
60 $app.DeleteHierarchy($doomed[0].GetAttribute('ID'), [DateTime]::MinValue, $false)
61 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
62 [IO.File]::WriteAllText((Join-Path $Root 'structure-04-deleted.xml'), $hierarchy, [Text.Encoding]::UTF8)
63 $app.SyncHierarchy($notebookId)
64 $app.CloseNotebook($notebookId, $false)
65 $notebookId = ''
66} finally {
67 if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
68 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
69 [GC]::Collect()
70 [GC]::WaitForPendingFinalizers()
71}
corpus/notebook-edit/native-structure/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/notebook-edit/native-structure/scripts/read.ps1 created+142
...@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/notebook-edit/native-structure/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "6731c4214437fe1cb6aa347b0004542b7f7e4260f47773bf9ec07c4e894de47c",
6 "mtime_ns": 1789206821144296891
7 },
8 {
9 "path": "links.one",
10 "bytes": 6128,
11 "sha256": "0cfb356ed374fe919f73fb73fdbe5d66e0baa484d4119f70074d9f869d6db78a",
12 "mtime_ns": 1789206821116200533
13 }
14]
corpus/notebook-edit/native-structure/structure-00-opened.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{A7F4607F-D12A-47A5-88D7-FF5FA2D3CB81}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T05:09:18.000Z" color="#B49EDE"><one:Section name="links" ID="{E0463D9D-C84B-0306-1E38-FEE855D8984B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-13T05:09:18.000Z" color="#8AA8E4"><one:Page ID="{414BFB14-A05C-0C3B-0893-DE88AD3B11B0}{1}{B0}" name="Read about Rust ﷟HYPERLINK &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-13T05:09:18.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/notebook-edit/native-structure/structure-01-created.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{A7F4607F-D12A-47A5-88D7-FF5FA2D3CB81}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T05:09:18.000Z" color="#B49EDE"><one:Section name="links" ID="{E0463D9D-C84B-0306-1E38-FEE855D8984B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-13T05:09:18.000Z" color="#8AA8E4"><one:Page ID="{414BFB14-A05C-0C3B-0893-DE88AD3B11B0}{1}{B0}" name="Read about Rust ﷟HYPERLINK &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-13T05:09:18.000Z" pageLevel="1"/></one:Section><one:Section name="Second" ID="{AB000D4A-AB4A-4264-B603-7145530CA982}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Second.one" lastModifiedTime="2026-09-13T05:09:18.000Z" color="#91BAAE"/><one:Section name="Third" ID="{F3BEE793-B6F8-4FC9-A1C2-32F9CA37C381}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Third.one" lastModifiedTime="2026-09-13T05:09:18.000Z" color="#F6B078"/><one:SectionGroup name="Group" ID="{F3970BF5-CE1F-4CD3-91A1-D33D00EBDC70}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group" lastModifiedTime="2026-09-13T05:09:18.000Z"><one:Section name="Inner" ID="{03E76E20-DE46-4B5E-B997-3C20BE47F524}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group\Inner.one" lastModifiedTime="2026-09-13T05:09:18.000Z" color="#D5A4BB"/></one:SectionGroup></one:Notebook>
corpus/notebook-edit/native-structure/structure-02-update.xml created+1
...@@ -0,0 +1 @@
1<?xml version="1.0"?><one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{23B261F4-2C71-48A9-8258-D374EDD68DD7}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T05:09:19.000Z" color="none" />
\ No newline at end of file
corpus/notebook-edit/native-structure/structure-03-renamed.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{23B261F4-2C71-48A9-8258-D374EDD68DD7}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T05:09:19.000Z" color="none"><one:Section name="links" ID="{EA9AF0AF-2A47-063B-1CA6-1C66F0BEF909}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-13T05:09:19.000Z" color="#8AA8E4"><one:Page ID="{4B973626-4250-0906-0A0D-3C06085D70F2}{1}{B0}" name="Read about Rust ﷟HYPERLINK &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-13T05:09:19.000Z" pageLevel="1"/></one:Section><one:Section name="Second" ID="{493572E1-8FCE-0F27-258F-AFB3454594E1}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Second.one" lastModifiedTime="2026-09-13T05:09:19.000Z" color="#91BAAE"/><one:Section name="Third" ID="{B7EB577D-B417-02B6-095A-3BEB205D2A80}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Third.one" lastModifiedTime="2026-09-13T05:09:19.000Z" color="#F6B078"/><one:SectionGroup name="Group" ID="{FF2F26E2-4241-452F-B037-A39A9C2A936A}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group" lastModifiedTime="2026-09-13T05:09:19.000Z"><one:Section name="Inner" ID="{9F34E518-BA41-0537-30CB-1B38B863A230}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group\Inner.one" lastModifiedTime="2026-09-13T05:09:19.000Z" color="#D5A4BB"/></one:SectionGroup></one:Notebook>
corpus/notebook-edit/native-structure/structure-03b-reopened.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{BD0EACB4-AD42-49F6-927E-DF64043E86BC}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T05:09:21.000Z" color="#B49EDE"><one:Section name="links" ID="{CF3DA334-CFD7-093C-2FAB-F07C99345EA9}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-13T05:09:21.000Z" color="#8AA8E4"><one:Page ID="{6E3065BD-A7C0-0601-3900-D01C61D7D752}{1}{B0}" name="Read about Rust ﷟HYPERLINK &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-13T05:09:21.000Z" pageLevel="1"/></one:Section><one:Section name="Second" ID="{475DDE76-45A3-0A2B-2A52-CFF6765AC2F6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Second.one" lastModifiedTime="2026-09-13T05:09:21.000Z" color="#91BAAE"/><one:Section name="Third" ID="{C77EE7BF-4445-08FF-2880-419548EB2335}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Third.one" lastModifiedTime="2026-09-13T05:09:21.000Z" color="#F6B078"/><one:SectionGroup name="Group" ID="{0D92F8A0-D382-4BE9-8FF1-19FA77A44A3C}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group" lastModifiedTime="2026-09-13T05:09:21.000Z"><one:Section name="Inner" ID="{39387BFF-0D9A-0EA7-39A0-60411A365D77}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group\Inner.one" lastModifiedTime="2026-09-13T05:09:21.000Z" color="#D5A4BB"/></one:SectionGroup></one:Notebook>
corpus/notebook-edit/native-structure/structure-04-deleted.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{BD0EACB4-AD42-49F6-927E-DF64043E86BC}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T05:09:22.000Z" color="#B49EDE"><one:Section name="links" ID="{CF3DA334-CFD7-093C-2FAB-F07C99345EA9}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-13T05:09:21.000Z" color="#8AA8E4"><one:Page ID="{6E3065BD-A7C0-0601-3900-D01C61D7D752}{1}{B0}" name="Read about Rust ﷟HYPERLINK &quot;https://example.invalid/rust&quot;the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-13T05:09:21.000Z" pageLevel="1"/></one:Section><one:Section name="Second" ID="{475DDE76-45A3-0A2B-2A52-CFF6765AC2F6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Second.one" lastModifiedTime="2026-09-13T05:09:21.000Z" color="#91BAAE"/><one:SectionGroup name="Group" ID="{0D92F8A0-D382-4BE9-8FF1-19FA77A44A3C}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group" lastModifiedTime="2026-09-13T05:09:21.000Z"><one:Section name="Inner" ID="{39387BFF-0D9A-0EA7-39A0-60411A365D77}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Group\Inner.one" lastModifiedTime="2026-09-13T05:09:21.000Z" color="#D5A4BB"/></one:SectionGroup><one:SectionGroup name="OneNote_RecycleBin" ID="{7AFFC2E8-F385-494E-99B4-57122B83768E}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin" lastModifiedTime="2026-09-13T05:09:22.000Z" isRecycleBin="true"><one:Section name="Deleted Pages" ID="{5938DFF2-9BE5-4BEC-BF04-EBAB38D584EA}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin\OneNote_DeletedPages.one" lastModifiedTime="2026-09-13T05:09:22.000Z" color="#E1E1E1" isInRecycleBin="true" isDeletedPages="true"/><one:Section name="Third" ID="{C77EE7BF-4445-08FF-2880-419548EB2335}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote_RecycleBin\Third.one" lastModifiedTime="2026-09-13T05:09:21.000Z" color="#F6B078" isInRecycleBin="true"/></one:SectionGroup></one:Notebook>
corpus/notebook-edit/native-structure/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/notebook-edit/structured/candidate/First.one created
Binary files /dev/null and b/corpus/notebook-edit/structured/candidate/First.one differ
corpus/notebook-edit/structured/candidate/Kept/Inner.one created
Binary files /dev/null and b/corpus/notebook-edit/structured/candidate/Kept/Inner.one differ
corpus/notebook-edit/structured/candidate/Kept/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/structured/candidate/Kept/Open Notebook.onetoc2 differ
corpus/notebook-edit/structured/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/structured/candidate/Open Notebook.onetoc2 differ
corpus/notebook-edit/structured/candidate/Renamed.one created
Binary files /dev/null and b/corpus/notebook-edit/structured/candidate/Renamed.one differ
corpus/notebook-edit/structured/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"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}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-A31627CC -ExpectedPages -1", "exit": 0, "stdout": "Read 3 sections and 3 pages.\r\n", "stderr": "", "error": null}
3{"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}
corpus/notebook-edit/structured/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-a31627cc", "hostname": "ONE-M6-A31627CC"}
corpus/notebook-edit/structured/cold/notebook/First.one created
Binary files /dev/null and b/corpus/notebook-edit/structured/cold/notebook/First.one differ
corpus/notebook-edit/structured/cold/notebook/Kept/Inner.one created
Binary files /dev/null and b/corpus/notebook-edit/structured/cold/notebook/Kept/Inner.one differ
corpus/notebook-edit/structured/cold/notebook/Kept/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/structured/cold/notebook/Kept/Open Notebook.onetoc2 differ
corpus/notebook-edit/structured/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/notebook-edit/structured/cold/notebook/Open Notebook.onetoc2 differ
corpus/notebook-edit/structured/cold/notebook/Renamed.one created
Binary files /dev/null and b/corpus/notebook-edit/structured/cold/notebook/Renamed.one differ
corpus/notebook-edit/structured/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-A31627CC",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/notebook-edit/structured/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{37DB968E-4B77-4958-9937-A9DD2B5ADCFF}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T06:03:05.000Z" color="#B49EDE"><one:Section name="Renamed" ID="{695D3E64-1B55-070B-2881-FF793FC6F7C1}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Renamed.one" lastModifiedTime="2026-09-13T06:03:04.000Z" color="#FFD75E"><one:Page ID="{5E2D981D-F81C-07E2-3D5F-25296F0BE839}{1}{B0}" name="Untitled page" dateTime="2026-09-13T06:01:23.000Z" lastModifiedTime="2026-09-13T06:03:04.000Z" pageLevel="1"/></one:Section><one:Section name="First" ID="{E307F8C7-70B9-0B17-2399-07555020C7A4}{1}{B0}" path="C:\one-tests\runs\capture\notebook\First.one" lastModifiedTime="2026-09-13T06:03:05.000Z" color="#8AA8E4"><one:Page ID="{70B35D81-6F2A-0AE7-1D16-203C61369245}{1}{B0}" name="First page" dateTime="2026-09-13T06:01:23.000Z" lastModifiedTime="2026-09-13T06:03:05.000Z" pageLevel="1"/></one:Section><one:SectionGroup name="Kept" ID="{3757140F-18FD-4415-A727-30EA4DBFC024}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Kept" lastModifiedTime="2026-09-13T06:03:04.000Z"><one:Section name="Inner" ID="{2D61415B-2BE9-04B9-119F-3F47F15C5F41}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Kept\Inner.one" lastModifiedTime="2026-09-13T06:03:04.000Z" color="#8AA8E4"><one:Page ID="{D9B9E908-7438-0EB5-167D-F62354A5BFF2}{1}{B0}" name="Untitled page" dateTime="2026-09-13T06:01:23.000Z" lastModifiedTime="2026-09-13T06:03:04.000Z" pageLevel="1"/></one:Section></one:SectionGroup></one:Notebook>
corpus/notebook-edit/structured/cold/read/page-000.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{5E2D981D-F81C-07E2-3D5F-25296F0BE839}{1}{B0}" name="Untitled page" dateTime="2026-09-13T06:01:23.000Z" lastModifiedTime="2026-09-13T06:03:04.000Z" pageLevel="1"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings></one:Page>
corpus/notebook-edit/structured/cold/read/page-001.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{70B35D81-6F2A-0AE7-1D16-203C61369245}{1}{B0}" name="First page" dateTime="2026-09-13T06:01:23.000Z" lastModifiedTime="2026-09-13T06:03:05.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-13T06:01:23.000Z" objectID="{E307F8C7-70B9-0B17-2399-07555020C7A4}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="110.3364486694336" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-13T06:01:23.000Z" lastModifiedTime="2026-09-13T06:01:23.000Z" objectID="{E307F8C7-70B9-0B17-2399-07555020C7A4}{24}{B0}" alignment="left"><one:T><![CDATA[First page]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/notebook-edit/structured/cold/read/page-002.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D9B9E908-7438-0EB5-167D-F62354A5BFF2}{1}{B0}" name="Untitled page" dateTime="2026-09-13T06:01:23.000Z" lastModifiedTime="2026-09-13T06:03:04.000Z" pageLevel="1"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings></one:Page>
corpus/notebook-edit/structured/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/notebook-edit/structured/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/m4/structure3",
3 "expected_pages": -1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/notebook-edit/structured/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/notebook-edit/structured/cold/scripts/read.ps1 created+142
...@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/notebook-edit/structured/cold/source.json created+32
...@@ -0,0 +1,32 @@
1[
2 {
3 "path": "First.one",
4 "bytes": 3212,
5 "sha256": "d926b2f5502dd1bb2768928e44020497248299b9e6c1c8ddcec999117279c177",
6 "mtime_ns": 1789279283377754822
7 },
8 {
9 "path": "Kept/Inner.one",
10 "bytes": 3164,
11 "sha256": "33e43aef96619ba34e4499b605e21540ac25475542ae8817e37ed10382ac25ca",
12 "mtime_ns": 1789279283538323423
13 },
14 {
15 "path": "Kept/Open Notebook.onetoc2",
16 "bytes": 3824,
17 "sha256": "432b3413622bb8eabacc640ffc8c6ad80dee94cd45bed6154f5afbc2ae443965",
18 "mtime_ns": 1789279283654960588
19 },
20 {
21 "path": "Open Notebook.onetoc2",
22 "bytes": 5408,
23 "sha256": "e713f4d9cca21552e7fd650c54b6072fc2d8875e48b738b00887324f82331728",
24 "mtime_ns": 1789279283758856923
25 },
26 {
27 "path": "Renamed.one",
28 "bytes": 5504,
29 "sha256": "9ee7bd23c1679ce5035c92adca0416730b149d11bedd1b4a7c181a0186df7007",
30 "mtime_ns": 1789279283726949659
31 }
32]
corpus/notebook-edit/structured/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
crates/notebook/README.md+44-1
...@@ -160,6 +160,17 @@ in one local transaction. The supplied images must still match `snapshot()` and...@@ -160,6 +160,17 @@ in one local transaction. The supplied images must still match `snapshot()` and
160so another overlapping remote edit can produce a new conflict. Pending intents160so another overlapping remote edit can produce a new conflict. Pending intents
161and uncertain attempts cannot be reviewed.161and uncertain attempts cannot be reviewed.
162162
163An attempt that stays `AwaitingConfirmation` is released by review, never by
164replay: `release_attempt(id, local, remote, archive, after)` on the replica
165(`release` on a session) exports the branch to a new archive, then retires
166the oldest edit's attempt. `Some(page)` continues from a page reviewed
167against the current remote image as a fresh intent under the same id,
168keeping later edits; `None` abandons the local branch, whose ids report
169`EditStatus::Archived { archive }` from then on, and the working image
170returns to the remote image. Neither writes a receipt: the uncertain
171publication may or may not have landed, and the archive is the record of
172what was attempted.
173
163Opening recognizes the application identity and the current schema version only;174Opening recognizes the application identity and the current schema version only;
164caches and archives written by other versions are refused unchanged. Until the175caches and archives written by other versions are refused unchanged. Until the
165application is usable end to end there are no migrations.176application is usable end to end there are no migrations.
...@@ -295,7 +306,8 @@ Discovery returns ordered sections and nested groups with file identities and...@@ -295,7 +306,8 @@ Discovery returns ordered sections and nested groups with file identities and
295share-relative paths. Section display-name overrides remain distinct from file306share-relative paths. Section display-name overrides remain distinct from file
296names. TOC references whose identities are absent from the directory remain307names. TOC references whose identities are absent from the directory remain
297inspectable; a cached filename never substitutes for an identity match.308inspectable; a cached filename never substitutes for an identity match.
298Reserved `_onefiles` directories are excluded from section-group traversal.309Reserved `_onefiles` directories are excluded from section-group traversal;
310OneNote's `OneNote_RecycleBin` is listed as the section group OneNote shows.
299Encrypted sections and valid storage with an unreadable document graph retain311Encrypted sections and valid storage with an unreadable document graph retain
300their identity as `Locked` or `Unreadable`, without being presented as empty pages.312their identity as `Locked` or `Unreadable`, without being presented as empty pages.
301Malformed storage, failed reads and ambiguous identities reject the discovery.313Malformed storage, failed reads and ambiguous identities reject the discovery.
...@@ -312,6 +324,37 @@ Missing files, permissions and size failures retain their I/O error kinds; an...@@ -312,6 +324,37 @@ Missing files, permissions and size failures retain their I/O error kinds; an
312empty payload is a successful empty buffer. Embedded payloads remain available324empty payload is a successful empty buffer. Embedded payloads remain available
313directly from the core document model.325directly from the core document model.
314326
327## Notebook structure
328
329`Notebook::create_section`, `create_group`, `rename`, `set_section_color`,
330`reorder` and `delete` change a notebook the way OneNote does: the table of
331contents (`Open Notebook.onetoc2`, created when a folder has none) gains,
332renames, reorders or loses entries; a section's colour lives in its own
333metadata; a deleted section moves into `OneNote_RecycleBin`, a group with its
334own TOC. Every created, renamed or moved file is placed with
335`onestore::place_file`, which sets the header's ancestor to the parent TOC's
336identity and the name CRC OneNote checks on open; a file without them is
337re-identified and listed anew. These operate on the mounted directory;
338the SMB transport has no create, rename or delete.
339
340`Notebook::find_page(url)` resolves a stored internal link to a section path
341and page space by identity: the linked section first, then every readable
342section, so a link follows its page when the section is renamed or moved and
343when the page itself was moved to another section. Other URLs and unknown
344targets are `None`.
345
346With the optional `protected` feature, `Notebook::unlock(path, password)`
347reads the pages of a `Locked` section for display; nothing is cached or
348written, and a wrong password is `Error::Protected(PasswordMismatch)`.
349
350`Notebook::refresh` rereads the directory on the caller's schedule and reports
351what another client changed as `Change`s keyed by file identity: a renamed or
352moved section is `Moved`, not removed and added, and a deleted section moves
353into `OneNote_RecycleBin`; a folder whose surviving entries changed sequence
354is `Reordered`. A failed read returns the error and keeps
355the previous catalog, so an unreachable share never reads as an emptied
356notebook.
357
315## SMB (feature `smb`)358## SMB (feature `smb`)
316359
317`notebook::smb` provides blocking SMB access for OneNote sections and360`notebook::smb` provides blocking SMB access for OneNote sections and
crates/notebook/examples/recovery_probe.rs+1
...@@ -91,6 +91,7 @@ fn report(cache: &Replica, root: &Path) -> Result<(), Box<dyn std::error::Error>...@@ -91,6 +91,7 @@ fn report(cache: &Replica, root: &Path) -> Result<(), Box<dyn std::error::Error>
91 }91 }
92 Some(EditStatus::Published { revision }) => ("published", Some(revision.to_string())),92 Some(EditStatus::Published { revision }) => ("published", Some(revision.to_string())),
93 Some(EditStatus::Conflict(_)) => ("conflict", None),93 Some(EditStatus::Conflict(_)) => ("conflict", None),
94 Some(EditStatus::Archived { archive }) => ("archived", Some(archive)),
94 None => ("missing", None),95 None => ("missing", None),
95 };96 };
96 println!(97 println!(
crates/notebook/src/lib.rs+4-1
...@@ -51,12 +51,15 @@ pub enum Error {...@@ -51,12 +51,15 @@ pub enum Error {
51 Discovery(#[from] discover::Error),51 Discovery(#[from] discover::Error),
52 #[error("External payload identity now refers to different bytes")]52 #[error("External payload identity now refers to different bytes")]
53 AssetChanged,53 AssetChanged,
54 #[cfg(feature = "protected")]
55 #[error(transparent)]
56 Protected(#[from] onestore::protected::Error),
54}57}
5558
56type Result<T> = std::result::Result<T, Error>;59type Result<T> = std::result::Result<T, Error>;
5760
58const APPLICATION_ID: u32 = 0x4f4e454f;61const APPLICATION_ID: u32 = 0x4f4e454f;
59const SCHEMA_VERSION: u32 = 12;62const SCHEMA_VERSION: u32 = 13;
6063
61/// An edited page model together with the stored model it was edited from.64/// An edited page model together with the stored model it was edited from.
62/// `before` is the precondition reconciliation checks against the remote page.65/// `before` is the precondition reconciliation checks against the remote page.
crates/notebook/src/schema.rs+4
...@@ -27,6 +27,10 @@ pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> {...@@ -27,6 +27,10 @@ pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> {
27 CREATE TABLE receipts (27 CREATE TABLE receipts (
28 edit_id INTEGER PRIMARY KEY CHECK(edit_id>0),28 edit_id INTEGER PRIMARY KEY CHECK(edit_id>0),
29 revision TEXT NOT NULL29 revision TEXT NOT NULL
30 ) STRICT;
31 CREATE TABLE archived (
32 edit_id INTEGER PRIMARY KEY CHECK(edit_id>0),
33 archive TEXT NOT NULL
30 ) STRICT;",34 ) STRICT;",
31 )?;35 )?;
32 transaction.execute_batch(CONFLICTS)?;36 transaction.execute_batch(CONFLICTS)?;
crates/notebook/src/session.rs+439-1
...@@ -8,7 +8,9 @@ use onestore::{...@@ -8,7 +8,9 @@ use onestore::{
8 CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, page::Page,8 CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, page::Page,
9};9};
10use std::{10use std::{
11 collections::BTreeMap,
11 io,12 io,
13 io::Write,
12 path::{Path, PathBuf},14 path::{Path, PathBuf},
13 sync::{15 sync::{
14 Arc,16 Arc,
...@@ -17,6 +19,52 @@ use std::{...@@ -17,6 +19,52 @@ use std::{
17 time::Duration,19 time::Duration,
18};20};
1921
22/// A difference between two catalog reads of a notebook, in catalog paths.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Change {
25 /// A section or group appeared, including one replacing a file under an existing path.
26 Added(String),
27 /// A section or group is no longer in the notebook.
28 Removed(String),
29 /// The same file now lives at another path: a rename or a move between groups.
30 Moved { from: String, to: String },
31 /// A folder's surviving sections and groups changed order.
32 Reordered(String),
33}
34
35/// Sections and groups are followed by file identity; a group without a TOC only by path.
36#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
37enum Key {
38 File([u8; 16]),
39 Folder(String),
40}
41
42type Entries = (BTreeMap<Key, String>, BTreeMap<String, Vec<Key>>);
43
44fn entries(folder: &discover::Folder) -> Entries {
45 fn walk(folder: &discover::Folder, out: &mut Entries) {
46 let mut order = Vec::new();
47 for section in &folder.sections {
48 let key = Key::File(section.file_id);
49 out.0.insert(key.clone(), section.path.clone());
50 order.push(key);
51 }
52 for group in &folder.groups {
53 let key = match &group.toc {
54 Some(toc) => Key::File(toc.file_id),
55 None => Key::Folder(group.path.clone()),
56 };
57 out.0.insert(key.clone(), group.path.clone());
58 order.push(key);
59 walk(group, out);
60 }
61 out.1.insert(folder.path.clone(), order);
62 }
63 let mut out = Entries::default();
64 walk(folder, &mut out);
65 out
66}
67
20/// A notebook directory and the cache directory holding its section replicas.68/// A notebook directory and the cache directory holding its section replicas.
21pub struct Notebook {69pub struct Notebook {
22 root: PathBuf,70 root: PathBuf,
...@@ -48,6 +96,365 @@ impl Notebook {...@@ -48,6 +96,365 @@ impl Notebook {
48 &self.catalog96 &self.catalog
49 }97 }
5098
99 /// Rereads the notebook directory and reports what changed since the last catalog,
100 /// keyed by file identity so a renamed or moved section stays the same section. A
101 /// failed read keeps the previous catalog: an unreachable notebook is not an empty one.
102 pub fn refresh(&mut self) -> Result<Vec<Change>> {
103 let catalog = discover::discover(
104 &mut discover::Local::open(&self.root)?,
105 discover::Limits {
106 entries: 100_000,
107 bytes_per_file: 256 * 1024 * 1024,
108 depth: 64,
109 },
110 )?;
111 let (before, before_orders) = entries(&self.catalog);
112 let (after, after_orders) = entries(&catalog);
113 let mut changes = Vec::new();
114 for key in before_orders.values().flatten() {
115 let from = &before[key];
116 match after.get(key) {
117 None => changes.push(Change::Removed(from.clone())),
118 Some(to) if to != from => changes.push(Change::Moved {
119 from: from.clone(),
120 to: to.clone(),
121 }),
122 Some(_) => {}
123 }
124 }
125 for key in after_orders.values().flatten() {
126 if !before.contains_key(key) {
127 changes.push(Change::Added(after[key].clone()));
128 }
129 }
130 for (folder, order) in &after_orders {
131 let Some(previous) = before_orders.get(folder) else {
132 continue;
133 };
134 let kept = |sequence: &[Key], other: &[Key]| -> Vec<Key> {
135 sequence
136 .iter()
137 .filter(|key| other.contains(key))
138 .cloned()
139 .collect()
140 };
141 if kept(previous, order) != kept(order, previous) {
142 changes.push(Change::Reordered(folder.clone()));
143 }
144 }
145 self.catalog = catalog;
146 Ok(changes)
147 }
148
149 fn folder(&self, path: &str) -> Result<&discover::Folder> {
150 let mut folders = vec![&self.catalog];
151 while let Some(folder) = folders.pop() {
152 if folder.path == path {
153 return Ok(folder);
154 }
155 folders.extend(&folder.groups);
156 }
157 Err(io::Error::from(io::ErrorKind::NotFound).into())
158 }
159
160 fn directory(&self, path: &str) -> Result<PathBuf> {
161 let directory = if path.is_empty() {
162 self.root.clone()
163 } else {
164 self.root.join(path).canonicalize()?
165 };
166 if !directory.starts_with(&self.root) || !directory.is_dir() {
167 return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());
168 }
169 Ok(directory)
170 }
171
172 /// A folder's TOC path and file identity, creating the TOC when the folder has none
173 /// (OneNote names it `Open Notebook.onetoc2`).
174 fn toc(&self, folder: &str) -> Result<(PathBuf, [u8; 16])> {
175 let directory = self.directory(folder)?;
176 match &self.folder(folder)?.toc {
177 Some(toc) => Ok((directory.join(&toc.filename), toc.file_id)),
178 None => {
179 let path = directory.join("Open Notebook.onetoc2");
180 let bytes = onestore::create_table_of_contents("Open Notebook.onetoc2", &[])?;
181 std::fs::File::create_new(&path)?.write_all(&bytes)?;
182 Ok((path, onestore::Store::parse(&bytes)?.header.file_id))
183 }
184 }
185 }
186
187 fn edit_toc(&self, folder: &str, edits: &[onestore::TocEdit]) -> Result<()> {
188 let (toc, _) = self.toc(folder)?;
189 let source = onestore::read_file(&toc)?;
190 PreparedEdit::table_of_contents(&source, edits)?
191 .commit_file(&toc)
192 .map_err(|error| error.error)?;
193 Ok(())
194 }
195
196 /// Creates `name.one` in `folder` with one empty page and lists it last in the folder's
197 /// TOC, as OneNote creates a section. Returns the new catalog path.
198 pub fn create_section(&mut self, folder: &str, name: &str, author: &str) -> Result<String> {
199 let filename = format!("{name}.one");
200 let directory = self.directory(folder)?;
201 let (_, ancestor) = self.toc(folder)?;
202 let bytes = onestore::create_section(&filename, "", author)?;
203 let path = directory.join(&filename);
204 std::fs::File::create_new(&path)?.write_all(&bytes)?;
205 onestore::place_file(&path, ancestor, &filename)?;
206 let identity = onestore::Store::parse(&bytes)?.header.file_id;
207 self.edit_toc(
208 folder,
209 &[onestore::TocEdit::Add {
210 filename: filename.clone(),
211 identity,
212 group: false,
213 }],
214 )?;
215 self.refresh()?;
216 Ok(catalog_path(folder, &filename))
217 }
218
219 /// Creates a section group: a folder with its own TOC, listed last in the parent's TOC.
220 pub fn create_group(&mut self, folder: &str, name: &str) -> Result<String> {
221 let directory = self.directory(folder)?;
222 if !component(name) {
223 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
224 }
225 let group = directory.join(name);
226 let (_, ancestor) = self.toc(folder)?;
227 std::fs::create_dir(&group)?;
228 let bytes = onestore::create_table_of_contents("Open Notebook.onetoc2", &[])?;
229 let path = group.join("Open Notebook.onetoc2");
230 std::fs::File::create_new(&path)?.write_all(&bytes)?;
231 onestore::place_file(&path, ancestor, name)?;
232 let identity = onestore::Store::parse(&bytes)?.header.file_id;
233 self.edit_toc(
234 folder,
235 &[onestore::TocEdit::Add {
236 filename: name.to_owned(),
237 identity,
238 group: true,
239 }],
240 )?;
241 self.refresh()?;
242 Ok(catalog_path(folder, name))
243 }
244
245 /// Renames a section or section group: the file or folder on disk and its TOC entry.
246 pub fn rename(&mut self, path: &str, name: &str) -> Result<String> {
247 let (folder, entry) = split(path);
248 let directory = self.directory(folder)?;
249 let (filename, identity) = self.entry(folder, entry)?;
250 let target = if filename.to_ascii_lowercase().ends_with(".one") {
251 format!("{name}.one")
252 } else {
253 name.to_owned()
254 };
255 if !component(&target) || directory.join(&target).exists() {
256 return Err(io::Error::from(io::ErrorKind::AlreadyExists).into());
257 }
258 std::fs::rename(directory.join(&filename), directory.join(&target))?;
259 let (_, ancestor) = self.toc(folder)?;
260 let placed = if target.ends_with(".one") {
261 directory.join(&target)
262 } else {
263 directory.join(&target).join("Open Notebook.onetoc2")
264 };
265 onestore::place_file(placed, ancestor, &target)?;
266 self.edit_toc(
267 folder,
268 &[onestore::TocEdit::Rename {
269 identity,
270 filename: target.clone(),
271 }],
272 )?;
273 self.refresh()?;
274 Ok(catalog_path(folder, &target))
275 }
276
277 /// Sets a section's colour (COLORREF) in its own metadata, where OneNote keeps it.
278 pub fn set_section_color(&mut self, path: &str, color: Option<u32>) -> Result<()> {
279 let file = self.root.join(path).canonicalize()?;
280 if !file.starts_with(&self.root) {
281 return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());
282 }
283 let source = onestore::read_file(&file)?;
284 let store = onestore::Store::parse(&source)?;
285 let index = onestore::RevisionIndex::parse(&store)?;
286 let document = Document::parse(&index)?;
287 let revision = document.active(document.root)?;
288 let metadata = *revision
289 .roots
290 .get(&2)
291 .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?;
292 onestore::commit_file_property(
293 &file,
294 &source,
295 document.root,
296 metadata,
297 0x14001cbe,
298 &color.unwrap_or(0xffff_ffff).to_le_bytes(),
299 )
300 .map_err(|error| error.error)?;
301 self.refresh()?;
302 Ok(())
303 }
304
305 /// Orders a folder's sections and groups; entries left out follow in their current order.
306 pub fn reorder(&mut self, folder: &str, paths: &[&str]) -> Result<()> {
307 let mut identities = Vec::new();
308 for path in paths {
309 let (parent, entry) = split(path);
310 if parent != folder {
311 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
312 }
313 identities.push(self.entry(folder, entry)?.1);
314 }
315 self.edit_toc(folder, &[onestore::TocEdit::Order(identities)])?;
316 self.refresh().map(drop)
317 }
318
319 /// Deletes a section the way OneNote does: the file moves into the notebook's
320 /// `OneNote_RecycleBin` folder, a section group with its own TOC that lists it (and that
321 /// the root TOC lists), and its own folder's TOC entry goes.
322 pub fn delete(&mut self, path: &str) -> Result<()> {
323 let (folder, entry) = split(path);
324 let directory = self.directory(folder)?;
325 let (filename, identity) = self.entry(folder, entry)?;
326 if !filename.to_ascii_lowercase().ends_with(".one") {
327 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
328 }
329 let bin = self.root.join("OneNote_RecycleBin");
330 let bin_toc = bin.join("Open Notebook.onetoc2");
331 if !bin_toc.exists() {
332 std::fs::create_dir_all(&bin)?;
333 let bytes = onestore::create_table_of_contents("Open Notebook.onetoc2", &[])?;
334 std::fs::File::create_new(&bin_toc)?.write_all(&bytes)?;
335 onestore::place_file(&bin_toc, self.toc("")?.1, "OneNote_RecycleBin")?;
336 let bin_identity = onestore::Store::parse(&bytes)?.header.file_id;
337 self.edit_toc(
338 "",
339 &[onestore::TocEdit::Add {
340 filename: "OneNote_RecycleBin".into(),
341 identity: bin_identity,
342 group: true,
343 }],
344 )?;
345 }
346 let mut target = filename.clone();
347 let mut attempt = 1;
348 while bin.join(&target).exists() {
349 attempt += 1;
350 let (stem, extension) = filename.rsplit_once('.').unwrap_or((&filename, ""));
351 target = format!("{stem} ({attempt}).{extension}");
352 }
353 std::fs::rename(directory.join(&filename), bin.join(&target))?;
354 let source = onestore::read_file(&bin_toc)?;
355 onestore::place_file(
356 bin.join(&target),
357 onestore::Store::parse(&source)?.header.file_id,
358 &target,
359 )?;
360 PreparedEdit::table_of_contents(
361 &source,
362 &[onestore::TocEdit::Add {
363 filename: target,
364 identity,
365 group: false,
366 }],
367 )?
368 .commit_file(&bin_toc)
369 .map_err(|error| error.error)?;
370 self.edit_toc(folder, &[onestore::TocEdit::Remove { identity }])?;
371 self.refresh().map(drop)
372 }
373
374 /// The stored filename and TOC identity of a folder's section or group.
375 fn entry(&self, folder: &str, name: &str) -> Result<(String, [u8; 16])> {
376 let parent = self.folder(folder)?;
377 if let Some(section) = parent
378 .sections
379 .iter()
380 .find(|section| split(&section.path).1 == name)
381 {
382 return Ok((name.to_owned(), section.file_id));
383 }
384 if let Some(group) = parent
385 .groups
386 .iter()
387 .find(|group| split(&group.path).1 == name)
388 {
389 let toc = group
390 .toc
391 .as_ref()
392 .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
393 return Ok((name.to_owned(), toc.file_id));
394 }
395 Err(io::Error::from(io::ErrorKind::NotFound).into())
396 }
397
398 /// The section path and page space a stored internal link opens, found by identity:
399 /// in the linked section first, then in every other readable section, so a link
400 /// follows its page across sections. `None` for other URLs and unknown targets.
401 pub fn find_page(&self, url: &str) -> Result<Option<(String, Option<ExGuid>)>> {
402 let Some(link) = onestore::page::link::parse_internal_link(url) else {
403 return Ok(None);
404 };
405 let mut sections = Vec::new();
406 let mut folders = vec![&self.catalog];
407 while let Some(folder) = folders.pop() {
408 sections.extend(folder.sections.iter().filter(|section| {
409 matches!(section.state, discover::SectionState::Readable { .. })
410 }));
411 folders.extend(&folder.groups);
412 }
413 sections.sort_by_key(|section| section.file_id != link.section);
414 let Some(page) = link.page else {
415 return Ok(sections
416 .first()
417 .filter(|section| section.file_id == link.section)
418 .map(|section| (section.path.clone(), None)));
419 };
420 for section in sections {
421 let bytes = onestore::read_file(self.root.join(&section.path))?;
422 let store = Store::parse(&bytes)?;
423 let index = RevisionIndex::parse(&store)?;
424 let document = Document::parse(&index)?;
425 for (space, _) in document.pages()? {
426 if Page::identity_of(document.active(space)?) == Some(page) {
427 return Ok(Some((section.path.clone(), Some(space))));
428 }
429 }
430 }
431 Ok(None)
432 }
433
434 /// The pages of a password-protected section, for reading: nothing is cached or
435 /// written, and the decoded buffers go when the pages have been built.
436 #[cfg(feature = "protected")]
437 pub fn unlock(&self, path: &str, password: &str) -> Result<Vec<(ExGuid, Page)>> {
438 let file = self.root.join(path).canonicalize()?;
439 if !file.starts_with(&self.root) {
440 return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());
441 }
442 let bytes = onestore::read_file(&file)?;
443 let store = Store::parse(&bytes)?;
444 let index = RevisionIndex::parse(&store)?;
445 let unlocked = onestore::protected::UnlockedSection::open(
446 &index,
447 password,
448 onestore::protected::Limits::default(),
449 )?;
450 let document = unlocked.document()?;
451 document
452 .pages()?
453 .into_iter()
454 .map(|(space, _)| Ok((space, Page::from_space(&document, space)?)))
455 .collect()
456 }
457
51 /// Opens a section by its catalog path.458 /// Opens a section by its catalog path.
52 pub fn section(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result<Section> {459 pub fn section(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result<Section> {
53 let mut folders = vec![&self.catalog];460 let mut folders = vec![&self.catalog];
...@@ -65,6 +472,25 @@ impl Notebook {...@@ -65,6 +472,25 @@ impl Notebook {
65 }472 }
66}473}
67474
475fn component(name: &str) -> bool {
476 !name.is_empty() && !name.contains(['/', '\\', '\0']) && name != "." && name != ".."
477}
478
479fn split(path: &str) -> (&str, &str) {
480 match path.rsplit_once('/') {
481 Some((folder, name)) => (folder, name),
482 None => ("", path),
483 }
484}
485
486fn catalog_path(folder: &str, name: &str) -> String {
487 if folder.is_empty() {
488 name.to_owned()
489 } else {
490 format!("{folder}/{name}")
491 }
492}
493
68/// What happened on the synchronization thread since the last poll.494/// What happened on the synchronization thread since the last poll.
69#[derive(Debug)]495#[derive(Debug)]
70pub enum Event {496pub enum Event {
...@@ -181,7 +607,7 @@ impl Section {...@@ -181,7 +607,7 @@ impl Section {
181 Ok(None) => Event::Refreshed,607 Ok(None) => Event::Refreshed,
182 Ok(Some((id, status))) => Event::Attempt {608 Ok(Some((id, status))) => Event::Attempt {
183 id: *id,609 id: *id,
184 status: *status,610 status: status.clone(),
185 },611 },
186 Err(Error::RemoteIo(error)) => Event::Unreachable(match error.raw_os_error() {612 Err(Error::RemoteIo(error)) => Event::Unreachable(match error.raw_os_error() {
187 Some(code) => io::Error::from_raw_os_error(code),613 Some(code) => io::Error::from_raw_os_error(code),
...@@ -308,6 +734,18 @@ impl Section {...@@ -308,6 +734,18 @@ impl Section {
308 Ok(())734 Ok(())
309 }735 }
310736
737 /// Retires an uncertain attempt after review, exporting the branch to `archive`
738 /// first: `Some(page)` continues from the reviewed page against `remote_page`,
739 /// `None` abandons the local branch. Neither claims the attempt was acknowledged.
740 pub fn release(&self, id: u64, archive: impl AsRef<Path>, after: Option<&Page>) -> Result<()> {
741 let local = self.replica.snapshot()?;
742 let remote = self.replica.remote_snapshot()?;
743 self.replica
744 .release_attempt(id, &local, &remote, archive.as_ref(), after)?;
745 self.wake();
746 Ok(())
747 }
748
311 /// Captures both images, the queue and its states in a read-only archive.749 /// Captures both images, the queue and its states in a read-only archive.
312 pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> {750 pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> {
313 self.replica.export_recovery(path)751 self.replica.export_recovery(path)
crates/notebook/src/sync.rs+130-1
...@@ -27,7 +27,7 @@ pub enum ConflictKind {...@@ -27,7 +27,7 @@ pub enum ConflictKind {
27 ContentChanged = 3,27 ContentChanged = 3,
28}28}
2929
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum EditStatus {31pub enum EditStatus {
32 Pending,32 Pending,
33 /// Retained publication attempt; this revision alone may be insufficient to confirm it.33 /// Retained publication attempt; this revision alone may be insufficient to confirm it.
...@@ -39,6 +39,11 @@ pub enum EditStatus {...@@ -39,6 +39,11 @@ pub enum EditStatus {
39 Published {39 Published {
40 revision: ExGuid,40 revision: ExGuid,
41 },41 },
42 /// Retired unpublished by a reviewed release; the archive at this path holds the
43 /// intent, its attempt evidence and both images.
44 Archived {
45 archive: String,
46 },
42}47}
4348
44impl Replica {49impl Replica {
...@@ -355,6 +360,120 @@ impl Replica {...@@ -355,6 +360,120 @@ impl Replica {
355 })360 })
356 }361 }
357362
363 /// Retires the oldest edit's uncertain attempt after review. The branch is first
364 /// exported to `archive` (a new file), which is the record: no receipt is written.
365 /// `Some(after)` continues from the reviewed page as a fresh intent against the
366 /// current remote page, keeping the edit's id and its dependents; `None` abandons
367 /// the whole local branch, whose edits become `Archived`, and the working image
368 /// returns to the remote image. Both need the reviewed images to be current.
369 pub fn release_attempt(
370 &self,
371 id: u64,
372 local: &[u8],
373 remote: &[u8],
374 archive: &Path,
375 after: Option<&Page>,
376 ) -> Result<()> {
377 let owner = self.sync_owner()?;
378 let intent = self
379 .pending()?
380 .into_iter()
381 .next()
382 .filter(|intent| intent.id == id)
383 .ok_or_else(|| {
384 io::Error::new(
385 io::ErrorKind::InvalidInput,
386 "Only the oldest edit can hold an attempt",
387 )
388 })?;
389 let continued = match after {
390 Some(after) => {
391 let Operation::Page(edit) = intent.operation else {
392 return Err(io::Error::new(
393 io::ErrorKind::InvalidInput,
394 "Select a page save to continue from",
395 )
396 .into());
397 };
398 let before = page_of(remote, intent.space)?.ok_or_else(|| {
399 io::Error::new(
400 io::ErrorKind::InvalidInput,
401 "The page is no longer in the remote section",
402 )
403 })?;
404 PreparedEdit::page(remote, intent.space, after, &edit.author)?;
405 Some(Operation::Page(PageIntent {
406 before,
407 after: after.clone(),
408 author: edit.author,
409 }))
410 }
411 None => None,
412 };
413 let mut connection = self
414 .connection
415 .lock()
416 .map_err(|_| io::Error::other("Cache owner panicked"))?;
417 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
418 let (base, working): (Vec<u8>, Vec<u8>) =
419 transaction.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| {
420 Ok((row.get(0)?, row.get(1)?))
421 })?;
422 if working != local || base != remote {
423 return Err(io::Error::new(
424 io::ErrorKind::ResourceBusy,
425 "The reviewed cache images changed",
426 )
427 .into());
428 }
429 let id = i64::try_from(id).map_err(io::Error::other)?;
430 let attempted: bool = transaction.query_row(
431 "SELECT EXISTS(SELECT 1 FROM attempt WHERE edit_id=?1)",
432 [id],
433 |row| row.get(0),
434 )?;
435 if !attempted {
436 return Err(io::Error::new(
437 io::ErrorKind::InvalidInput,
438 "The edit has no uncertain attempt",
439 )
440 .into());
441 }
442 drop(transaction);
443 drop(connection);
444 self.export_recovery(archive)?;
445 let mut connection = self
446 .connection
447 .lock()
448 .map_err(|_| io::Error::other("Cache owner panicked"))?;
449 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
450 transaction.execute("DELETE FROM attempt WHERE edit_id=?1", [id])?;
451 match continued {
452 Some(operation) => {
453 transaction.execute(
454 "UPDATE edits SET operation=?1 WHERE id=?2",
455 params![
456 serde_json::to_string(&operation).map_err(io::Error::other)?,
457 id
458 ],
459 )?;
460 }
461 None => {
462 transaction.execute(
463 "INSERT INTO archived(edit_id, archive) SELECT id, ?1 FROM edits",
464 [archive.to_string_lossy().into_owned()],
465 )?;
466 transaction.execute("DELETE FROM edits", [])?;
467 transaction.execute("UPDATE replica SET working=base WHERE id=1", [])?;
468 }
469 }
470 transaction.commit()?;
471 drop(connection);
472 drop(owner);
473 self.wake_sync();
474 Ok(())
475 }
476
358 fn resolve_conflict(477 fn resolve_conflict(
359 &self,478 &self,
360 id: u64,479 id: u64,
...@@ -462,6 +581,16 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat...@@ -462,6 +581,16 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat
462 revision: revision.parse()?,581 revision: revision.parse()?,
463 }));582 }));
464 }583 }
584 if let Some(archive) = connection
585 .query_row(
586 "SELECT archive FROM archived WHERE edit_id=?1",
587 [id],
588 |row| row.get::<_, String>(0),
589 )
590 .optional()?
591 {
592 return Ok(Some(EditStatus::Archived { archive }));
593 }
465 let record: Option<(Option<String>, Option<i64>, String)> = connection.query_row(594 let record: Option<(Option<String>, Option<i64>, String)> = connection.query_row(
466 "SELECT attempt.revisions, conflicts.kind, edits.space FROM edits LEFT JOIN attempt ON attempt.edit_id=edits.id LEFT JOIN conflicts ON conflicts.edit_id=edits.id WHERE edits.id=?1", [id], |row| Ok((row.get(0)?,row.get(1)?, row.get(2)?))).optional()?;595 "SELECT attempt.revisions, conflicts.kind, edits.space FROM edits LEFT JOIN attempt ON attempt.edit_id=edits.id LEFT JOIN conflicts ON conflicts.edit_id=edits.id WHERE edits.id=?1", [id], |row| Ok((row.get(0)?,row.get(1)?, row.get(2)?))).optional()?;
467 Ok(match record {596 Ok(match record {
crates/notebook/tests/changes.rs created+78
...@@ -0,0 +1,78 @@
1use notebook::session::{Change, Notebook};
2use std::io::Write;
3
4/// One client watches a notebook while another changes it on disk; changes follow file
5/// identity, and an unreachable notebook keeps the last catalog.
6#[test]
7fn external_structure_changes_are_reported_by_identity() {
8 let temporary = tempfile::tempdir().unwrap();
9 let root = temporary.path().join("notebook");
10 std::fs::create_dir(&root).unwrap();
11 let first = onestore::create_section("First.one", "First page", "Author").unwrap();
12 std::fs::File::create_new(root.join("First.one"))
13 .unwrap()
14 .write_all(&first)
15 .unwrap();
16 let first_id = onestore::Store::parse(&first).unwrap().header.file_id;
17 std::fs::write(
18 root.join("Open Notebook.onetoc2"),
19 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("First.one", first_id)])
20 .unwrap(),
21 )
22 .unwrap();
23 let mut watcher = Notebook::open(&root, temporary.path().join("watcher")).unwrap();
24 let mut other = Notebook::open(&root, temporary.path().join("other")).unwrap();
25 assert_eq!(watcher.refresh().unwrap(), []);
26
27 other.create_section("", "Second", "Author").unwrap();
28 other.create_group("", "Group").unwrap();
29 other.create_section("Group", "Inner", "Author").unwrap();
30 assert_eq!(
31 watcher.refresh().unwrap(),
32 [
33 Change::Added("Second.one".into()),
34 Change::Added("Group".into()),
35 Change::Added("Group/Inner.one".into()),
36 ]
37 );
38
39 other.rename("Second.one", "Renamed").unwrap();
40 assert_eq!(
41 watcher.refresh().unwrap(),
42 [Change::Moved {
43 from: "Second.one".into(),
44 to: "Renamed.one".into()
45 }]
46 );
47
48 other.reorder("", &["Renamed.one", "First.one"]).unwrap();
49 assert_eq!(watcher.refresh().unwrap(), [Change::Reordered("".into())]);
50
51 other.delete("First.one").unwrap();
52 assert_eq!(
53 watcher.refresh().unwrap(),
54 [
55 Change::Moved {
56 from: "First.one".into(),
57 to: "OneNote_RecycleBin/First.one".into()
58 },
59 Change::Added("OneNote_RecycleBin".into()),
60 ]
61 );
62 std::fs::remove_dir_all(root.join("OneNote_RecycleBin")).unwrap();
63 assert_eq!(
64 watcher.refresh().unwrap(),
65 [
66 Change::Removed("OneNote_RecycleBin".into()),
67 Change::Removed("OneNote_RecycleBin/First.one".into()),
68 ]
69 );
70
71 let away = temporary.path().join("away");
72 std::fs::rename(&root, &away).unwrap();
73 assert!(watcher.refresh().is_err());
74 assert_eq!(watcher.catalog().sections.len(), 1);
75 std::fs::rename(&away, &root).unwrap();
76 assert_eq!(watcher.refresh().unwrap(), []);
77 assert_eq!(watcher.refresh().unwrap(), []);
78}
crates/notebook/tests/links.rs created+78
...@@ -0,0 +1,78 @@
1use notebook::session::Notebook;
2use onestore::page::link::{LinkTarget, internal_link};
3use std::io::Write;
4
5fn write_section(root: &std::path::Path, name: &str, text: &str) -> [u8; 16] {
6 let bytes = onestore::create_section(name, text, "Author").unwrap();
7 std::fs::File::create_new(root.join(name))
8 .unwrap()
9 .write_all(&bytes)
10 .unwrap();
11 onestore::Store::parse(&bytes).unwrap().header.file_id
12}
13
14/// Links resolve by identity: a page keeps its link when its section is renamed or moved,
15/// and a link whose section identity is stale still finds the page in another section.
16#[test]
17fn internal_links_find_their_page_by_identity() {
18 let temporary = tempfile::tempdir().unwrap();
19 let root = temporary.path().join("notebook");
20 std::fs::create_dir(&root).unwrap();
21 let first = write_section(&root, "First.one", "Target page");
22 let second = write_section(&root, "Second.one", "Other page");
23 std::fs::write(
24 root.join("Open Notebook.onetoc2"),
25 onestore::create_table_of_contents(
26 "Open Notebook.onetoc2",
27 &[("First.one", first), ("Second.one", second)],
28 )
29 .unwrap(),
30 )
31 .unwrap();
32 let mut notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap();
33 let section = notebook.section("First.one", || {}).unwrap();
34 let (space, _) = section.pages().unwrap()[0];
35 let page = section.page(space).unwrap();
36 section.close().unwrap();
37 let target = LinkTarget::Page {
38 identity: page.identity.unwrap(),
39 title: &page.title,
40 };
41 let link = internal_link(first, "C:\\notebook\\First.one", target);
42 assert_eq!(
43 notebook.find_page(&link).unwrap(),
44 Some(("First.one".to_owned(), Some(space)))
45 );
46 assert_eq!(
47 notebook
48 .find_page(&internal_link(first, "", LinkTarget::Section))
49 .unwrap(),
50 Some(("First.one".to_owned(), None))
51 );
52 assert_eq!(
53 notebook.find_page("https://example.invalid/").unwrap(),
54 None
55 );
56
57 notebook.create_group("", "Group").unwrap();
58 notebook.rename("First.one", "Renamed").unwrap();
59 assert_eq!(
60 notebook.find_page(&link).unwrap(),
61 Some(("Renamed.one".to_owned(), Some(space)))
62 );
63
64 let stale = internal_link(second, "", target);
65 assert_eq!(
66 notebook.find_page(&stale).unwrap(),
67 Some(("Renamed.one".to_owned(), Some(space)))
68 );
69 let unknown = internal_link(
70 second,
71 "",
72 LinkTarget::Page {
73 identity: [7; 16],
74 title: "Gone",
75 },
76 );
77 assert_eq!(notebook.find_page(&unknown).unwrap(), None);
78}
crates/notebook/tests/protected.rs created+29
...@@ -0,0 +1,29 @@
1#![cfg(feature = "protected")]
2
3use notebook::{discover::SectionState, session::Notebook};
4use std::path::Path;
5
6/// A locked section is discovered as such and reads with its password, page by page.
7#[test]
8fn a_locked_section_unlocks_for_reading() {
9 let root = Path::new(concat!(
10 env!("CARGO_MANIFEST_DIR"),
11 "/../../corpus/native-protected-boundaries"
12 ));
13 let manifest: serde_json::Value =
14 serde_json::from_slice(&std::fs::read(root.join("manifest.json")).unwrap()).unwrap();
15 let password = manifest["password"].as_str().unwrap();
16 let temporary = tempfile::tempdir().unwrap();
17 let notebook = Notebook::open(root.join("notebook"), temporary.path()).unwrap();
18 let section = &notebook.catalog().sections[0];
19 assert!(matches!(section.state, SectionState::Locked));
20 let pages = notebook.unlock(&section.path, password).unwrap();
21 assert_eq!(pages.len(), 11);
22 assert!(pages.iter().all(|(_, page)| !page.title.is_empty()));
23 assert!(matches!(
24 notebook.unlock(&section.path, "wrong"),
25 Err(notebook::Error::Protected(
26 onestore::protected::Error::PasswordMismatch
27 ))
28 ));
29}
crates/notebook/tests/session.rs+97
...@@ -680,3 +680,100 @@ fn a_conflicting_save_is_reviewed_against_the_remote_page_and_archived_for_recov...@@ -680,3 +680,100 @@ fn a_conflicting_save_is_reviewed_against_the_remote_page_and_archived_for_recov
680 assert!(section.queue().unwrap().is_empty());680 assert!(section.queue().unwrap().is_empty());
681 section.close().unwrap();681 section.close().unwrap();
682}682}
683
684#[path = "support/server.rs"]
685mod server;
686
687/// An uncertain attempt survives a restart as `AwaitingConfirmation`; after exporting the
688/// archive the user either continues from a reviewed page or abandons the branch. Neither
689/// path records a receipt for the uncertain attempt.
690#[test]
691fn an_uncertain_attempt_is_released_after_restart_by_review() {
692 for continued in [true, false] {
693 let directory = tempfile::tempdir().unwrap();
694 let file = directory.path().join("notes.one");
695 let cache = directory.path().join("cache.sqlite");
696 let source = onestore::create_section("notes.one", "Original", "Author").unwrap();
697 std::fs::write(&file, &source).unwrap();
698 let replica = notebook::Replica::create(&cache, &source).unwrap();
699 let space = space_of(&source);
700 let page = model_ops::page_of(&source, space);
701 let text = first_text(&page);
702 let local = edited(&page, "Uncertain ");
703 let id = replica
704 .save(&source, space, &local, "Author")
705 .unwrap()
706 .unwrap();
707 let mut faulty = server::Server::new(&source);
708 faulty.fault = server::Fault::UnknownBefore;
709 assert!(replica.sync_once(&mut faulty).is_err());
710 assert!(matches!(
711 replica.status(id).unwrap(),
712 Some(EditStatus::AwaitingConfirmation { .. })
713 ));
714 drop(replica);
715
716 let notified = Arc::new(AtomicUsize::new(0));
717 let counter = Arc::clone(&notified);
718 let section = Section::resume(&file, notebook::Replica::open(&cache).unwrap(), move || {
719 counter.fetch_add(1, Ordering::SeqCst);
720 })
721 .unwrap();
722 wait(
723 &section,
724 |event| matches!(event, Event::Attempt { id: n, status: EditStatus::AwaitingConfirmation { .. } } if *n == id),
725 );
726 assert_eq!(
727 section
728 .queue()
729 .unwrap()
730 .iter()
731 .map(|edit| edit.id)
732 .collect::<Vec<_>>(),
733 [id]
734 );
735 assert_same(section.page(space).unwrap(), &local);
736 let archive = directory.path().join("review.sqlite");
737 if continued {
738 let mut reviewed = section.remote_page(space).unwrap();
739 model_ops::replace_text(&mut reviewed, text, 0..0, "Reviewed ");
740 section.release(id, &archive, Some(&reviewed)).unwrap();
741 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
742 published(&section, id);
743 assert_same(stored_page(&file, space), &reviewed);
744 } else {
745 section.release(id, &archive, None).unwrap();
746 assert_eq!(
747 section.status(id).unwrap(),
748 Some(EditStatus::Archived {
749 archive: archive.to_string_lossy().into_owned()
750 })
751 );
752 assert_same(section.page(space).unwrap(), &page);
753 assert_eq!(stored_page(&file, space), page);
754 }
755 let recovery = Recovery::open(&archive).unwrap();
756 assert_eq!(recovery.pending().unwrap().len(), 1);
757 assert!(matches!(
758 recovery.status(id).unwrap(),
759 Some(EditStatus::AwaitingConfirmation { .. })
760 ));
761 assert!(section.queue().unwrap().is_empty());
762 assert!(
763 section
764 .release(id, directory.path().join("again.sqlite"), None)
765 .is_err()
766 );
767 section.close().unwrap();
768 }
769}
770
771fn space_of(source: &[u8]) -> ExGuid {
772 let store = onestore::Store::parse(source).unwrap();
773 let index = onestore::RevisionIndex::parse(&store).unwrap();
774 onestore::document::Document::parse(&index)
775 .unwrap()
776 .pages()
777 .unwrap()[0]
778 .0
779}
crates/notebook/tests/structure.rs created+173
...@@ -0,0 +1,173 @@
1use notebook::session::Notebook;
2use std::io::Write;
3
4/// Each file's header names its parent TOC and carries the name CRC OneNote wrote for the
5/// same name in `corpus/notebook-edit/{structured,deleted}/cold`.
6fn placement(root: &std::path::Path, expected: &[(&str, u32)]) {
7 let header = |path: std::path::PathBuf| {
8 onestore::Store::parse(&std::fs::read(path).unwrap())
9 .unwrap()
10 .header
11 };
12 for (path, crc) in expected {
13 let (folder, name) = path.rsplit_once('/').unwrap_or(("", path));
14 let parent = header(root.join(folder).join("Open Notebook.onetoc2")).file_id;
15 let file = if name.ends_with(".one") {
16 root.join(path)
17 } else {
18 root.join(path).join("Open Notebook.onetoc2")
19 };
20 let header = header(file);
21 assert_eq!((header.ancestor, header.name_crc), (parent, *crc), "{path}");
22 }
23}
24
25fn names(folder: &notebook::discover::Folder) -> (Vec<String>, Vec<String>) {
26 (
27 folder.sections.iter().map(|s| s.path.clone()).collect(),
28 folder.groups.iter().map(|g| g.path.clone()).collect(),
29 )
30}
31
32/// `NOTEBOOK_STRUCTURE_EXPORT` names a new directory receiving the final notebook for a
33/// cold reopen.
34#[test]
35fn sections_and_groups_are_created_renamed_coloured_ordered_and_deleted() {
36 let temporary = tempfile::tempdir().unwrap();
37 let root = temporary.path().join("notebook");
38 std::fs::create_dir(&root).unwrap();
39 let first = onestore::create_section("First.one", "First page", "Author").unwrap();
40 std::fs::File::create_new(root.join("First.one"))
41 .unwrap()
42 .write_all(&first)
43 .unwrap();
44 let first_id = onestore::Store::parse(&first).unwrap().header.file_id;
45 std::fs::write(
46 root.join("Open Notebook.onetoc2"),
47 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("First.one", first_id)])
48 .unwrap(),
49 )
50 .unwrap();
51 let toc = onestore::read_file(root.join("Open Notebook.onetoc2")).unwrap();
52 let toc_id = onestore::Store::parse(&toc).unwrap().header.file_id;
53 onestore::place_file(root.join("First.one"), toc_id, "First.one").unwrap();
54 let mut notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap();
55 assert_eq!(
56 notebook.create_section("", "Second", "Author").unwrap(),
57 "Second.one"
58 );
59 assert_eq!(notebook.create_group("", "Archive").unwrap(), "Archive");
60 assert_eq!(
61 notebook
62 .create_section("Archive", "Inner", "Author")
63 .unwrap(),
64 "Archive/Inner.one"
65 );
66 assert_eq!(
67 names(notebook.catalog()),
68 (
69 vec!["First.one".to_owned(), "Second.one".to_owned()],
70 vec!["Archive".to_owned()]
71 )
72 );
73 assert_eq!(
74 names(&notebook.catalog().groups[0]),
75 (vec!["Archive/Inner.one".to_owned()], vec![])
76 );
77 assert_eq!(
78 notebook.rename("Second.one", "Renamed").unwrap(),
79 "Renamed.one"
80 );
81 assert_eq!(notebook.rename("Archive", "Kept").unwrap(), "Kept");
82 notebook
83 .set_section_color("Renamed.one", Some(0x5ed7ff))
84 .unwrap();
85 notebook.reorder("", &["Kept", "Renamed.one"]).unwrap();
86 assert_eq!(
87 names(notebook.catalog()),
88 (
89 vec!["Renamed.one".to_owned(), "First.one".to_owned()],
90 vec!["Kept".to_owned()]
91 )
92 );
93 let toc = notebook.catalog().toc.as_ref().unwrap();
94 let orders: Vec<(Option<String>, u32)> = toc
95 .unresolved
96 .iter()
97 .map(|entry| (entry.filename.clone(), entry.order))
98 .collect();
99 assert!(orders.is_empty(), "{orders:?}");
100 let colour = onestore::read_file(root.join("Renamed.one")).unwrap();
101 let store = onestore::Store::parse(&colour).unwrap();
102 let index = onestore::RevisionIndex::parse(&store).unwrap();
103 let document = onestore::document::Document::parse(&index).unwrap();
104 let revision = document.active(document.root).unwrap();
105 let onestore::document::Kind::SectionMetadata { color, .. } =
106 &revision.nodes[&revision.roots[&2]].kind
107 else {
108 panic!()
109 };
110 assert_eq!(*color, Some(0x5ed7ff));
111 placement(
112 &root,
113 &[
114 ("First.one", 0x2e1b24f3),
115 ("Renamed.one", 0x6108912a),
116 ("Kept", 0xf2a400f4),
117 ("Kept/Inner.one", 0x83e261da),
118 ],
119 );
120 if let Some(directory) = std::env::var_os("NOTEBOOK_STRUCTURE_EXPORT") {
121 copy_dir(&root, std::path::Path::new(&directory));
122 }
123 notebook.delete("First.one").unwrap();
124 assert_eq!(
125 names(notebook.catalog()),
126 (
127 vec!["Renamed.one".to_owned()],
128 vec!["Kept".to_owned(), "OneNote_RecycleBin".to_owned()]
129 )
130 );
131 assert_eq!(
132 names(&notebook.catalog().groups[1]),
133 (vec!["OneNote_RecycleBin/First.one".to_owned()], vec![])
134 );
135 assert!(root.join("OneNote_RecycleBin/First.one").exists());
136 let bin = onestore::read_file(root.join("OneNote_RecycleBin/Open Notebook.onetoc2")).unwrap();
137 assert!(
138 String::from_utf16_lossy(
139 &bin.chunks_exact(2)
140 .map(|c| u16::from_le_bytes([c[0], c[1]]))
141 .collect::<Vec<_>>()
142 )
143 .contains("First.one")
144 );
145 assert!(notebook.rename("Kept/Inner.one", "First").is_ok());
146 assert!(notebook.delete("Missing.one").is_err());
147 placement(
148 &root,
149 &[
150 ("Renamed.one", 0x6108912a),
151 ("Kept", 0xf2a400f4),
152 ("Kept/First.one", 0x2e1b24f3),
153 ("OneNote_RecycleBin", 0x986b646a),
154 ("OneNote_RecycleBin/First.one", 0x2e1b24f3),
155 ],
156 );
157 if let Some(directory) = std::env::var_os("NOTEBOOK_STRUCTURE_EXPORT_DELETED") {
158 copy_dir(&root, std::path::Path::new(&directory));
159 }
160}
161
162fn copy_dir(from: &std::path::Path, to: &std::path::Path) {
163 std::fs::create_dir_all(to).unwrap();
164 for entry in std::fs::read_dir(from).unwrap() {
165 let entry = entry.unwrap();
166 let target = to.join(entry.file_name());
167 if entry.file_type().unwrap().is_dir() {
168 copy_dir(&entry.path(), &target);
169 } else {
170 std::fs::copy(entry.path(), target).unwrap();
171 }
172 }
173}
crates/notebook/tests/sync.rs+18-18
...@@ -81,7 +81,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin...@@ -81,7 +81,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin
81 assert_eq!(archive.snapshot().unwrap(), working);81 assert_eq!(archive.snapshot().unwrap(), working);
82 assert_eq!(archive.remote_snapshot().unwrap(), remote);82 assert_eq!(archive.remote_snapshot().unwrap(), remote);
83 assert_eq!(archive.pending().unwrap(), pending);83 assert_eq!(archive.pending().unwrap(), pending);
84 assert_eq!(archive.status(published).unwrap(), Some(receipt));84 assert_eq!(archive.status(published).unwrap(), Some(receipt.clone()));
85 assert_eq!(archive.status(attempted).unwrap(), uncertain);85 assert_eq!(archive.status(attempted).unwrap(), uncertain);
86 for edit in &pending {86 for edit in &pending {
87 assert_eq!(87 assert_eq!(
...@@ -89,7 +89,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin...@@ -89,7 +89,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin
89 cache.status(edit.id).unwrap()89 cache.status(edit.id).unwrap()
90 );90 );
91 }91 }
92 let EditStatus::Published { revision } = receipt else {92 let EditStatus::Published { revision } = receipt.clone() else {
93 panic!()93 panic!()
94 };94 };
95 assert_eq!(archive.receipts().unwrap(), [(published, revision)].into());95 assert_eq!(archive.receipts().unwrap(), [(published, revision)].into());
...@@ -158,7 +158,7 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv...@@ -158,7 +158,7 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv
158 let archive = Recovery::open(&path).unwrap();158 let archive = Recovery::open(&path).unwrap();
159 assert_eq!(text(&archive.snapshot().unwrap()).2, "Local");159 assert_eq!(text(&archive.snapshot().unwrap()).2, "Local");
160 assert_eq!(archive.remote_snapshot().unwrap(), changed);160 assert_eq!(archive.remote_snapshot().unwrap(), changed);
161 assert_eq!(archive.status(id).unwrap(), Some(outcome.1));161 assert_eq!(archive.status(id).unwrap(), Some(outcome.1.clone()));
162 assert_eq!(archive.summary().unwrap().conflicts, 1);162 assert_eq!(archive.summary().unwrap().conflicts, 1);
163 assert_eq!(archive.summary().unwrap().uncertain_edits, 0);163 assert_eq!(archive.summary().unwrap().uncertain_edits, 0);
164 drop(archive);164 drop(archive);
...@@ -173,7 +173,7 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv...@@ -173,7 +173,7 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv
173 assert!(Recovery::open(&path).is_err());173 assert!(Recovery::open(&path).is_err());
174 assert_eq!(std::fs::read(&path).unwrap(), before);174 assert_eq!(std::fs::read(&path).unwrap(), before);
175 }175 }
176 assert_eq!(cache.status(id).unwrap(), Some(outcome.1));176 assert_eq!(cache.status(id).unwrap(), Some(outcome.1.clone()));
177 assert_eq!(text(&cache.snapshot().unwrap()).2, "Local");177 assert_eq!(text(&cache.snapshot().unwrap()).2, "Local");
178}178}
179179
...@@ -195,7 +195,7 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() {...@@ -195,7 +195,7 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() {
195 assert!(cache.pending().unwrap().is_empty());195 assert!(cache.pending().unwrap().is_empty());
196 drop(cache);196 drop(cache);
197 let cache = Replica::open(&path).unwrap();197 let cache = Replica::open(&path).unwrap();
198 assert_eq!(cache.status(id).unwrap(), Some(outcome.1));198 assert_eq!(cache.status(id).unwrap(), Some(outcome.1.clone()));
199 assert_eq!(cache.sync_once(&mut server).unwrap(), None);199 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
200 assert_eq!(server.publications, 1);200 assert_eq!(server.publications, 1);
201 assert_eq!(cache.status(id + 1).unwrap(), None);201 assert_eq!(cache.status(id + 1).unwrap(), None);
...@@ -849,17 +849,17 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() {...@@ -849,17 +849,17 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() {
849 assert_eq!(cache.sync_once(&mut server).unwrap(), None);849 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
850 assert_eq!(cache.snapshot().unwrap(), source);850 assert_eq!(cache.snapshot().unwrap(), source);
851 assert_eq!(cache.remote_snapshot().unwrap(), source);851 assert_eq!(cache.remote_snapshot().unwrap(), source);
852 assert_eq!(cache.status(published).unwrap(), Some(receipt));852 assert_eq!(cache.status(published).unwrap(), Some(receipt.clone()));
853 assert_eq!(server.publications, 1);853 assert_eq!(server.publications, 1);
854 assert!(cache.pending().unwrap().is_empty());854 assert!(cache.pending().unwrap().is_empty());
855 let archive = notebook::Recovery::open(directory.path().join("published.sqlite")).unwrap();855 let archive = notebook::Recovery::open(directory.path().join("published.sqlite")).unwrap();
856 assert_eq!(archive.snapshot().unwrap(), published_image);856 assert_eq!(archive.snapshot().unwrap(), published_image);
857 assert_eq!(archive.status(published).unwrap(), Some(receipt));857 assert_eq!(archive.status(published).unwrap(), Some(receipt.clone()));
858 assert_eq!(text(&archive.snapshot().unwrap()).2, "Published Original");858 assert_eq!(text(&archive.snapshot().unwrap()).2, "Published Original");
859 drop(cache);859 drop(cache);
860 let cache = Replica::open(path).unwrap();860 let cache = Replica::open(path).unwrap();
861 assert_eq!(text(&cache.snapshot().unwrap()).2, "Original");861 assert_eq!(text(&cache.snapshot().unwrap()).2, "Original");
862 assert_eq!(cache.status(published).unwrap(), Some(receipt));862 assert_eq!(cache.status(published).unwrap(), Some(receipt.clone()));
863}863}
864864
865#[test]865#[test]
...@@ -892,10 +892,10 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {...@@ -892,10 +892,10 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {
892 let attempts = server.publications;892 let attempts = server.publications;
893 let cache = Replica::open(&path).unwrap();893 let cache = Replica::open(&path).unwrap();
894 let (_, status) = cache.sync_once(&mut server).unwrap().unwrap();894 let (_, status) = cache.sync_once(&mut server).unwrap().unwrap();
895 assert_eq!(cache.status(published).unwrap(), Some(receipt));895 assert_eq!(cache.status(published).unwrap(), Some(receipt.clone()));
896 if uncertain {896 if uncertain {
897 assert!(matches!(status, EditStatus::AwaitingConfirmation { .. }));897 assert!(matches!(status, EditStatus::AwaitingConfirmation { .. }));
898 assert_eq!(Some(status), prior_status);898 assert_eq!(Some(status.clone()), prior_status);
899 assert_eq!(server.publications, attempts);899 assert_eq!(server.publications, attempts);
900 assert_eq!(server.visible, source);900 assert_eq!(server.visible, source);
901 assert_eq!(cache.snapshot().unwrap(), local);901 assert_eq!(cache.snapshot().unwrap(), local);
...@@ -913,8 +913,8 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {...@@ -913,8 +913,8 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {
913 assert_eq!(archive.status(queued).unwrap(), prior_status);913 assert_eq!(archive.status(queued).unwrap(), prior_status);
914 drop(cache);914 drop(cache);
915 let cache = Replica::open(&path).unwrap();915 let cache = Replica::open(&path).unwrap();
916 assert_eq!(cache.status(queued).unwrap(), Some(status));916 assert_eq!(cache.status(queued).unwrap(), Some(status.clone()));
917 assert_eq!(cache.status(published).unwrap(), Some(receipt));917 assert_eq!(cache.status(published).unwrap(), Some(receipt.clone()));
918 }918 }
919}919}
920920
...@@ -934,7 +934,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {...@@ -934,7 +934,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {
934 let conflict = EditStatus::Conflict(ConflictKind::ContentChanged);934 let conflict = EditStatus::Conflict(ConflictKind::ContentChanged);
935 assert_eq!(935 assert_eq!(
936 cache.sync_once(&mut server).unwrap(),936 cache.sync_once(&mut server).unwrap(),
937 Some((head, conflict))937 Some((head, conflict.clone()))
938 );938 );
939 let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap();939 let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap();
940 let dependent = save(&cache, object, end..end, " Later").unwrap();940 let dependent = save(&cache, object, end..end, " Later").unwrap();
...@@ -947,7 +947,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {...@@ -947,7 +947,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {
947 .unwrap();947 .unwrap();
948 drop(cache);948 drop(cache);
949 let cache = Replica::open(&path).unwrap();949 let cache = Replica::open(&path).unwrap();
950 assert_eq!(cache.status(head).unwrap(), Some(conflict));950 assert_eq!(cache.status(head).unwrap(), Some(conflict.clone()));
951 assert_eq!(cache.pending().unwrap(), pending);951 assert_eq!(cache.pending().unwrap(), pending);
952 assert_eq!(cache.snapshot().unwrap(), local);952 assert_eq!(cache.snapshot().unwrap(), local);
953 let reviewed = edited(&source, object, 0..0, "Revised ");953 let reviewed = edited(&source, object, 0..0, "Revised ");
...@@ -956,7 +956,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {...@@ -956,7 +956,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {
956 server.durable.clone_from(&changed);956 server.durable.clone_from(&changed);
957 assert_eq!(957 assert_eq!(
958 cache.sync_once(&mut server).unwrap(),958 cache.sync_once(&mut server).unwrap(),
959 Some((head, conflict))959 Some((head, conflict.clone()))
960 );960 );
961 assert!(961 assert!(
962 matches!(cache.review_page(head, &local, &source, &reviewed),962 matches!(cache.review_page(head, &local, &source, &reviewed),
...@@ -975,7 +975,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {...@@ -975,7 +975,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {
975 Some((actual, EditStatus::Published { .. })) if actual == head));975 Some((actual, EditStatus::Published { .. })) if actual == head));
976 assert_eq!(976 assert_eq!(
977 cache.sync_once(&mut server).unwrap(),977 cache.sync_once(&mut server).unwrap(),
978 Some((dependent, conflict))978 Some((dependent, conflict.clone()))
979 );979 );
980 assert_eq!(server.publications, 2);980 assert_eq!(server.publications, 2);
981 let dependent_local = cache.snapshot().unwrap();981 let dependent_local = cache.snapshot().unwrap();
...@@ -989,11 +989,11 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {...@@ -989,11 +989,11 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {
989 assert!(matches!(cache.sync_once(&mut server).unwrap(),989 assert!(matches!(cache.sync_once(&mut server).unwrap(),
990 Some((actual, EditStatus::Published { .. })) if actual == dependent));990 Some((actual, EditStatus::Published { .. })) if actual == dependent));
991 assert_eq!(text(&server.durable).2, "Revised Original Later Remote");991 assert_eq!(text(&server.durable).2, "Revised Original Later Remote");
992 assert_eq!(cache.status(published).unwrap(), Some(receipt));992 assert_eq!(cache.status(published).unwrap(), Some(receipt.clone()));
993 let archive = notebook::Recovery::open(directory.path().join("conflicted.sqlite")).unwrap();993 let archive = notebook::Recovery::open(directory.path().join("conflicted.sqlite")).unwrap();
994 assert_eq!(archive.snapshot().unwrap(), local);994 assert_eq!(archive.snapshot().unwrap(), local);
995 assert_eq!(archive.pending().unwrap(), pending);995 assert_eq!(archive.pending().unwrap(), pending);
996 assert_eq!(archive.status(head).unwrap(), Some(conflict));996 assert_eq!(archive.status(head).unwrap(), Some(conflict.clone()));
997}997}
998998
999#[test]999#[test]
crates/notebook/tests/sync_formatting.rs+5-2
...@@ -391,10 +391,13 @@ fn uncertain_formatting_attempts_keep_the_original_attempt_and_never_replay() {...@@ -391,10 +391,13 @@ fn uncertain_formatting_attempts_keep_the_original_attempt_and_never_replay() {
391 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));391 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));
392 drop(cache);392 drop(cache);
393 let cache = Replica::open(&path).unwrap();393 let cache = Replica::open(&path).unwrap();
394 assert_eq!(cache.status(id).unwrap(), Some(state));394 assert_eq!(cache.status(id).unwrap(), Some(state.clone()));
395 if matches!(fault, Fault::UnknownBefore) {395 if matches!(fault, Fault::UnknownBefore) {
396 for _ in 0..3 {396 for _ in 0..3 {
397 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, state)));397 assert_eq!(
398 cache.sync_once(&mut server).unwrap(),
399 Some((id, state.clone()))
400 );
398 }401 }
399 assert_eq!(server.publications, 1);402 assert_eq!(server.publications, 1);
400 assert_eq!(server.durable, OUTLINES);403 assert_eq!(server.durable, OUTLINES);
crates/notebook/tests/sync_outline.rs+8-2
...@@ -410,11 +410,17 @@ fn an_uncertain_layout_attempt_confirms_by_revision_or_by_an_equal_remote_page()...@@ -410,11 +410,17 @@ fn an_uncertain_layout_attempt_confirms_by_revision_or_by_an_equal_remote_page()
410 assert_eq!((server.publications, server.confirmations), (1, 1));410 assert_eq!((server.publications, server.confirmations), (1, 1));
411 continue;411 continue;
412 }412 }
413 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, status)));413 assert_eq!(
414 cache.sync_once(&mut server).unwrap(),
415 Some((id, status.clone()))
416 );
414 server.visible = remote_with(&source, space, |page| {417 server.visible = remote_with(&source, space, |page| {
415 resized(216.0, true)(outline_mut(page, outline));418 resized(216.0, true)(outline_mut(page, outline));
416 });419 });
417 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, status)));420 assert_eq!(
421 cache.sync_once(&mut server).unwrap(),
422 Some((id, status.clone()))
423 );
418 assert_eq!(cache.snapshot().unwrap(), local);424 assert_eq!(cache.snapshot().unwrap(), local);
419 server.visible = remote_with(&source, space, |page| {425 server.visible = remote_with(&source, space, |page| {
420 resized(144.0, true)(outline_mut(page, outline));426 resized(144.0, true)(outline_mut(page, outline));
crates/notebook/tests/sync_page.rs+6-3
...@@ -140,7 +140,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {...@@ -140,7 +140,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {
140 cache.export_recovery(&archive).unwrap();140 cache.export_recovery(&archive).unwrap();
141 assert_eq!(141 assert_eq!(
142 Recovery::open(&archive).unwrap().status(id).unwrap(),142 Recovery::open(&archive).unwrap().status(id).unwrap(),
143 Some(attempted)143 Some(attempted.clone())
144 );144 );
145 drop(cache);145 drop(cache);
146 let db = rusqlite::Connection::open(&path).unwrap();146 let db = rusqlite::Connection::open(&path).unwrap();
...@@ -163,7 +163,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {...@@ -163,7 +163,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {
163 assert!(observed[..212] == server.durable[..212]);163 assert!(observed[..212] == server.durable[..212]);
164 assert!(observed[252..] == server.durable[252..]);164 assert!(observed[252..] == server.durable[252..]);
165 } else {165 } else {
166 assert_eq!(result, (id, attempted));166 assert_eq!(result, (id, attempted.clone()));
167 assert_eq!(cache.snapshot().unwrap(), local);167 assert_eq!(cache.snapshot().unwrap(), local);
168 }168 }
169 }169 }
...@@ -219,7 +219,10 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() {...@@ -219,7 +219,10 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() {
219 .contains_key(&revisions[&page.space()])219 .contains_key(&revisions[&page.space()])
220 );220 );
221 let cache = Replica::open(&path).unwrap();221 let cache = Replica::open(&path).unwrap();
222 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, attempted)));222 assert_eq!(
223 cache.sync_once(&mut server).unwrap(),
224 Some((id, attempted.clone()))
225 );
223 assert_eq!(server.confirmations, 0);226 assert_eq!(server.confirmations, 0);
224 assert_eq!(server.publications, 1);227 assert_eq!(server.publications, 1);
225 server.visible = complete;228 server.visible = complete;
crates/notebook/tests/sync_pages.rs+6-3
...@@ -302,7 +302,7 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() {...@@ -302,7 +302,7 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() {
302 cache.export_recovery(&archive).unwrap();302 cache.export_recovery(&archive).unwrap();
303 let recovery = Recovery::open(&archive).unwrap();303 let recovery = Recovery::open(&archive).unwrap();
304 assert_eq!(recovery.pending().unwrap(), queue);304 assert_eq!(recovery.pending().unwrap(), queue);
305 assert_eq!(recovery.status(id).unwrap(), Some(attempted));305 assert_eq!(recovery.status(id).unwrap(), Some(attempted.clone()));
306 assert_eq!(recovery.snapshot().unwrap(), local);306 assert_eq!(recovery.snapshot().unwrap(), local);
307 assert!(307 assert!(
308 cache308 cache
...@@ -315,7 +315,7 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() {...@@ -315,7 +315,7 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() {
315 assert!(matches!(result.1, EditStatus::Published { .. }));315 assert!(matches!(result.1, EditStatus::Published { .. }));
316 assert_eq!(server.confirmations, 1);316 assert_eq!(server.confirmations, 1);
317 } else {317 } else {
318 assert_eq!(result, (id, attempted));318 assert_eq!(result, (id, attempted.clone()));
319 assert_eq!(server.confirmations, 0);319 assert_eq!(server.confirmations, 0);
320 }320 }
321 }321 }
...@@ -366,7 +366,10 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() {...@@ -366,7 +366,10 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() {
366 let cache = Replica::open(&path).unwrap();366 let cache = Replica::open(&path).unwrap();
367 let complete = server.visible.clone();367 let complete = server.visible.clone();
368 server.visible = source;368 server.visible = source;
369 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, attempted)));369 assert_eq!(
370 cache.sync_once(&mut server).unwrap(),
371 Some((id, attempted.clone()))
372 );
370 assert_eq!(server.confirmations, 0);373 assert_eq!(server.confirmations, 0);
371 server.visible = complete;374 server.visible = complete;
372 assert!(matches!(375 assert!(matches!(
crates/notebook/tests/sync_paragraph.rs+5-2
...@@ -732,7 +732,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {...@@ -732,7 +732,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {
732 let state = cache.status(id).unwrap().unwrap();732 let state = cache.status(id).unwrap().unwrap();
733 drop(cache);733 drop(cache);
734 let cache = Replica::open(&path).unwrap();734 let cache = Replica::open(&path).unwrap();
735 assert_eq!(cache.status(id).unwrap(), Some(state));735 assert_eq!(cache.status(id).unwrap(), Some(state.clone()));
736 match fault {736 match fault {
737 Fault::Before => {737 Fault::Before => {
738 assert_eq!(state, EditStatus::Pending);738 assert_eq!(state, EditStatus::Pending);
...@@ -748,7 +748,10 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {...@@ -748,7 +748,10 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {
748 assert_eq!(cache.pending().unwrap(), pending);748 assert_eq!(cache.pending().unwrap(), pending);
749 assert_eq!(cache.snapshot().unwrap(), local);749 assert_eq!(cache.snapshot().unwrap(), local);
750 for _ in 0..3 {750 for _ in 0..3 {
751 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, state)));751 assert_eq!(
752 cache.sync_once(&mut server).unwrap(),
753 Some((id, state.clone()))
754 );
752 }755 }
753 assert_eq!(server.publications, 1);756 assert_eq!(server.publications, 1);
754 assert_eq!(server.durable, BEFORE);757 assert_eq!(server.durable, BEFORE);
crates/notebook/tests/sync_worker.rs+9-9
...@@ -108,7 +108,7 @@ fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() {...@@ -108,7 +108,7 @@ fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() {
108 },108 },
109 move |result| {109 move |result| {
110 observed_tx110 observed_tx
111 .send(result.as_ref().copied().map_err(|error| error.to_string()))111 .send(result.as_ref().cloned().map_err(|error| error.to_string()))
112 .unwrap();112 .unwrap();
113 },113 },
114 )114 )
...@@ -163,7 +163,7 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {...@@ -163,7 +163,7 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {
163 Duration::from_secs(3600),163 Duration::from_secs(3600),
164 move || Ok(shared.clone()),164 move || Ok(shared.clone()),
165 move |result| {165 move |result| {
166 observed_tx.send(*result.as_ref().unwrap()).unwrap();166 observed_tx.send(result.as_ref().unwrap().clone()).unwrap();
167 if first {167 if first {
168 first = false;168 first = false;
169 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();169 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();
...@@ -305,7 +305,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove...@@ -305,7 +305,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove
305 Duration::from_secs(3600),305 Duration::from_secs(3600),
306 move || Ok(shared.clone()),306 move || Ok(shared.clone()),
307 move |result| {307 move |result| {
308 tx.send(*result.as_ref().unwrap()).unwrap();308 tx.send(result.as_ref().unwrap().clone()).unwrap();
309 },309 },
310 )310 )
311 .unwrap();311 .unwrap();
...@@ -377,7 +377,7 @@ fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() {...@@ -377,7 +377,7 @@ fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() {
377 Duration::from_millis(10),377 Duration::from_millis(10),
378 move || Ok(shared.clone()),378 move || Ok(shared.clone()),
379 move |result| {379 move |result| {
380 tx.send(result.as_ref().copied().map_err(|_| ())).unwrap();380 tx.send(result.as_ref().cloned().map_err(|_| ())).unwrap();
381 },381 },
382 )382 )
383 .unwrap();383 .unwrap();
...@@ -401,7 +401,7 @@ fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() {...@@ -401,7 +401,7 @@ fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() {
401 if let Some(previous) = previous {401 if let Some(previous) = previous {
402 assert_eq!(previous, status);402 assert_eq!(previous, status);
403 }403 }
404 previous = Some(status);404 previous = Some(status.clone());
405 }405 }
406 assert!(406 assert!(
407 started.elapsed() >= Duration::from_millis(30),407 started.elapsed() >= Duration::from_millis(30),
...@@ -508,7 +508,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {...@@ -508,7 +508,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {
508 Duration::from_secs(3600),508 Duration::from_secs(3600),
509 move || Ok(shared.clone()),509 move || Ok(shared.clone()),
510 move |result| {510 move |result| {
511 tx.send(*result.as_ref().unwrap()).unwrap();511 tx.send(result.as_ref().unwrap().clone()).unwrap();
512 },512 },
513 )513 )
514 .unwrap();514 .unwrap();
...@@ -535,7 +535,7 @@ fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() {...@@ -535,7 +535,7 @@ fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() {
535 Duration::from_secs(3600),535 Duration::from_secs(3600),
536 move || Ok(shared.clone()),536 move || Ok(shared.clone()),
537 move |result| {537 move |result| {
538 tx.send(*result.as_ref().unwrap()).unwrap();538 tx.send(result.as_ref().unwrap().clone()).unwrap();
539 if first {539 if first {
540 first = false;540 first = false;
541 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();541 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();
...@@ -615,7 +615,7 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() {...@@ -615,7 +615,7 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() {
615 Duration::from_millis(1),615 Duration::from_millis(1),
616 move || Ok(remote.take().expect("Contention caused a reconnect")),616 move || Ok(remote.take().expect("Contention caused a reconnect")),
617 move |result| {617 move |result| {
618 tx.send(result.as_ref().copied().map_err(|error| error.to_string()))618 tx.send(result.as_ref().cloned().map_err(|error| error.to_string()))
619 .unwrap();619 .unwrap();
620 },620 },
621 )621 )
...@@ -678,7 +678,7 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() {...@@ -678,7 +678,7 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() {
678 .unwrap();678 .unwrap();
679 tx.send((second, None)).unwrap();679 tx.send((second, None)).unwrap();
680 }680 }
681 Ok(Some((id, status))) => tx.send((*id, Some(*status))).unwrap(),681 Ok(Some((id, status))) => tx.send((*id, Some(status.clone()))).unwrap(),
682 Ok(None) => {}682 Ok(None) => {}
683 other => panic!("Unexpected worker result: {other:?}"),683 other => panic!("Unexpected worker result: {other:?}"),
684 },684 },
crates/onestore/README.md+4-1
...@@ -43,6 +43,7 @@ harness also accepts `--client-profile release`....@@ -43,6 +43,7 @@ harness also accepts `--client-profile release`.
43| `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes |43| `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes |
44| `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities |44| `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities |
45| `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 |45| `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 |
46| `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 |
46| `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` |47| `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` |
47| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |48| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |
48| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |49| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |
...@@ -50,6 +51,8 @@ harness also accepts `--client-profile release`....@@ -50,6 +51,8 @@ harness also accepts `--client-profile release`.
50| `PageEdit`, `PreparedEdit::pages` | Publish explicitly selected page moves and indentation changes together, preserving page content and historical revisions |51| `PageEdit`, `PreparedEdit::pages` | Publish explicitly selected page moves and indentation changes together, preserving page content and historical revisions |
51| `PreparedEdit::delete_pages_permanently` | Remove explicit pages and their section references atomically while retaining stored revisions |52| `PreparedEdit::delete_pages_permanently` | Remove explicit pages and their section references atomically while retaining stored revisions |
52| `create_table_of_contents` | Create ordered section entries from filenames and file identities |53| `create_table_of_contents` | Create ordered section entries from filenames and file identities |
54| `TocEdit`, `PreparedEdit::table_of_contents` | Add, rename, colour, order and remove a table of contents' section and group entries as one revision |
55| `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 |
53| `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references |56| `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references |
54| `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range across ordinary text runs; publish text, run boundaries and modification time together |57| `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range across ordinary text runs; publish text, run boundaries and modification time together |
55| `Insertion`, `PreparedEdit::insert` | Insert paragraphs into editable containers or positioned outlines into a page, retaining intent identities across rebases |58| `Insertion`, `PreparedEdit::insert` | Insert paragraphs into editable containers or positioned outlines into a page, retaining intent identities across rebases |
...@@ -57,7 +60,7 @@ harness also accepts `--client-profile release`....@@ -57,7 +60,7 @@ harness also accepts `--client-profile release`.
57| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |60| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
58| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |61| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
59| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |62| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |
60| `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, 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 |63| `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 |
61| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |64| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |
62| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |65| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
63| `read_file` | Read a snapshot under whole-file exclusion |66| `read_file` | Read a snapshot under whole-file exclusion |
crates/onestore/src/commit.rs+37
...@@ -71,6 +71,31 @@ impl Drop for FileIo {...@@ -71,6 +71,31 @@ impl Drop for FileIo {
71 }71 }
72}72}
7373
74/// Places a file in its notebook the way OneNote does on adoption: the header's
75/// `guidAncestor` becomes the parent table of contents' file identity and `crcName` the CRC
76/// of `name` (a section's file name, a group's folder name). OneNote re-identifies a file
77/// whose header disagrees with its location, which orphans its TOC entry.
78#[cfg(any(unix, windows))]
79pub fn place_file(path: impl AsRef<Path>, ancestor: [u8; 16], name: &str) -> io::Result<()> {
80 let mut io = FileIo::open(path, true)?;
81 let mut header = [0; 1024];
82 let result = (|| {
83 if io.read_at(0, &mut header)? != header.len() {
84 return Err(io::Error::from(ErrorKind::UnexpectedEof));
85 }
86 crate::Header::parse(&header)
87 .map_err(|error| io::Error::new(ErrorKind::InvalidData, error.message))?;
88 let placement = crate::create::placement(ancestor, name);
89 if io.write_at(128, &placement)? != placement.len() {
90 return Err(io::Error::from(ErrorKind::WriteZero));
91 }
92 io.flush()
93 })();
94 let released = io.release();
95 result?;
96 released
97}
98
74/// Reads a snapshot under the same whole-file exclusion used for commits.99/// Reads a snapshot under the same whole-file exclusion used for commits.
75/// Native writers can expose incomplete graphs to unlocked filesystem reads.100/// Native writers can expose incomplete graphs to unlocked filesystem reads.
76#[cfg(any(unix, windows))]101#[cfg(any(unix, windows))]
...@@ -192,6 +217,18 @@ impl<'a> PreparedEdit<'a> {...@@ -192,6 +217,18 @@ impl<'a> PreparedEdit<'a> {
192 })217 })
193 }218 }
194219
220 /// Applies table-of-contents edits (sections and section groups: add, rename, colour,
221 /// order, remove) as one revision of a `.onetoc2` file.
222 pub fn table_of_contents(
223 source: &'a [u8],
224 edits: &[crate::TocEdit],
225 ) -> Result<Self, crate::Error> {
226 Ok(Self {
227 source,
228 written: crate::toc::edit_table_of_contents(source, edits)?,
229 })
230 }
231
195 /// Permanently removes explicitly selected pages from the section in one transaction.232 /// Permanently removes explicitly selected pages from the section in one transaction.
196 /// Subpages must be selected explicitly; a surviving first subpage becomes top-level.233 /// Subpages must be selected explicitly; a surviving first subpage becomes top-level.
197 /// Creates no recycle-bin copies. Prior revisions remain stored; this is not secure erasure.234 /// Creates no recycle-bin copies. Prior revisions remain stored; this is not secure erasure.
crates/onestore/src/create.rs+10-2
...@@ -101,6 +101,15 @@ struct NewSpace {...@@ -101,6 +101,15 @@ struct NewSpace {
101 objects: Vec<NewObject>,101 objects: Vec<NewObject>,
102}102}
103103
104/// Header bytes 128..148: `guidAncestor` and `crcName`, which OneNote checks against a
105/// file's location and name; a mismatch makes it re-identify the file.
106pub(crate) fn placement(ancestor: [u8; 16], name: &str) -> [u8; 20] {
107 let mut bytes = [0; 20];
108 bytes[..16].copy_from_slice(&ancestor);
109 bytes[16..].copy_from_slice(&(!crc(u32::MAX, &string(name), FileType::Section)).to_le_bytes());
110 bytes
111}
112
104pub(crate) fn current_timestamps() -> Result<(u32, u64)> {113pub(crate) fn current_timestamps() -> Result<(u32, u64)> {
105 let now = SystemTime::now()114 let now = SystemTime::now()
106 .duration_since(UNIX_EPOCH)115 .duration_since(UNIX_EPOCH)
...@@ -537,8 +546,7 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result...@@ -537,8 +546,7 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result
537 output[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes());546 output[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes());
538 }547 }
539 output[96..100].copy_from_slice(&1_u32.to_le_bytes());548 output[96..100].copy_from_slice(&1_u32.to_le_bytes());
540 output[144..148]549 output[128..148].copy_from_slice(&placement([0; 16], file_name));
541 .copy_from_slice(&(!crc(u32::MAX, &string(file_name), FileType::Section)).to_le_bytes());
542 for (at, chunk) in hashed_chunk550 for (at, chunk) in hashed_chunk
543 .map(|chunk| (148, chunk))551 .map(|chunk| (148, chunk))
544 .into_iter()552 .into_iter()
crates/onestore/src/lib.rs+4-1
...@@ -21,6 +21,7 @@ pub mod protected;...@@ -21,6 +21,7 @@ pub mod protected;
21mod revisions;21mod revisions;
22mod snapshot;22mod snapshot;
23mod store;23mod store;
24mod toc;
24mod tree;25mod tree;
25mod write;26mod write;
2627
...@@ -30,7 +31,8 @@ pub use commit::{...@@ -30,7 +31,8 @@ pub use commit::{
30};31};
31#[cfg(any(unix, windows))]32#[cfg(any(unix, windows))]
32pub use commit::{33pub use commit::{
33 commit_file_property, commit_file_text, confirm_file_snapshot, read_file, read_file_limited,34 commit_file_property, commit_file_text, confirm_file_snapshot, place_file, read_file,
35 read_file_limited,
34};36};
35pub use create::{create_section, create_table_of_contents};37pub use create::{create_section, create_table_of_contents};
36pub use edit::replace_text;38pub use edit::replace_text;
...@@ -45,5 +47,6 @@ pub use properties::{IdStream, Property, PropertySets, Value};...@@ -45,5 +47,6 @@ pub use properties::{IdStream, Property, PropertySets, Value};
45pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex};47pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex};
46pub use snapshot::{read_snapshot, read_storage_snapshot};48pub use snapshot::{read_snapshot, read_storage_snapshot};
47pub use store::{Chunk, Error, FileType, Header, Node, NodeList, Reference, Store};49pub use store::{Chunk, Error, FileType, Header, Node, NodeList, Reference, Store};
50pub use toc::TocEdit;
48pub use tree::TreeEdit;51pub use tree::TreeEdit;
49pub use write::replace_property_bytes;52pub use write::replace_property_bytes;
crates/onestore/src/page/link.rs+43
...@@ -62,6 +62,49 @@ pub fn internal_link(section: [u8; 16], base_path: &str, target: LinkTarget<'_>)...@@ -62,6 +62,49 @@ pub fn internal_link(section: [u8; 16], base_path: &str, target: LinkTarget<'_>)
62 url62 url
63}63}
6464
65/// The identities a stored internal link names. OneNote resolves links by identity, so
66/// the title and base path in the URL are hints only.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub struct InternalLink {
69 pub section: [u8; 16],
70 pub page: Option<[u8; 16]>,
71 pub object: Option<ExGuid>,
72}
73
74/// The identities in an `onenote:#…` link, or `None` for any other URL.
75pub fn parse_internal_link(url: &str) -> Option<InternalLink> {
76 let guid = |value: &str| {
77 format!("{value},0")
78 .parse::<ExGuid>()
79 .ok()
80 .map(|id| id.guid)
81 };
82 let parts: Vec<&str> = url.strip_prefix("onenote:#")?.split('&').collect();
83 let value = |name: &str| {
84 parts.iter().find_map(|part| {
85 part.strip_prefix(name)
86 .and_then(|rest| rest.strip_prefix('='))
87 })
88 };
89 let section = guid(value("section-id")?)?;
90 let page = match value("page-id") {
91 Some(id) => Some(guid(id)?),
92 None => None,
93 };
94 let object = match parts.iter().position(|part| part.starts_with("object-id=")) {
95 Some(at) => Some(ExGuid {
96 guid: guid(&parts[at]["object-id=".len()..])?,
97 n: parts.get(at + 1)?.parse().ok()?,
98 }),
99 None => None,
100 };
101 Some(InternalLink {
102 section,
103 page,
104 object,
105 })
106}
107
65/// Percent-encodes a page title the way OneNote does in a link fragment.108/// Percent-encodes a page title the way OneNote does in a link fragment.
66fn encoded(title: &str) -> String {109fn encoded(title: &str) -> String {
67 let mut out = String::new();110 let mut out = String::new();
crates/onestore/src/page/mod.rs+17-6
...@@ -331,6 +331,22 @@ impl Page {...@@ -331,6 +331,22 @@ impl Page {
331 Self::from_revision(revision, id)331 Self::from_revision(revision, id)
332 }332 }
333333
334 /// The notebook-management identity of the page in an active page revision, which
335 /// internal links name.
336 pub fn identity_of(revision: &Revision<'_>) -> Option<[u8; 16]> {
337 revision
338 .roots
339 .get(&2)
340 .and_then(|id| revision.nodes.get(id))
341 .filter(|node| matches!(node.kind, Kind::Metadata { .. }))
342 .and_then(|node| node.extra.first())
343 .and_then(|fields| fields.iter().find(|field| field.id == 0x1c001c30))
344 .and_then(|field| match field.value {
345 FieldValue::Bytes(bytes) => bytes.try_into().ok(),
346 _ => None,
347 })
348 }
349
334 /// The single active page declared in one page object space.350 /// The single active page declared in one page object space.
335 pub fn from_space(document: &Document<'_>, space: ExGuid) -> Result<Self, Error> {351 pub fn from_space(document: &Document<'_>, space: ExGuid) -> Result<Self, Error> {
336 let pages = document.pages_in(space)?;352 let pages = document.pages_in(space)?;
...@@ -365,12 +381,7 @@ impl Page {...@@ -365,12 +381,7 @@ impl Page {
365 .and_then(|node| node.extra.first());381 .and_then(|node| node.extra.first());
366 let mut page = Self {382 let mut page = Self {
367 title: page_title(revision, id).unwrap_or_default().to_owned(),383 title: page_title(revision, id).unwrap_or_default().to_owned(),
368 identity: metadata384 identity: Self::identity_of(revision),
369 .and_then(|fields| fields.iter().find(|field| field.id == 0x1c001c30))
370 .and_then(|field| match field.value {
371 FieldValue::Bytes(bytes) => bytes.try_into().ok(),
372 _ => None,
373 }),
374 created: metadata385 created: metadata
375 .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65))386 .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65))
376 .map(|field| {387 .map(|field| {
crates/onestore/src/page/write.rs+69-11
...@@ -1,7 +1,9 @@...@@ -1,7 +1,9 @@
1//! Publishes an edited page model by lowering the difference from the stored page onto1//! Publishes an edited page model by lowering the difference from the stored page onto
2//! the typed writers, then squashing their transactions into one revision per space.2//! the typed writers, then squashing their transactions into one revision per space.
33
4use super::{Image, Ink, Math, Outline, Page, PageObject, PageParagraph, ParagraphContent, Table};4use super::{
5 Attachment, Image, Ink, Math, Outline, Page, PageObject, PageParagraph, ParagraphContent, Table,
6};
5use crate::{7use crate::{
6 Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets,8 Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets,
7 RevisionIndex, Store, TextAttribute, TreeEdit, Value,9 RevisionIndex, Store, TextAttribute, TreeEdit, Value,
...@@ -650,8 +652,8 @@ impl Lowering<'_> {...@@ -650,8 +652,8 @@ impl Lowering<'_> {
650 }652 }
651 }653 }
652 (ParagraphContent::Attachment(a), ParagraphContent::Attachment(b)) => {654 (ParagraphContent::Attachment(a), ParagraphContent::Attachment(b)) => {
653 if a != b {655 if a.id != b.id {
654 return Err(invalid("A stored attachment cannot be edited"));656 return Err(invalid("Attachment identity cannot change"));
655 }657 }
656 }658 }
657 _ => return Err(invalid("Paragraph content type cannot change")),659 _ => return Err(invalid("Paragraph content type cannot change")),
...@@ -904,21 +906,24 @@ impl Lowering<'_> {...@@ -904,21 +906,24 @@ impl Lowering<'_> {
904 let ParagraphContent::Attachment(attachment) = &paragraph.content else {906 let ParagraphContent::Attachment(attachment) = &paragraph.content else {
905 continue;907 continue;
906 };908 };
909 let name = attachment.filename.as_str();
910 if name.is_empty() || name.contains(['\0', '/', '\\']) {
911 return Err(invalid(
912 "An attachment needs a file name without path separators",
913 ));
914 }
907 if let Some(previous) = old.paragraphs.get(paragraph_id) {915 if let Some(previous) = old.paragraphs.get(paragraph_id) {
908 if previous.content != paragraph.content {916 let ParagraphContent::Attachment(stored) = &previous.content else {
909 return Err(invalid("A stored attachment cannot be edited"));917 return Err(invalid("Paragraph content type cannot change"));
918 };
919 if stored != attachment {
920 self.edit_attachment(stored, attachment)?;
910 }921 }
911 continue;922 continue;
912 }923 }
913 let Some(bytes) = &attachment.bytes else {924 let Some(bytes) = &attachment.bytes else {
914 return Err(invalid("A new attachment needs its payload"));925 return Err(invalid("A new attachment needs its payload"));
915 };926 };
916 let name = attachment.filename.as_str();
917 if name.is_empty() || name.contains(['\0', '/', '\\']) {
918 return Err(invalid(
919 "An attachment needs a file name without path separators",
920 ));
921 }
922 let extension = name927 let extension = name
923 .rfind('.')928 .rfind('.')
924 .filter(|dot| *dot > 0)929 .filter(|dot| *dot > 0)
...@@ -1314,6 +1319,59 @@ impl Lowering<'_> {...@@ -1314,6 +1319,59 @@ impl Lowering<'_> {
1314 /// Writes a moved, resized or described picture the way OneNote stores one: the1319 /// Writes a moved, resized or described picture the way OneNote stores one: the
1315 /// position, the layout width and height with the user flag and the description on1320 /// position, the layout width and height with the user flag and the description on
1316 /// the picture object, leaving the intrinsic size alone.1321 /// the picture object, leaving the intrinsic size alone.
1322 /// A stored attachment keeps its payload and preview; its shown name, recorded source
1323 /// path and icon size change in place, as OneNote's rename does.
1324 fn edit_attachment(
1325 &mut self,
1326 stored: &Attachment,
1327 attachment: &Attachment,
1328 ) -> Result<(), Error> {
1329 let mut values: Values = vec![(
1330 0x14001d7a,
1331 crate::create::current_timestamps()?
1332 .0
1333 .to_le_bytes()
1334 .to_vec(),
1335 )];
1336 let mut removed = Vec::new();
1337 if attachment.filename != stored.filename {
1338 let name = crate::create::string(&attachment.filename);
1339 values.push((0x1c001c22, name.clone()));
1340 values.push((0x1c001d9c, name));
1341 }
1342 if attachment.source_path != stored.source_path {
1343 match &attachment.source_path {
1344 Some(path) => values.push((0x1c001d9d, crate::create::string(path))),
1345 None => removed.push(0x1c001d9d),
1346 }
1347 }
1348 if attachment.size != stored.size {
1349 match attachment.size {
1350 Some([width, height]) => {
1351 if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) {
1352 return Err(invalid("Attachment icon size must be positive"));
1353 }
1354 values.push((0x140034cd, (width / 36.0).to_le_bytes().to_vec()));
1355 values.push((0x140034ce, (height / 36.0).to_le_bytes().to_vec()));
1356 }
1357 None => removed.extend([0x140034cd, 0x140034ce]),
1358 }
1359 }
1360 let (space, object) = (self.space, self.id(attachment.id));
1361 self.apply(|current| {
1362 crate::write::write_revision(current, space, |raw| {
1363 let mut node = PropertyObject::from_object(&raw.objects[&object])?;
1364 node.remove(&removed)?;
1365 let values: Vec<(u32, &[u8])> = values
1366 .iter()
1367 .map(|(id, bytes)| (*id, bytes.as_slice()))
1368 .collect();
1369 node.set(&values)?;
1370 Ok(BTreeMap::from([(object, node)]))
1371 })
1372 })
1373 }
1374
1317 fn edit_image(&mut self, stored: &Image, image: &Image) -> Result<(), Error> {1375 fn edit_image(&mut self, stored: &Image, image: &Image) -> Result<(), Error> {
1318 picture_fixed_fields(stored, image)?;1376 picture_fixed_fields(stored, image)?;
1319 let mut values: Values = vec![(1377 let mut values: Values = vec![(
crates/onestore/src/store.rs+9-1
...@@ -71,6 +71,10 @@ impl Chunk {...@@ -71,6 +71,10 @@ impl Chunk {
71pub struct Header {71pub struct Header {
72 pub file_type: FileType,72 pub file_type: FileType,
73 pub file_id: [u8; 16],73 pub file_id: [u8; 16],
74 /// The parent table of contents' file identity; zero outside a notebook.
75 pub ancestor: [u8; 16],
76 /// CRC of the file name (a section's file name, a group's folder name).
77 pub name_crc: u32,
74 pub transaction_count: u32,78 pub transaction_count: u32,
75 pub expected_length: u64,79 pub expected_length: u64,
76 pub version_id: [u8; 16],80 pub version_id: [u8; 16],
...@@ -166,7 +170,9 @@ impl Header {...@@ -166,7 +170,9 @@ impl Header {
166 message: "Missing committed transaction",170 message: "Missing committed transaction",
167 });171 });
168 }172 }
169 c.take(48)?;173 c.take(28)?;
174 let ancestor = c.read()?;
175 let name_crc = u32::from_le_bytes(c.read()?);
170 let hashed_chunks = c.chunk()?;176 let hashed_chunks = c.chunk()?;
171 let transaction_log = c.chunk()?;177 let transaction_log = c.chunk()?;
172 let root = c.chunk()?;178 let root = c.chunk()?;
...@@ -179,6 +185,8 @@ impl Header {...@@ -179,6 +185,8 @@ impl Header {
179 Ok(Self {185 Ok(Self {
180 file_type,186 file_type,
181 file_id,187 file_id,
188 ancestor,
189 name_crc,
182 transaction_count,190 transaction_count,
183 expected_length,191 expected_length,
184 version_id,192 version_id,
crates/onestore/src/toc.rs created+226
...@@ -0,0 +1,226 @@
1//! Edits to a notebook's table of contents: the documented `jcidPersistablePropertyContainerForTOC`
2//! root lists section and section-group entries, each carrying a file identity, an ordering
3//! number, a filename and (for sections) a colour.
4
5use crate::{
6 Error, ExGuid, PropertySets, Store, Value,
7 document::{Document, Kind},
8 revisions::RevisionIndex,
9 write::{PropertyObject, fresh_guid, write_revision},
10};
11use std::collections::BTreeMap;
12
13fn invalid(message: &'static str) -> Error {
14 Error { offset: 0, message }
15}
16
17/// One change to a table of contents, addressed by the entry's file identity.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum TocEdit {
20 /// A section (`name.one`) or, with `group`, a section-group folder appended last.
21 Add {
22 filename: String,
23 identity: [u8; 16],
24 group: bool,
25 },
26 Rename {
27 identity: [u8; 16],
28 filename: String,
29 },
30 /// Section colour as COLORREF; `None` restores OneNote's "undefined" 0xffffffff.
31 Color {
32 identity: [u8; 16],
33 color: Option<u32>,
34 },
35 /// Every entry, in the wanted order; entries left out keep their relative order after these.
36 Order(Vec<[u8; 16]>),
37 Remove {
38 identity: [u8; 16],
39 },
40}
41
42fn component(name: &str) -> bool {
43 !name.is_empty() && !name.contains(['/', '\\', '\0']) && name != "." && name != ".."
44}
45
46/// The table of contents with `edits` applied in order.
47type PropertyChange = (u32, Option<Vec<u8>>);
48
49pub(crate) fn edit_table_of_contents(source: &[u8], edits: &[TocEdit]) -> Result<Vec<u8>, Error> {
50 let store = Store::parse(source)?;
51 if store.header.file_type != crate::FileType::TableOfContents {
52 return Err(invalid("Choose a table-of-contents file"));
53 }
54 let index = RevisionIndex::parse(&store)?;
55 let document = Document::parse(&index)?;
56 let space = document.root;
57 let revision = document.active(space)?;
58 let root = *revision
59 .roots
60 .get(&1)
61 .ok_or_else(|| invalid("Missing notebook TOC root"))?;
62 let Some(Kind::Toc { entries, .. }) = revision.nodes.get(&root).map(|node| &node.kind) else {
63 return Err(invalid("Missing notebook TOC root"));
64 };
65 // Entries in stored order with their identities and filenames.
66 let mut listed: Vec<(ExGuid, [u8; 16], String)> = Vec::new();
67 for id in entries {
68 let Some(Kind::Toc {
69 filename,
70 identity: Some(identity),
71 ..
72 }) = revision.nodes.get(id).map(|node| &node.kind)
73 else {
74 return Err(invalid("Incomplete notebook TOC reference"));
75 };
76 listed.push((*id, *identity, filename.clone().unwrap_or_default()));
77 }
78 let mut created: BTreeMap<ExGuid, PropertyObject> = BTreeMap::new();
79 // Property values to set (or remove, `None`) on each existing entry.
80 let mut changes: BTreeMap<ExGuid, Vec<PropertyChange>> = BTreeMap::new();
81 for edit in edits {
82 let position = |identity: &[u8; 16]| {
83 listed
84 .iter()
85 .position(|(_, known, _)| known == identity)
86 .ok_or_else(|| invalid("The table of contents has no entry with that identity"))
87 };
88 match edit {
89 TocEdit::Add {
90 filename,
91 identity,
92 group,
93 } => {
94 if !component(filename)
95 || (!group && !filename.to_ascii_lowercase().ends_with(".one"))
96 || *identity == [0; 16]
97 {
98 return Err(invalid(
99 "A TOC entry needs a section filename or group folder name and an identity",
100 ));
101 }
102 if listed.iter().any(|(_, known, name)| {
103 known == identity || name.eq_ignore_ascii_case(filename)
104 }) {
105 return Err(invalid(
106 "The table of contents already lists that section or group",
107 ));
108 }
109 let id = ExGuid {
110 guid: fresh_guid()?,
111 n: 10,
112 };
113 let mut values: Vec<(u32, Vec<u8>)> = vec![
114 (0x1c001d94, identity.to_vec()),
115 (0x14001cb9, 0u32.to_le_bytes().to_vec()),
116 (0x1c001d6b, crate::create::string(filename)),
117 ];
118 if !group {
119 values.push((0x14001cbe, vec![0xff; 4]));
120 }
121 let mut object = PropertyObject {
122 jcid: 0x20001,
123 bytes: crate::create::properties(&values)?,
124 global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])),
125 };
126 object.reference(id)?;
127 created.insert(id, object);
128 listed.push((id, *identity, filename.clone()));
129 }
130 TocEdit::Rename { identity, filename } => {
131 if !component(filename) {
132 return Err(invalid(
133 "A TOC entry needs a filename without path separators",
134 ));
135 }
136 let at = position(identity)?;
137 if listed
138 .iter()
139 .enumerate()
140 .any(|(i, (_, _, name))| i != at && name.eq_ignore_ascii_case(filename))
141 {
142 return Err(invalid("The table of contents already lists that name"));
143 }
144 let id = listed[at].0;
145 listed[at].2 = filename.clone();
146 changes
147 .entry(id)
148 .or_default()
149 .push((0x1c001d6b, Some(crate::create::string(filename))));
150 }
151 TocEdit::Color { identity, color } => {
152 let id = listed[position(identity)?].0;
153 changes.entry(id).or_default().push((
154 0x14001cbe,
155 Some(color.unwrap_or(0xffff_ffff).to_le_bytes().to_vec()),
156 ));
157 }
158 TocEdit::Order(wanted) => {
159 let mut ordered: Vec<(ExGuid, [u8; 16], String)> = Vec::new();
160 for identity in wanted {
161 let at = position(identity)?;
162 if ordered.iter().any(|(_, known, _)| known == identity) {
163 return Err(invalid("An entry is ordered twice"));
164 }
165 ordered.push(listed[at].clone());
166 }
167 let rest: Vec<_> = listed
168 .iter()
169 .filter(|(_, known, _)| !wanted.contains(known))
170 .cloned()
171 .collect();
172 ordered.extend(rest);
173 listed = ordered;
174 }
175 TocEdit::Remove { identity } => {
176 let at = position(identity)?;
177 let (id, _, _) = listed.remove(at);
178 changes.remove(&id);
179 created.remove(&id);
180 }
181 }
182 }
183 let listed = listed;
184 write_revision(source, space, |raw| {
185 let mut changed = BTreeMap::new();
186 let mut root_object = PropertyObject::from_object(&raw.objects[&root])?;
187 let mut references = Vec::new();
188 for (order, (id, _, _)) in listed.iter().enumerate() {
189 let mut object = match created.remove(id) {
190 Some(object) => object,
191 None => PropertyObject::from_object(&raw.objects[id])?,
192 };
193 let stored_order = PropertySets::parse(&object.bytes)?.sets[0]
194 .iter()
195 .find(|p| p.id == 0x14001cb9)
196 .and_then(|p| match p.value {
197 Value::Bytes(b) => b.try_into().ok().map(u32::from_le_bytes),
198 _ => None,
199 });
200 let mut updates: Vec<(u32, Vec<u8>)> = Vec::new();
201 if stored_order != Some(order as u32 + 1) {
202 updates.push((0x14001cb9, (order as u32 + 1).to_le_bytes().to_vec()));
203 }
204 for (property, value) in changes.get(id).into_iter().flatten() {
205 match value {
206 Some(bytes) => updates.push((*property, bytes.clone())),
207 None => object.remove(&[*property])?,
208 }
209 }
210 let updates: Vec<(u32, &[u8])> = updates
211 .iter()
212 .map(|(id, bytes)| (*id, bytes.as_slice()))
213 .collect();
214 if !updates.is_empty() {
215 object.set(&updates)?;
216 }
217 references.extend(root_object.reference(*id)?);
218 if !updates.is_empty() || created.contains_key(id) || !raw.objects.contains_key(id) {
219 changed.insert(*id, object);
220 }
221 }
222 root_object.set(&[(0x24001cf6, &references)])?;
223 changed.insert(root, root_object);
224 Ok(changed)
225 })
226}
crates/onestore/src/write.rs+18-10
...@@ -838,6 +838,7 @@ pub(crate) fn write_revisions_with_payloads(...@@ -838,6 +838,7 @@ pub(crate) fn write_revisions_with_payloads(
838 )838 )
839 }839 }
840 };840 };
841 let existing: BTreeSet<ExGuid> = revision.objects.keys().copied().collect();
841 let reachable = if rid.is_some() {842 let reachable = if rid.is_some() {
842 revision.reachable()?843 revision.reachable()?
843 } else {844 } else {
...@@ -859,10 +860,10 @@ pub(crate) fn write_revisions_with_payloads(...@@ -859,10 +860,10 @@ pub(crate) fn write_revisions_with_payloads(
859 message: "An existing object's type cannot be changed",860 message: "An existing object's type cannot be changed",
860 });861 });
861 }862 }
862 } else if !is_section {863 } else if !is_section && replacement.jcid != 0x20001 {
863 return Err(Error {864 return Err(Error {
864 offset: 0,865 offset: 0,
865 message: "New objects require a section file",866 message: "New objects in a table of contents are its entries",
866 });867 });
867 }868 }
868 if replacement.global_ids.keys().any(|i| *i > 0xffffff) {869 if replacement.global_ids.keys().any(|i| *i > 0xffffff) {
...@@ -1006,10 +1007,16 @@ pub(crate) fn write_revisions_with_payloads(...@@ -1006,10 +1007,16 @@ pub(crate) fn write_revisions_with_payloads(
1006 .iter()1007 .iter()
1007 .filter(|(id, _)| checkpoint || changed.contains(id))1008 .filter(|(id, _)| checkpoint || changed.contains(id))
1008 .collect();1009 .collect();
1009 let toc_table = if checkpoint && !is_section {1010 // A table-of-contents manifest has one global id table (sections group objects,
1010 if selected.iter().any(|(_, object)| {1011 // each group with its own table); OneNote resolves every node against it.
1011 object.jcid != 0x20001 || !matches!(object.data, ObjectData::Properties(_))1012 let toc_table = if is_section {
1012 }) {1013 None
1014 } else {
1015 if checkpoint
1016 && selected.iter().any(|(_, object)| {
1017 object.jcid != 0x20001 || !matches!(object.data, ObjectData::Properties(_))
1018 })
1019 {
1013 return Err(Error {1020 return Err(Error {
1014 offset: 0,1021 offset: 0,
1015 message: "TOC checkpoint requires table-of-contents property objects",1022 message: "TOC checkpoint requires table-of-contents property objects",
...@@ -1032,8 +1039,6 @@ pub(crate) fn write_revisions_with_payloads(...@@ -1032,8 +1039,6 @@ pub(crate) fn write_revisions_with_payloads(
1032 .map(|(i, guid)| (u32::try_from(i).unwrap(), guid))1039 .map(|(i, guid)| (u32::try_from(i).unwrap(), guid))
1033 .collect::<BTreeMap<_, _>>(),1040 .collect::<BTreeMap<_, _>>(),
1034 )1041 )
1035 } else {
1036 None
1037 };1042 };
1038 let mut groups = BTreeMap::<_, Vec<_>>::new();1043 let mut groups = BTreeMap::<_, Vec<_>>::new();
1039 for (id, object) in selected {1044 for (id, object) in selected {
...@@ -1144,10 +1149,13 @@ pub(crate) fn write_revisions_with_payloads(...@@ -1144,10 +1149,13 @@ pub(crate) fn write_revisions_with_payloads(
1144 length: u64::try_from(bytes.len()).unwrap(),1149 length: u64::try_from(bytes.len()).unwrap(),
1145 }1150 }
1146 };1151 };
1152 // A table-of-contents object is declared when the revision is a
1153 // checkpoint or the object is new, and revised otherwise.
1154 let declared = checkpoint || !existing.contains(&id);
1147 if is_section {1155 if is_section {
1148 declaration.extend_from_slice(&object.jcid.to_le_bytes());1156 declaration.extend_from_slice(&object.jcid.to_le_bytes());
1149 declaration.push(flags);1157 declaration.push(flags);
1150 } else if checkpoint {1158 } else if declared {
1151 let body = 1_u64 | (u64::from(flags & 1) << 16);1159 let body = 1_u64 | (u64::from(flags & 1) << 16);
1152 declaration.extend_from_slice(&body.to_le_bytes()[..6]);1160 declaration.extend_from_slice(&body.to_le_bytes()[..6]);
1153 } else {1161 } else {
...@@ -1161,7 +1169,7 @@ pub(crate) fn write_revisions_with_payloads(...@@ -1161,7 +1169,7 @@ pub(crate) fn write_revisions_with_payloads(
1161 group.push(node(1169 group.push(node(
1162 if is_section {1170 if is_section {
1163 if readonly { 0xc5 } else { 0xa5 }1171 if readonly { 0xc5 } else { 0xa5 }
1164 } else if checkpoint {1172 } else if declared {
1165 0x2e1173 0x2e
1166 } else {1174 } else {
1167 0x421175 0x42
crates/onestore/tests/page_attachments.rs+29-3
...@@ -169,7 +169,7 @@ fn an_attachment_inserted_on_a_fresh_page_reads_back_and_can_be_removed() {...@@ -169,7 +169,7 @@ fn an_attachment_inserted_on_a_fresh_page_reads_back_and_can_be_removed() {
169}169}
170170
171#[test]171#[test]
172fn attachments_need_a_file_name_and_stored_ones_stay_fixed() {172fn attachments_need_a_file_name_and_stored_ones_are_renamed_in_place() {
173 let source = onestore::create_section("files.one", "Text", "Author").unwrap();173 let source = onestore::create_section("files.one", "Text", "Author").unwrap();
174 let (space, before) = first_page(&source);174 let (space, before) = first_page(&source);
175 let mut after = before.clone();175 let mut after = before.clone();
...@@ -192,12 +192,38 @@ fn attachments_need_a_file_name_and_stored_ones_stay_fixed() {...@@ -192,12 +192,38 @@ fn attachments_need_a_file_name_and_stored_ones_stay_fixed() {
192 if let PageObject::Outline(outline) = object {192 if let PageObject::Outline(outline) = object {
193 for paragraph in &mut outline.paragraphs {193 for paragraph in &mut outline.paragraphs {
194 if let ParagraphContent::Attachment(attachment) = &mut paragraph.content {194 if let ParagraphContent::Attachment(attachment) = &mut paragraph.content {
195 attachment.filename = "renamed.txt".into();195 attachment.filename = "renamed \u{1f980}.txt".into();
196 attachment.source_path = Some("C:\\inputs\\renamed \u{1f980}.txt".into());
196 found += 1;197 found += 1;
197 }198 }
198 }199 }
199 }200 }
200 }201 }
201 assert_eq!(found, 1);202 assert_eq!(found, 1);
202 assert!(PreparedEdit::page(NATIVE, space, &renamed, AUTHOR).is_err());203 let written = PreparedEdit::page(NATIVE, space, &renamed, AUTHOR).unwrap();
204 let stored = page_in(written.as_bytes(), space);
205 let (before, after) = (attachments(&native)[0], attachments(&stored)[0]);
206 assert_eq!(after.filename, "renamed \u{1f980}.txt");
207 assert_eq!(
208 after.source_path.as_deref(),
209 Some("C:\\inputs\\renamed \u{1f980}.txt")
210 );
211 assert_eq!((after.id, after.size), (before.id, before.size));
212 assert_eq!(after.bytes, before.bytes);
213 assert_eq!(after.preview, before.preview);
214 if let Some(directory) = std::env::var_os("ONESTORE_ATTACHMENT_RENAME_EXPORT") {
215 let directory = std::path::PathBuf::from(directory);
216 std::fs::create_dir_all(&directory).unwrap();
217 std::fs::write(directory.join("synthetic.one"), written.as_bytes()).unwrap();
218 let file_id = Store::parse(written.as_bytes()).unwrap().header.file_id;
219 std::fs::write(
220 directory.join("Open Notebook.onetoc2"),
221 onestore::create_table_of_contents(
222 "Open Notebook.onetoc2",
223 &[("synthetic.one", file_id)],
224 )
225 .unwrap(),
226 )
227 .unwrap();
228 }
203}229}
crates/onestore/tests/page_links.rs+27-1
...@@ -3,7 +3,7 @@ use onestore::{...@@ -3,7 +3,7 @@ use onestore::{
3 document::Document,3 document::Document,
4 page::{4 page::{
5 Page, PageObject, PageParagraph, Paragraph,5 Page, PageObject, PageParagraph, Paragraph,
6 link::{LinkTarget, internal_link},6 link::{InternalLink, LinkTarget, internal_link, parse_internal_link},
7 text::Edit,7 text::Edit,
8 },8 },
9};9};
...@@ -258,6 +258,32 @@ fn internal_links_match_what_onenote_stores() {...@@ -258,6 +258,32 @@ fn internal_links_match_what_onenote_stores() {
258 ),258 ),
259 "onenote:#A%20b%2Fc&section-id={00000000-0000-0000-0000-000000000000}&page-id={00000000-0000-0000-0000-000000000000}&end&base-path=p"259 "onenote:#A%20b%2Fc&section-id={00000000-0000-0000-0000-000000000000}&page-id={00000000-0000-0000-0000-000000000000}&end&base-path=p"
260 );260 );
261 let identity = target.identity.unwrap();
262 assert_eq!(
263 stored[1..]
264 .iter()
265 .map(|url| parse_internal_link(url))
266 .collect::<Vec<_>>(),
267 [
268 Some(InternalLink {
269 section,
270 page: Some(identity),
271 object: None
272 }),
273 Some(InternalLink {
274 section,
275 page: Some(identity),
276 object: Some(paragraph)
277 }),
278 Some(InternalLink {
279 section,
280 page: None,
281 object: None
282 }),
283 ]
284 );
285 assert_eq!(parse_internal_link(&stored[0]), None);
286 assert_eq!(parse_internal_link("onenote:#x&page-id={0}"), None);
261}287}
262288
263/// `ONESTORE_INTERNAL_LINK_EXPORT` names a new directory receiving the candidate for a cold289/// `ONESTORE_INTERNAL_LINK_EXPORT` names a new directory receiving the candidate for a cold
crates/onestore/tests/toc.rs created+108
...@@ -0,0 +1,108 @@
1use onestore::{PreparedEdit, RevisionIndex, Store, TocEdit, document::Document};
2
3fn entries(bytes: &[u8]) -> Vec<(String, [u8; 16], u32, Option<u32>)> {
4 let store = Store::parse(bytes).unwrap();
5 assert!(store.checksum_mismatches.is_empty());
6 let index = RevisionIndex::parse(&store).unwrap();
7 let document = Document::parse(&index).unwrap();
8 let revision = document.active(document.root).unwrap();
9 let root = &revision.nodes[&revision.roots[&1]];
10 let onestore::document::Kind::Toc { entries, .. } = &root.kind else {
11 panic!()
12 };
13 entries
14 .iter()
15 .map(|id| match &revision.nodes[id].kind {
16 onestore::document::Kind::Toc {
17 filename,
18 identity,
19 order,
20 color,
21 ..
22 } => (
23 filename.clone().unwrap(),
24 identity.unwrap(),
25 order.unwrap(),
26 *color,
27 ),
28 _ => panic!(),
29 })
30 .collect()
31}
32
33#[test]
34fn sections_and_groups_are_added_renamed_coloured_ordered_and_removed() {
35 let a = [1; 16];
36 let b = [2; 16];
37 let toc = onestore::create_table_of_contents(
38 "Open Notebook.onetoc2",
39 &[("Alpha.one", a), ("Beta.one", b)],
40 )
41 .unwrap();
42 let c = [3; 16];
43 let group = [4; 16];
44 let written = PreparedEdit::table_of_contents(
45 &toc,
46 &[
47 TocEdit::Add {
48 filename: "Gamma.one".into(),
49 identity: c,
50 group: false,
51 },
52 TocEdit::Add {
53 filename: "Archive".into(),
54 identity: group,
55 group: true,
56 },
57 ],
58 )
59 .unwrap();
60 assert_eq!(
61 entries(written.as_bytes()),
62 [
63 ("Alpha.one".to_owned(), a, 1, Some(0xffff_ffff)),
64 ("Beta.one".to_owned(), b, 2, Some(0xffff_ffff)),
65 ("Gamma.one".to_owned(), c, 3, Some(0xffff_ffff)),
66 ("Archive".to_owned(), group, 4, None),
67 ]
68 );
69 let again = PreparedEdit::table_of_contents(
70 written.as_bytes(),
71 &[
72 TocEdit::Rename {
73 identity: b,
74 filename: "Renamed.one".into(),
75 },
76 TocEdit::Color {
77 identity: c,
78 color: Some(0x00d7ff),
79 },
80 TocEdit::Order(vec![group, c]),
81 TocEdit::Remove { identity: a },
82 ],
83 )
84 .unwrap();
85 assert_eq!(
86 entries(again.as_bytes()),
87 [
88 ("Archive".to_owned(), group, 1, None),
89 ("Gamma.one".to_owned(), c, 2, Some(0x00d7ff)),
90 ("Renamed.one".to_owned(), b, 3, Some(0xffff_ffff)),
91 ]
92 );
93 assert!(
94 PreparedEdit::table_of_contents(
95 again.as_bytes(),
96 &[TocEdit::Add {
97 filename: "renamed.one".into(),
98 identity: [9; 16],
99 group: false
100 }]
101 )
102 .is_err()
103 );
104 assert!(
105 PreparedEdit::table_of_contents(again.as_bytes(), &[TocEdit::Remove { identity: a }])
106 .is_err()
107 );
108}
tools/native/notebook-reorder.ps1 created+50
...@@ -0,0 +1,50 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
6$app = New-Object -ComObject OneNote.Application
7$notebookId = ''
8function Read-Tree {
9 $deadline = [DateTime]::UtcNow.AddSeconds(60)
10 do {
11 $hierarchy = ''
12 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
13 [xml]$tree = $hierarchy
14 if (@($tree.SelectNodes('//*[local-name()="Section"]')).Count -ge 4) { return $hierarchy }
15 Start-Sleep -Milliseconds 500
16 } while ([DateTime]::UtcNow -lt $deadline)
17 throw 'The notebook did not finish loading.'
18}
19try {
20 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
21 $hierarchy = Read-Tree
22 [IO.File]::WriteAllText((Join-Path $Root 'reorder-00-opened.xml'), $hierarchy, [Text.Encoding]::UTF8)
23 [xml]$tree = $hierarchy
24 $manager = New-Object Xml.XmlNamespaceManager($tree.NameTable)
25 $manager.AddNamespace('one', $namespace)
26 $notebookNode = $tree.DocumentElement
27 $sections = @($notebookNode.SelectNodes('one:Section', $manager))
28 if ($sections.Count -lt 3) { throw "Expected three root sections, found $($sections.Count)." }
29 $last = $sections[$sections.Count - 1]
30 [void]$notebookNode.RemoveChild($last)
31 [void]$notebookNode.InsertBefore($last, $sections[0])
32 [IO.File]::WriteAllText((Join-Path $Root 'reorder-01-update.xml'), $tree.OuterXml, [Text.Encoding]::UTF8)
33 $app.UpdateHierarchy($tree.OuterXml, 1)
34 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
35 [IO.File]::WriteAllText((Join-Path $Root 'reorder-02-reordered.xml'), $hierarchy, [Text.Encoding]::UTF8)
36 $app.SyncHierarchy($notebookId)
37 $app.CloseNotebook($notebookId, $false)
38 $notebookId = ''
39 Copy-Item -Recurse (Join-Path $Root 'notebook') (Join-Path $Root 'notebook-reordered')
40 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
41 $hierarchy = Read-Tree
42 [IO.File]::WriteAllText((Join-Path $Root 'reorder-03-reopened.xml'), $hierarchy, [Text.Encoding]::UTF8)
43 $app.CloseNotebook($notebookId, $false)
44 $notebookId = ''
45} finally {
46 if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
47 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
48 [GC]::Collect()
49 [GC]::WaitForPendingFinalizers()
50}
tools/native/notebook-structure.ps1 created+71
...@@ -0,0 +1,71 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
6$app = New-Object -ComObject OneNote.Application
7$notebookId = ''
8$log = @()
9try {
10 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
11 $hierarchy = ''
12 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
13 [IO.File]::WriteAllText((Join-Path $Root 'structure-00-opened.xml'), $hierarchy, [Text.Encoding]::UTF8)
14 $second = ''
15 $app.OpenHierarchy('Second.one', $notebookId, [ref]$second, 3)
16 $third = ''
17 $app.OpenHierarchy('Third.one', $notebookId, [ref]$third, 3)
18 $group = ''
19 $app.OpenHierarchy('Group', $notebookId, [ref]$group, 2)
20 $inner = ''
21 $app.OpenHierarchy('Inner.one', $group, [ref]$inner, 3)
22 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
23 [IO.File]::WriteAllText((Join-Path $Root 'structure-01-created.xml'), $hierarchy, [Text.Encoding]::UTF8)
24 $app.SyncHierarchy($notebookId)
25 $app.CloseNotebook($notebookId, $false)
26 $notebookId = ''
27 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
28 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
29 [xml]$tree = $hierarchy
30 $manager = New-Object Xml.XmlNamespaceManager($tree.NameTable)
31 $manager.AddNamespace('one', $namespace)
32 $sections = @($tree.SelectNodes('//one:Section', $manager))
33 foreach ($section in $sections) {
34 switch ($section.GetAttribute('name')) {
35 'Second' { $section.SetAttribute('name', 'Renamed'); $section.SetAttribute('color', '#FFD75E') }
36 'Third' { $section.SetAttribute('color', '#B7C997') }
37 }
38 }
39 [IO.File]::WriteAllText((Join-Path $Root 'structure-02-update.xml'), $tree.OuterXml, [Text.Encoding]::UTF8)
40 $app.UpdateHierarchy($tree.OuterXml, 1)
41 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
42 [IO.File]::WriteAllText((Join-Path $Root 'structure-03-renamed.xml'), $hierarchy, [Text.Encoding]::UTF8)
43 $app.SyncHierarchy($notebookId)
44 $app.CloseNotebook($notebookId, $false)
45 $notebookId = ''
46 Copy-Item -Recurse (Join-Path $Root 'notebook') (Join-Path $Root 'notebook-renamed')
47 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
48 $deadline = [DateTime]::UtcNow.AddSeconds(60)
49 do {
50 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
51 [xml]$tree = $hierarchy
52 if (@($tree.SelectNodes('//*[local-name()="Section"]')).Count -ge 4) { break }
53 Start-Sleep -Milliseconds 500
54 } while ([DateTime]::UtcNow -lt $deadline)
55 $manager = New-Object Xml.XmlNamespaceManager($tree.NameTable)
56 $manager.AddNamespace('one', $namespace)
57 [IO.File]::WriteAllText((Join-Path $Root 'structure-03b-reopened.xml'), $hierarchy, [Text.Encoding]::UTF8)
58 $doomed = @($tree.SelectNodes('//*[local-name()="Section"]') | Where-Object { $_.GetAttribute('name') -eq 'Third' })
59 if ($doomed.Count -ne 1) { throw "Expected one section named Third, found $($doomed.Count)." }
60 $app.DeleteHierarchy($doomed[0].GetAttribute('ID'), [DateTime]::MinValue, $false)
61 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
62 [IO.File]::WriteAllText((Join-Path $Root 'structure-04-deleted.xml'), $hierarchy, [Text.Encoding]::UTF8)
63 $app.SyncHierarchy($notebookId)
64 $app.CloseNotebook($notebookId, $false)
65 $notebookId = ''
66} finally {
67 if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
68 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
69 [GC]::Collect()
70 [GC]::WaitForPendingFinalizers()
71}
tools/test_attachment_edit.py+9
...@@ -31,6 +31,15 @@ class AttachmentEditTest(unittest.TestCase):...@@ -31,6 +31,15 @@ class AttachmentEditTest(unittest.TestCase):
31 def test_a_rust_inserted_attachment_with_an_icon_preview_renders_natively(self):31 def test_a_rust_inserted_attachment_with_an_icon_preview_renders_natively(self):
32 self.check('icon')32 self.check('icon')
3333
34 def test_a_stored_attachment_renamed_by_rust_shows_its_new_name_and_source(self):
35 with TemporaryDirectory() as temporary:
36 read = Path(temporary) / 'read'
37 shutil.copytree(FIXTURE / 'renamed/cold/read', read)
38 compare(FIXTURE / 'renamed/candidate', read)
39 page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / 'renamed/cold/read').glob('page-*.xml')))
40 inserted, = page.iter('{%s}InsertedFile' % ns['one'])
41 self.assertEqual((inserted.get('preferredName'), inserted.get('pathSource')), ('renamed 🦀.txt', 'C:\\inputs\\renamed 🦀.txt'))
42
3443
35if __name__ == '__main__':44if __name__ == '__main__':
36 unittest.main()45 unittest.main()
tools/test_notebook_edit.py created+65
...@@ -0,0 +1,65 @@
1from pathlib import Path
2import runpy
3import shutil
4from tempfile import TemporaryDirectory
5import unittest
6import uuid
7import xml.etree.ElementTree as ET
8
9from native_xml import ns
10
11ROOT = Path(__file__).resolve().parent.parent
12FIXTURE = ROOT / 'corpus/notebook-edit'
13compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
14
15
16def file_identity(path):
17 return uuid.UUID(bytes_le=path.read_bytes()[16:32])
18
19
20def entries(node):
21 return [(child.tag.rsplit('}', 1)[-1], child.get('name'), child.get('color'), entries(child))
22 for child in node if child.tag.rsplit('}', 1)[-1] in ('Section', 'SectionGroup')]
23
24
25class NotebookEditTest(unittest.TestCase):
26 def check(self, row, expected):
27 with TemporaryDirectory() as temporary:
28 read = Path(temporary) / 'read'
29 shutil.copytree(FIXTURE / row / 'cold/read', read)
30 compare(FIXTURE / row / 'candidate', read)
31 hierarchy = ET.parse(FIXTURE / row / 'cold/read/hierarchy.xml').getroot()
32 self.assertEqual(entries(hierarchy), expected)
33 for candidate in sorted((FIXTURE / row / 'candidate').rglob('*.one*')):
34 relative = candidate.relative_to(FIXTURE / row / 'candidate')
35 self.assertEqual(file_identity(FIXTURE / row / 'cold/notebook' / relative), file_identity(candidate), str(relative))
36
37 def test_rust_sections_and_groups_open_natively_in_the_written_order(self):
38 self.check('structured', [
39 ('Section', 'Renamed', '#FFD75E', []),
40 ('Section', 'First', '#8AA8E4', []),
41 ('SectionGroup', 'Kept', None, [('Section', 'Inner', '#8AA8E4', [])]),
42 ])
43
44 def test_a_rust_deleted_section_sits_in_the_native_recycle_bin(self):
45 self.check('deleted', [
46 ('Section', 'Renamed', '#FFD75E', []),
47 ('SectionGroup', 'Kept', None, [('Section', 'First', '#8AA8E4', [])]),
48 ('SectionGroup', 'OneNote_RecycleBin', None, [('Section', 'First', '#8AA8E4', [])]),
49 ])
50 hierarchy = ET.parse(FIXTURE / 'deleted/cold/read/hierarchy.xml').getroot()
51 bin = hierarchy.find("one:SectionGroup[@name='OneNote_RecycleBin']", ns)
52 self.assertEqual(bin.get('isRecycleBin'), 'true')
53 self.assertEqual(bin.find('one:Section', ns).get('isInRecycleBin'), 'true')
54
55 def test_onenote_re_identifies_files_whose_header_disagrees_with_their_place(self):
56 # The native session lists a Rust section created with a zero ancestor twice: once
57 # under the identity Rust wrote and once under the identity OneNote assigned.
58 toc = (FIXTURE / 'native-structure/notebook/Open Notebook.onetoc2').read_bytes()
59 links = FIXTURE / 'native-structure/notebook/links.one'
60 self.assertEqual(uuid.UUID(bytes_le=links.read_bytes()[128:144]), file_identity(FIXTURE / 'native-structure/notebook/Open Notebook.onetoc2'))
61 self.assertIn(file_identity(links).bytes_le, toc)
62
63
64if __name__ == '__main__':
65 unittest.main()