| author | |
| committer | |
| log | 75e67fc117387e3301560b6a3ee21075f3487b71 |
| tree | 80f39d3880472ba3eac1f33123f9a1e9d2a0df55 |
| parent | 5b4f04b30a869396c509201367a1d5b296becf7e |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.1161 files changed, 3315 insertions(+), 79 deletions(-)
corpus/attachment-edit/README.md+12| ... | ... | @@ -21,3 +21,15 @@ without a VM. Regenerate with `ONESTORE_ATTACHMENT_EXPORT` set to a directory |
| 21 | 21 | while running the test (it writes `plain/` and `icon/`), then cold-open each |
| 22 | 22 | with `tools/native_runner.py OUTPUT/plain COLD --expected-pages 1 |
| 23 | 23 | --collect-notebook`. |
| 24 | ||
| 25 | ## Renaming a stored attachment | |
| 26 | ||
| 27 | `renamed/candidate` is the writer's output for | |
| 28 | `attachments_need_a_file_name_and_stored_ones_are_renamed_in_place` in | |
| 29 | `crates/onestore/tests/page_attachments.rs`: the native attachment of | |
| 30 | `corpus/native/cold-05-06-attachment` with its shown name and recorded source | |
| 31 | path 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 | |
| 34 | new values. Regenerate with `ONESTORE_ATTACHMENT_RENAME_EXPORT` and cold-open | |
| 35 | with `--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 @@ |
| 1 | Fictitious 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 | |
| 3 | style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span | |
| 4 | style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span | |
| 5 | style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span | |
| 6 | style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed' | |
| 7 | lang=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 | |
| 8 | AAAASUVORK5CYII=</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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($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)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-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 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([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' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($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 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 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 | |
| 4 | a Rust-created notebook (`tools/native/notebook-structure.ps1`): it created | |
| 5 | two sections and a section group with a section inside, renamed the notebook, | |
| 6 | reopened it, and deleted a section; `structure-*.xml` are the hierarchy reads | |
| 7 | after each step, `notebook-renamed/` the files before the delete. OneNote | |
| 8 | keeps the documented table of contents (MS-ONE 2.2.14): one root object with | |
| 9 | an entry array whose entries carry the file identity, order, filename and | |
| 10 | colour (`0xffffffff` for sections, absent for groups); a section's colour | |
| 11 | lives in its own metadata. Deleting moves the file into `OneNote_RecycleBin`, | |
| 12 | a group with its own TOC that the root lists. OneNote also names every file | |
| 13 | for its place in the header: `guidAncestor` is the parent TOC's file | |
| 14 | identity and `crcName` the CRC of the section file name or the group folder | |
| 15 | name. The Rust section `links.one`, created with a zero ancestor, was | |
| 16 | re-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 | |
| 19 | through `UpdateHierarchy` (`tools/native/notebook-reorder.ps1`): the | |
| 20 | in-session read shows the new order, but after `SyncHierarchy`, close and | |
| 21 | reopen the order is back and the TOC unchanged, so section order cannot be | |
| 22 | authored through COM; the order rows below are proved by cold reads of Rust | |
| 23 | candidates alone. Section display order is the entry's ordering number | |
| 24 | ascending, 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 | |
| 28 | through `notebook::session::Notebook` (create sections and a group, rename | |
| 29 | both, colour a section, order the root as group, renamed, first), placed the | |
| 30 | same 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 | |
| 32 | written order with the written colours, the recycle bin carries | |
| 33 | `isRecycleBin`, and every file keeps the identity Rust wrote. | |
| 34 | Every TOC revision the writer appends carries one global id table; with one | |
| 35 | table per object group, as the section writer emits, OneNote applied a | |
| 36 | reordered entry's value to the wrong entry. `tools/test_notebook_edit.py` | |
| 37 | checks this without a VM. Regenerate with | |
| 38 | `NOTEBOOK_STRUCTURE_EXPORT` and `NOTEBOOK_STRUCTURE_EXPORT_DELETED` set to | |
| 39 | new 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($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)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-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 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([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' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($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 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 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 | |
| 3 | href="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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | |
| 2 | Set-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 | function 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 | } | |
| 19 | try { | |
| 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($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)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-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 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([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' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($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 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 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 | |
| 3 | href="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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | |
| 2 | Set-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 = @() | |
| 9 | try { | |
| 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($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)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-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 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([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' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($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 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($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)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-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 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([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' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($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 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); | |
| 103 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | |
| 104 | sha256 = $hash; bytes = $bytes.Length } | |
| 105 | } | |
| 106 | $index++ | |
| 107 | } | |
| 108 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | |
| 109 | $all = '' | |
| 110 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | |
| 111 | [xml]$finalTree = $all | |
| 112 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | |
| 113 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | |
| 114 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | |
| 115 | } | |
| 116 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | |
| 117 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | |
| 118 | } catch { | |
| 119 | $failure = $_ | |
| 120 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 121 | throw | |
| 122 | } finally { | |
| 123 | try { | |
| 124 | try { | |
| 125 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | |
| 126 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | |
| 127 | $app.CloseNotebook($notebookId, $false) | |
| 128 | } | |
| 129 | } catch { | |
| 130 | if ($null -eq $failure) { throw } | |
| 131 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | |
| 132 | } | |
| 133 | } finally { | |
| 134 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 135 | } | |
| 136 | $app = $null | |
| 137 | [GC]::Collect() | |
| 138 | [GC]::WaitForPendingFinalizers() | |
| 139 | if (-not $UseCurrentCache -and -not $CloneHost) { | |
| 140 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | |
| 141 | } | |
| 142 | } |
corpus/notebook-edit/structured/cold/source.json created+32| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "path": "First.one", | |
| 4 | "bytes": 3212, | |
| 5 | "sha256": "d926b2f5502dd1bb2768928e44020497248299b9e6c1c8ddcec999117279c177", | |
| 6 | "mtime_ns": 1789279283377754822 | |
| 7 | }, | |
| 8 | { | |
| 9 | "path": "Kept/Inner.one", | |
| 10 | "bytes": 3164, | |
| 11 | "sha256": "33e43aef96619ba34e4499b605e21540ac25475542ae8817e37ed10382ac25ca", | |
| 12 | "mtime_ns": 1789279283538323423 | |
| 13 | }, | |
| 14 | { | |
| 15 | "path": "Kept/Open Notebook.onetoc2", | |
| 16 | "bytes": 3824, | |
| 17 | "sha256": "432b3413622bb8eabacc640ffc8c6ad80dee94cd45bed6154f5afbc2ae443965", | |
| 18 | "mtime_ns": 1789279283654960588 | |
| 19 | }, | |
| 20 | { | |
| 21 | "path": "Open Notebook.onetoc2", | |
| 22 | "bytes": 5408, | |
| 23 | "sha256": "e713f4d9cca21552e7fd650c54b6072fc2d8875e48b738b00887324f82331728", | |
| 24 | "mtime_ns": 1789279283758856923 | |
| 25 | }, | |
| 26 | { | |
| 27 | "path": "Renamed.one", | |
| 28 | "bytes": 5504, | |
| 29 | "sha256": "9ee7bd23c1679ce5035c92adca0416730b149d11bedd1b4a7c181a0186df7007", | |
| 30 | "mtime_ns": 1789279283726949659 | |
| 31 | } | |
| 32 | ] |
corpus/notebook-edit/structured/cold/teardown.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"absent": true} |
crates/notebook/README.md+44-1| ... | ... | @@ -160,6 +160,17 @@ in one local transaction. The supplied images must still match `snapshot()` and |
| 160 | 160 | so another overlapping remote edit can produce a new conflict. Pending intents |
| 161 | 161 | and uncertain attempts cannot be reviewed. |
| 162 | 162 | |
| 163 | An attempt that stays `AwaitingConfirmation` is released by review, never by | |
| 164 | replay: `release_attempt(id, local, remote, archive, after)` on the replica | |
| 165 | (`release` on a session) exports the branch to a new archive, then retires | |
| 166 | the oldest edit's attempt. `Some(page)` continues from a page reviewed | |
| 167 | against the current remote image as a fresh intent under the same id, | |
| 168 | keeping later edits; `None` abandons the local branch, whose ids report | |
| 169 | `EditStatus::Archived { archive }` from then on, and the working image | |
| 170 | returns to the remote image. Neither writes a receipt: the uncertain | |
| 171 | publication may or may not have landed, and the archive is the record of | |
| 172 | what was attempted. | |
| 173 | ||
| 163 | 174 | Opening recognizes the application identity and the current schema version only; |
| 164 | 175 | caches and archives written by other versions are refused unchanged. Until the |
| 165 | 176 | application is usable end to end there are no migrations. |
| ... | ... | @@ -295,7 +306,8 @@ Discovery returns ordered sections and nested groups with file identities and |
| 295 | 306 | share-relative paths. Section display-name overrides remain distinct from file |
| 296 | 307 | names. TOC references whose identities are absent from the directory remain |
| 297 | 308 | inspectable; a cached filename never substitutes for an identity match. |
| 298 | Reserved `_onefiles` directories are excluded from section-group traversal. | |
| 309 | Reserved `_onefiles` directories are excluded from section-group traversal; | |
| 310 | OneNote's `OneNote_RecycleBin` is listed as the section group OneNote shows. | |
| 299 | 311 | Encrypted sections and valid storage with an unreadable document graph retain |
| 300 | 312 | their identity as `Locked` or `Unreadable`, without being presented as empty pages. |
| 301 | 313 | Malformed storage, failed reads and ambiguous identities reject the discovery. |
| ... | ... | @@ -312,6 +324,37 @@ Missing files, permissions and size failures retain their I/O error kinds; an |
| 312 | 324 | empty payload is a successful empty buffer. Embedded payloads remain available |
| 313 | 325 | directly from the core document model. |
| 314 | 326 | |
| 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 | |
| 331 | contents (`Open Notebook.onetoc2`, created when a folder has none) gains, | |
| 332 | renames, reorders or loses entries; a section's colour lives in its own | |
| 333 | metadata; a deleted section moves into `OneNote_RecycleBin`, a group with its | |
| 334 | own 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 | |
| 336 | identity and the name CRC OneNote checks on open; a file without them is | |
| 337 | re-identified and listed anew. These operate on the mounted directory; | |
| 338 | the SMB transport has no create, rename or delete. | |
| 339 | ||
| 340 | `Notebook::find_page(url)` resolves a stored internal link to a section path | |
| 341 | and page space by identity: the linked section first, then every readable | |
| 342 | section, so a link follows its page when the section is renamed or moved and | |
| 343 | when the page itself was moved to another section. Other URLs and unknown | |
| 344 | targets are `None`. | |
| 345 | ||
| 346 | With the optional `protected` feature, `Notebook::unlock(path, password)` | |
| 347 | reads the pages of a `Locked` section for display; nothing is cached or | |
| 348 | written, and a wrong password is `Error::Protected(PasswordMismatch)`. | |
| 349 | ||
| 350 | `Notebook::refresh` rereads the directory on the caller's schedule and reports | |
| 351 | what another client changed as `Change`s keyed by file identity: a renamed or | |
| 352 | moved section is `Moved`, not removed and added, and a deleted section moves | |
| 353 | into `OneNote_RecycleBin`; a folder whose surviving entries changed sequence | |
| 354 | is `Reordered`. A failed read returns the error and keeps | |
| 355 | the previous catalog, so an unreachable share never reads as an emptied | |
| 356 | notebook. | |
| 357 | ||
| 315 | 358 | ## SMB (feature `smb`) |
| 316 | 359 | |
| 317 | 360 | `notebook::smb` provides blocking SMB access for OneNote sections and |
crates/notebook/examples/recovery_probe.rs+1| ... | ... | @@ -91,6 +91,7 @@ fn report(cache: &Replica, root: &Path) -> Result<(), Box<dyn std::error::Error> |
| 91 | 91 | } |
| 92 | 92 | Some(EditStatus::Published { revision }) => ("published", Some(revision.to_string())), |
| 93 | 93 | Some(EditStatus::Conflict(_)) => ("conflict", None), |
| 94 | Some(EditStatus::Archived { archive }) => ("archived", Some(archive)), | |
| 94 | 95 | None => ("missing", None), |
| 95 | 96 | }; |
| 96 | 97 | println!( |
crates/notebook/src/lib.rs+4-1| ... | ... | @@ -51,12 +51,15 @@ pub enum Error { |
| 51 | 51 | Discovery(#[from] discover::Error), |
| 52 | 52 | #[error("External payload identity now refers to different bytes")] |
| 53 | 53 | AssetChanged, |
| 54 | #[cfg(feature = "protected")] | |
| 55 | #[error(transparent)] | |
| 56 | Protected(#[from] onestore::protected::Error), | |
| 54 | 57 | } |
| 55 | 58 | |
| 56 | 59 | type Result<T> = std::result::Result<T, Error>; |
| 57 | 60 | |
| 58 | 61 | const APPLICATION_ID: u32 = 0x4f4e454f; |
| 59 | const SCHEMA_VERSION: u32 = 12; | |
| 62 | const SCHEMA_VERSION: u32 = 13; | |
| 60 | 63 | |
| 61 | 64 | /// An edited page model together with the stored model it was edited from. |
| 62 | 65 | /// `before` is the precondition reconciliation checks against the remote page. |
crates/notebook/src/schema.rs+4| ... | ... | @@ -27,6 +27,10 @@ pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> { |
| 27 | 27 | CREATE TABLE receipts ( |
| 28 | 28 | edit_id INTEGER PRIMARY KEY CHECK(edit_id>0), |
| 29 | 29 | revision TEXT NOT NULL |
| 30 | ) STRICT; | |
| 31 | CREATE TABLE archived ( | |
| 32 | edit_id INTEGER PRIMARY KEY CHECK(edit_id>0), | |
| 33 | archive TEXT NOT NULL | |
| 30 | 34 | ) STRICT;", |
| 31 | 35 | )?; |
| 32 | 36 | transaction.execute_batch(CONFLICTS)?; |
crates/notebook/src/session.rs+439-1| ... | ... | @@ -8,7 +8,9 @@ use onestore::{ |
| 8 | 8 | CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, page::Page, |
| 9 | 9 | }; |
| 10 | 10 | use std::{ |
| 11 | collections::BTreeMap, | |
| 11 | 12 | io, |
| 13 | io::Write, | |
| 12 | 14 | path::{Path, PathBuf}, |
| 13 | 15 | sync::{ |
| 14 | 16 | Arc, |
| ... | ... | @@ -17,6 +19,52 @@ use std::{ |
| 17 | 19 | time::Duration, |
| 18 | 20 | }; |
| 19 | 21 | |
| 22 | /// A difference between two catalog reads of a notebook, in catalog paths. | |
| 23 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 24 | pub 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)] | |
| 37 | enum Key { | |
| 38 | File([u8; 16]), | |
| 39 | Folder(String), | |
| 40 | } | |
| 41 | ||
| 42 | type Entries = (BTreeMap<Key, String>, BTreeMap<String, Vec<Key>>); | |
| 43 | ||
| 44 | fn entries(folder: &discover::Folder) -> Entries { | |
| 45 | fn walk(folder: &discover::Folder, out: &mut Entries) { | |
| 46 | let mut order = Vec::new(); | |
| 47 | for section in &folder.sections { | |
| 48 | let key = Key::File(section.file_id); | |
| 49 | out.0.insert(key.clone(), section.path.clone()); | |
| 50 | order.push(key); | |
| 51 | } | |
| 52 | for group in &folder.groups { | |
| 53 | let key = match &group.toc { | |
| 54 | Some(toc) => Key::File(toc.file_id), | |
| 55 | None => Key::Folder(group.path.clone()), | |
| 56 | }; | |
| 57 | out.0.insert(key.clone(), group.path.clone()); | |
| 58 | order.push(key); | |
| 59 | walk(group, out); | |
| 60 | } | |
| 61 | out.1.insert(folder.path.clone(), order); | |
| 62 | } | |
| 63 | let mut out = Entries::default(); | |
| 64 | walk(folder, &mut out); | |
| 65 | out | |
| 66 | } | |
| 67 | ||
| 20 | 68 | /// A notebook directory and the cache directory holding its section replicas. |
| 21 | 69 | pub struct Notebook { |
| 22 | 70 | root: PathBuf, |
| ... | ... | @@ -48,6 +96,365 @@ impl Notebook { |
| 48 | 96 | &self.catalog |
| 49 | 97 | } |
| 50 | 98 | |
| 99 | /// Rereads the notebook directory and reports what changed since the last catalog, | |
| 100 | /// keyed by file identity so a renamed or moved section stays the same section. A | |
| 101 | /// failed read keeps the previous catalog: an unreachable notebook is not an empty one. | |
| 102 | pub fn refresh(&mut self) -> Result<Vec<Change>> { | |
| 103 | let catalog = discover::discover( | |
| 104 | &mut discover::Local::open(&self.root)?, | |
| 105 | discover::Limits { | |
| 106 | entries: 100_000, | |
| 107 | bytes_per_file: 256 * 1024 * 1024, | |
| 108 | depth: 64, | |
| 109 | }, | |
| 110 | )?; | |
| 111 | let (before, before_orders) = entries(&self.catalog); | |
| 112 | let (after, after_orders) = entries(&catalog); | |
| 113 | let mut changes = Vec::new(); | |
| 114 | for key in before_orders.values().flatten() { | |
| 115 | let from = &before[key]; | |
| 116 | match after.get(key) { | |
| 117 | None => changes.push(Change::Removed(from.clone())), | |
| 118 | Some(to) if to != from => changes.push(Change::Moved { | |
| 119 | from: from.clone(), | |
| 120 | to: to.clone(), | |
| 121 | }), | |
| 122 | Some(_) => {} | |
| 123 | } | |
| 124 | } | |
| 125 | for key in after_orders.values().flatten() { | |
| 126 | if !before.contains_key(key) { | |
| 127 | changes.push(Change::Added(after[key].clone())); | |
| 128 | } | |
| 129 | } | |
| 130 | for (folder, order) in &after_orders { | |
| 131 | let Some(previous) = before_orders.get(folder) else { | |
| 132 | continue; | |
| 133 | }; | |
| 134 | let kept = |sequence: &[Key], other: &[Key]| -> Vec<Key> { | |
| 135 | sequence | |
| 136 | .iter() | |
| 137 | .filter(|key| other.contains(key)) | |
| 138 | .cloned() | |
| 139 | .collect() | |
| 140 | }; | |
| 141 | if kept(previous, order) != kept(order, previous) { | |
| 142 | changes.push(Change::Reordered(folder.clone())); | |
| 143 | } | |
| 144 | } | |
| 145 | self.catalog = catalog; | |
| 146 | Ok(changes) | |
| 147 | } | |
| 148 | ||
| 149 | fn folder(&self, path: &str) -> Result<&discover::Folder> { | |
| 150 | let mut folders = vec![&self.catalog]; | |
| 151 | while let Some(folder) = folders.pop() { | |
| 152 | if folder.path == path { | |
| 153 | return Ok(folder); | |
| 154 | } | |
| 155 | folders.extend(&folder.groups); | |
| 156 | } | |
| 157 | Err(io::Error::from(io::ErrorKind::NotFound).into()) | |
| 158 | } | |
| 159 | ||
| 160 | fn directory(&self, path: &str) -> Result<PathBuf> { | |
| 161 | let directory = if path.is_empty() { | |
| 162 | self.root.clone() | |
| 163 | } else { | |
| 164 | self.root.join(path).canonicalize()? | |
| 165 | }; | |
| 166 | if !directory.starts_with(&self.root) || !directory.is_dir() { | |
| 167 | return Err(io::Error::from(io::ErrorKind::PermissionDenied).into()); | |
| 168 | } | |
| 169 | Ok(directory) | |
| 170 | } | |
| 171 | ||
| 172 | /// A folder's TOC path and file identity, creating the TOC when the folder has none | |
| 173 | /// (OneNote names it `Open Notebook.onetoc2`). | |
| 174 | fn toc(&self, folder: &str) -> Result<(PathBuf, [u8; 16])> { | |
| 175 | let directory = self.directory(folder)?; | |
| 176 | match &self.folder(folder)?.toc { | |
| 177 | Some(toc) => Ok((directory.join(&toc.filename), toc.file_id)), | |
| 178 | None => { | |
| 179 | let path = directory.join("Open Notebook.onetoc2"); | |
| 180 | let bytes = onestore::create_table_of_contents("Open Notebook.onetoc2", &[])?; | |
| 181 | std::fs::File::create_new(&path)?.write_all(&bytes)?; | |
| 182 | Ok((path, onestore::Store::parse(&bytes)?.header.file_id)) | |
| 183 | } | |
| 184 | } | |
| 185 | } | |
| 186 | ||
| 187 | fn edit_toc(&self, folder: &str, edits: &[onestore::TocEdit]) -> Result<()> { | |
| 188 | let (toc, _) = self.toc(folder)?; | |
| 189 | let source = onestore::read_file(&toc)?; | |
| 190 | PreparedEdit::table_of_contents(&source, edits)? | |
| 191 | .commit_file(&toc) | |
| 192 | .map_err(|error| error.error)?; | |
| 193 | Ok(()) | |
| 194 | } | |
| 195 | ||
| 196 | /// Creates `name.one` in `folder` with one empty page and lists it last in the folder's | |
| 197 | /// TOC, as OneNote creates a section. Returns the new catalog path. | |
| 198 | pub fn create_section(&mut self, folder: &str, name: &str, author: &str) -> Result<String> { | |
| 199 | let filename = format!("{name}.one"); | |
| 200 | let directory = self.directory(folder)?; | |
| 201 | let (_, ancestor) = self.toc(folder)?; | |
| 202 | let bytes = onestore::create_section(&filename, "", author)?; | |
| 203 | let path = directory.join(&filename); | |
| 204 | std::fs::File::create_new(&path)?.write_all(&bytes)?; | |
| 205 | onestore::place_file(&path, ancestor, &filename)?; | |
| 206 | let identity = onestore::Store::parse(&bytes)?.header.file_id; | |
| 207 | self.edit_toc( | |
| 208 | folder, | |
| 209 | &[onestore::TocEdit::Add { | |
| 210 | filename: filename.clone(), | |
| 211 | identity, | |
| 212 | group: false, | |
| 213 | }], | |
| 214 | )?; | |
| 215 | self.refresh()?; | |
| 216 | Ok(catalog_path(folder, &filename)) | |
| 217 | } | |
| 218 | ||
| 219 | /// Creates a section group: a folder with its own TOC, listed last in the parent's TOC. | |
| 220 | pub fn create_group(&mut self, folder: &str, name: &str) -> Result<String> { | |
| 221 | let directory = self.directory(folder)?; | |
| 222 | if !component(name) { | |
| 223 | return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); | |
| 224 | } | |
| 225 | let group = directory.join(name); | |
| 226 | let (_, ancestor) = self.toc(folder)?; | |
| 227 | std::fs::create_dir(&group)?; | |
| 228 | let bytes = onestore::create_table_of_contents("Open Notebook.onetoc2", &[])?; | |
| 229 | let path = group.join("Open Notebook.onetoc2"); | |
| 230 | std::fs::File::create_new(&path)?.write_all(&bytes)?; | |
| 231 | onestore::place_file(&path, ancestor, name)?; | |
| 232 | let identity = onestore::Store::parse(&bytes)?.header.file_id; | |
| 233 | self.edit_toc( | |
| 234 | folder, | |
| 235 | &[onestore::TocEdit::Add { | |
| 236 | filename: name.to_owned(), | |
| 237 | identity, | |
| 238 | group: true, | |
| 239 | }], | |
| 240 | )?; | |
| 241 | self.refresh()?; | |
| 242 | Ok(catalog_path(folder, name)) | |
| 243 | } | |
| 244 | ||
| 245 | /// Renames a section or section group: the file or folder on disk and its TOC entry. | |
| 246 | pub fn rename(&mut self, path: &str, name: &str) -> Result<String> { | |
| 247 | let (folder, entry) = split(path); | |
| 248 | let directory = self.directory(folder)?; | |
| 249 | let (filename, identity) = self.entry(folder, entry)?; | |
| 250 | let target = if filename.to_ascii_lowercase().ends_with(".one") { | |
| 251 | format!("{name}.one") | |
| 252 | } else { | |
| 253 | name.to_owned() | |
| 254 | }; | |
| 255 | if !component(&target) || directory.join(&target).exists() { | |
| 256 | return Err(io::Error::from(io::ErrorKind::AlreadyExists).into()); | |
| 257 | } | |
| 258 | std::fs::rename(directory.join(&filename), directory.join(&target))?; | |
| 259 | let (_, ancestor) = self.toc(folder)?; | |
| 260 | let placed = if target.ends_with(".one") { | |
| 261 | directory.join(&target) | |
| 262 | } else { | |
| 263 | directory.join(&target).join("Open Notebook.onetoc2") | |
| 264 | }; | |
| 265 | onestore::place_file(placed, ancestor, &target)?; | |
| 266 | self.edit_toc( | |
| 267 | folder, | |
| 268 | &[onestore::TocEdit::Rename { | |
| 269 | identity, | |
| 270 | filename: target.clone(), | |
| 271 | }], | |
| 272 | )?; | |
| 273 | self.refresh()?; | |
| 274 | Ok(catalog_path(folder, &target)) | |
| 275 | } | |
| 276 | ||
| 277 | /// Sets a section's colour (COLORREF) in its own metadata, where OneNote keeps it. | |
| 278 | pub fn set_section_color(&mut self, path: &str, color: Option<u32>) -> Result<()> { | |
| 279 | let file = self.root.join(path).canonicalize()?; | |
| 280 | if !file.starts_with(&self.root) { | |
| 281 | return Err(io::Error::from(io::ErrorKind::PermissionDenied).into()); | |
| 282 | } | |
| 283 | let source = onestore::read_file(&file)?; | |
| 284 | let store = onestore::Store::parse(&source)?; | |
| 285 | let index = onestore::RevisionIndex::parse(&store)?; | |
| 286 | let document = Document::parse(&index)?; | |
| 287 | let revision = document.active(document.root)?; | |
| 288 | let metadata = *revision | |
| 289 | .roots | |
| 290 | .get(&2) | |
| 291 | .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?; | |
| 292 | onestore::commit_file_property( | |
| 293 | &file, | |
| 294 | &source, | |
| 295 | document.root, | |
| 296 | metadata, | |
| 297 | 0x14001cbe, | |
| 298 | &color.unwrap_or(0xffff_ffff).to_le_bytes(), | |
| 299 | ) | |
| 300 | .map_err(|error| error.error)?; | |
| 301 | self.refresh()?; | |
| 302 | Ok(()) | |
| 303 | } | |
| 304 | ||
| 305 | /// Orders a folder's sections and groups; entries left out follow in their current order. | |
| 306 | pub fn reorder(&mut self, folder: &str, paths: &[&str]) -> Result<()> { | |
| 307 | let mut identities = Vec::new(); | |
| 308 | for path in paths { | |
| 309 | let (parent, entry) = split(path); | |
| 310 | if parent != folder { | |
| 311 | return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); | |
| 312 | } | |
| 313 | identities.push(self.entry(folder, entry)?.1); | |
| 314 | } | |
| 315 | self.edit_toc(folder, &[onestore::TocEdit::Order(identities)])?; | |
| 316 | self.refresh().map(drop) | |
| 317 | } | |
| 318 | ||
| 319 | /// Deletes a section the way OneNote does: the file moves into the notebook's | |
| 320 | /// `OneNote_RecycleBin` folder, a section group with its own TOC that lists it (and that | |
| 321 | /// the root TOC lists), and its own folder's TOC entry goes. | |
| 322 | pub fn delete(&mut self, path: &str) -> Result<()> { | |
| 323 | let (folder, entry) = split(path); | |
| 324 | let directory = self.directory(folder)?; | |
| 325 | let (filename, identity) = self.entry(folder, entry)?; | |
| 326 | if !filename.to_ascii_lowercase().ends_with(".one") { | |
| 327 | return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); | |
| 328 | } | |
| 329 | let bin = self.root.join("OneNote_RecycleBin"); | |
| 330 | let bin_toc = bin.join("Open Notebook.onetoc2"); | |
| 331 | if !bin_toc.exists() { | |
| 332 | std::fs::create_dir_all(&bin)?; | |
| 333 | let bytes = onestore::create_table_of_contents("Open Notebook.onetoc2", &[])?; | |
| 334 | std::fs::File::create_new(&bin_toc)?.write_all(&bytes)?; | |
| 335 | onestore::place_file(&bin_toc, self.toc("")?.1, "OneNote_RecycleBin")?; | |
| 336 | let bin_identity = onestore::Store::parse(&bytes)?.header.file_id; | |
| 337 | self.edit_toc( | |
| 338 | "", | |
| 339 | &[onestore::TocEdit::Add { | |
| 340 | filename: "OneNote_RecycleBin".into(), | |
| 341 | identity: bin_identity, | |
| 342 | group: true, | |
| 343 | }], | |
| 344 | )?; | |
| 345 | } | |
| 346 | let mut target = filename.clone(); | |
| 347 | let mut attempt = 1; | |
| 348 | while bin.join(&target).exists() { | |
| 349 | attempt += 1; | |
| 350 | let (stem, extension) = filename.rsplit_once('.').unwrap_or((&filename, "")); | |
| 351 | target = format!("{stem} ({attempt}).{extension}"); | |
| 352 | } | |
| 353 | std::fs::rename(directory.join(&filename), bin.join(&target))?; | |
| 354 | let source = onestore::read_file(&bin_toc)?; | |
| 355 | onestore::place_file( | |
| 356 | bin.join(&target), | |
| 357 | onestore::Store::parse(&source)?.header.file_id, | |
| 358 | &target, | |
| 359 | )?; | |
| 360 | PreparedEdit::table_of_contents( | |
| 361 | &source, | |
| 362 | &[onestore::TocEdit::Add { | |
| 363 | filename: target, | |
| 364 | identity, | |
| 365 | group: false, | |
| 366 | }], | |
| 367 | )? | |
| 368 | .commit_file(&bin_toc) | |
| 369 | .map_err(|error| error.error)?; | |
| 370 | self.edit_toc(folder, &[onestore::TocEdit::Remove { identity }])?; | |
| 371 | self.refresh().map(drop) | |
| 372 | } | |
| 373 | ||
| 374 | /// The stored filename and TOC identity of a folder's section or group. | |
| 375 | fn entry(&self, folder: &str, name: &str) -> Result<(String, [u8; 16])> { | |
| 376 | let parent = self.folder(folder)?; | |
| 377 | if let Some(section) = parent | |
| 378 | .sections | |
| 379 | .iter() | |
| 380 | .find(|section| split(&section.path).1 == name) | |
| 381 | { | |
| 382 | return Ok((name.to_owned(), section.file_id)); | |
| 383 | } | |
| 384 | if let Some(group) = parent | |
| 385 | .groups | |
| 386 | .iter() | |
| 387 | .find(|group| split(&group.path).1 == name) | |
| 388 | { | |
| 389 | let toc = group | |
| 390 | .toc | |
| 391 | .as_ref() | |
| 392 | .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?; | |
| 393 | return Ok((name.to_owned(), toc.file_id)); | |
| 394 | } | |
| 395 | Err(io::Error::from(io::ErrorKind::NotFound).into()) | |
| 396 | } | |
| 397 | ||
| 398 | /// The section path and page space a stored internal link opens, found by identity: | |
| 399 | /// in the linked section first, then in every other readable section, so a link | |
| 400 | /// follows its page across sections. `None` for other URLs and unknown targets. | |
| 401 | pub fn find_page(&self, url: &str) -> Result<Option<(String, Option<ExGuid>)>> { | |
| 402 | let Some(link) = onestore::page::link::parse_internal_link(url) else { | |
| 403 | return Ok(None); | |
| 404 | }; | |
| 405 | let mut sections = Vec::new(); | |
| 406 | let mut folders = vec![&self.catalog]; | |
| 407 | while let Some(folder) = folders.pop() { | |
| 408 | sections.extend(folder.sections.iter().filter(|section| { | |
| 409 | matches!(section.state, discover::SectionState::Readable { .. }) | |
| 410 | })); | |
| 411 | folders.extend(&folder.groups); | |
| 412 | } | |
| 413 | sections.sort_by_key(|section| section.file_id != link.section); | |
| 414 | let Some(page) = link.page else { | |
| 415 | return Ok(sections | |
| 416 | .first() | |
| 417 | .filter(|section| section.file_id == link.section) | |
| 418 | .map(|section| (section.path.clone(), None))); | |
| 419 | }; | |
| 420 | for section in sections { | |
| 421 | let bytes = onestore::read_file(self.root.join(&section.path))?; | |
| 422 | let store = Store::parse(&bytes)?; | |
| 423 | let index = RevisionIndex::parse(&store)?; | |
| 424 | let document = Document::parse(&index)?; | |
| 425 | for (space, _) in document.pages()? { | |
| 426 | if Page::identity_of(document.active(space)?) == Some(page) { | |
| 427 | return Ok(Some((section.path.clone(), Some(space)))); | |
| 428 | } | |
| 429 | } | |
| 430 | } | |
| 431 | Ok(None) | |
| 432 | } | |
| 433 | ||
| 434 | /// The pages of a password-protected section, for reading: nothing is cached or | |
| 435 | /// written, and the decoded buffers go when the pages have been built. | |
| 436 | #[cfg(feature = "protected")] | |
| 437 | pub fn unlock(&self, path: &str, password: &str) -> Result<Vec<(ExGuid, Page)>> { | |
| 438 | let file = self.root.join(path).canonicalize()?; | |
| 439 | if !file.starts_with(&self.root) { | |
| 440 | return Err(io::Error::from(io::ErrorKind::PermissionDenied).into()); | |
| 441 | } | |
| 442 | let bytes = onestore::read_file(&file)?; | |
| 443 | let store = Store::parse(&bytes)?; | |
| 444 | let index = RevisionIndex::parse(&store)?; | |
| 445 | let unlocked = onestore::protected::UnlockedSection::open( | |
| 446 | &index, | |
| 447 | password, | |
| 448 | onestore::protected::Limits::default(), | |
| 449 | )?; | |
| 450 | let document = unlocked.document()?; | |
| 451 | document | |
| 452 | .pages()? | |
| 453 | .into_iter() | |
| 454 | .map(|(space, _)| Ok((space, Page::from_space(&document, space)?))) | |
| 455 | .collect() | |
| 456 | } | |
| 457 | ||
| 51 | 458 | /// Opens a section by its catalog path. |
| 52 | 459 | pub fn section(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result<Section> { |
| 53 | 460 | let mut folders = vec![&self.catalog]; |
| ... | ... | @@ -65,6 +472,25 @@ impl Notebook { |
| 65 | 472 | } |
| 66 | 473 | } |
| 67 | 474 | |
| 475 | fn component(name: &str) -> bool { | |
| 476 | !name.is_empty() && !name.contains(['/', '\\', '\0']) && name != "." && name != ".." | |
| 477 | } | |
| 478 | ||
| 479 | fn split(path: &str) -> (&str, &str) { | |
| 480 | match path.rsplit_once('/') { | |
| 481 | Some((folder, name)) => (folder, name), | |
| 482 | None => ("", path), | |
| 483 | } | |
| 484 | } | |
| 485 | ||
| 486 | fn catalog_path(folder: &str, name: &str) -> String { | |
| 487 | if folder.is_empty() { | |
| 488 | name.to_owned() | |
| 489 | } else { | |
| 490 | format!("{folder}/{name}") | |
| 491 | } | |
| 492 | } | |
| 493 | ||
| 68 | 494 | /// What happened on the synchronization thread since the last poll. |
| 69 | 495 | #[derive(Debug)] |
| 70 | 496 | pub enum Event { |
| ... | ... | @@ -181,7 +607,7 @@ impl Section { |
| 181 | 607 | Ok(None) => Event::Refreshed, |
| 182 | 608 | Ok(Some((id, status))) => Event::Attempt { |
| 183 | 609 | id: *id, |
| 184 | status: *status, | |
| 610 | status: status.clone(), | |
| 185 | 611 | }, |
| 186 | 612 | Err(Error::RemoteIo(error)) => Event::Unreachable(match error.raw_os_error() { |
| 187 | 613 | Some(code) => io::Error::from_raw_os_error(code), |
| ... | ... | @@ -308,6 +734,18 @@ impl Section { |
| 308 | 734 | Ok(()) |
| 309 | 735 | } |
| 310 | 736 | |
| 737 | /// Retires an uncertain attempt after review, exporting the branch to `archive` | |
| 738 | /// first: `Some(page)` continues from the reviewed page against `remote_page`, | |
| 739 | /// `None` abandons the local branch. Neither claims the attempt was acknowledged. | |
| 740 | pub fn release(&self, id: u64, archive: impl AsRef<Path>, after: Option<&Page>) -> Result<()> { | |
| 741 | let local = self.replica.snapshot()?; | |
| 742 | let remote = self.replica.remote_snapshot()?; | |
| 743 | self.replica | |
| 744 | .release_attempt(id, &local, &remote, archive.as_ref(), after)?; | |
| 745 | self.wake(); | |
| 746 | Ok(()) | |
| 747 | } | |
| 748 | ||
| 311 | 749 | /// Captures both images, the queue and its states in a read-only archive. |
| 312 | 750 | pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> { |
| 313 | 751 | self.replica.export_recovery(path) |
crates/notebook/src/sync.rs+130-1| ... | ... | @@ -27,7 +27,7 @@ pub enum ConflictKind { |
| 27 | 27 | ContentChanged = 3, |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 30 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 31 | 31 | pub enum EditStatus { |
| 32 | 32 | Pending, |
| 33 | 33 | /// Retained publication attempt; this revision alone may be insufficient to confirm it. |
| ... | ... | @@ -39,6 +39,11 @@ pub enum EditStatus { |
| 39 | 39 | Published { |
| 40 | 40 | revision: ExGuid, |
| 41 | 41 | }, |
| 42 | /// Retired unpublished by a reviewed release; the archive at this path holds the | |
| 43 | /// intent, its attempt evidence and both images. | |
| 44 | Archived { | |
| 45 | archive: String, | |
| 46 | }, | |
| 42 | 47 | } |
| 43 | 48 | |
| 44 | 49 | impl Replica { |
| ... | ... | @@ -355,6 +360,120 @@ impl Replica { |
| 355 | 360 | }) |
| 356 | 361 | } |
| 357 | 362 | |
| 363 | /// Retires the oldest edit's uncertain attempt after review. The branch is first | |
| 364 | /// exported to `archive` (a new file), which is the record: no receipt is written. | |
| 365 | /// `Some(after)` continues from the reviewed page as a fresh intent against the | |
| 366 | /// current remote page, keeping the edit's id and its dependents; `None` abandons | |
| 367 | /// the whole local branch, whose edits become `Archived`, and the working image | |
| 368 | /// returns to the remote image. Both need the reviewed images to be current. | |
| 369 | pub fn release_attempt( | |
| 370 | &self, | |
| 371 | id: u64, | |
| 372 | local: &[u8], | |
| 373 | remote: &[u8], | |
| 374 | archive: &Path, | |
| 375 | after: Option<&Page>, | |
| 376 | ) -> Result<()> { | |
| 377 | let owner = self.sync_owner()?; | |
| 378 | let intent = self | |
| 379 | .pending()? | |
| 380 | .into_iter() | |
| 381 | .next() | |
| 382 | .filter(|intent| intent.id == id) | |
| 383 | .ok_or_else(|| { | |
| 384 | io::Error::new( | |
| 385 | io::ErrorKind::InvalidInput, | |
| 386 | "Only the oldest edit can hold an attempt", | |
| 387 | ) | |
| 388 | })?; | |
| 389 | let continued = match after { | |
| 390 | Some(after) => { | |
| 391 | let Operation::Page(edit) = intent.operation else { | |
| 392 | return Err(io::Error::new( | |
| 393 | io::ErrorKind::InvalidInput, | |
| 394 | "Select a page save to continue from", | |
| 395 | ) | |
| 396 | .into()); | |
| 397 | }; | |
| 398 | let before = page_of(remote, intent.space)?.ok_or_else(|| { | |
| 399 | io::Error::new( | |
| 400 | io::ErrorKind::InvalidInput, | |
| 401 | "The page is no longer in the remote section", | |
| 402 | ) | |
| 403 | })?; | |
| 404 | PreparedEdit::page(remote, intent.space, after, &edit.author)?; | |
| 405 | Some(Operation::Page(PageIntent { | |
| 406 | before, | |
| 407 | after: after.clone(), | |
| 408 | author: edit.author, | |
| 409 | })) | |
| 410 | } | |
| 411 | None => None, | |
| 412 | }; | |
| 413 | let mut connection = self | |
| 414 | .connection | |
| 415 | .lock() | |
| 416 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | |
| 417 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | |
| 418 | let (base, working): (Vec<u8>, Vec<u8>) = | |
| 419 | transaction.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| { | |
| 420 | Ok((row.get(0)?, row.get(1)?)) | |
| 421 | })?; | |
| 422 | if working != local || base != remote { | |
| 423 | return Err(io::Error::new( | |
| 424 | io::ErrorKind::ResourceBusy, | |
| 425 | "The reviewed cache images changed", | |
| 426 | ) | |
| 427 | .into()); | |
| 428 | } | |
| 429 | let id = i64::try_from(id).map_err(io::Error::other)?; | |
| 430 | let attempted: bool = transaction.query_row( | |
| 431 | "SELECT EXISTS(SELECT 1 FROM attempt WHERE edit_id=?1)", | |
| 432 | [id], | |
| 433 | |row| row.get(0), | |
| 434 | )?; | |
| 435 | if !attempted { | |
| 436 | return Err(io::Error::new( | |
| 437 | io::ErrorKind::InvalidInput, | |
| 438 | "The edit has no uncertain attempt", | |
| 439 | ) | |
| 440 | .into()); | |
| 441 | } | |
| 442 | drop(transaction); | |
| 443 | drop(connection); | |
| 444 | self.export_recovery(archive)?; | |
| 445 | let mut connection = self | |
| 446 | .connection | |
| 447 | .lock() | |
| 448 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | |
| 449 | let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | |
| 450 | transaction.execute("DELETE FROM attempt WHERE edit_id=?1", [id])?; | |
| 451 | match continued { | |
| 452 | Some(operation) => { | |
| 453 | transaction.execute( | |
| 454 | "UPDATE edits SET operation=?1 WHERE id=?2", | |
| 455 | params![ | |
| 456 | serde_json::to_string(&operation).map_err(io::Error::other)?, | |
| 457 | id | |
| 458 | ], | |
| 459 | )?; | |
| 460 | } | |
| 461 | None => { | |
| 462 | transaction.execute( | |
| 463 | "INSERT INTO archived(edit_id, archive) SELECT id, ?1 FROM edits", | |
| 464 | [archive.to_string_lossy().into_owned()], | |
| 465 | )?; | |
| 466 | transaction.execute("DELETE FROM edits", [])?; | |
| 467 | transaction.execute("UPDATE replica SET working=base WHERE id=1", [])?; | |
| 468 | } | |
| 469 | } | |
| 470 | transaction.commit()?; | |
| 471 | drop(connection); | |
| 472 | drop(owner); | |
| 473 | self.wake_sync(); | |
| 474 | Ok(()) | |
| 475 | } | |
| 476 | ||
| 358 | 477 | fn resolve_conflict( |
| 359 | 478 | &self, |
| 360 | 479 | id: u64, |
| ... | ... | @@ -462,6 +581,16 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat |
| 462 | 581 | revision: revision.parse()?, |
| 463 | 582 | })); |
| 464 | 583 | } |
| 584 | if let Some(archive) = connection | |
| 585 | .query_row( | |
| 586 | "SELECT archive FROM archived WHERE edit_id=?1", | |
| 587 | [id], | |
| 588 | |row| row.get::<_, String>(0), | |
| 589 | ) | |
| 590 | .optional()? | |
| 591 | { | |
| 592 | return Ok(Some(EditStatus::Archived { archive })); | |
| 593 | } | |
| 465 | 594 | let record: Option<(Option<String>, Option<i64>, String)> = connection.query_row( |
| 466 | 595 | "SELECT attempt.revisions, conflicts.kind, edits.space FROM edits LEFT JOIN attempt ON attempt.edit_id=edits.id LEFT JOIN conflicts ON conflicts.edit_id=edits.id WHERE edits.id=?1", [id], |row| Ok((row.get(0)?,row.get(1)?, row.get(2)?))).optional()?; |
| 467 | 596 | Ok(match record { |
crates/notebook/tests/changes.rs created+78| ... | ... | @@ -0,0 +1,78 @@ |
| 1 | use notebook::session::{Change, Notebook}; | |
| 2 | use 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] | |
| 7 | fn 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 @@ |
| 1 | use notebook::session::Notebook; | |
| 2 | use onestore::page::link::{LinkTarget, internal_link}; | |
| 3 | use std::io::Write; | |
| 4 | ||
| 5 | fn 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] | |
| 17 | fn 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 | ||
| 3 | use notebook::{discover::SectionState, session::Notebook}; | |
| 4 | use std::path::Path; | |
| 5 | ||
| 6 | /// A locked section is discovered as such and reads with its password, page by page. | |
| 7 | #[test] | |
| 8 | fn a_locked_section_unlocks_for_reading() { | |
| 9 | let root = Path::new(concat!( | |
| 10 | env!("CARGO_MANIFEST_DIR"), | |
| 11 | "/../../corpus/native-protected-boundaries" | |
| 12 | )); | |
| 13 | let manifest: serde_json::Value = | |
| 14 | serde_json::from_slice(&std::fs::read(root.join("manifest.json")).unwrap()).unwrap(); | |
| 15 | let password = manifest["password"].as_str().unwrap(); | |
| 16 | let temporary = tempfile::tempdir().unwrap(); | |
| 17 | let notebook = Notebook::open(root.join("notebook"), temporary.path()).unwrap(); | |
| 18 | let section = &notebook.catalog().sections[0]; | |
| 19 | assert!(matches!(section.state, SectionState::Locked)); | |
| 20 | let pages = notebook.unlock(&section.path, password).unwrap(); | |
| 21 | assert_eq!(pages.len(), 11); | |
| 22 | assert!(pages.iter().all(|(_, page)| !page.title.is_empty())); | |
| 23 | assert!(matches!( | |
| 24 | notebook.unlock(&section.path, "wrong"), | |
| 25 | Err(notebook::Error::Protected( | |
| 26 | onestore::protected::Error::PasswordMismatch | |
| 27 | )) | |
| 28 | )); | |
| 29 | } |
crates/notebook/tests/session.rs+97| ... | ... | @@ -680,3 +680,100 @@ fn a_conflicting_save_is_reviewed_against_the_remote_page_and_archived_for_recov |
| 680 | 680 | assert!(section.queue().unwrap().is_empty()); |
| 681 | 681 | section.close().unwrap(); |
| 682 | 682 | } |
| 683 | ||
| 684 | #[path = "support/server.rs"] | |
| 685 | mod 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] | |
| 691 | fn 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 | ||
| 771 | fn 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 @@ |
| 1 | use notebook::session::Notebook; | |
| 2 | use 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`. | |
| 6 | fn 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 | ||
| 25 | fn 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] | |
| 35 | fn 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 | ||
| 162 | fn copy_dir(from: &std::path::Path, to: &std::path::Path) { | |
| 163 | std::fs::create_dir_all(to).unwrap(); | |
| 164 | for entry in std::fs::read_dir(from).unwrap() { | |
| 165 | let entry = entry.unwrap(); | |
| 166 | let target = to.join(entry.file_name()); | |
| 167 | if entry.file_type().unwrap().is_dir() { | |
| 168 | copy_dir(&entry.path(), &target); | |
| 169 | } else { | |
| 170 | std::fs::copy(entry.path(), target).unwrap(); | |
| 171 | } | |
| 172 | } | |
| 173 | } |
crates/notebook/tests/sync.rs+18-18| ... | ... | @@ -81,7 +81,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin |
| 81 | 81 | assert_eq!(archive.snapshot().unwrap(), working); |
| 82 | 82 | assert_eq!(archive.remote_snapshot().unwrap(), remote); |
| 83 | 83 | assert_eq!(archive.pending().unwrap(), pending); |
| 84 | assert_eq!(archive.status(published).unwrap(), Some(receipt)); | |
| 84 | assert_eq!(archive.status(published).unwrap(), Some(receipt.clone())); | |
| 85 | 85 | assert_eq!(archive.status(attempted).unwrap(), uncertain); |
| 86 | 86 | for edit in &pending { |
| 87 | 87 | assert_eq!( |
| ... | ... | @@ -89,7 +89,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin |
| 89 | 89 | cache.status(edit.id).unwrap() |
| 90 | 90 | ); |
| 91 | 91 | } |
| 92 | let EditStatus::Published { revision } = receipt else { | |
| 92 | let EditStatus::Published { revision } = receipt.clone() else { | |
| 93 | 93 | panic!() |
| 94 | 94 | }; |
| 95 | 95 | assert_eq!(archive.receipts().unwrap(), [(published, revision)].into()); |
| ... | ... | @@ -158,7 +158,7 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv |
| 158 | 158 | let archive = Recovery::open(&path).unwrap(); |
| 159 | 159 | assert_eq!(text(&archive.snapshot().unwrap()).2, "Local"); |
| 160 | 160 | assert_eq!(archive.remote_snapshot().unwrap(), changed); |
| 161 | assert_eq!(archive.status(id).unwrap(), Some(outcome.1)); | |
| 161 | assert_eq!(archive.status(id).unwrap(), Some(outcome.1.clone())); | |
| 162 | 162 | assert_eq!(archive.summary().unwrap().conflicts, 1); |
| 163 | 163 | assert_eq!(archive.summary().unwrap().uncertain_edits, 0); |
| 164 | 164 | drop(archive); |
| ... | ... | @@ -173,7 +173,7 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv |
| 173 | 173 | assert!(Recovery::open(&path).is_err()); |
| 174 | 174 | assert_eq!(std::fs::read(&path).unwrap(), before); |
| 175 | 175 | } |
| 176 | assert_eq!(cache.status(id).unwrap(), Some(outcome.1)); | |
| 176 | assert_eq!(cache.status(id).unwrap(), Some(outcome.1.clone())); | |
| 177 | 177 | assert_eq!(text(&cache.snapshot().unwrap()).2, "Local"); |
| 178 | 178 | } |
| 179 | 179 | |
| ... | ... | @@ -195,7 +195,7 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() { |
| 195 | 195 | assert!(cache.pending().unwrap().is_empty()); |
| 196 | 196 | drop(cache); |
| 197 | 197 | let cache = Replica::open(&path).unwrap(); |
| 198 | assert_eq!(cache.status(id).unwrap(), Some(outcome.1)); | |
| 198 | assert_eq!(cache.status(id).unwrap(), Some(outcome.1.clone())); | |
| 199 | 199 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); |
| 200 | 200 | assert_eq!(server.publications, 1); |
| 201 | 201 | assert_eq!(cache.status(id + 1).unwrap(), None); |
| ... | ... | @@ -849,17 +849,17 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() { |
| 849 | 849 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); |
| 850 | 850 | assert_eq!(cache.snapshot().unwrap(), source); |
| 851 | 851 | assert_eq!(cache.remote_snapshot().unwrap(), source); |
| 852 | assert_eq!(cache.status(published).unwrap(), Some(receipt)); | |
| 852 | assert_eq!(cache.status(published).unwrap(), Some(receipt.clone())); | |
| 853 | 853 | assert_eq!(server.publications, 1); |
| 854 | 854 | assert!(cache.pending().unwrap().is_empty()); |
| 855 | 855 | let archive = notebook::Recovery::open(directory.path().join("published.sqlite")).unwrap(); |
| 856 | 856 | assert_eq!(archive.snapshot().unwrap(), published_image); |
| 857 | assert_eq!(archive.status(published).unwrap(), Some(receipt)); | |
| 857 | assert_eq!(archive.status(published).unwrap(), Some(receipt.clone())); | |
| 858 | 858 | assert_eq!(text(&archive.snapshot().unwrap()).2, "Published Original"); |
| 859 | 859 | drop(cache); |
| 860 | 860 | let cache = Replica::open(path).unwrap(); |
| 861 | 861 | assert_eq!(text(&cache.snapshot().unwrap()).2, "Original"); |
| 862 | assert_eq!(cache.status(published).unwrap(), Some(receipt)); | |
| 862 | assert_eq!(cache.status(published).unwrap(), Some(receipt.clone())); | |
| 863 | 863 | } |
| 864 | 864 | |
| 865 | 865 | #[test] |
| ... | ... | @@ -892,10 +892,10 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() { |
| 892 | 892 | let attempts = server.publications; |
| 893 | 893 | let cache = Replica::open(&path).unwrap(); |
| 894 | 894 | let (_, status) = cache.sync_once(&mut server).unwrap().unwrap(); |
| 895 | assert_eq!(cache.status(published).unwrap(), Some(receipt)); | |
| 895 | assert_eq!(cache.status(published).unwrap(), Some(receipt.clone())); | |
| 896 | 896 | if uncertain { |
| 897 | 897 | assert!(matches!(status, EditStatus::AwaitingConfirmation { .. })); |
| 898 | assert_eq!(Some(status), prior_status); | |
| 898 | assert_eq!(Some(status.clone()), prior_status); | |
| 899 | 899 | assert_eq!(server.publications, attempts); |
| 900 | 900 | assert_eq!(server.visible, source); |
| 901 | 901 | assert_eq!(cache.snapshot().unwrap(), local); |
| ... | ... | @@ -913,8 +913,8 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() { |
| 913 | 913 | assert_eq!(archive.status(queued).unwrap(), prior_status); |
| 914 | 914 | drop(cache); |
| 915 | 915 | 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())); | |
| 918 | 918 | } |
| 919 | 919 | } |
| 920 | 920 | |
| ... | ... | @@ -934,7 +934,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() { |
| 934 | 934 | let conflict = EditStatus::Conflict(ConflictKind::ContentChanged); |
| 935 | 935 | assert_eq!( |
| 936 | 936 | cache.sync_once(&mut server).unwrap(), |
| 937 | Some((head, conflict)) | |
| 937 | Some((head, conflict.clone())) | |
| 938 | 938 | ); |
| 939 | 939 | let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap(); |
| 940 | 940 | let dependent = save(&cache, object, end..end, " Later").unwrap(); |
| ... | ... | @@ -947,7 +947,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() { |
| 947 | 947 | .unwrap(); |
| 948 | 948 | drop(cache); |
| 949 | 949 | let cache = Replica::open(&path).unwrap(); |
| 950 | assert_eq!(cache.status(head).unwrap(), Some(conflict)); | |
| 950 | assert_eq!(cache.status(head).unwrap(), Some(conflict.clone())); | |
| 951 | 951 | assert_eq!(cache.pending().unwrap(), pending); |
| 952 | 952 | assert_eq!(cache.snapshot().unwrap(), local); |
| 953 | 953 | let reviewed = edited(&source, object, 0..0, "Revised "); |
| ... | ... | @@ -956,7 +956,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() { |
| 956 | 956 | server.durable.clone_from(&changed); |
| 957 | 957 | assert_eq!( |
| 958 | 958 | cache.sync_once(&mut server).unwrap(), |
| 959 | Some((head, conflict)) | |
| 959 | Some((head, conflict.clone())) | |
| 960 | 960 | ); |
| 961 | 961 | assert!( |
| 962 | 962 | matches!(cache.review_page(head, &local, &source, &reviewed), |
| ... | ... | @@ -975,7 +975,7 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() { |
| 975 | 975 | Some((actual, EditStatus::Published { .. })) if actual == head)); |
| 976 | 976 | assert_eq!( |
| 977 | 977 | cache.sync_once(&mut server).unwrap(), |
| 978 | Some((dependent, conflict)) | |
| 978 | Some((dependent, conflict.clone())) | |
| 979 | 979 | ); |
| 980 | 980 | assert_eq!(server.publications, 2); |
| 981 | 981 | let dependent_local = cache.snapshot().unwrap(); |
| ... | ... | @@ -989,11 +989,11 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() { |
| 989 | 989 | assert!(matches!(cache.sync_once(&mut server).unwrap(), |
| 990 | 990 | Some((actual, EditStatus::Published { .. })) if actual == dependent)); |
| 991 | 991 | assert_eq!(text(&server.durable).2, "Revised Original Later Remote"); |
| 992 | assert_eq!(cache.status(published).unwrap(), Some(receipt)); | |
| 992 | assert_eq!(cache.status(published).unwrap(), Some(receipt.clone())); | |
| 993 | 993 | let archive = notebook::Recovery::open(directory.path().join("conflicted.sqlite")).unwrap(); |
| 994 | 994 | assert_eq!(archive.snapshot().unwrap(), local); |
| 995 | 995 | assert_eq!(archive.pending().unwrap(), pending); |
| 996 | assert_eq!(archive.status(head).unwrap(), Some(conflict)); | |
| 996 | assert_eq!(archive.status(head).unwrap(), Some(conflict.clone())); | |
| 997 | 997 | } |
| 998 | 998 | |
| 999 | 999 | #[test] |
crates/notebook/tests/sync_formatting.rs+5-2| ... | ... | @@ -391,10 +391,13 @@ fn uncertain_formatting_attempts_keep_the_original_attempt_and_never_replay() { |
| 391 | 391 | assert!(matches!(state, EditStatus::AwaitingConfirmation { .. })); |
| 392 | 392 | drop(cache); |
| 393 | 393 | let cache = Replica::open(&path).unwrap(); |
| 394 | assert_eq!(cache.status(id).unwrap(), Some(state)); | |
| 394 | assert_eq!(cache.status(id).unwrap(), Some(state.clone())); | |
| 395 | 395 | if matches!(fault, Fault::UnknownBefore) { |
| 396 | 396 | for _ in 0..3 { |
| 397 | assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, state))); | |
| 397 | assert_eq!( | |
| 398 | cache.sync_once(&mut server).unwrap(), | |
| 399 | Some((id, state.clone())) | |
| 400 | ); | |
| 398 | 401 | } |
| 399 | 402 | assert_eq!(server.publications, 1); |
| 400 | 403 | assert_eq!(server.durable, OUTLINES); |
crates/notebook/tests/sync_outline.rs+8-2| ... | ... | @@ -410,11 +410,17 @@ fn an_uncertain_layout_attempt_confirms_by_revision_or_by_an_equal_remote_page() |
| 410 | 410 | assert_eq!((server.publications, server.confirmations), (1, 1)); |
| 411 | 411 | continue; |
| 412 | 412 | } |
| 413 | assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, status))); | |
| 413 | assert_eq!( | |
| 414 | cache.sync_once(&mut server).unwrap(), | |
| 415 | Some((id, status.clone())) | |
| 416 | ); | |
| 414 | 417 | server.visible = remote_with(&source, space, |page| { |
| 415 | 418 | resized(216.0, true)(outline_mut(page, outline)); |
| 416 | 419 | }); |
| 417 | assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, status))); | |
| 420 | assert_eq!( | |
| 421 | cache.sync_once(&mut server).unwrap(), | |
| 422 | Some((id, status.clone())) | |
| 423 | ); | |
| 418 | 424 | assert_eq!(cache.snapshot().unwrap(), local); |
| 419 | 425 | server.visible = remote_with(&source, space, |page| { |
| 420 | 426 | resized(144.0, true)(outline_mut(page, outline)); |
crates/notebook/tests/sync_page.rs+6-3| ... | ... | @@ -140,7 +140,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() { |
| 140 | 140 | cache.export_recovery(&archive).unwrap(); |
| 141 | 141 | assert_eq!( |
| 142 | 142 | Recovery::open(&archive).unwrap().status(id).unwrap(), |
| 143 | Some(attempted) | |
| 143 | Some(attempted.clone()) | |
| 144 | 144 | ); |
| 145 | 145 | drop(cache); |
| 146 | 146 | let db = rusqlite::Connection::open(&path).unwrap(); |
| ... | ... | @@ -163,7 +163,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() { |
| 163 | 163 | assert!(observed[..212] == server.durable[..212]); |
| 164 | 164 | assert!(observed[252..] == server.durable[252..]); |
| 165 | 165 | } else { |
| 166 | assert_eq!(result, (id, attempted)); | |
| 166 | assert_eq!(result, (id, attempted.clone())); | |
| 167 | 167 | assert_eq!(cache.snapshot().unwrap(), local); |
| 168 | 168 | } |
| 169 | 169 | } |
| ... | ... | @@ -219,7 +219,10 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() { |
| 219 | 219 | .contains_key(&revisions[&page.space()]) |
| 220 | 220 | ); |
| 221 | 221 | let cache = Replica::open(&path).unwrap(); |
| 222 | assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, attempted))); | |
| 222 | assert_eq!( | |
| 223 | cache.sync_once(&mut server).unwrap(), | |
| 224 | Some((id, attempted.clone())) | |
| 225 | ); | |
| 223 | 226 | assert_eq!(server.confirmations, 0); |
| 224 | 227 | assert_eq!(server.publications, 1); |
| 225 | 228 | server.visible = complete; |
crates/notebook/tests/sync_pages.rs+6-3| ... | ... | @@ -302,7 +302,7 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() { |
| 302 | 302 | cache.export_recovery(&archive).unwrap(); |
| 303 | 303 | let recovery = Recovery::open(&archive).unwrap(); |
| 304 | 304 | assert_eq!(recovery.pending().unwrap(), queue); |
| 305 | assert_eq!(recovery.status(id).unwrap(), Some(attempted)); | |
| 305 | assert_eq!(recovery.status(id).unwrap(), Some(attempted.clone())); | |
| 306 | 306 | assert_eq!(recovery.snapshot().unwrap(), local); |
| 307 | 307 | assert!( |
| 308 | 308 | cache |
| ... | ... | @@ -315,7 +315,7 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() { |
| 315 | 315 | assert!(matches!(result.1, EditStatus::Published { .. })); |
| 316 | 316 | assert_eq!(server.confirmations, 1); |
| 317 | 317 | } else { |
| 318 | assert_eq!(result, (id, attempted)); | |
| 318 | assert_eq!(result, (id, attempted.clone())); | |
| 319 | 319 | assert_eq!(server.confirmations, 0); |
| 320 | 320 | } |
| 321 | 321 | } |
| ... | ... | @@ -366,7 +366,10 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() { |
| 366 | 366 | let cache = Replica::open(&path).unwrap(); |
| 367 | 367 | let complete = server.visible.clone(); |
| 368 | 368 | server.visible = source; |
| 369 | assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, attempted))); | |
| 369 | assert_eq!( | |
| 370 | cache.sync_once(&mut server).unwrap(), | |
| 371 | Some((id, attempted.clone())) | |
| 372 | ); | |
| 370 | 373 | assert_eq!(server.confirmations, 0); |
| 371 | 374 | server.visible = complete; |
| 372 | 375 | assert!(matches!( |
crates/notebook/tests/sync_paragraph.rs+5-2| ... | ... | @@ -732,7 +732,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { |
| 732 | 732 | let state = cache.status(id).unwrap().unwrap(); |
| 733 | 733 | drop(cache); |
| 734 | 734 | let cache = Replica::open(&path).unwrap(); |
| 735 | assert_eq!(cache.status(id).unwrap(), Some(state)); | |
| 735 | assert_eq!(cache.status(id).unwrap(), Some(state.clone())); | |
| 736 | 736 | match fault { |
| 737 | 737 | Fault::Before => { |
| 738 | 738 | assert_eq!(state, EditStatus::Pending); |
| ... | ... | @@ -748,7 +748,10 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() { |
| 748 | 748 | assert_eq!(cache.pending().unwrap(), pending); |
| 749 | 749 | assert_eq!(cache.snapshot().unwrap(), local); |
| 750 | 750 | for _ in 0..3 { |
| 751 | assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, state))); | |
| 751 | assert_eq!( | |
| 752 | cache.sync_once(&mut server).unwrap(), | |
| 753 | Some((id, state.clone())) | |
| 754 | ); | |
| 752 | 755 | } |
| 753 | 756 | assert_eq!(server.publications, 1); |
| 754 | 757 | assert_eq!(server.durable, BEFORE); |
crates/notebook/tests/sync_worker.rs+9-9| ... | ... | @@ -108,7 +108,7 @@ fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() { |
| 108 | 108 | }, |
| 109 | 109 | move |result| { |
| 110 | 110 | observed_tx |
| 111 | .send(result.as_ref().copied().map_err(|error| error.to_string())) | |
| 111 | .send(result.as_ref().cloned().map_err(|error| error.to_string())) | |
| 112 | 112 | .unwrap(); |
| 113 | 113 | }, |
| 114 | 114 | ) |
| ... | ... | @@ -163,7 +163,7 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() { |
| 163 | 163 | Duration::from_secs(3600), |
| 164 | 164 | move || Ok(shared.clone()), |
| 165 | 165 | move |result| { |
| 166 | observed_tx.send(*result.as_ref().unwrap()).unwrap(); | |
| 166 | observed_tx.send(result.as_ref().unwrap().clone()).unwrap(); | |
| 167 | 167 | if first { |
| 168 | 168 | first = false; |
| 169 | 169 | resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); |
| ... | ... | @@ -305,7 +305,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove |
| 305 | 305 | Duration::from_secs(3600), |
| 306 | 306 | move || Ok(shared.clone()), |
| 307 | 307 | move |result| { |
| 308 | tx.send(*result.as_ref().unwrap()).unwrap(); | |
| 308 | tx.send(result.as_ref().unwrap().clone()).unwrap(); | |
| 309 | 309 | }, |
| 310 | 310 | ) |
| 311 | 311 | .unwrap(); |
| ... | ... | @@ -377,7 +377,7 @@ fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() { |
| 377 | 377 | Duration::from_millis(10), |
| 378 | 378 | move || Ok(shared.clone()), |
| 379 | 379 | move |result| { |
| 380 | tx.send(result.as_ref().copied().map_err(|_| ())).unwrap(); | |
| 380 | tx.send(result.as_ref().cloned().map_err(|_| ())).unwrap(); | |
| 381 | 381 | }, |
| 382 | 382 | ) |
| 383 | 383 | .unwrap(); |
| ... | ... | @@ -401,7 +401,7 @@ fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() { |
| 401 | 401 | if let Some(previous) = previous { |
| 402 | 402 | assert_eq!(previous, status); |
| 403 | 403 | } |
| 404 | previous = Some(status); | |
| 404 | previous = Some(status.clone()); | |
| 405 | 405 | } |
| 406 | 406 | assert!( |
| 407 | 407 | started.elapsed() >= Duration::from_millis(30), |
| ... | ... | @@ -508,7 +508,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() { |
| 508 | 508 | Duration::from_secs(3600), |
| 509 | 509 | move || Ok(shared.clone()), |
| 510 | 510 | move |result| { |
| 511 | tx.send(*result.as_ref().unwrap()).unwrap(); | |
| 511 | tx.send(result.as_ref().unwrap().clone()).unwrap(); | |
| 512 | 512 | }, |
| 513 | 513 | ) |
| 514 | 514 | .unwrap(); |
| ... | ... | @@ -535,7 +535,7 @@ fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() { |
| 535 | 535 | Duration::from_secs(3600), |
| 536 | 536 | move || Ok(shared.clone()), |
| 537 | 537 | move |result| { |
| 538 | tx.send(*result.as_ref().unwrap()).unwrap(); | |
| 538 | tx.send(result.as_ref().unwrap().clone()).unwrap(); | |
| 539 | 539 | if first { |
| 540 | 540 | first = false; |
| 541 | 541 | resume_rx.recv_timeout(Duration::from_secs(5)).unwrap(); |
| ... | ... | @@ -615,7 +615,7 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() { |
| 615 | 615 | Duration::from_millis(1), |
| 616 | 616 | move || Ok(remote.take().expect("Contention caused a reconnect")), |
| 617 | 617 | move |result| { |
| 618 | tx.send(result.as_ref().copied().map_err(|error| error.to_string())) | |
| 618 | tx.send(result.as_ref().cloned().map_err(|error| error.to_string())) | |
| 619 | 619 | .unwrap(); |
| 620 | 620 | }, |
| 621 | 621 | ) |
| ... | ... | @@ -678,7 +678,7 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() { |
| 678 | 678 | .unwrap(); |
| 679 | 679 | tx.send((second, None)).unwrap(); |
| 680 | 680 | } |
| 681 | Ok(Some((id, status))) => tx.send((*id, Some(*status))).unwrap(), | |
| 681 | Ok(Some((id, status))) => tx.send((*id, Some(status.clone()))).unwrap(), | |
| 682 | 682 | Ok(None) => {} |
| 683 | 683 | other => panic!("Unexpected worker result: {other:?}"), |
| 684 | 684 | }, |
crates/onestore/README.md+4-1| ... | ... | @@ -43,6 +43,7 @@ harness also accepts `--client-profile release`. |
| 43 | 43 | | `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes | |
| 44 | 44 | | `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities | |
| 45 | 45 | | `Document::active`, `Document::pages_in`, `Revision::parents`, `RevisionIndex::active` | Resolve the active revision, the pages of a space and parent links without repeating the lookups | |
| 46 | | `page::link::internal_link`, `page::link::parse_internal_link` | Build and read the `onenote:#…` URLs OneNote stores for links to sections, pages and paragraphs, by identity | | |
| 46 | 47 | | `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments, ink drawings and handwriting decoded to stroke polylines in page points) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` | |
| 47 | 48 | | `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | |
| 48 | 49 | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | |
| ... | ... | @@ -50,6 +51,8 @@ harness also accepts `--client-profile release`. |
| 50 | 51 | | `PageEdit`, `PreparedEdit::pages` | Publish explicitly selected page moves and indentation changes together, preserving page content and historical revisions | |
| 51 | 52 | | `PreparedEdit::delete_pages_permanently` | Remove explicit pages and their section references atomically while retaining stored revisions | |
| 52 | 53 | | `create_table_of_contents` | Create ordered section entries from filenames and file identities | |
| 54 | | `TocEdit`, `PreparedEdit::table_of_contents` | Add, rename, colour, order and remove a table of contents' section and group entries as one revision | | |
| 55 | | `place_file` | Name a file for its notebook as OneNote does on adoption (parent TOC identity and name CRC in the header), so OneNote keeps its identity | | |
| 53 | 56 | | `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references | |
| 54 | 57 | | `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range across ordinary text runs; publish text, run boundaries and modification time together | |
| 55 | 58 | | `Insertion`, `PreparedEdit::insert` | Insert paragraphs into editable containers or positioned outlines into a page, retaining intent identities across rebases | |
| ... | ... | @@ -57,7 +60,7 @@ harness also accepts `--client-profile release`. |
| 57 | 60 | | `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules | |
| 58 | 61 | | `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | |
| 59 | 62 | | `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | |
| 60 | | `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks (external and, via `page::link`, to pages, paragraphs and sections), bullets and numbering, note tags, table rows, columns, cell shading and indents, nested tables, inserted pictures (in paragraphs or on the page) and attachments, picture position, size and description, ink drawings and handwriting (strokes added and erased), equations (built from `page::Math` trees), paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities | | |
| 63 | | `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks (external and, via `page::link`, to pages, paragraphs and sections), bullets and numbering, note tags, table rows, columns, cell shading and indents, nested tables, inserted pictures (in paragraphs or on the page) and attachments, picture position, size and description, a stored attachment's shown name, source path and icon size, ink drawings and handwriting (strokes added and erased), equations (built from `page::Math` trees), paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities | | |
| 61 | 64 | | `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically | |
| 62 | 65 | | `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter | |
| 63 | 66 | | `read_file` | Read a snapshot under whole-file exclusion | |
crates/onestore/src/commit.rs+37| ... | ... | @@ -71,6 +71,31 @@ impl Drop for FileIo { |
| 71 | 71 | } |
| 72 | 72 | } |
| 73 | 73 | |
| 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))] | |
| 79 | pub fn place_file(path: impl AsRef<Path>, ancestor: [u8; 16], name: &str) -> io::Result<()> { | |
| 80 | let mut io = FileIo::open(path, true)?; | |
| 81 | let mut header = [0; 1024]; | |
| 82 | let result = (|| { | |
| 83 | if io.read_at(0, &mut header)? != header.len() { | |
| 84 | return Err(io::Error::from(ErrorKind::UnexpectedEof)); | |
| 85 | } | |
| 86 | crate::Header::parse(&header) | |
| 87 | .map_err(|error| io::Error::new(ErrorKind::InvalidData, error.message))?; | |
| 88 | let placement = crate::create::placement(ancestor, name); | |
| 89 | if io.write_at(128, &placement)? != placement.len() { | |
| 90 | return Err(io::Error::from(ErrorKind::WriteZero)); | |
| 91 | } | |
| 92 | io.flush() | |
| 93 | })(); | |
| 94 | let released = io.release(); | |
| 95 | result?; | |
| 96 | released | |
| 97 | } | |
| 98 | ||
| 74 | 99 | /// Reads a snapshot under the same whole-file exclusion used for commits. |
| 75 | 100 | /// Native writers can expose incomplete graphs to unlocked filesystem reads. |
| 76 | 101 | #[cfg(any(unix, windows))] |
| ... | ... | @@ -192,6 +217,18 @@ impl<'a> PreparedEdit<'a> { |
| 192 | 217 | }) |
| 193 | 218 | } |
| 194 | 219 | |
| 220 | /// Applies table-of-contents edits (sections and section groups: add, rename, colour, | |
| 221 | /// order, remove) as one revision of a `.onetoc2` file. | |
| 222 | pub fn table_of_contents( | |
| 223 | source: &'a [u8], | |
| 224 | edits: &[crate::TocEdit], | |
| 225 | ) -> Result<Self, crate::Error> { | |
| 226 | Ok(Self { | |
| 227 | source, | |
| 228 | written: crate::toc::edit_table_of_contents(source, edits)?, | |
| 229 | }) | |
| 230 | } | |
| 231 | ||
| 195 | 232 | /// Permanently removes explicitly selected pages from the section in one transaction. |
| 196 | 233 | /// Subpages must be selected explicitly; a surviving first subpage becomes top-level. |
| 197 | 234 | /// Creates no recycle-bin copies. Prior revisions remain stored; this is not secure erasure. |
crates/onestore/src/create.rs+10-2| ... | ... | @@ -101,6 +101,15 @@ struct NewSpace { |
| 101 | 101 | objects: Vec<NewObject>, |
| 102 | 102 | } |
| 103 | 103 | |
| 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. | |
| 106 | pub(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 | ||
| 104 | 113 | pub(crate) fn current_timestamps() -> Result<(u32, u64)> { |
| 105 | 114 | let now = SystemTime::now() |
| 106 | 115 | .duration_since(UNIX_EPOCH) |
| ... | ... | @@ -537,8 +546,7 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec<NewSpace>) -> Result |
| 537 | 546 | output[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes()); |
| 538 | 547 | } |
| 539 | 548 | 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)); | |
| 542 | 550 | for (at, chunk) in hashed_chunk |
| 543 | 551 | .map(|chunk| (148, chunk)) |
| 544 | 552 | .into_iter() |
crates/onestore/src/lib.rs+4-1| ... | ... | @@ -21,6 +21,7 @@ pub mod protected; |
| 21 | 21 | mod revisions; |
| 22 | 22 | mod snapshot; |
| 23 | 23 | mod store; |
| 24 | mod toc; | |
| 24 | 25 | mod tree; |
| 25 | 26 | mod write; |
| 26 | 27 | |
| ... | ... | @@ -30,7 +31,8 @@ pub use commit::{ |
| 30 | 31 | }; |
| 31 | 32 | #[cfg(any(unix, windows))] |
| 32 | 33 | pub use commit::{ |
| 33 | commit_file_property, commit_file_text, confirm_file_snapshot, read_file, read_file_limited, | |
| 34 | commit_file_property, commit_file_text, confirm_file_snapshot, place_file, read_file, | |
| 35 | read_file_limited, | |
| 34 | 36 | }; |
| 35 | 37 | pub use create::{create_section, create_table_of_contents}; |
| 36 | 38 | pub use edit::replace_text; |
| ... | ... | @@ -45,5 +47,6 @@ pub use properties::{IdStream, Property, PropertySets, Value}; |
| 45 | 47 | pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex}; |
| 46 | 48 | pub use snapshot::{read_snapshot, read_storage_snapshot}; |
| 47 | 49 | pub use store::{Chunk, Error, FileType, Header, Node, NodeList, Reference, Store}; |
| 50 | pub use toc::TocEdit; | |
| 48 | 51 | pub use tree::TreeEdit; |
| 49 | 52 | pub use write::replace_property_bytes; |
crates/onestore/src/page/link.rs+43| ... | ... | @@ -62,6 +62,49 @@ pub fn internal_link(section: [u8; 16], base_path: &str, target: LinkTarget<'_>) |
| 62 | 62 | url |
| 63 | 63 | } |
| 64 | 64 | |
| 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)] | |
| 68 | pub 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. | |
| 75 | pub fn parse_internal_link(url: &str) -> Option<InternalLink> { | |
| 76 | let guid = |value: &str| { | |
| 77 | format!("{value},0") | |
| 78 | .parse::<ExGuid>() | |
| 79 | .ok() | |
| 80 | .map(|id| id.guid) | |
| 81 | }; | |
| 82 | let parts: Vec<&str> = url.strip_prefix("onenote:#")?.split('&').collect(); | |
| 83 | let value = |name: &str| { | |
| 84 | parts.iter().find_map(|part| { | |
| 85 | part.strip_prefix(name) | |
| 86 | .and_then(|rest| rest.strip_prefix('=')) | |
| 87 | }) | |
| 88 | }; | |
| 89 | let section = guid(value("section-id")?)?; | |
| 90 | let page = match value("page-id") { | |
| 91 | Some(id) => Some(guid(id)?), | |
| 92 | None => None, | |
| 93 | }; | |
| 94 | let object = match parts.iter().position(|part| part.starts_with("object-id=")) { | |
| 95 | Some(at) => Some(ExGuid { | |
| 96 | guid: guid(&parts[at]["object-id=".len()..])?, | |
| 97 | n: parts.get(at + 1)?.parse().ok()?, | |
| 98 | }), | |
| 99 | None => None, | |
| 100 | }; | |
| 101 | Some(InternalLink { | |
| 102 | section, | |
| 103 | page, | |
| 104 | object, | |
| 105 | }) | |
| 106 | } | |
| 107 | ||
| 65 | 108 | /// Percent-encodes a page title the way OneNote does in a link fragment. |
| 66 | 109 | fn encoded(title: &str) -> String { |
| 67 | 110 | let mut out = String::new(); |
crates/onestore/src/page/mod.rs+17-6| ... | ... | @@ -331,6 +331,22 @@ impl Page { |
| 331 | 331 | Self::from_revision(revision, id) |
| 332 | 332 | } |
| 333 | 333 | |
| 334 | /// The notebook-management identity of the page in an active page revision, which | |
| 335 | /// internal links name. | |
| 336 | pub fn identity_of(revision: &Revision<'_>) -> Option<[u8; 16]> { | |
| 337 | revision | |
| 338 | .roots | |
| 339 | .get(&2) | |
| 340 | .and_then(|id| revision.nodes.get(id)) | |
| 341 | .filter(|node| matches!(node.kind, Kind::Metadata { .. })) | |
| 342 | .and_then(|node| node.extra.first()) | |
| 343 | .and_then(|fields| fields.iter().find(|field| field.id == 0x1c001c30)) | |
| 344 | .and_then(|field| match field.value { | |
| 345 | FieldValue::Bytes(bytes) => bytes.try_into().ok(), | |
| 346 | _ => None, | |
| 347 | }) | |
| 348 | } | |
| 349 | ||
| 334 | 350 | /// The single active page declared in one page object space. |
| 335 | 351 | pub fn from_space(document: &Document<'_>, space: ExGuid) -> Result<Self, Error> { |
| 336 | 352 | let pages = document.pages_in(space)?; |
| ... | ... | @@ -365,12 +381,7 @@ impl Page { |
| 365 | 381 | .and_then(|node| node.extra.first()); |
| 366 | 382 | let mut page = Self { |
| 367 | 383 | 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), | |
| 374 | 385 | created: metadata |
| 375 | 386 | .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65)) |
| 376 | 387 | .map(|field| { |
crates/onestore/src/page/write.rs+69-11| ... | ... | @@ -1,7 +1,9 @@ |
| 1 | 1 | //! Publishes an edited page model by lowering the difference from the stored page onto |
| 2 | 2 | //! the typed writers, then squashing their transactions into one revision per space. |
| 3 | 3 | |
| 4 | use super::{Image, Ink, Math, Outline, Page, PageObject, PageParagraph, ParagraphContent, Table}; | |
| 4 | use super::{ | |
| 5 | Attachment, Image, Ink, Math, Outline, Page, PageObject, PageParagraph, ParagraphContent, Table, | |
| 6 | }; | |
| 5 | 7 | use crate::{ |
| 6 | 8 | Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets, |
| 7 | 9 | RevisionIndex, Store, TextAttribute, TreeEdit, Value, |
| ... | ... | @@ -650,8 +652,8 @@ impl Lowering<'_> { |
| 650 | 652 | } |
| 651 | 653 | } |
| 652 | 654 | (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")); | |
| 655 | 657 | } |
| 656 | 658 | } |
| 657 | 659 | _ => return Err(invalid("Paragraph content type cannot change")), |
| ... | ... | @@ -904,21 +906,24 @@ impl Lowering<'_> { |
| 904 | 906 | let ParagraphContent::Attachment(attachment) = &paragraph.content else { |
| 905 | 907 | continue; |
| 906 | 908 | }; |
| 909 | let name = attachment.filename.as_str(); | |
| 910 | if name.is_empty() || name.contains(['\0', '/', '\\']) { | |
| 911 | return Err(invalid( | |
| 912 | "An attachment needs a file name without path separators", | |
| 913 | )); | |
| 914 | } | |
| 907 | 915 | 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)?; | |
| 910 | 921 | } |
| 911 | 922 | continue; |
| 912 | 923 | } |
| 913 | 924 | let Some(bytes) = &attachment.bytes else { |
| 914 | 925 | return Err(invalid("A new attachment needs its payload")); |
| 915 | 926 | }; |
| 916 | let name = attachment.filename.as_str(); | |
| 917 | if name.is_empty() || name.contains(['\0', '/', '\\']) { | |
| 918 | return Err(invalid( | |
| 919 | "An attachment needs a file name without path separators", | |
| 920 | )); | |
| 921 | } | |
| 922 | 927 | let extension = name |
| 923 | 928 | .rfind('.') |
| 924 | 929 | .filter(|dot| *dot > 0) |
| ... | ... | @@ -1314,6 +1319,59 @@ impl Lowering<'_> { |
| 1314 | 1319 | /// Writes a moved, resized or described picture the way OneNote stores one: the |
| 1315 | 1320 | /// position, the layout width and height with the user flag and the description on |
| 1316 | 1321 | /// the picture object, leaving the intrinsic size alone. |
| 1322 | /// A stored attachment keeps its payload and preview; its shown name, recorded source | |
| 1323 | /// path and icon size change in place, as OneNote's rename does. | |
| 1324 | fn edit_attachment( | |
| 1325 | &mut self, | |
| 1326 | stored: &Attachment, | |
| 1327 | attachment: &Attachment, | |
| 1328 | ) -> Result<(), Error> { | |
| 1329 | let mut values: Values = vec![( | |
| 1330 | 0x14001d7a, | |
| 1331 | crate::create::current_timestamps()? | |
| 1332 | .0 | |
| 1333 | .to_le_bytes() | |
| 1334 | .to_vec(), | |
| 1335 | )]; | |
| 1336 | let mut removed = Vec::new(); | |
| 1337 | if attachment.filename != stored.filename { | |
| 1338 | let name = crate::create::string(&attachment.filename); | |
| 1339 | values.push((0x1c001c22, name.clone())); | |
| 1340 | values.push((0x1c001d9c, name)); | |
| 1341 | } | |
| 1342 | if attachment.source_path != stored.source_path { | |
| 1343 | match &attachment.source_path { | |
| 1344 | Some(path) => values.push((0x1c001d9d, crate::create::string(path))), | |
| 1345 | None => removed.push(0x1c001d9d), | |
| 1346 | } | |
| 1347 | } | |
| 1348 | if attachment.size != stored.size { | |
| 1349 | match attachment.size { | |
| 1350 | Some([width, height]) => { | |
| 1351 | if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { | |
| 1352 | return Err(invalid("Attachment icon size must be positive")); | |
| 1353 | } | |
| 1354 | values.push((0x140034cd, (width / 36.0).to_le_bytes().to_vec())); | |
| 1355 | values.push((0x140034ce, (height / 36.0).to_le_bytes().to_vec())); | |
| 1356 | } | |
| 1357 | None => removed.extend([0x140034cd, 0x140034ce]), | |
| 1358 | } | |
| 1359 | } | |
| 1360 | let (space, object) = (self.space, self.id(attachment.id)); | |
| 1361 | self.apply(|current| { | |
| 1362 | crate::write::write_revision(current, space, |raw| { | |
| 1363 | let mut node = PropertyObject::from_object(&raw.objects[&object])?; | |
| 1364 | node.remove(&removed)?; | |
| 1365 | let values: Vec<(u32, &[u8])> = values | |
| 1366 | .iter() | |
| 1367 | .map(|(id, bytes)| (*id, bytes.as_slice())) | |
| 1368 | .collect(); | |
| 1369 | node.set(&values)?; | |
| 1370 | Ok(BTreeMap::from([(object, node)])) | |
| 1371 | }) | |
| 1372 | }) | |
| 1373 | } | |
| 1374 | ||
| 1317 | 1375 | fn edit_image(&mut self, stored: &Image, image: &Image) -> Result<(), Error> { |
| 1318 | 1376 | picture_fixed_fields(stored, image)?; |
| 1319 | 1377 | let mut values: Values = vec![( |
crates/onestore/src/store.rs+9-1| ... | ... | @@ -71,6 +71,10 @@ impl Chunk { |
| 71 | 71 | pub struct Header { |
| 72 | 72 | pub file_type: FileType, |
| 73 | 73 | pub file_id: [u8; 16], |
| 74 | /// The parent table of contents' file identity; zero outside a notebook. | |
| 75 | pub ancestor: [u8; 16], | |
| 76 | /// CRC of the file name (a section's file name, a group's folder name). | |
| 77 | pub name_crc: u32, | |
| 74 | 78 | pub transaction_count: u32, |
| 75 | 79 | pub expected_length: u64, |
| 76 | 80 | pub version_id: [u8; 16], |
| ... | ... | @@ -166,7 +170,9 @@ impl Header { |
| 166 | 170 | message: "Missing committed transaction", |
| 167 | 171 | }); |
| 168 | 172 | } |
| 169 | c.take(48)?; | |
| 173 | c.take(28)?; | |
| 174 | let ancestor = c.read()?; | |
| 175 | let name_crc = u32::from_le_bytes(c.read()?); | |
| 170 | 176 | let hashed_chunks = c.chunk()?; |
| 171 | 177 | let transaction_log = c.chunk()?; |
| 172 | 178 | let root = c.chunk()?; |
| ... | ... | @@ -179,6 +185,8 @@ impl Header { |
| 179 | 185 | Ok(Self { |
| 180 | 186 | file_type, |
| 181 | 187 | file_id, |
| 188 | ancestor, | |
| 189 | name_crc, | |
| 182 | 190 | transaction_count, |
| 183 | 191 | expected_length, |
| 184 | 192 | version_id, |
crates/onestore/src/toc.rs created+226| ... | ... | @@ -0,0 +1,226 @@ |
| 1 | //! Edits to a notebook's table of contents: the documented `jcidPersistablePropertyContainerForTOC` | |
| 2 | //! root lists section and section-group entries, each carrying a file identity, an ordering | |
| 3 | //! number, a filename and (for sections) a colour. | |
| 4 | ||
| 5 | use crate::{ | |
| 6 | Error, ExGuid, PropertySets, Store, Value, | |
| 7 | document::{Document, Kind}, | |
| 8 | revisions::RevisionIndex, | |
| 9 | write::{PropertyObject, fresh_guid, write_revision}, | |
| 10 | }; | |
| 11 | use std::collections::BTreeMap; | |
| 12 | ||
| 13 | fn 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)] | |
| 19 | pub 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 | ||
| 42 | fn 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. | |
| 47 | type PropertyChange = (u32, Option<Vec<u8>>); | |
| 48 | ||
| 49 | pub(crate) fn edit_table_of_contents(source: &[u8], edits: &[TocEdit]) -> Result<Vec<u8>, Error> { | |
| 50 | let store = Store::parse(source)?; | |
| 51 | if store.header.file_type != crate::FileType::TableOfContents { | |
| 52 | return Err(invalid("Choose a table-of-contents file")); | |
| 53 | } | |
| 54 | let index = RevisionIndex::parse(&store)?; | |
| 55 | let document = Document::parse(&index)?; | |
| 56 | let space = document.root; | |
| 57 | let revision = document.active(space)?; | |
| 58 | let root = *revision | |
| 59 | .roots | |
| 60 | .get(&1) | |
| 61 | .ok_or_else(|| invalid("Missing notebook TOC root"))?; | |
| 62 | let Some(Kind::Toc { entries, .. }) = revision.nodes.get(&root).map(|node| &node.kind) else { | |
| 63 | return Err(invalid("Missing notebook TOC root")); | |
| 64 | }; | |
| 65 | // Entries in stored order with their identities and filenames. | |
| 66 | let mut listed: Vec<(ExGuid, [u8; 16], String)> = Vec::new(); | |
| 67 | for id in entries { | |
| 68 | let Some(Kind::Toc { | |
| 69 | filename, | |
| 70 | identity: Some(identity), | |
| 71 | .. | |
| 72 | }) = revision.nodes.get(id).map(|node| &node.kind) | |
| 73 | else { | |
| 74 | return Err(invalid("Incomplete notebook TOC reference")); | |
| 75 | }; | |
| 76 | listed.push((*id, *identity, filename.clone().unwrap_or_default())); | |
| 77 | } | |
| 78 | let mut created: BTreeMap<ExGuid, PropertyObject> = BTreeMap::new(); | |
| 79 | // Property values to set (or remove, `None`) on each existing entry. | |
| 80 | let mut changes: BTreeMap<ExGuid, Vec<PropertyChange>> = BTreeMap::new(); | |
| 81 | for edit in edits { | |
| 82 | let position = |identity: &[u8; 16]| { | |
| 83 | listed | |
| 84 | .iter() | |
| 85 | .position(|(_, known, _)| known == identity) | |
| 86 | .ok_or_else(|| invalid("The table of contents has no entry with that identity")) | |
| 87 | }; | |
| 88 | match edit { | |
| 89 | TocEdit::Add { | |
| 90 | filename, | |
| 91 | identity, | |
| 92 | group, | |
| 93 | } => { | |
| 94 | if !component(filename) | |
| 95 | || (!group && !filename.to_ascii_lowercase().ends_with(".one")) | |
| 96 | || *identity == [0; 16] | |
| 97 | { | |
| 98 | return Err(invalid( | |
| 99 | "A TOC entry needs a section filename or group folder name and an identity", | |
| 100 | )); | |
| 101 | } | |
| 102 | if listed.iter().any(|(_, known, name)| { | |
| 103 | known == identity || name.eq_ignore_ascii_case(filename) | |
| 104 | }) { | |
| 105 | return Err(invalid( | |
| 106 | "The table of contents already lists that section or group", | |
| 107 | )); | |
| 108 | } | |
| 109 | let id = ExGuid { | |
| 110 | guid: fresh_guid()?, | |
| 111 | n: 10, | |
| 112 | }; | |
| 113 | let mut values: Vec<(u32, Vec<u8>)> = vec![ | |
| 114 | (0x1c001d94, identity.to_vec()), | |
| 115 | (0x14001cb9, 0u32.to_le_bytes().to_vec()), | |
| 116 | (0x1c001d6b, crate::create::string(filename)), | |
| 117 | ]; | |
| 118 | if !group { | |
| 119 | values.push((0x14001cbe, vec![0xff; 4])); | |
| 120 | } | |
| 121 | let mut object = PropertyObject { | |
| 122 | jcid: 0x20001, | |
| 123 | bytes: crate::create::properties(&values)?, | |
| 124 | global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), | |
| 125 | }; | |
| 126 | object.reference(id)?; | |
| 127 | created.insert(id, object); | |
| 128 | listed.push((id, *identity, filename.clone())); | |
| 129 | } | |
| 130 | TocEdit::Rename { identity, filename } => { | |
| 131 | if !component(filename) { | |
| 132 | return Err(invalid( | |
| 133 | "A TOC entry needs a filename without path separators", | |
| 134 | )); | |
| 135 | } | |
| 136 | let at = position(identity)?; | |
| 137 | if listed | |
| 138 | .iter() | |
| 139 | .enumerate() | |
| 140 | .any(|(i, (_, _, name))| i != at && name.eq_ignore_ascii_case(filename)) | |
| 141 | { | |
| 142 | return Err(invalid("The table of contents already lists that name")); | |
| 143 | } | |
| 144 | let id = listed[at].0; | |
| 145 | listed[at].2 = filename.clone(); | |
| 146 | changes | |
| 147 | .entry(id) | |
| 148 | .or_default() | |
| 149 | .push((0x1c001d6b, Some(crate::create::string(filename)))); | |
| 150 | } | |
| 151 | TocEdit::Color { identity, color } => { | |
| 152 | let id = listed[position(identity)?].0; | |
| 153 | changes.entry(id).or_default().push(( | |
| 154 | 0x14001cbe, | |
| 155 | Some(color.unwrap_or(0xffff_ffff).to_le_bytes().to_vec()), | |
| 156 | )); | |
| 157 | } | |
| 158 | TocEdit::Order(wanted) => { | |
| 159 | let mut ordered: Vec<(ExGuid, [u8; 16], String)> = Vec::new(); | |
| 160 | for identity in wanted { | |
| 161 | let at = position(identity)?; | |
| 162 | if ordered.iter().any(|(_, known, _)| known == identity) { | |
| 163 | return Err(invalid("An entry is ordered twice")); | |
| 164 | } | |
| 165 | ordered.push(listed[at].clone()); | |
| 166 | } | |
| 167 | let rest: Vec<_> = listed | |
| 168 | .iter() | |
| 169 | .filter(|(_, known, _)| !wanted.contains(known)) | |
| 170 | .cloned() | |
| 171 | .collect(); | |
| 172 | ordered.extend(rest); | |
| 173 | listed = ordered; | |
| 174 | } | |
| 175 | TocEdit::Remove { identity } => { | |
| 176 | let at = position(identity)?; | |
| 177 | let (id, _, _) = listed.remove(at); | |
| 178 | changes.remove(&id); | |
| 179 | created.remove(&id); | |
| 180 | } | |
| 181 | } | |
| 182 | } | |
| 183 | let listed = listed; | |
| 184 | write_revision(source, space, |raw| { | |
| 185 | let mut changed = BTreeMap::new(); | |
| 186 | let mut root_object = PropertyObject::from_object(&raw.objects[&root])?; | |
| 187 | let mut references = Vec::new(); | |
| 188 | for (order, (id, _, _)) in listed.iter().enumerate() { | |
| 189 | let mut object = match created.remove(id) { | |
| 190 | Some(object) => object, | |
| 191 | None => PropertyObject::from_object(&raw.objects[id])?, | |
| 192 | }; | |
| 193 | let stored_order = PropertySets::parse(&object.bytes)?.sets[0] | |
| 194 | .iter() | |
| 195 | .find(|p| p.id == 0x14001cb9) | |
| 196 | .and_then(|p| match p.value { | |
| 197 | Value::Bytes(b) => b.try_into().ok().map(u32::from_le_bytes), | |
| 198 | _ => None, | |
| 199 | }); | |
| 200 | let mut updates: Vec<(u32, Vec<u8>)> = Vec::new(); | |
| 201 | if stored_order != Some(order as u32 + 1) { | |
| 202 | updates.push((0x14001cb9, (order as u32 + 1).to_le_bytes().to_vec())); | |
| 203 | } | |
| 204 | for (property, value) in changes.get(id).into_iter().flatten() { | |
| 205 | match value { | |
| 206 | Some(bytes) => updates.push((*property, bytes.clone())), | |
| 207 | None => object.remove(&[*property])?, | |
| 208 | } | |
| 209 | } | |
| 210 | let updates: Vec<(u32, &[u8])> = updates | |
| 211 | .iter() | |
| 212 | .map(|(id, bytes)| (*id, bytes.as_slice())) | |
| 213 | .collect(); | |
| 214 | if !updates.is_empty() { | |
| 215 | object.set(&updates)?; | |
| 216 | } | |
| 217 | references.extend(root_object.reference(*id)?); | |
| 218 | if !updates.is_empty() || created.contains_key(id) || !raw.objects.contains_key(id) { | |
| 219 | changed.insert(*id, object); | |
| 220 | } | |
| 221 | } | |
| 222 | root_object.set(&[(0x24001cf6, &references)])?; | |
| 223 | changed.insert(root, root_object); | |
| 224 | Ok(changed) | |
| 225 | }) | |
| 226 | } |
crates/onestore/src/write.rs+18-10| ... | ... | @@ -838,6 +838,7 @@ pub(crate) fn write_revisions_with_payloads( |
| 838 | 838 | ) |
| 839 | 839 | } |
| 840 | 840 | }; |
| 841 | let existing: BTreeSet<ExGuid> = revision.objects.keys().copied().collect(); | |
| 841 | 842 | let reachable = if rid.is_some() { |
| 842 | 843 | revision.reachable()? |
| 843 | 844 | } else { |
| ... | ... | @@ -859,10 +860,10 @@ pub(crate) fn write_revisions_with_payloads( |
| 859 | 860 | message: "An existing object's type cannot be changed", |
| 860 | 861 | }); |
| 861 | 862 | } |
| 862 | } else if !is_section { | |
| 863 | } else if !is_section && replacement.jcid != 0x20001 { | |
| 863 | 864 | return Err(Error { |
| 864 | 865 | offset: 0, |
| 865 | message: "New objects require a section file", | |
| 866 | message: "New objects in a table of contents are its entries", | |
| 866 | 867 | }); |
| 867 | 868 | } |
| 868 | 869 | if replacement.global_ids.keys().any(|i| *i > 0xffffff) { |
| ... | ... | @@ -1006,10 +1007,16 @@ pub(crate) fn write_revisions_with_payloads( |
| 1006 | 1007 | .iter() |
| 1007 | 1008 | .filter(|(id, _)| checkpoint || changed.contains(id)) |
| 1008 | 1009 | .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 | { | |
| 1013 | 1020 | return Err(Error { |
| 1014 | 1021 | offset: 0, |
| 1015 | 1022 | message: "TOC checkpoint requires table-of-contents property objects", |
| ... | ... | @@ -1032,8 +1039,6 @@ pub(crate) fn write_revisions_with_payloads( |
| 1032 | 1039 | .map(|(i, guid)| (u32::try_from(i).unwrap(), guid)) |
| 1033 | 1040 | .collect::<BTreeMap<_, _>>(), |
| 1034 | 1041 | ) |
| 1035 | } else { | |
| 1036 | None | |
| 1037 | 1042 | }; |
| 1038 | 1043 | let mut groups = BTreeMap::<_, Vec<_>>::new(); |
| 1039 | 1044 | for (id, object) in selected { |
| ... | ... | @@ -1144,10 +1149,13 @@ pub(crate) fn write_revisions_with_payloads( |
| 1144 | 1149 | length: u64::try_from(bytes.len()).unwrap(), |
| 1145 | 1150 | } |
| 1146 | 1151 | }; |
| 1152 | // A table-of-contents object is declared when the revision is a | |
| 1153 | // checkpoint or the object is new, and revised otherwise. | |
| 1154 | let declared = checkpoint || !existing.contains(&id); | |
| 1147 | 1155 | if is_section { |
| 1148 | 1156 | declaration.extend_from_slice(&object.jcid.to_le_bytes()); |
| 1149 | 1157 | declaration.push(flags); |
| 1150 | } else if checkpoint { | |
| 1158 | } else if declared { | |
| 1151 | 1159 | let body = 1_u64 | (u64::from(flags & 1) << 16); |
| 1152 | 1160 | declaration.extend_from_slice(&body.to_le_bytes()[..6]); |
| 1153 | 1161 | } else { |
| ... | ... | @@ -1161,7 +1169,7 @@ pub(crate) fn write_revisions_with_payloads( |
| 1161 | 1169 | group.push(node( |
| 1162 | 1170 | if is_section { |
| 1163 | 1171 | if readonly { 0xc5 } else { 0xa5 } |
| 1164 | } else if checkpoint { | |
| 1172 | } else if declared { | |
| 1165 | 1173 | 0x2e |
| 1166 | 1174 | } else { |
| 1167 | 1175 | 0x42 |
crates/onestore/tests/page_attachments.rs+29-3| ... | ... | @@ -169,7 +169,7 @@ fn an_attachment_inserted_on_a_fresh_page_reads_back_and_can_be_removed() { |
| 169 | 169 | } |
| 170 | 170 | |
| 171 | 171 | #[test] |
| 172 | fn attachments_need_a_file_name_and_stored_ones_stay_fixed() { | |
| 172 | fn attachments_need_a_file_name_and_stored_ones_are_renamed_in_place() { | |
| 173 | 173 | let source = onestore::create_section("files.one", "Text", "Author").unwrap(); |
| 174 | 174 | let (space, before) = first_page(&source); |
| 175 | 175 | let mut after = before.clone(); |
| ... | ... | @@ -192,12 +192,38 @@ fn attachments_need_a_file_name_and_stored_ones_stay_fixed() { |
| 192 | 192 | if let PageObject::Outline(outline) = object { |
| 193 | 193 | for paragraph in &mut outline.paragraphs { |
| 194 | 194 | if let ParagraphContent::Attachment(attachment) = &mut paragraph.content { |
| 195 | attachment.filename = "renamed.txt".into(); | |
| 195 | attachment.filename = "renamed \u{1f980}.txt".into(); | |
| 196 | attachment.source_path = Some("C:\\inputs\\renamed \u{1f980}.txt".into()); | |
| 196 | 197 | found += 1; |
| 197 | 198 | } |
| 198 | 199 | } |
| 199 | 200 | } |
| 200 | 201 | } |
| 201 | 202 | assert_eq!(found, 1); |
| 202 | assert!(PreparedEdit::page(NATIVE, space, &renamed, AUTHOR).is_err()); | |
| 203 | let written = PreparedEdit::page(NATIVE, space, &renamed, AUTHOR).unwrap(); | |
| 204 | let stored = page_in(written.as_bytes(), space); | |
| 205 | let (before, after) = (attachments(&native)[0], attachments(&stored)[0]); | |
| 206 | assert_eq!(after.filename, "renamed \u{1f980}.txt"); | |
| 207 | assert_eq!( | |
| 208 | after.source_path.as_deref(), | |
| 209 | Some("C:\\inputs\\renamed \u{1f980}.txt") | |
| 210 | ); | |
| 211 | assert_eq!((after.id, after.size), (before.id, before.size)); | |
| 212 | assert_eq!(after.bytes, before.bytes); | |
| 213 | assert_eq!(after.preview, before.preview); | |
| 214 | if let Some(directory) = std::env::var_os("ONESTORE_ATTACHMENT_RENAME_EXPORT") { | |
| 215 | let directory = std::path::PathBuf::from(directory); | |
| 216 | std::fs::create_dir_all(&directory).unwrap(); | |
| 217 | std::fs::write(directory.join("synthetic.one"), written.as_bytes()).unwrap(); | |
| 218 | let file_id = Store::parse(written.as_bytes()).unwrap().header.file_id; | |
| 219 | std::fs::write( | |
| 220 | directory.join("Open Notebook.onetoc2"), | |
| 221 | onestore::create_table_of_contents( | |
| 222 | "Open Notebook.onetoc2", | |
| 223 | &[("synthetic.one", file_id)], | |
| 224 | ) | |
| 225 | .unwrap(), | |
| 226 | ) | |
| 227 | .unwrap(); | |
| 228 | } | |
| 203 | 229 | } |
crates/onestore/tests/page_links.rs+27-1| ... | ... | @@ -3,7 +3,7 @@ use onestore::{ |
| 3 | 3 | document::Document, |
| 4 | 4 | page::{ |
| 5 | 5 | Page, PageObject, PageParagraph, Paragraph, |
| 6 | link::{LinkTarget, internal_link}, | |
| 6 | link::{InternalLink, LinkTarget, internal_link, parse_internal_link}, | |
| 7 | 7 | text::Edit, |
| 8 | 8 | }, |
| 9 | 9 | }; |
| ... | ... | @@ -258,6 +258,32 @@ fn internal_links_match_what_onenote_stores() { |
| 258 | 258 | ), |
| 259 | 259 | "onenote:#A%20b%2Fc&section-id={00000000-0000-0000-0000-000000000000}&page-id={00000000-0000-0000-0000-000000000000}&end&base-path=p" |
| 260 | 260 | ); |
| 261 | let identity = target.identity.unwrap(); | |
| 262 | assert_eq!( | |
| 263 | stored[1..] | |
| 264 | .iter() | |
| 265 | .map(|url| parse_internal_link(url)) | |
| 266 | .collect::<Vec<_>>(), | |
| 267 | [ | |
| 268 | Some(InternalLink { | |
| 269 | section, | |
| 270 | page: Some(identity), | |
| 271 | object: None | |
| 272 | }), | |
| 273 | Some(InternalLink { | |
| 274 | section, | |
| 275 | page: Some(identity), | |
| 276 | object: Some(paragraph) | |
| 277 | }), | |
| 278 | Some(InternalLink { | |
| 279 | section, | |
| 280 | page: None, | |
| 281 | object: None | |
| 282 | }), | |
| 283 | ] | |
| 284 | ); | |
| 285 | assert_eq!(parse_internal_link(&stored[0]), None); | |
| 286 | assert_eq!(parse_internal_link("onenote:#x&page-id={0}"), None); | |
| 261 | 287 | } |
| 262 | 288 | |
| 263 | 289 | /// `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 @@ |
| 1 | use onestore::{PreparedEdit, RevisionIndex, Store, TocEdit, document::Document}; | |
| 2 | ||
| 3 | fn 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] | |
| 34 | fn 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | |
| 2 | Set-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 | function 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 | } | |
| 19 | try { | |
| 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | |
| 2 | Set-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 = @() | |
| 9 | try { | |
| 10 | $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0) | |
| 11 | $hierarchy = '' | |
| 12 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 13 | [IO.File]::WriteAllText((Join-Path $Root 'structure-00-opened.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 14 | $second = '' | |
| 15 | $app.OpenHierarchy('Second.one', $notebookId, [ref]$second, 3) | |
| 16 | $third = '' | |
| 17 | $app.OpenHierarchy('Third.one', $notebookId, [ref]$third, 3) | |
| 18 | $group = '' | |
| 19 | $app.OpenHierarchy('Group', $notebookId, [ref]$group, 2) | |
| 20 | $inner = '' | |
| 21 | $app.OpenHierarchy('Inner.one', $group, [ref]$inner, 3) | |
| 22 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 23 | [IO.File]::WriteAllText((Join-Path $Root 'structure-01-created.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 24 | $app.SyncHierarchy($notebookId) | |
| 25 | $app.CloseNotebook($notebookId, $false) | |
| 26 | $notebookId = '' | |
| 27 | $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0) | |
| 28 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 29 | [xml]$tree = $hierarchy | |
| 30 | $manager = New-Object Xml.XmlNamespaceManager($tree.NameTable) | |
| 31 | $manager.AddNamespace('one', $namespace) | |
| 32 | $sections = @($tree.SelectNodes('//one:Section', $manager)) | |
| 33 | foreach ($section in $sections) { | |
| 34 | switch ($section.GetAttribute('name')) { | |
| 35 | 'Second' { $section.SetAttribute('name', 'Renamed'); $section.SetAttribute('color', '#FFD75E') } | |
| 36 | 'Third' { $section.SetAttribute('color', '#B7C997') } | |
| 37 | } | |
| 38 | } | |
| 39 | [IO.File]::WriteAllText((Join-Path $Root 'structure-02-update.xml'), $tree.OuterXml, [Text.Encoding]::UTF8) | |
| 40 | $app.UpdateHierarchy($tree.OuterXml, 1) | |
| 41 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 42 | [IO.File]::WriteAllText((Join-Path $Root 'structure-03-renamed.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 43 | $app.SyncHierarchy($notebookId) | |
| 44 | $app.CloseNotebook($notebookId, $false) | |
| 45 | $notebookId = '' | |
| 46 | Copy-Item -Recurse (Join-Path $Root 'notebook') (Join-Path $Root 'notebook-renamed') | |
| 47 | $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0) | |
| 48 | $deadline = [DateTime]::UtcNow.AddSeconds(60) | |
| 49 | do { | |
| 50 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$tree = $hierarchy | |
| 52 | if (@($tree.SelectNodes('//*[local-name()="Section"]')).Count -ge 4) { break } | |
| 53 | Start-Sleep -Milliseconds 500 | |
| 54 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 55 | $manager = New-Object Xml.XmlNamespaceManager($tree.NameTable) | |
| 56 | $manager.AddNamespace('one', $namespace) | |
| 57 | [IO.File]::WriteAllText((Join-Path $Root 'structure-03b-reopened.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 58 | $doomed = @($tree.SelectNodes('//*[local-name()="Section"]') | Where-Object { $_.GetAttribute('name') -eq 'Third' }) | |
| 59 | if ($doomed.Count -ne 1) { throw "Expected one section named Third, found $($doomed.Count)." } | |
| 60 | $app.DeleteHierarchy($doomed[0].GetAttribute('ID'), [DateTime]::MinValue, $false) | |
| 61 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 62 | [IO.File]::WriteAllText((Join-Path $Root 'structure-04-deleted.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 63 | $app.SyncHierarchy($notebookId) | |
| 64 | $app.CloseNotebook($notebookId, $false) | |
| 65 | $notebookId = '' | |
| 66 | } finally { | |
| 67 | if ($notebookId) { $app.CloseNotebook($notebookId, $false) } | |
| 68 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | |
| 69 | [GC]::Collect() | |
| 70 | [GC]::WaitForPendingFinalizers() | |
| 71 | } |
tools/test_attachment_edit.py+9| ... | ... | @@ -31,6 +31,15 @@ class AttachmentEditTest(unittest.TestCase): |
| 31 | 31 | def test_a_rust_inserted_attachment_with_an_icon_preview_renders_natively(self): |
| 32 | 32 | self.check('icon') |
| 33 | 33 | |
| 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 | ||
| 34 | 43 | |
| 35 | 44 | if __name__ == '__main__': |
| 36 | 45 | unittest.main() |
tools/test_notebook_edit.py created+65| ... | ... | @@ -0,0 +1,65 @@ |
| 1 | from pathlib import Path | |
| 2 | import runpy | |
| 3 | import shutil | |
| 4 | from tempfile import TemporaryDirectory | |
| 5 | import unittest | |
| 6 | import uuid | |
| 7 | import xml.etree.ElementTree as ET | |
| 8 | ||
| 9 | from native_xml import ns | |
| 10 | ||
| 11 | ROOT = Path(__file__).resolve().parent.parent | |
| 12 | FIXTURE = ROOT / 'corpus/notebook-edit' | |
| 13 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 14 | ||
| 15 | ||
| 16 | def file_identity(path): | |
| 17 | return uuid.UUID(bytes_le=path.read_bytes()[16:32]) | |
| 18 | ||
| 19 | ||
| 20 | def 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 | ||
| 25 | class 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 | ||
| 64 | if __name__ == '__main__': | |
| 65 | unittest.main() |