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
2121while running the test (it writes `plain/` and `icon/`), then cold-open each
2222with `tools/native_runner.py OUTPUT/plain COLD --expected-pages 1
2323--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
160160so another overlapping remote edit can produce a new conflict. Pending intents
161161and 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
163174Opening recognizes the application identity and the current schema version only;
164175caches and archives written by other versions are refused unchanged. Until the
165176application is usable end to end there are no migrations.
......@@ -295,7 +306,8 @@ Discovery returns ordered sections and nested groups with file identities and
295306share-relative paths. Section display-name overrides remain distinct from file
296307names. TOC references whose identities are absent from the directory remain
297308inspectable; 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.
299311Encrypted sections and valid storage with an unreadable document graph retain
300312their identity as `Locked` or `Unreadable`, without being presented as empty pages.
301313Malformed 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
312324empty payload is a successful empty buffer. Embedded payloads remain available
313325directly 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
315358## SMB (feature `smb`)
316359
317360`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>
9191 }
9292 Some(EditStatus::Published { revision }) => ("published", Some(revision.to_string())),
9393 Some(EditStatus::Conflict(_)) => ("conflict", None),
94 Some(EditStatus::Archived { archive }) => ("archived", Some(archive)),
9495 None => ("missing", None),
9596 };
9697 println!(
crates/notebook/src/lib.rs+4-1
......@@ -51,12 +51,15 @@ pub enum Error {
5151 Discovery(#[from] discover::Error),
5252 #[error("External payload identity now refers to different bytes")]
5353 AssetChanged,
54 #[cfg(feature = "protected")]
55 #[error(transparent)]
56 Protected(#[from] onestore::protected::Error),
5457}
5558
5659type Result<T> = std::result::Result<T, Error>;
5760
5861const APPLICATION_ID: u32 = 0x4f4e454f;
59const SCHEMA_VERSION: u32 = 12;
62const SCHEMA_VERSION: u32 = 13;
6063
6164/// An edited page model together with the stored model it was edited from.
6265/// `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<()> {
2727 CREATE TABLE receipts (
2828 edit_id INTEGER PRIMARY KEY CHECK(edit_id>0),
2929 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
3034 ) STRICT;",
3135 )?;
3236 transaction.execute_batch(CONFLICTS)?;
crates/notebook/src/session.rs+439-1
......@@ -8,7 +8,9 @@ use onestore::{
88 CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, page::Page,
99};
1010use std::{
11 collections::BTreeMap,
1112 io,
13 io::Write,
1214 path::{Path, PathBuf},
1315 sync::{
1416 Arc,
......@@ -17,6 +19,52 @@ use std::{
1719 time::Duration,
1820};
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
2068/// A notebook directory and the cache directory holding its section replicas.
2169pub struct Notebook {
2270 root: PathBuf,
......@@ -48,6 +96,365 @@ impl Notebook {
4896 &self.catalog
4997 }
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
51458 /// Opens a section by its catalog path.
52459 pub fn section(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result<Section> {
53460 let mut folders = vec![&self.catalog];
......@@ -65,6 +472,25 @@ impl Notebook {
65472 }
66473}
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
68494/// What happened on the synchronization thread since the last poll.
69495#[derive(Debug)]
70496pub enum Event {
......@@ -181,7 +607,7 @@ impl Section {
181607 Ok(None) => Event::Refreshed,
182608 Ok(Some((id, status))) => Event::Attempt {
183609 id: *id,
184 status: *status,
610 status: status.clone(),
185611 },
186612 Err(Error::RemoteIo(error)) => Event::Unreachable(match error.raw_os_error() {
187613 Some(code) => io::Error::from_raw_os_error(code),
......@@ -308,6 +734,18 @@ impl Section {
308734 Ok(())
309735 }
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
311749 /// Captures both images, the queue and its states in a read-only archive.
312750 pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> {
313751 self.replica.export_recovery(path)
crates/notebook/src/sync.rs+130-1
......@@ -27,7 +27,7 @@ pub enum ConflictKind {
2727 ContentChanged = 3,
2828}
2929
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[derive(Debug, Clone, PartialEq, Eq)]
3131pub enum EditStatus {
3232 Pending,
3333 /// Retained publication attempt; this revision alone may be insufficient to confirm it.
......@@ -39,6 +39,11 @@ pub enum EditStatus {
3939 Published {
4040 revision: ExGuid,
4141 },
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 },
4247}
4348
4449impl Replica {
......@@ -355,6 +360,120 @@ impl Replica {
355360 })
356361 }
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
358477 fn resolve_conflict(
359478 &self,
360479 id: u64,
......@@ -462,6 +581,16 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat
462581 revision: revision.parse()?,
463582 }));
464583 }
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 }
465594 let record: Option<(Option<String>, Option<i64>, String)> = connection.query_row(
466595 "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()?;
467596 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
680680 assert!(section.queue().unwrap().is_empty());
681681 section.close().unwrap();
682682}
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
8181 assert_eq!(archive.snapshot().unwrap(), working);
8282 assert_eq!(archive.remote_snapshot().unwrap(), remote);
8383 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()));
8585 assert_eq!(archive.status(attempted).unwrap(), uncertain);
8686 for edit in &pending {
8787 assert_eq!(
......@@ -89,7 +89,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin
8989 cache.status(edit.id).unwrap()
9090 );
9191 }
92 let EditStatus::Published { revision } = receipt else {
92 let EditStatus::Published { revision } = receipt.clone() else {
9393 panic!()
9494 };
9595 assert_eq!(archive.receipts().unwrap(), [(published, revision)].into());
......@@ -158,7 +158,7 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv
158158 let archive = Recovery::open(&path).unwrap();
159159 assert_eq!(text(&archive.snapshot().unwrap()).2, "Local");
160160 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()));
162162 assert_eq!(archive.summary().unwrap().conflicts, 1);
163163 assert_eq!(archive.summary().unwrap().uncertain_edits, 0);
164164 drop(archive);
......@@ -173,7 +173,7 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv
173173 assert!(Recovery::open(&path).is_err());
174174 assert_eq!(std::fs::read(&path).unwrap(), before);
175175 }
176 assert_eq!(cache.status(id).unwrap(), Some(outcome.1));
176 assert_eq!(cache.status(id).unwrap(), Some(outcome.1.clone()));
177177 assert_eq!(text(&cache.snapshot().unwrap()).2, "Local");
178178}
179179
......@@ -195,7 +195,7 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() {
195195 assert!(cache.pending().unwrap().is_empty());
196196 drop(cache);
197197 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()));
199199 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
200200 assert_eq!(server.publications, 1);
201201 assert_eq!(cache.status(id + 1).unwrap(), None);
......@@ -849,17 +849,17 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() {
849849 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
850850 assert_eq!(cache.snapshot().unwrap(), source);
851851 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()));
853853 assert_eq!(server.publications, 1);
854854 assert!(cache.pending().unwrap().is_empty());
855855 let archive = notebook::Recovery::open(directory.path().join("published.sqlite")).unwrap();
856856 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()));
858858 assert_eq!(text(&archive.snapshot().unwrap()).2, "Published Original");
859859 drop(cache);
860860 let cache = Replica::open(path).unwrap();
861861 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()));
863863}
864864
865865#[test]
......@@ -892,10 +892,10 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {
892892 let attempts = server.publications;
893893 let cache = Replica::open(&path).unwrap();
894894 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()));
896896 if uncertain {
897897 assert!(matches!(status, EditStatus::AwaitingConfirmation { .. }));
898 assert_eq!(Some(status), prior_status);
898 assert_eq!(Some(status.clone()), prior_status);
899899 assert_eq!(server.publications, attempts);
900900 assert_eq!(server.visible, source);
901901 assert_eq!(cache.snapshot().unwrap(), local);
......@@ -913,8 +913,8 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {
913913 assert_eq!(archive.status(queued).unwrap(), prior_status);
914914 drop(cache);
915915 let cache = Replica::open(&path).unwrap();
916 assert_eq!(cache.status(queued).unwrap(), Some(status));
917 assert_eq!(cache.status(published).unwrap(), Some(receipt));
916 assert_eq!(cache.status(queued).unwrap(), Some(status.clone()));
917 assert_eq!(cache.status(published).unwrap(), Some(receipt.clone()));
918918 }
919919}
920920
......@@ -934,7 +934,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {
934934 let conflict = EditStatus::Conflict(ConflictKind::ContentChanged);
935935 assert_eq!(
936936 cache.sync_once(&mut server).unwrap(),
937 Some((head, conflict))
937 Some((head, conflict.clone()))
938938 );
939939 let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap();
940940 let dependent = save(&cache, object, end..end, " Later").unwrap();
......@@ -947,7 +947,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {
947947 .unwrap();
948948 drop(cache);
949949 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()));
951951 assert_eq!(cache.pending().unwrap(), pending);
952952 assert_eq!(cache.snapshot().unwrap(), local);
953953 let reviewed = edited(&source, object, 0..0, "Revised ");
......@@ -956,7 +956,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {
956956 server.durable.clone_from(&changed);
957957 assert_eq!(
958958 cache.sync_once(&mut server).unwrap(),
959 Some((head, conflict))
959 Some((head, conflict.clone()))
960960 );
961961 assert!(
962962 matches!(cache.review_page(head, &local, &source, &reviewed),
......@@ -975,7 +975,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {
975975 Some((actual, EditStatus::Published { .. })) if actual == head));
976976 assert_eq!(
977977 cache.sync_once(&mut server).unwrap(),
978 Some((dependent, conflict))
978 Some((dependent, conflict.clone()))
979979 );
980980 assert_eq!(server.publications, 2);
981981 let dependent_local = cache.snapshot().unwrap();
......@@ -989,11 +989,11 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {
989989 assert!(matches!(cache.sync_once(&mut server).unwrap(),
990990 Some((actual, EditStatus::Published { .. })) if actual == dependent));
991991 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()));
993993 let archive = notebook::Recovery::open(directory.path().join("conflicted.sqlite")).unwrap();
994994 assert_eq!(archive.snapshot().unwrap(), local);
995995 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()));
997997}
998998
999999#[test]
crates/notebook/tests/sync_formatting.rs+5-2
......@@ -391,10 +391,13 @@ fn uncertain_formatting_attempts_keep_the_original_attempt_and_never_replay() {
391391 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));
392392 drop(cache);
393393 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()));
395395 if matches!(fault, Fault::UnknownBefore) {
396396 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 );
398401 }
399402 assert_eq!(server.publications, 1);
400403 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()
410410 assert_eq!((server.publications, server.confirmations), (1, 1));
411411 continue;
412412 }
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 );
414417 server.visible = remote_with(&source, space, |page| {
415418 resized(216.0, true)(outline_mut(page, outline));
416419 });
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 );
418424 assert_eq!(cache.snapshot().unwrap(), local);
419425 server.visible = remote_with(&source, space, |page| {
420426 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() {
140140 cache.export_recovery(&archive).unwrap();
141141 assert_eq!(
142142 Recovery::open(&archive).unwrap().status(id).unwrap(),
143 Some(attempted)
143 Some(attempted.clone())
144144 );
145145 drop(cache);
146146 let db = rusqlite::Connection::open(&path).unwrap();
......@@ -163,7 +163,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {
163163 assert!(observed[..212] == server.durable[..212]);
164164 assert!(observed[252..] == server.durable[252..]);
165165 } else {
166 assert_eq!(result, (id, attempted));
166 assert_eq!(result, (id, attempted.clone()));
167167 assert_eq!(cache.snapshot().unwrap(), local);
168168 }
169169 }
......@@ -219,7 +219,10 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() {
219219 .contains_key(&revisions[&page.space()])
220220 );
221221 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 );
223226 assert_eq!(server.confirmations, 0);
224227 assert_eq!(server.publications, 1);
225228 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() {
302302 cache.export_recovery(&archive).unwrap();
303303 let recovery = Recovery::open(&archive).unwrap();
304304 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()));
306306 assert_eq!(recovery.snapshot().unwrap(), local);
307307 assert!(
308308 cache
......@@ -315,7 +315,7 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() {
315315 assert!(matches!(result.1, EditStatus::Published { .. }));
316316 assert_eq!(server.confirmations, 1);
317317 } else {
318 assert_eq!(result, (id, attempted));
318 assert_eq!(result, (id, attempted.clone()));
319319 assert_eq!(server.confirmations, 0);
320320 }
321321 }
......@@ -366,7 +366,10 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() {
366366 let cache = Replica::open(&path).unwrap();
367367 let complete = server.visible.clone();
368368 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 );
370373 assert_eq!(server.confirmations, 0);
371374 server.visible = complete;
372375 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() {
732732 let state = cache.status(id).unwrap().unwrap();
733733 drop(cache);
734734 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()));
736736 match fault {
737737 Fault::Before => {
738738 assert_eq!(state, EditStatus::Pending);
......@@ -748,7 +748,10 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {
748748 assert_eq!(cache.pending().unwrap(), pending);
749749 assert_eq!(cache.snapshot().unwrap(), local);
750750 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 );
752755 }
753756 assert_eq!(server.publications, 1);
754757 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() {
108108 },
109109 move |result| {
110110 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()))
112112 .unwrap();
113113 },
114114 )
......@@ -163,7 +163,7 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {
163163 Duration::from_secs(3600),
164164 move || Ok(shared.clone()),
165165 move |result| {
166 observed_tx.send(*result.as_ref().unwrap()).unwrap();
166 observed_tx.send(result.as_ref().unwrap().clone()).unwrap();
167167 if first {
168168 first = false;
169169 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();
......@@ -305,7 +305,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove
305305 Duration::from_secs(3600),
306306 move || Ok(shared.clone()),
307307 move |result| {
308 tx.send(*result.as_ref().unwrap()).unwrap();
308 tx.send(result.as_ref().unwrap().clone()).unwrap();
309309 },
310310 )
311311 .unwrap();
......@@ -377,7 +377,7 @@ fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() {
377377 Duration::from_millis(10),
378378 move || Ok(shared.clone()),
379379 move |result| {
380 tx.send(result.as_ref().copied().map_err(|_| ())).unwrap();
380 tx.send(result.as_ref().cloned().map_err(|_| ())).unwrap();
381381 },
382382 )
383383 .unwrap();
......@@ -401,7 +401,7 @@ fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() {
401401 if let Some(previous) = previous {
402402 assert_eq!(previous, status);
403403 }
404 previous = Some(status);
404 previous = Some(status.clone());
405405 }
406406 assert!(
407407 started.elapsed() >= Duration::from_millis(30),
......@@ -508,7 +508,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {
508508 Duration::from_secs(3600),
509509 move || Ok(shared.clone()),
510510 move |result| {
511 tx.send(*result.as_ref().unwrap()).unwrap();
511 tx.send(result.as_ref().unwrap().clone()).unwrap();
512512 },
513513 )
514514 .unwrap();
......@@ -535,7 +535,7 @@ fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() {
535535 Duration::from_secs(3600),
536536 move || Ok(shared.clone()),
537537 move |result| {
538 tx.send(*result.as_ref().unwrap()).unwrap();
538 tx.send(result.as_ref().unwrap().clone()).unwrap();
539539 if first {
540540 first = false;
541541 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();
......@@ -615,7 +615,7 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() {
615615 Duration::from_millis(1),
616616 move || Ok(remote.take().expect("Contention caused a reconnect")),
617617 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()))
619619 .unwrap();
620620 },
621621 )
......@@ -678,7 +678,7 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() {
678678 .unwrap();
679679 tx.send((second, None)).unwrap();
680680 }
681 Ok(Some((id, status))) => tx.send((*id, Some(*status))).unwrap(),
681 Ok(Some((id, status))) => tx.send((*id, Some(status.clone()))).unwrap(),
682682 Ok(None) => {}
683683 other => panic!("Unexpected worker result: {other:?}"),
684684 },
crates/onestore/README.md+4-1
......@@ -43,6 +43,7 @@ harness also accepts `--client-profile release`.
4343| `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes |
4444| `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities |
4545| `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 |
4647| `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` |
4748| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |
4849| `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`.
5051| `PageEdit`, `PreparedEdit::pages` | Publish explicitly selected page moves and indentation changes together, preserving page content and historical revisions |
5152| `PreparedEdit::delete_pages_permanently` | Remove explicit pages and their section references atomically while retaining stored revisions |
5253| `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 |
5356| `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references |
5457| `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range across ordinary text runs; publish text, run boundaries and modification time together |
5558| `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`.
5760| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
5861| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
5962| `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 |
6164| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |
6265| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
6366| `read_file` | Read a snapshot under whole-file exclusion |
crates/onestore/src/commit.rs+37
......@@ -71,6 +71,31 @@ impl Drop for FileIo {
7171 }
7272}
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
7499/// Reads a snapshot under the same whole-file exclusion used for commits.
75100/// Native writers can expose incomplete graphs to unlocked filesystem reads.
76101#[cfg(any(unix, windows))]
......@@ -192,6 +217,18 @@ impl<'a> PreparedEdit<'a> {
192217 })
193218 }
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
195232 /// Permanently removes explicitly selected pages from the section in one transaction.
196233 /// Subpages must be selected explicitly; a surviving first subpage becomes top-level.
197234 /// 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 {
101101 objects: Vec<NewObject>,
102102}
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
104113pub(crate) fn current_timestamps() -> Result<(u32, u64)> {
105114 let now = SystemTime::now()
106115 .duration_since(UNIX_EPOCH)
......@@ -537,8 +546,7 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result
537546 output[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes());
538547 }
539548 output[96..100].copy_from_slice(&1_u32.to_le_bytes());
540 output[144..148]
541 .copy_from_slice(&(!crc(u32::MAX, &string(file_name), FileType::Section)).to_le_bytes());
549 output[128..148].copy_from_slice(&placement([0; 16], file_name));
542550 for (at, chunk) in hashed_chunk
543551 .map(|chunk| (148, chunk))
544552 .into_iter()
crates/onestore/src/lib.rs+4-1
......@@ -21,6 +21,7 @@ pub mod protected;
2121mod revisions;
2222mod snapshot;
2323mod store;
24mod toc;
2425mod tree;
2526mod write;
2627
......@@ -30,7 +31,8 @@ pub use commit::{
3031};
3132#[cfg(any(unix, windows))]
3233pub 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,
3436};
3537pub use create::{create_section, create_table_of_contents};
3638pub use edit::replace_text;
......@@ -45,5 +47,6 @@ pub use properties::{IdStream, Property, PropertySets, Value};
4547pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex};
4648pub use snapshot::{read_snapshot, read_storage_snapshot};
4749pub use store::{Chunk, Error, FileType, Header, Node, NodeList, Reference, Store};
50pub use toc::TocEdit;
4851pub use tree::TreeEdit;
4952pub 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<'_>)
6262 url
6363}
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
65108/// Percent-encodes a page title the way OneNote does in a link fragment.
66109fn encoded(title: &str) -> String {
67110 let mut out = String::new();
crates/onestore/src/page/mod.rs+17-6
......@@ -331,6 +331,22 @@ impl Page {
331331 Self::from_revision(revision, id)
332332 }
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
334350 /// The single active page declared in one page object space.
335351 pub fn from_space(document: &Document<'_>, space: ExGuid) -> Result<Self, Error> {
336352 let pages = document.pages_in(space)?;
......@@ -365,12 +381,7 @@ impl Page {
365381 .and_then(|node| node.extra.first());
366382 let mut page = Self {
367383 title: page_title(revision, id).unwrap_or_default().to_owned(),
368 identity: metadata
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 }),
384 identity: Self::identity_of(revision),
374385 created: metadata
375386 .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65))
376387 .map(|field| {
crates/onestore/src/page/write.rs+69-11
......@@ -1,7 +1,9 @@
11//! Publishes an edited page model by lowering the difference from the stored page onto
22//! 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};
57use crate::{
68 Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets,
79 RevisionIndex, Store, TextAttribute, TreeEdit, Value,
......@@ -650,8 +652,8 @@ impl Lowering<'_> {
650652 }
651653 }
652654 (ParagraphContent::Attachment(a), ParagraphContent::Attachment(b)) => {
653 if a != b {
654 return Err(invalid("A stored attachment cannot be edited"));
655 if a.id != b.id {
656 return Err(invalid("Attachment identity cannot change"));
655657 }
656658 }
657659 _ => return Err(invalid("Paragraph content type cannot change")),
......@@ -904,21 +906,24 @@ impl Lowering<'_> {
904906 let ParagraphContent::Attachment(attachment) = &paragraph.content else {
905907 continue;
906908 };
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 }
907915 if let Some(previous) = old.paragraphs.get(paragraph_id) {
908 if previous.content != paragraph.content {
909 return Err(invalid("A stored attachment cannot be edited"));
916 let ParagraphContent::Attachment(stored) = &previous.content else {
917 return Err(invalid("Paragraph content type cannot change"));
918 };
919 if stored != attachment {
920 self.edit_attachment(stored, attachment)?;
910921 }
911922 continue;
912923 }
913924 let Some(bytes) = &attachment.bytes else {
914925 return Err(invalid("A new attachment needs its payload"));
915926 };
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 }
922927 let extension = name
923928 .rfind('.')
924929 .filter(|dot| *dot > 0)
......@@ -1314,6 +1319,59 @@ impl Lowering<'_> {
13141319 /// Writes a moved, resized or described picture the way OneNote stores one: the
13151320 /// position, the layout width and height with the user flag and the description on
13161321 /// 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
13171375 fn edit_image(&mut self, stored: &Image, image: &Image) -> Result<(), Error> {
13181376 picture_fixed_fields(stored, image)?;
13191377 let mut values: Values = vec![(
crates/onestore/src/store.rs+9-1
......@@ -71,6 +71,10 @@ impl Chunk {
7171pub struct Header {
7272 pub file_type: FileType,
7373 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,
7478 pub transaction_count: u32,
7579 pub expected_length: u64,
7680 pub version_id: [u8; 16],
......@@ -166,7 +170,9 @@ impl Header {
166170 message: "Missing committed transaction",
167171 });
168172 }
169 c.take(48)?;
173 c.take(28)?;
174 let ancestor = c.read()?;
175 let name_crc = u32::from_le_bytes(c.read()?);
170176 let hashed_chunks = c.chunk()?;
171177 let transaction_log = c.chunk()?;
172178 let root = c.chunk()?;
......@@ -179,6 +185,8 @@ impl Header {
179185 Ok(Self {
180186 file_type,
181187 file_id,
188 ancestor,
189 name_crc,
182190 transaction_count,
183191 expected_length,
184192 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(
838838 )
839839 }
840840 };
841 let existing: BTreeSet<ExGuid> = revision.objects.keys().copied().collect();
841842 let reachable = if rid.is_some() {
842843 revision.reachable()?
843844 } else {
......@@ -859,10 +860,10 @@ pub(crate) fn write_revisions_with_payloads(
859860 message: "An existing object's type cannot be changed",
860861 });
861862 }
862 } else if !is_section {
863 } else if !is_section && replacement.jcid != 0x20001 {
863864 return Err(Error {
864865 offset: 0,
865 message: "New objects require a section file",
866 message: "New objects in a table of contents are its entries",
866867 });
867868 }
868869 if replacement.global_ids.keys().any(|i| *i > 0xffffff) {
......@@ -1006,10 +1007,16 @@ pub(crate) fn write_revisions_with_payloads(
10061007 .iter()
10071008 .filter(|(id, _)| checkpoint || changed.contains(id))
10081009 .collect();
1009 let toc_table = if checkpoint && !is_section {
1010 if selected.iter().any(|(_, object)| {
1011 object.jcid != 0x20001 || !matches!(object.data, ObjectData::Properties(_))
1012 }) {
1010 // A table-of-contents manifest has one global id table (sections group objects,
1011 // each group with its own table); OneNote resolves every node against it.
1012 let toc_table = if is_section {
1013 None
1014 } else {
1015 if checkpoint
1016 && selected.iter().any(|(_, object)| {
1017 object.jcid != 0x20001 || !matches!(object.data, ObjectData::Properties(_))
1018 })
1019 {
10131020 return Err(Error {
10141021 offset: 0,
10151022 message: "TOC checkpoint requires table-of-contents property objects",
......@@ -1032,8 +1039,6 @@ pub(crate) fn write_revisions_with_payloads(
10321039 .map(|(i, guid)| (u32::try_from(i).unwrap(), guid))
10331040 .collect::<BTreeMap<_, _>>(),
10341041 )
1035 } else {
1036 None
10371042 };
10381043 let mut groups = BTreeMap::<_, Vec<_>>::new();
10391044 for (id, object) in selected {
......@@ -1144,10 +1149,13 @@ pub(crate) fn write_revisions_with_payloads(
11441149 length: u64::try_from(bytes.len()).unwrap(),
11451150 }
11461151 };
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);
11471155 if is_section {
11481156 declaration.extend_from_slice(&object.jcid.to_le_bytes());
11491157 declaration.push(flags);
1150 } else if checkpoint {
1158 } else if declared {
11511159 let body = 1_u64 | (u64::from(flags & 1) << 16);
11521160 declaration.extend_from_slice(&body.to_le_bytes()[..6]);
11531161 } else {
......@@ -1161,7 +1169,7 @@ pub(crate) fn write_revisions_with_payloads(
11611169 group.push(node(
11621170 if is_section {
11631171 if readonly { 0xc5 } else { 0xa5 }
1164 } else if checkpoint {
1172 } else if declared {
11651173 0x2e
11661174 } else {
11671175 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() {
169169}
170170
171171#[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() {
173173 let source = onestore::create_section("files.one", "Text", "Author").unwrap();
174174 let (space, before) = first_page(&source);
175175 let mut after = before.clone();
......@@ -192,12 +192,38 @@ fn attachments_need_a_file_name_and_stored_ones_stay_fixed() {
192192 if let PageObject::Outline(outline) = object {
193193 for paragraph in &mut outline.paragraphs {
194194 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());
196197 found += 1;
197198 }
198199 }
199200 }
200201 }
201202 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 }
203229}
crates/onestore/tests/page_links.rs+27-1
......@@ -3,7 +3,7 @@ use onestore::{
33 document::Document,
44 page::{
55 Page, PageObject, PageParagraph, Paragraph,
6 link::{LinkTarget, internal_link},
6 link::{InternalLink, LinkTarget, internal_link, parse_internal_link},
77 text::Edit,
88 },
99};
......@@ -258,6 +258,32 @@ fn internal_links_match_what_onenote_stores() {
258258 ),
259259 "onenote:#A%20b%2Fc&section-id={00000000-0000-0000-0000-000000000000}&page-id={00000000-0000-0000-0000-000000000000}&end&base-path=p"
260260 );
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);
261287}
262288
263289/// `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):
3131 def test_a_rust_inserted_attachment_with_an_icon_preview_renders_natively(self):
3232 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
3544if __name__ == '__main__':
3645 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()