authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 15:29:25-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 17:00:32-07:00
logbcb13ba046299393ccadffa70cc2c57963ffc3ce
treee3385fdebbc3ae00d47dc853bbdf7aa52062cc8e
parent23f0f23fc7064901d2942eb0d0ff5c4adea3afd6
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: build internal links from page and section identities; tag colours gated

OneNote 2010 was observed linking to a page, a paragraph and the section through the COM API on a Rust-written page (corpus/link-edit/native-links, authored by tools/native/page-link.ps1): each link is the same HYPERLINK field code as an external one whose URL is the relative form onenote:#Title&section-id={section file identity}&page-id={page notebook-management identity}&end&base-path=<section path>, with &object-id={paragraph identity}&n instead of &end for a paragraph and neither title nor page for a section. The page model now carries that page identity (Page::identity, from the metadata object, absent on damaged pages), Section::identity exposes the file identity, and onestore::page::link::internal_link builds the URL; the native URLs are reproduced byte for byte from stored identities, and corpus/link-edit/internal is the cold read of a Rust page linking to a second page. The base path is the caller's, as OneNote writes its own. The tag corpus is regenerated with a red-on-yellow definition so custom tag colours and highlights have their native gate. Assisted-by: claude-fable-5.1

61 files changed, 952 insertions(+), 34 deletions(-)

corpus/link-edit/README.md+22
...@@ -15,3 +15,25 @@ embedded objects and runs with associated data stay refused....@@ -15,3 +15,25 @@ embedded objects and runs with associated data stay refused.
15`ONESTORE_LINK_EXPORT` set to a new absolute directory while running the test,15`ONESTORE_LINK_EXPORT` set to a new absolute directory while running the test,
16then cold-open it with `tools/native_runner.py OUTPUT COLD --expected-pages 116then cold-open it with `tools/native_runner.py OUTPUT COLD --expected-pages 1
17--collect-notebook`.17--collect-notebook`.
18
19## Internal links
20
21`native-links/` is OneNote 2010 adding links to a page, to a paragraph on it
22and to the section on the Rust-authored page above through the COM API
23(`tools/native/page-link.ps1`; `links.json` holds the `onenote:///…` URLs
24`GetHyperlinkToObject` returned, `update.xml` the submitted page). In
25`notebook/`, OneNote stored each as a `HYPERLINK` field code with the
26relative form `onenote:#Link%20target&section-id={section file identity}
27&page-id={page notebook-management identity}&end&base-path=<section path>`;
28a paragraph link ends with `&object-id={paragraph identity}&n` instead of
29`&end`, and a section link has neither title nor page. OneNote rewrote the
30target outline after linking, so its own paragraph link names an identity
31(`n` 28) the current outline no longer holds.
32
33`internal/candidate` is the writer's output for
34`a_page_links_to_another_page_and_its_paragraph` in
35`crates/onestore/tests/page_links.rs`: a section created in Rust with a
36second page, whose first page links to that page and to its first paragraph
37with URLs built by `onestore::page::link::internal_link`. `internal/cold` is
38its cold read with both links. Regenerate with
39`ONESTORE_INTERNAL_LINK_EXPORT` and cold-open with `--expected-pages 2`.
corpus/link-edit/internal/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/link-edit/internal/candidate/Open Notebook.onetoc2 differ
corpus/link-edit/internal/candidate/links.one created
Binary files /dev/null and b/corpus/link-edit/internal/candidate/links.one differ
corpus/link-edit/internal/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-A4418B81 -ExpectedPages 2", "exit": 0, "stdout": "Read 1 sections and 2 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/link-edit/internal/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-a4418b81", "hostname": "ONE-M6-A4418B81"}
corpus/link-edit/internal/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/link-edit/internal/cold/notebook/Open Notebook.onetoc2 differ
corpus/link-edit/internal/cold/notebook/links.one created
Binary files /dev/null and b/corpus/link-edit/internal/cold/notebook/links.one differ
corpus/link-edit/internal/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-A4418B81",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/link-edit/internal/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="{A475638B-F081-40E1-B0A7-056122CE68EA}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T22:13:46.000Z" color="#B49EDE"><one:Section name="links" ID="{D78A6D3C-7091-0DF6-2EFC-514D3F2E4C85}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-12T22:13:46.000Z" color="#8AA8E4"><one:Page ID="{8D0B3F30-F486-0365-1D6A-714CBE91E07B}{1}{B0}" name="Linking page ﷟HYPERLINK &quot;onenote:#Link%20target&amp;section-id={A45E149D-BEE4-4BB2-AF69-072EF644999A}&amp;page-id={1E1AB5A6-E512-42BE-A763-78914DE104F9}&amp;end&amp;base-path=C:\one-tests\runs\capture\notebook\links.one&quot;Link target" dateTime="2026-09-12T22:12:09.000Z" lastModifiedTime="2026-09-12T22:13:46.000Z" pageLevel="1"/><one:Page ID="{109B4059-8CAB-09BB-0559-34EA53917DD2}{1}{B0}" name="Link target" dateTime="2026-09-12T22:12:09.000Z" lastModifiedTime="2026-09-12T22:13:46.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/link-edit/internal/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="{109B4059-8CAB-09BB-0559-34EA53917DD2}{1}{B0}" name="Link target" dateTime="2026-09-12T22:12:09.000Z" lastModifiedTime="2026-09-12T22:13:46.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" lastModifiedBy="Author" creationTime="2026-09-12T22:12:09.000Z" lastModifiedTime="2026-09-12T22:12:09.000Z" objectID="{109B4059-8CAB-09BB-0559-34EA53917DD2}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Link target]]></one:T></one:OE></one:Title></one:Page>
corpus/link-edit/internal/cold/read/page-001.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="{8D0B3F30-F486-0365-1D6A-714CBE91E07B}{1}{B0}" name="Linking page ﷟HYPERLINK &quot;onenote:#Link%20target&amp;section-id={A45E149D-BEE4-4BB2-AF69-072EF644999A}&amp;page-id={1E1AB5A6-E512-42BE-A763-78914DE104F9}&amp;end&amp;base-path=C:\one-tests\runs\capture\notebook\links.one&quot;Link target" dateTime="2026-09-12T22:12:09.000Z" lastModifiedTime="2026-09-12T22:13:46.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-12T22:12:09.000Z" objectID="{D78A6D3C-7091-0DF6-2EFC-514D3F2E4C85}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="172.3725128173828" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T22:12:09.000Z" lastModifiedTime="2026-09-12T22:12:09.000Z" objectID="{D78A6D3C-7091-0DF6-2EFC-514D3F2E4C85}{24}{B0}" alignment="left"><one:T><![CDATA[Linking page <a
3href="onenote:#Link%20target&amp;section-id={A45E149D-BEE4-4BB2-AF69-072EF644999A}&amp;page-id={1E1AB5A6-E512-42BE-A763-78914DE104F9}&amp;end&amp;base-path=C:\one-tests\runs\capture\notebook\links.one">Link target</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/link-edit/internal/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/link-edit/internal/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/m4/ilink",
3 "expected_pages": 2,
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/link-edit/internal/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/link-edit/internal/cold/scripts/read.ps1 created+142
...@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/link-edit/internal/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "fdee47831417ef5d21624890fe8d9f3d73a4ded56cacbbe2ebb978788786681b",
6 "mtime_ns": 1789251129798178239
7 },
8 {
9 "path": "links.one",
10 "bytes": 9200,
11 "sha256": "16b69f8178ce8a23e37ca844b89ae736f4e2c57827205fcfc1e51501b2865d17",
12 "mtime_ns": 1789251129797893821
13 }
14]
corpus/link-edit/internal/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/link-edit/native-links/before.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="{920F855C-A13E-02F6-000A-0CE8D35FEB66}{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-12T22:04:34.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="{330243D5-C929-0DCB-16A1-2C882BBC629D}{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="{330243D5-C929-0DCB-16A1-2C882BBC629D}{24}{B0}" alignment="left"><one:T><![CDATA[Read about Rust <a
3href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/link-edit/native-links/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-66B8F3A0", "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-66B8F3A0 -ExpectedPages 2 -UseCurrentCache", "exit": 0, "stdout": "Read 1 sections and 2 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/link-edit/native-links/links.json created+7
...@@ -0,0 +1,7 @@
1{
2 "paragraph": "onenote:///C:\\one-tests\\runs\\capture\\notebook\\links.one#Link%20target\u0026section-id={F8C0DD10-38DD-49C1-B6E4-B882569883D1}\u0026page-id={566651E4-0067-42F8-883E-206560B4FCFA}\u0026object-id={1A505D95-B393-0320-0F64-0970FA28243C}\u002628",
3 "section": "onenote:///C:\\one-tests\\runs\\capture\\notebook\\links.one#section-id={F8C0DD10-38DD-49C1-B6E4-B882569883D1}\u0026end",
4 "target_paragraph": "{1DAB917D-E947-4865-BCEA-2612693D9939}{40}{B0}",
5 "page": "onenote:///C:\\one-tests\\runs\\capture\\notebook\\links.one#Link%20target\u0026section-id={F8C0DD10-38DD-49C1-B6E4-B882569883D1}\u0026page-id={566651E4-0067-42F8-883E-206560B4FCFA}\u0026end",
6 "target": "{38458021-4A30-4602-B127-7D34E7287AC7}{1}{B0}"
7}
corpus/link-edit/native-links/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-66b8f3a0", "hostname": "ONE-M6-66B8F3A0"}
corpus/link-edit/native-links/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/link-edit/native-links/notebook/Open Notebook.onetoc2 differ
corpus/link-edit/native-links/notebook/links.one created
Binary files /dev/null and b/corpus/link-edit/native-links/notebook/links.one differ
corpus/link-edit/native-links/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-66B8F3A0",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/link-edit/native-links/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="{FD4B73ED-1D7D-4C62-8B6F-60B7DA2B502C}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T22:06:07.000Z" color="#B49EDE"><one:Section name="links" ID="{C588D77C-1E62-0A6F-2BBA-369AB0BA6184}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-12T22:06:07.000Z" color="#8AA8E4"><one:Page ID="{648511F5-7675-0552-3D11-16FA4859E87F}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-12T22:06:07.000Z" pageLevel="1"/><one:Page ID="{CECF1488-9D7B-41A6-8C3C-67267C2E79DE}{1}{B0}" name="Link target" dateTime="2026-09-12T22:04:37.000Z" lastModifiedTime="2026-09-12T22:06:07.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/link-edit/native-links/read/page-000.xml created+6
...@@ -0,0 +1,6 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{648511F5-7675-0552-3D11-16FA4859E87F}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-12T22:06:07.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="Author" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-12T22:04:42.000Z" objectID="{C588D77C-1E62-0A6F-2BBA-369AB0BA6184}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="198.3904571533203" height="53.71086883544922"/><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-12T22:04:42.000Z" objectID="{C588D77C-1E62-0A6F-2BBA-369AB0BA6184}{24}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Read about Rust <a
3href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE><one:OE creationTime="2026-09-12T22:04:42.000Z" lastModifiedTime="2026-09-12T22:04:42.000Z" objectID="{8B99BDC7-61D8-45CE-BBEC-39FFF44C8422}{25}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a
4href="onenote:#Link%20target&amp;section-id={F8C0DD10-38DD-49C1-B6E4-B882569883D1}&amp;page-id={566651E4-0067-42F8-883E-206560B4FCFA}&amp;end&amp;base-path=C:\one-tests\runs\capture\notebook\links.one">Page link</a>]]></one:T></one:OE><one:OE creationTime="2026-09-12T22:04:42.000Z" lastModifiedTime="2026-09-12T22:04:42.000Z" objectID="{8B99BDC7-61D8-45CE-BBEC-39FFF44C8422}{30}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a
5href="onenote:#Link%20target&amp;section-id={F8C0DD10-38DD-49C1-B6E4-B882569883D1}&amp;page-id={566651E4-0067-42F8-883E-206560B4FCFA}&amp;object-id={1A505D95-B393-0320-0F64-0970FA28243C}&amp;28&amp;base-path=C:\one-tests\runs\capture\notebook\links.one">Paragraph link</a>]]></one:T></one:OE><one:OE creationTime="2026-09-12T22:04:42.000Z" lastModifiedTime="2026-09-12T22:04:42.000Z" objectID="{8B99BDC7-61D8-45CE-BBEC-39FFF44C8422}{35}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a
6href="onenote:#section-id={F8C0DD10-38DD-49C1-B6E4-B882569883D1}&amp;end&amp;base-path=C:\one-tests\runs\capture\notebook\links.one">Section link</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/link-edit/native-links/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="{CECF1488-9D7B-41A6-8C3C-67267C2E79DE}{1}{B0}" name="Link target" dateTime="2026-09-12T22:04:37.000Z" lastModifiedTime="2026-09-12T22:06:07.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:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-12T22:04:38.000Z" lastModifiedTime="2026-09-12T22:04:38.000Z" objectID="{EB2105D4-3E0C-4FC1-81F1-3C00F23B9A20}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Link target]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-12T22:04:38.000Z" objectID="{EB2105D4-3E0C-4FC1-81F1-3C00F23B9A20}{39}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="117.0292816162109" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-12T22:04:38.000Z" lastModifiedTime="2026-09-12T22:04:38.000Z" objectID="{EB2105D4-3E0C-4FC1-81F1-3C00F23B9A20}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Target paragraph]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/link-edit/native-links/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/link-edit/native-links/run.json created+19
...@@ -0,0 +1,19 @@
1{
2 "notebook": "/Users/clo/dev/one/corpus/link-edit/candidate",
3 "expected_pages": 2,
4 "author": "tools/native/page-link.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": "a67883aa9e5ffd80b9709d8558f37184cf05d79b28b35ce0db3b2f8a16b57225",
16 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
17 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
18 }
19}
corpus/link-edit/native-links/scripts/author.ps1 created+63
...@@ -0,0 +1,63 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
6$app = New-Object -ComObject OneNote.Application
7$notebookId = ''
8try {
9 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
10 $sectionId = ''
11 $app.OpenHierarchy('links.one', $notebookId, [ref]$sectionId, 0)
12 $deadline = [DateTime]::UtcNow.AddSeconds(30)
13 $page = $null
14 $pages = @()
15 do {
16 $hierarchy = ''
17 $app.GetHierarchy($sectionId, 4, [ref]$hierarchy, 1)
18 [xml]$tree = $hierarchy
19 $pages = @($tree.SelectNodes('//*[local-name()="Page"]'))
20 if ($pages.Count -eq 1) {
21 $content = ''
22 $app.GetPageContent($pages[0].GetAttribute('ID'), [ref]$content, 3, 1)
23 [xml]$page = $content
24 if (@($page.SelectNodes('//*[local-name()="OE"]')).Count -ge 1) { break }
25 }
26 Start-Sleep -Milliseconds 250
27 } while ([DateTime]::UtcNow -lt $deadline)
28 if ($pages.Count -ne 1) { throw 'Expected one page in the section.' }
29 [IO.File]::WriteAllText((Join-Path $Root 'before.xml'), $content, [Text.Encoding]::UTF8)
30 $targetId = ''
31 $app.CreateNewPage($sectionId, [ref]$targetId, 0)
32 $target = '<one:Page xmlns:one="' + $namespace + '" ID="' + $targetId + '"><one:Title><one:OE><one:T><![CDATA[Link target]]></one:T></one:OE></one:Title><one:Outline><one:Position x="36" y="86"/><one:Size width="300" height="30"/><one:OEChildren><one:OE><one:T><![CDATA[Target paragraph]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>'
33 $app.UpdatePageContent($target, [DateTime]::MinValue, 1, $true)
34 $targetContent = ''
35 $app.GetPageContent($targetId, [ref]$targetContent, 3, 1)
36 [xml]$targetPage = $targetContent
37 $targetParagraph = $targetPage.SelectSingleNode('//*[local-name()="Outline"]/*[local-name()="OEChildren"]/*[local-name()="OE"]').GetAttribute('objectID')
38 $pageLink = ''
39 $app.GetHyperlinkToObject($targetId, '', [ref]$pageLink)
40 $paragraphLink = ''
41 $app.GetHyperlinkToObject($targetId, $targetParagraph, [ref]$paragraphLink)
42 $sectionLink = ''
43 $app.GetHyperlinkToObject($sectionId, '', [ref]$sectionLink)
44 @{ page = $pageLink; paragraph = $paragraphLink; section = $sectionLink; target = $targetId; target_paragraph = $targetParagraph } | ConvertTo-Json | Set-Content (Join-Path $Root 'links.json') -Encoding UTF8
45 $children = $page.SelectSingleNode('//*[local-name()="Outline"]/*[local-name()="OEChildren"]')
46 foreach ($entry in @(@('Page link', $pageLink), @('Paragraph link', $paragraphLink), @('Section link', $sectionLink))) {
47 $oe = $page.CreateElement('one', 'OE', $namespace)
48 $t = $page.CreateElement('one', 'T', $namespace)
49 [void]$t.AppendChild($page.CreateCDataSection('<a href="' + [Security.SecurityElement]::Escape($entry[1]) + '">' + $entry[0] + '</a>'))
50 [void]$oe.AppendChild($t)
51 [void]$children.AppendChild($oe)
52 }
53 [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $page.OuterXml, [Text.Encoding]::UTF8)
54 $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false)
55 $app.SyncHierarchy($notebookId)
56 $app.CloseNotebook($notebookId, $false)
57 $notebookId = ''
58} finally {
59 if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
60 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
61 [GC]::Collect()
62 [GC]::WaitForPendingFinalizers()
63}
corpus/link-edit/native-links/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/link-edit/native-links/scripts/read.ps1 created+142
...@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/link-edit/native-links/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/link-edit/native-links/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/link-edit/native-links/update.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?><one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{920F855C-A13E-02F6-000A-0CE8D35FEB66}{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-12T22:04:34.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="{330243D5-C929-0DCB-16A1-2C882BBC629D}{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="{330243D5-C929-0DCB-16A1-2C882BBC629D}{24}{B0}" alignment="left"><one:T><![CDATA[Read about Rust <a
2href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE><one:OE><one:T><![CDATA[<a href="onenote:///C:\one-tests\runs\capture\notebook\links.one#Link%20target&amp;section-id={F8C0DD10-38DD-49C1-B6E4-B882569883D1}&amp;page-id={566651E4-0067-42F8-883E-206560B4FCFA}&amp;end">Page link</a>]]></one:T></one:OE><one:OE><one:T><![CDATA[<a href="onenote:///C:\one-tests\runs\capture\notebook\links.one#Link%20target&amp;section-id={F8C0DD10-38DD-49C1-B6E4-B882569883D1}&amp;page-id={566651E4-0067-42F8-883E-206560B4FCFA}&amp;object-id={1A505D95-B393-0320-0F64-0970FA28243C}&amp;28">Paragraph link</a>]]></one:T></one:OE><one:OE><one:T><![CDATA[<a href="onenote:///C:\one-tests\runs\capture\notebook\links.one#section-id={F8C0DD10-38DD-49C1-B6E4-B882569883D1}&amp;end">Section link</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
\ No newline at end of file
corpus/tag-edit/README.md+3-2
...@@ -4,7 +4,7 @@...@@ -4,7 +4,7 @@
4`new_definitions_and_tags_publish_on_a_fresh_page` in4`new_definitions_and_tags_publish_on_a_fresh_page` in
5`crates/onestore/tests/page_tags.rs`: on a section created in Rust, two tag5`crates/onestore/tests/page_tags.rs`: on a section created in Rust, two tag
6definitions are added ("Rust task", type 0, symbol 3; "Important", type 1,6definitions are added ("Rust task", type 0, symbol 3; "Important", type 1,
7symbol 13), the first paragraph gains an open task, a second paragraph a7symbol 13, red text on a yellow highlight), the first paragraph gains an open task, a second paragraph a
8completed task with creation and completion dates, a third paragraph both8completed task with creation and completion dates, a third paragraph both
9tags, and a fourth paragraph none. Tags are property-set arrays on the text9tags, and a fourth paragraph none. Tags are property-set arrays on the text
10object referencing definition objects (jcid 0x120043) that carry the model's10object referencing definition objects (jcid 0x120043) that carry the model's
...@@ -12,7 +12,8 @@ identities after squash; an element holds one tag per action type, as OneNote...@@ -12,7 +12,8 @@ identities after squash; an element holds one tag per action type, as OneNote
12requires.12requires.
1313
14`cold/` is a fresh OneNote 2010 read: both `TagDef` entries appear with their14`cold/` is a fresh OneNote 2010 read: both `TagDef` entries appear with their
15type, symbol and name, the tags read back with their completion state and15type, symbol, name and colours (`fontColor="#FF0000"`,
16`highlightColor="#FFFF00"` for the second), the tags read back with their completion state and
16dates, and the untagged paragraph has none. `tools/test_tag_edit.py` checks17dates, and the untagged paragraph has none. `tools/test_tag_edit.py` checks
17this without a VM. Regenerate with `ONESTORE_TAG_EXPORT` set to a new absolute18this without a VM. Regenerate with `ONESTORE_TAG_EXPORT` set to a new absolute
18directory while running the test, then cold-open it with19directory while running the test, then cold-open it with
corpus/tag-edit/candidate/Open Notebook.onetoc2
Binary files a/corpus/tag-edit/candidate/Open Notebook.onetoc2 and b/corpus/tag-edit/candidate/Open Notebook.onetoc2 differ
corpus/tag-edit/candidate/tags.one
Binary files a/corpus/tag-edit/candidate/tags.one and b/corpus/tag-edit/candidate/tags.one differ
corpus/tag-edit/cold/commands.jsonl+1-1
...@@ -1,3 +1,3 @@...@@ -1,3 +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}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-CFD607C8 -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "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-6BD92C98 -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}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/tag-edit/cold/machine.json+1-1
...@@ -1 +1 @@...@@ -1 +1 @@
1{"name": "m6-cfd607c8", "hostname": "ONE-M6-CFD607C8"}1{"name": "m6-6bd92c98", "hostname": "ONE-M6-6BD92C98"}
corpus/tag-edit/cold/notebook/Open Notebook.onetoc2
Binary files a/corpus/tag-edit/cold/notebook/Open Notebook.onetoc2 and b/corpus/tag-edit/cold/notebook/Open Notebook.onetoc2 differ
corpus/tag-edit/cold/notebook/tags.one
Binary files a/corpus/tag-edit/cold/notebook/tags.one and b/corpus/tag-edit/cold/notebook/tags.one differ
corpus/tag-edit/cold/read/environment.json+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1{ 1{
2 "powershell": "5.1.14409.1005", 2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010", 3 "schema": "xs2010",
4 "hostname": "ONE-M6-CFD607C8", 4 "hostname": "ONE-M6-6BD92C98",
5 "cold": true, 5 "cold": true,
6 "onenote": "14.0.4763.1000" 6 "onenote": "14.0.4763.1000"
7} 7}
corpus/tag-edit/cold/read/hierarchy.xml+1-1
...@@ -1,2 +1,2 @@...@@ -1,2 +1,2 @@
1<?xml version="1.0"?> 1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{776770BA-9325-43FF-BB17-ADC70305D68D}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T09:33:49.000Z" color="#B49EDE"><one:Section name="tags" ID="{124F54B0-18DB-05C6-2862-7599822E55B2}{1}{B0}" path="C:\one-tests\runs\capture\notebook\tags.one" lastModifiedTime="2026-09-12T09:33:49.000Z" color="#8AA8E4"><one:Page ID="{8B9A6BBA-7DEE-0679-2C42-81467412EBEF}{1}{B0}" name="Open task" dateTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:33:49.000Z" pageLevel="1"/></one:Section></one:Notebook> 2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{D0E58B8A-B51D-44EF-8DA1-CA3B30B9B685}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T22:04:15.000Z" color="#B49EDE"><one:Section name="tags" ID="{895156DB-06D9-07F3-3689-416780D80F8B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\tags.one" lastModifiedTime="2026-09-12T22:04:15.000Z" color="#8AA8E4"><one:Page ID="{E94AB3B0-8E65-0A67-380D-75B50FFFFECD}{1}{B0}" name="Open task" dateTime="2026-09-12T22:02:47.000Z" lastModifiedTime="2026-09-12T22:04:15.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/tag-edit/cold/read/page-000.xml+1-1
...@@ -1,2 +1,2 @@...@@ -1,2 +1,2 @@
1<?xml version="1.0"?> 1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{8B9A6BBA-7DEE-0679-2C42-81467412EBEF}{1}{B0}" name="Open task" dateTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:33:49.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="Rust task"/><one:TagDef index="1" type="1" symbol="13" fontColor="automatic" highlightColor="none" name="Important"/><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:32:16.000Z" objectID="{124F54B0-18DB-05C6-2862-7599822E55B2}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="158.2036895751953" height="53.71086883544922"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:32:16.000Z" objectID="{124F54B0-18DB-05C6-2862-7599822E55B2}{24}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Open task]]></one:T></one:OE><one:OE author="Tag author" lastModifiedBy="Tag author" creationTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:32:16.000Z" objectID="{13111015-B879-0D3E-10E3-0C769EF30D4A}{0}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2020-01-02T04:05:06.000Z"/><one:T><![CDATA[Completed task]]></one:T></one:OE><one:OE author="Tag author" lastModifiedBy="Tag author" creationTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:32:16.000Z" objectID="{F2B0539D-F9DE-062B-07BC-8EFA0C693E00}{0}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:Tag index="1" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Important open task]]></one:T></one:OE><one:OE author="Tag author" lastModifiedBy="Tag author" creationTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:32:16.000Z" objectID="{8F65AAD5-5CD7-0F4E-24EF-4FACE80A618B}{0}{B0}" alignment="left"><one:T><![CDATA[Untagged]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> 2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E94AB3B0-8E65-0A67-380D-75B50FFFFECD}{1}{B0}" name="Open task" dateTime="2026-09-12T22:02:47.000Z" lastModifiedTime="2026-09-12T22:04:15.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="Rust task"/><one:TagDef index="1" type="1" symbol="13" fontColor="#FF0000" highlightColor="#FFFF00" name="Important"/><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-12T22:02:47.000Z" objectID="{895156DB-06D9-07F3-3689-416780D80F8B}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="158.2036895751953" height="53.71086883544922"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T22:02:47.000Z" lastModifiedTime="2026-09-12T22:02:47.000Z" objectID="{895156DB-06D9-07F3-3689-416780D80F8B}{24}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Open task]]></one:T></one:OE><one:OE author="Tag author" lastModifiedBy="Tag author" creationTime="2026-09-12T22:02:47.000Z" lastModifiedTime="2026-09-12T22:02:47.000Z" objectID="{5D7040B7-F4B6-0C4D-00DA-A8932314CF98}{0}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2020-01-02T04:05:06.000Z"/><one:T><![CDATA[Completed task]]></one:T></one:OE><one:OE author="Tag author" lastModifiedBy="Tag author" creationTime="2026-09-12T22:02:47.000Z" lastModifiedTime="2026-09-12T22:02:47.000Z" objectID="{66249A3A-3DE8-0181-0785-5664C59CE40C}{0}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:Tag index="1" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Important open task]]></one:T></one:OE><one:OE author="Tag author" lastModifiedBy="Tag author" creationTime="2026-09-12T22:02:47.000Z" lastModifiedTime="2026-09-12T22:02:47.000Z" objectID="{D58C9666-1D8D-03DD-2071-2D3AA71E2BE9}{0}{B0}" alignment="left"><one:T><![CDATA[Untagged]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/tag-edit/cold/source.json+5-5
...@@ -2,13 +2,13 @@...@@ -2,13 +2,13 @@
2 {2 {
3 "path": "Open Notebook.onetoc2",3 "path": "Open Notebook.onetoc2",
4 "bytes": 1596,4 "bytes": 1596,
5 "sha256": "f3ce1472c9be35b1fb52683c5214e62850787e8e13bbd1918fed81fd19a48a65",5 "sha256": "4f64a5de0f6a4a1bfc92ec83d50f6cfd986b0c72d803dd9096b277b294b1288f",
6 "mtime_ns": 17892055363909858086 "mtime_ns": 1789250567099455316
7 },7 },
8 {8 {
9 "path": "tags.one",9 "path": "tags.one",
10 "bytes": 8352,10 "bytes": 8368,
11 "sha256": "601a8998b691bab66830196b3b139be6ea58d6f29d5bc69b3efa9892e2fc1550",11 "sha256": "bb1715165c52bcc443a012178604d9d1c9b5d4d9fb9c18b6ecb806583db5305d",
12 "mtime_ns": 178920553639069893212 "mtime_ns": 1789250567099157565
13 }13 }
14]14]
crates/canvas/src/editor.rs+4
...@@ -114,6 +114,7 @@ pub struct CanvasEditor {...@@ -114,6 +114,7 @@ pub struct CanvasEditor {
114#[derive(Default)]114#[derive(Default)]
115struct PageHeader {115struct PageHeader {
116 title: String,116 title: String,
117 identity: Option<[u8; 16]>,
117 created: Option<u64>,118 created: Option<u64>,
118 margin_origin: [f32; 2],119 margin_origin: [f32; 2],
119 areas: Vec<page::TitleArea>,120 areas: Vec<page::TitleArea>,
...@@ -699,6 +700,7 @@ impl CanvasEditor {...@@ -699,6 +700,7 @@ impl CanvasEditor {
699 editor.objects = objects;700 editor.objects = objects;
700 editor.header = PageHeader {701 editor.header = PageHeader {
701 title: page.title,702 title: page.title,
703 identity: page.identity,
702 created: page.created,704 created: page.created,
703 margin_origin: page.margin_origin,705 margin_origin: page.margin_origin,
704 areas,706 areas,
...@@ -767,6 +769,7 @@ impl CanvasEditor {...@@ -767,6 +769,7 @@ impl CanvasEditor {
767 );769 );
768 Ok(Page {770 Ok(Page {
769 title: self.header.title.clone(),771 title: self.header.title.clone(),
772 identity: self.header.identity,
770 created: self773 created: self
771 .date774 .date
772 .as_ref()775 .as_ref()
...@@ -4384,6 +4387,7 @@ mod tests {...@@ -4384,6 +4387,7 @@ mod tests {
4384 let mut editor = CanvasEditor::from_page(4387 let mut editor = CanvasEditor::from_page(
4385 Page {4388 Page {
4386 title: "Header".into(),4389 title: "Header".into(),
4390 identity: None,
4387 created: None,4391 created: None,
4388 margin_origin: [36.0, 14.4],4392 margin_origin: [36.0, 14.4],
4389 definitions: BTreeMap::new(),4393 definitions: BTreeMap::new(),
crates/canvas/src/gpu/page.rs+7
...@@ -550,6 +550,7 @@ mod tests {...@@ -550,6 +550,7 @@ mod tests {
550 let id = fields[0].id;550 let id = fields[0].id;
551 let source = fields[0].paragraphs.clone();551 let source = fields[0].paragraphs.clone();
552 let page = Page {552 let page = Page {
553 identity: None,
553 created: Some(1),554 created: Some(1),
554 title: "Header".into(),555 title: "Header".into(),
555 margin_origin: [36.0, 14.4],556 margin_origin: [36.0, 14.4],
...@@ -987,6 +988,7 @@ mod tests {...@@ -987,6 +988,7 @@ mod tests {
987 fields[0].min_width = Some(162.0);988 fields[0].min_width = Some(162.0);
988 let title_id = fields[0].id;989 let title_id = fields[0].id;
989 let page = Page {990 let page = Page {
991 identity: None,
990 created: None,992 created: None,
991 title: "Header".into(),993 title: "Header".into(),
992 margin_origin: [36.0, 14.4],994 margin_origin: [36.0, 14.4],
...@@ -1060,6 +1062,7 @@ mod tests {...@@ -1060,6 +1062,7 @@ mod tests {
1060 })1062 })
1061 .collect();1063 .collect();
1062 let page = Page {1064 let page = Page {
1065 identity: None,
1063 created: None,1066 created: None,
1064 title: String::new(),1067 title: String::new(),
1065 margin_origin: [0.0; 2],1068 margin_origin: [0.0; 2],
...@@ -1138,6 +1141,7 @@ mod tests {...@@ -1138,6 +1141,7 @@ mod tests {
1138 PageObject::Outline(tagged)1141 PageObject::Outline(tagged)
1139 };1142 };
1140 let page = Page {1143 let page = Page {
1144 identity: None,
1141 created: None,1145 created: None,
1142 title: String::new(),1146 title: String::new(),
1143 margin_origin: [36.0, 14.0],1147 margin_origin: [36.0, 14.0],
...@@ -1208,6 +1212,7 @@ mod tests {...@@ -1208,6 +1212,7 @@ mod tests {
1208 layout: Layout::default(),1212 layout: Layout::default(),
1209 };1213 };
1210 let page = Page {1214 let page = Page {
1215 identity: None,
1211 created: None,1216 created: None,
1212 title: String::new(),1217 title: String::new(),
1213 margin_origin: [0.0; 2],1218 margin_origin: [0.0; 2],
...@@ -1383,6 +1388,7 @@ mod tests {...@@ -1383,6 +1388,7 @@ mod tests {
1383 .unwrap();1388 .unwrap();
1384 }1389 }
1385 let page = || Page {1390 let page = || Page {
1391 identity: None,
1386 created: None,1392 created: None,
1387 title: String::new(),1393 title: String::new(),
1388 margin_origin: [0.0; 2],1394 margin_origin: [0.0; 2],
...@@ -1504,6 +1510,7 @@ mod tests {...@@ -1504,6 +1510,7 @@ mod tests {
1504 .unwrap();1510 .unwrap();
1505 }1511 }
1506 let page = |width| Page {1512 let page = |width| Page {
1513 identity: None,
1507 created: None,1514 created: None,
1508 title: String::new(),1515 title: String::new(),
1509 margin_origin: [0.0; 2],1516 margin_origin: [0.0; 2],
crates/notebook/README.md+3-2
...@@ -14,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot,...@@ -14,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot,
14writes the difference into the working image and queues one14writes the difference into the working image and queues one
15`Operation::Page(PageIntent { before, after, author })`; `before` is the page the15`Operation::Page(PageIntent { before, after, author })`; `before` is the page the
16edit started from and is the precondition reconciliation checks. Text, styles,16edit started from and is the precondition reconciliation checks. Text, styles,
17paragraph formatting, hyperlinks, bullets, numbering, note tags, table rows, columns, cell shading, nested tables, pictures (insertion in paragraphs or on the page, position, size, description) and attachments, paragraph structure,17paragraph formatting, hyperlinks (external and internal), bullets, numbering, note tags, table rows, columns, cell shading, nested tables, pictures (insertion in paragraphs or on the page, position, size, description) and attachments, paragraph structure,
18outline layout, insertions and deletions are all differences between `before` and18outline layout, insertions and deletions are all differences between `before` and
19`after`; the library never sees editor operations.19`after`; the library never sees editor operations.
2020
...@@ -60,7 +60,8 @@ a replica stored under the cache directory, named by the section's document...@@ -60,7 +60,8 @@ a replica stored under the cache directory, named by the section's document
60identity so the same file reopens the same queue after a relaunch. A section60identity so the same file reopens the same queue after a relaunch. A section
61publishes in the background to the file itself under OneNote-compatible61publishes in the background to the file itself under OneNote-compatible
62exclusion. `pages()` lists page spaces and titles from the local image,62exclusion. `pages()` lists page spaces and titles from the local image,
63`page(space)` returns the model to edit, and `save(space, before, after,63`page(space)` returns the model to edit (its `identity` and the section's
64`identity()` feed `onestore::page::link::internal_link`), and `save(space, before, after,
64author)` queues the edited model: `Save::Queued(id)` is durable locally,65author)` queues the edited model: `Save::Queued(id)` is durable locally,
65`Save::Unchanged` means the model equals the stored page, and `Save::Stale`66`Save::Unchanged` means the model equals the stored page, and `Save::Stale`
66means the stored page no longer matches `before` because the section changed67means the stored page no longer matches `before` because the section changed
crates/notebook/src/merge.rs+1
...@@ -116,6 +116,7 @@ pub(crate) fn merge(base: &Page, ours: &Page, theirs: &Page) -> Option<Page> {...@@ -116,6 +116,7 @@ pub(crate) fn merge(base: &Page, ours: &Page, theirs: &Page) -> Option<Page> {
116 }116 }
117 Some(Page {117 Some(Page {
118 title: theirs.title.clone(),118 title: theirs.title.clone(),
119 identity: theirs.identity,
119 created: theirs.created,120 created: theirs.created,
120 margin_origin: theirs.margin_origin,121 margin_origin: theirs.margin_origin,
121 objects: result,122 objects: result,
crates/notebook/src/session.rs+6
...@@ -209,6 +209,12 @@ impl Section {...@@ -209,6 +209,12 @@ impl Section {
209 &self.file209 &self.file
210 }210 }
211211
212 /// The section file identity, which internal links name as `section-id`
213 /// (`onestore::page::link::internal_link`).
214 pub fn identity(&self) -> Result<[u8; 16]> {
215 Ok(Store::parse(&self.replica.snapshot()?)?.header.file_id)
216 }
217
212 /// Page spaces and titles in section order, from the local working image.218 /// Page spaces and titles in section order, from the local working image.
213 pub fn pages(&self) -> Result<Vec<(ExGuid, String)>> {219 pub fn pages(&self) -> Result<Vec<(ExGuid, String)>> {
214 let snapshot = self.replica.snapshot()?;220 let snapshot = self.replica.snapshot()?;
crates/onestore/README.md+1-1
...@@ -57,7 +57,7 @@ harness also accepts `--client-profile release`....@@ -57,7 +57,7 @@ harness also accepts `--client-profile release`.
57| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |57| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
58| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |58| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
59| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |59| `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, 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, 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 |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, paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities |
61| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |61| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |
62| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |62| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
63| `read_file` | Read a snapshot under whole-file exclusion |63| `read_file` | Read a snapshot under whole-file exclusion |
crates/onestore/src/page/link.rs created+77
...@@ -0,0 +1,77 @@
1//! Links between pages, paragraphs and sections of the same notebook, in the form OneNote
2//! stores them: a `HYPERLINK` field code whose URL names the section by its file identity
3//! and the page by its notebook-management identity, with the section path as `base-path`.
4
5use crate::ExGuid;
6
7/// What an internal link opens.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum LinkTarget<'a> {
10 Section,
11 Page {
12 identity: [u8; 16],
13 title: &'a str,
14 },
15 /// A paragraph or other object on a page, named by its stored identity.
16 Object {
17 identity: [u8; 16],
18 title: &'a str,
19 object: ExGuid,
20 },
21}
22
23/// The URL OneNote 2010 stores for a link into the section whose file identity is `section`
24/// and whose file is at `base_path` (the path as the linking client sees it):
25/// `onenote:#Title&section-id={…}&page-id={…}&end&base-path=…`.
26pub fn internal_link(section: [u8; 16], base_path: &str, target: LinkTarget<'_>) -> String {
27 let guid = |bytes: [u8; 16]| {
28 ExGuid { guid: bytes, n: 0 }
29 .to_string()
30 .split(',')
31 .next()
32 .unwrap()
33 .to_owned()
34 };
35 let mut url = String::from("onenote:#");
36 match target {
37 LinkTarget::Section => {}
38 LinkTarget::Page { title, .. } | LinkTarget::Object { title, .. } => {
39 url.push_str(&encoded(title));
40 url.push('&');
41 }
42 }
43 url.push_str(&format!("section-id={}", guid(section)));
44 match target {
45 LinkTarget::Section => url.push_str("&end"),
46 LinkTarget::Page { identity, .. } => {
47 url.push_str(&format!("&page-id={}&end", guid(identity)));
48 }
49 LinkTarget::Object {
50 identity, object, ..
51 } => {
52 url.push_str(&format!(
53 "&page-id={}&object-id={}&{}",
54 guid(identity),
55 guid(object.guid),
56 object.n
57 ));
58 }
59 }
60 url.push_str("&base-path=");
61 url.push_str(base_path);
62 url
63}
64
65/// Percent-encodes a page title the way OneNote does in a link fragment.
66fn encoded(title: &str) -> String {
67 let mut out = String::new();
68 for byte in title.bytes() {
69 match byte {
70 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
71 out.push(byte as char);
72 }
73 _ => out.push_str(&format!("%{byte:02X}")),
74 }
75 }
76 out
77}
crates/onestore/src/page/mod.rs+36-15
...@@ -10,6 +10,7 @@ use std::{...@@ -10,6 +10,7 @@ use std::{
10 sync::Arc,10 sync::Arc,
11};11};
1212
13pub mod link;
13pub mod text;14pub mod text;
14pub(crate) mod write;15pub(crate) mod write;
15pub use text::Paragraph;16pub use text::Paragraph;
...@@ -24,6 +25,8 @@ pub enum DateField {...@@ -24,6 +25,8 @@ pub enum DateField {
24#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]25#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
25pub struct Page {26pub struct Page {
26 pub title: String,27 pub title: String,
28 /// The page's notebook-management identity, which internal links name as `page-id`.
29 pub identity: Option<[u8; 16]>,
27 /// FILETIME ticks from the page's TopologyCreationTimeStamp.30 /// FILETIME ticks from the page's TopologyCreationTimeStamp.
28 pub created: Option<u64>,31 pub created: Option<u64>,
29 pub margin_origin: [f32; 2],32 pub margin_origin: [f32; 2],
...@@ -343,14 +346,21 @@ impl Page {...@@ -343,14 +346,21 @@ impl Page {
343 else {346 else {
344 return Err(invalid("Canvas root is not a page"));347 return Err(invalid("Canvas root is not a page"));
345 };348 };
349 let metadata = revision
350 .roots
351 .get(&2)
352 .and_then(|id| revision.nodes.get(id))
353 .filter(|node| matches!(node.kind, Kind::Metadata { .. }))
354 .and_then(|node| node.extra.first());
346 let mut page = Self {355 let mut page = Self {
347 title: page_title(revision, id).unwrap_or_default().to_owned(),356 title: page_title(revision, id).unwrap_or_default().to_owned(),
348 created: revision357 identity: metadata
349 .roots358 .and_then(|fields| fields.iter().find(|field| field.id == 0x1c001c30))
350 .get(&2)359 .and_then(|field| match field.value {
351 .and_then(|id| revision.nodes.get(id))360 FieldValue::Bytes(bytes) => bytes.try_into().ok(),
352 .filter(|node| matches!(node.kind, Kind::Metadata { .. }))361 _ => None,
353 .and_then(|node| node.extra.first())362 }),
363 created: metadata
354 .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65))364 .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65))
355 .map(|field| {365 .map(|field| {
356 let FieldValue::Bytes(bytes) = field.value else {366 let FieldValue::Bytes(bytes) = field.value else {
...@@ -847,7 +857,7 @@ impl PageParagraph {...@@ -847,7 +857,7 @@ impl PageParagraph {
847#[cfg(test)]857#[cfg(test)]
848mod tests {858mod tests {
849 use super::*;859 use super::*;
850 use crate::document::{Element, TextRun};860 use crate::document::{Element, Field, FieldValue, TextRun};
851861
852 fn id(n: u32) -> ExGuid {862 fn id(n: u32) -> ExGuid {
853 ExGuid {863 ExGuid {
...@@ -856,6 +866,8 @@ mod tests {...@@ -856,6 +866,8 @@ mod tests {
856 }866 }
857 }867 }
858868
869 const IDENTITY: [u8; 16] = [7; 16];
870
859 fn element(kind: Kind<'_>) -> Element<'_> {871 fn element(kind: Kind<'_>) -> Element<'_> {
860 Element {872 Element {
861 jcid: 0,873 jcid: 0,
...@@ -918,10 +930,14 @@ mod tests {...@@ -918,10 +930,14 @@ mod tests {
918 name: Some("Body".into()),930 name: Some("Body".into()),
919 });931 });
920 style.format.font_size = Some(12.0);932 style.format.font_size = Some(12.0);
921 let metadata = element(Kind::Metadata {933 let mut metadata = element(Kind::Metadata {
922 title: Some("Page title".into()),934 title: Some("Page title".into()),
923 level: None,935 level: None,
924 });936 });
937 metadata.extra = vec![vec![Field {
938 id: 0x1c001c30,
939 value: FieldValue::Bytes(&IDENTITY),
940 }]];
925 Revision {941 Revision {
926 roots: BTreeMap::from([(2, id(6))]),942 roots: BTreeMap::from([(2, id(6))]),
927 nodes: BTreeMap::from([943 nodes: BTreeMap::from([
...@@ -1183,14 +1199,19 @@ mod tests {...@@ -1183,14 +1199,19 @@ mod tests {
11831199
1184 #[test]1200 #[test]
1185 fn creation_time_comes_from_page_metadata_and_date_role_from_the_title_child() {1201 fn creation_time_comes_from_page_metadata_and_date_role_from_the_title_child() {
1186 use crate::document::{Field, FieldValue};
1187 let mut source = revision();1202 let mut source = revision();
1188 source.nodes.get_mut(&id(1)).unwrap().created = Some(7);1203 source.nodes.get_mut(&id(1)).unwrap().created = Some(7);
1189 let bytes = 134_333_468_649_123_456_u64.to_le_bytes();1204 let bytes = 134_333_468_649_123_456_u64.to_le_bytes();
1190 source.nodes.get_mut(&id(6)).unwrap().extra = vec![vec![Field {1205 source.nodes.get_mut(&id(6)).unwrap().extra = vec![vec![
1191 id: 0x18001c65,1206 Field {
1192 value: FieldValue::Bytes(&bytes),1207 id: 0x18001c65,
1193 }]];1208 value: FieldValue::Bytes(&bytes),
1209 },
1210 Field {
1211 id: 0x1c001c30,
1212 value: FieldValue::Bytes(&IDENTITY),
1213 },
1214 ]];
1194 source.nodes.get_mut(&id(1)).unwrap().children.clear();1215 source.nodes.get_mut(&id(1)).unwrap().children.clear();
1195 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7));1216 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7));
1196 let mut title = element(Kind::Title);1217 let mut title = element(Kind::Title);
...@@ -1217,12 +1238,12 @@ mod tests {...@@ -1217,12 +1238,12 @@ mod tests {
1217 source.nodes.get_mut(&id(6)).unwrap().extra[0][0].value = FieldValue::Bytes(&bytes[..7]);1238 source.nodes.get_mut(&id(6)).unwrap().extra[0][0].value = FieldValue::Bytes(&bytes[..7]);
1218 assert!(Page::from_revision(&source, id(1)).is_err());1239 assert!(Page::from_revision(&source, id(1)).is_err());
1219 source.nodes.get_mut(&id(6)).unwrap().extra.clear();1240 source.nodes.get_mut(&id(6)).unwrap().extra.clear();
1220 assert_eq!(Page::from_revision(&source, id(1)).unwrap().created, None);1241 let bare = Page::from_revision(&source, id(1)).unwrap();
1242 assert_eq!((bare.created, bare.identity), (None, None));
1221 }1243 }
12221244
1223 #[test]1245 #[test]
1224 fn title_role_and_minimum_width_come_from_root_properties() {1246 fn title_role_and_minimum_width_come_from_root_properties() {
1225 use crate::document::{Field, FieldValue};
1226 let mut source = revision();1247 let mut source = revision();
1227 source.nodes.get_mut(&id(1)).unwrap().children.clear();1248 source.nodes.get_mut(&id(1)).unwrap().children.clear();
1228 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7));1249 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7));
crates/onestore/tests/page_links.rs+149-1
...@@ -1,11 +1,18 @@...@@ -1,11 +1,18 @@
1use onestore::{1use onestore::{
2 ExGuid, PreparedEdit, RevisionIndex, Store,2 ExGuid, PreparedEdit, RevisionIndex, Store,
3 document::Document,3 document::Document,
4 page::{Page, PageObject, PageParagraph, Paragraph, text::Edit},4 page::{
5 Page, PageObject, PageParagraph, Paragraph,
6 link::{LinkTarget, internal_link},
7 text::Edit,
8 },
5};9};
610
7const OUTLINES: &[u8] =11const OUTLINES: &[u8] =
8 include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");12 include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
13const NATIVE_LINKS: &[u8] =
14 include_bytes!("../../../corpus/link-edit/native-links/notebook/links.one");
15const NATIVE_BASE_PATH: &str = r"C:\one-tests\runs\capture\notebook\links.one";
9const AUTHOR: &str = "Link author";16const AUTHOR: &str = "Link author";
10const CODE: &str = "\u{fddf}HYPERLINK \"https://example.invalid/rust\"";17const CODE: &str = "\u{fddf}HYPERLINK \"https://example.invalid/rust\"";
1118
...@@ -191,3 +198,144 @@ fn a_link_is_added_to_a_fresh_page_and_reads_back() {...@@ -191,3 +198,144 @@ fn a_link_is_added_to_a_fresh_page_and_reads_back() {
191 .unwrap();198 .unwrap();
192 }199 }
193}200}
201
202fn field_codes(page: &mut Page) -> Vec<String> {
203 body_paragraphs(page)
204 .iter()
205 .filter_map(|p| p.text())
206 .filter_map(|t| {
207 let text = t.text.text();
208 let start = text.find("\u{fddf}HYPERLINK \"")? + "\u{fddf}HYPERLINK \"".len();
209 let end = start + text[start..].find('"')?;
210 Some(text[start..end].to_owned())
211 })
212 .collect()
213}
214
215/// The URLs OneNote 2010 wrote for links to a page, a paragraph and the section
216/// (`corpus/link-edit/native-links`) are what `internal_link` produces from the stored
217/// identities: the section file identity, the target page's notebook-management identity
218/// and the paragraph's stored identity.
219#[test]
220fn internal_links_match_what_onenote_stores() {
221 let section = Store::parse(NATIVE_LINKS).unwrap().header.file_id;
222 let (_, target) = page_by_title(NATIVE_LINKS, "Link target");
223 let mut target_paragraphs = target.clone();
224 // OneNote rewrote the target outline after linking; the link keeps the paragraph
225 // identity it saw then (n 28), whose page half is the current paragraph's.
226 let paragraph = ExGuid {
227 guid: body_paragraphs(&mut target_paragraphs)[0].id.guid,
228 n: 28,
229 };
230 let (_, mut source) = page_by_title(NATIVE_LINKS, "Read about Rust the Rust site");
231 let stored = field_codes(&mut source);
232 let page = LinkTarget::Page {
233 identity: target.identity.unwrap(),
234 title: &target.title,
235 };
236 let object = LinkTarget::Object {
237 identity: target.identity.unwrap(),
238 title: &target.title,
239 object: paragraph,
240 };
241 assert_eq!(
242 stored,
243 [
244 "https://example.invalid/rust".to_owned(),
245 internal_link(section, NATIVE_BASE_PATH, page),
246 internal_link(section, NATIVE_BASE_PATH, object),
247 internal_link(section, NATIVE_BASE_PATH, LinkTarget::Section),
248 ]
249 );
250 assert_eq!(
251 internal_link(
252 [0; 16],
253 "p",
254 LinkTarget::Page {
255 identity: [0; 16],
256 title: "A b/c"
257 }
258 ),
259 "onenote:#A%20b%2Fc&section-id={00000000-0000-0000-0000-000000000000}&page-id={00000000-0000-0000-0000-000000000000}&end&base-path=p"
260 );
261}
262
263/// `ONESTORE_INTERNAL_LINK_EXPORT` names a new directory receiving the candidate for a cold
264/// reopen: a section whose first page links to its second page and to a paragraph on it.
265#[test]
266fn a_page_links_to_another_page_and_its_paragraph() {
267 let source = onestore::create_section("links.one", "Linking page", "Author").unwrap();
268 let creation = onestore::PageCreation::new(None, Some("Link target"), "Author").unwrap();
269 let with_target = onestore::PreparedEdit::create_page(&source, &creation)
270 .unwrap()
271 .as_bytes()
272 .to_vec();
273 let section = Store::parse(&with_target).unwrap().header.file_id;
274 let (_, target) = page_by_title(&with_target, "Link target");
275 let (space, before) = page_by_title(&with_target, "Linking page");
276 let paragraph = target
277 .objects
278 .iter()
279 .find_map(|object| match object {
280 PageObject::Outline(outline) if !outline.title => outline.paragraphs.first(),
281 _ => None,
282 })
283 .map(|p| p.id);
284 let base_path = r"C:\one-tests\runs\capture\notebook\links.one";
285 let page_url = internal_link(
286 section,
287 base_path,
288 LinkTarget::Page {
289 identity: target.identity.unwrap(),
290 title: &target.title,
291 },
292 );
293 let mut after = before.clone();
294 let text = &mut body_paragraphs(&mut after)[0].text_mut().unwrap().text;
295 let end = text.utf16_offset(text.text().len()).unwrap();
296 let base = text.format_at(end).unwrap().clone();
297 let mut code = base.clone();
298 code.hyperlink = Some(true);
299 code.hyperlink_label = Some(true);
300 code.hidden = Some(true);
301 let mut visible = base.clone();
302 visible.hyperlink = Some(true);
303 visible.hyperlink_label = Some(true);
304 let mut runs = vec![
305 (" ".to_owned(), base.clone()),
306 (format!("\u{fddf}HYPERLINK \"{page_url}\""), code.clone()),
307 ("Link target".to_owned(), visible.clone()),
308 ];
309 if let Some(paragraph) = paragraph {
310 let object_url = internal_link(
311 section,
312 base_path,
313 LinkTarget::Object {
314 identity: target.identity.unwrap(),
315 title: &target.title,
316 object: paragraph,
317 },
318 );
319 runs.push((" ".to_owned(), base.clone()));
320 runs.push((format!("\u{fddf}HYPERLINK \"{object_url}\""), code));
321 runs.push(("its paragraph".to_owned(), visible));
322 }
323 text.apply(Edit {
324 range: end..end,
325 replacement: Paragraph::from_runs(runs),
326 })
327 .unwrap();
328 let written = PreparedEdit::page(&with_target, space, &after, AUTHOR).unwrap();
329 assert_same(written.as_bytes(), space, &after);
330 if let Some(directory) = std::env::var_os("ONESTORE_INTERNAL_LINK_EXPORT") {
331 let directory = std::path::PathBuf::from(directory);
332 std::fs::create_dir(&directory).unwrap();
333 std::fs::write(directory.join("links.one"), written.as_bytes()).unwrap();
334 std::fs::write(
335 directory.join("Open Notebook.onetoc2"),
336 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("links.one", section)])
337 .unwrap(),
338 )
339 .unwrap();
340 }
341}
crates/onestore/tests/page_tags.rs+9-1
...@@ -267,10 +267,18 @@ fn new_definitions_and_tags_publish_on_a_fresh_page() {...@@ -267,10 +267,18 @@ fn new_definitions_and_tags_publish_on_a_fresh_page() {
267 let important = new_id().unwrap();267 let important = new_id().unwrap();
268 after.definitions.insert(task, definition("Rust task", 3));268 after.definitions.insert(task, definition("Rust task", 3));
269 let mut important_definition = definition("Important", 13);269 let mut important_definition = definition("Important", 13);
270 let Kind::TagDefinition { action_type, .. } = &mut important_definition.kind else {270 let Kind::TagDefinition {
271 action_type,
272 color,
273 highlight,
274 ..
275 } = &mut important_definition.kind
276 else {
271 panic!()277 panic!()
272 };278 };
273 *action_type = Some(1);279 *action_type = Some(1);
280 *color = Some(0x0000_00ff);
281 *highlight = Some(0x0000_ffff);
274 after.definitions.insert(important, important_definition);282 after.definitions.insert(important, important_definition);
275 body_paragraphs(&mut after)[0]283 body_paragraphs(&mut after)[0]
276 .text_mut()284 .text_mut()
crates/snowbound/src/accessibility.rs+2
...@@ -611,6 +611,7 @@ mod tests {...@@ -611,6 +611,7 @@ mod tests {
611 .snapshot();611 .snapshot();
612 title.title = true;612 title.title = true;
613 let page = Page {613 let page = Page {
614 identity: None,
614 title: "Header".into(),615 title: "Header".into(),
615 created: None,616 created: None,
616 margin_origin: [36.0, 14.4],617 margin_origin: [36.0, 14.4],
...@@ -700,6 +701,7 @@ mod tests {...@@ -700,6 +701,7 @@ mod tests {
700 use canvas::gpu::page::PageScene;701 use canvas::gpu::page::PageScene;
701 use onestore::page::{Page, PageObject, Unsupported};702 use onestore::page::{Page, PageObject, Unsupported};
702 let page = Page {703 let page = Page {
704 identity: None,
703 created: None,705 created: None,
704 title: String::new(),706 title: String::new(),
705 margin_origin: [0.0; 2],707 margin_origin: [0.0; 2],
crates/snowbound/src/main.rs+4
...@@ -2126,6 +2126,7 @@ mod tests {...@@ -2126,6 +2126,7 @@ mod tests {
2126 });2126 });
2127 fields[0].title = true;2127 fields[0].title = true;
2128 let page = Page {2128 let page = Page {
2129 identity: None,
2129 title: "Header".into(),2130 title: "Header".into(),
2130 created: Some(1),2131 created: Some(1),
2131 margin_origin: [36.0, 14.4],2132 margin_origin: [36.0, 14.4],
...@@ -2229,6 +2230,7 @@ mod tests {...@@ -2229,6 +2230,7 @@ mod tests {
2229 );2230 );
2230 }2231 }
2231 let page = onestore::page::Page {2232 let page = onestore::page::Page {
2233 identity: None,
2232 title: String::new(),2234 title: String::new(),
2233 created: None,2235 created: None,
2234 margin_origin: [0.0; 2],2236 margin_origin: [0.0; 2],
...@@ -2966,6 +2968,7 @@ mod tests {...@@ -2966,6 +2968,7 @@ mod tests {
2966 objects.reverse();2968 objects.reverse();
2967 }2969 }
2968 let page = Page {2970 let page = Page {
2971 identity: None,
2969 created: None,2972 created: None,
2970 title: String::new(),2973 title: String::new(),
2971 margin_origin: [0.0; 2],2974 margin_origin: [0.0; 2],
...@@ -3143,6 +3146,7 @@ mod tests {...@@ -3143,6 +3146,7 @@ mod tests {
3143 )3146 )
3144 .unwrap();3147 .unwrap();
3145 let page = Page {3148 let page = Page {
3149 identity: None,
3146 created: None,3150 created: None,
3147 title: String::new(),3151 title: String::new(),
3148 margin_origin: [0.0; 2],3152 margin_origin: [0.0; 2],
tools/native/page-link.ps1 created+63
...@@ -0,0 +1,63 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
6$app = New-Object -ComObject OneNote.Application
7$notebookId = ''
8try {
9 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
10 $sectionId = ''
11 $app.OpenHierarchy('links.one', $notebookId, [ref]$sectionId, 0)
12 $deadline = [DateTime]::UtcNow.AddSeconds(30)
13 $page = $null
14 $pages = @()
15 do {
16 $hierarchy = ''
17 $app.GetHierarchy($sectionId, 4, [ref]$hierarchy, 1)
18 [xml]$tree = $hierarchy
19 $pages = @($tree.SelectNodes('//*[local-name()="Page"]'))
20 if ($pages.Count -eq 1) {
21 $content = ''
22 $app.GetPageContent($pages[0].GetAttribute('ID'), [ref]$content, 3, 1)
23 [xml]$page = $content
24 if (@($page.SelectNodes('//*[local-name()="OE"]')).Count -ge 1) { break }
25 }
26 Start-Sleep -Milliseconds 250
27 } while ([DateTime]::UtcNow -lt $deadline)
28 if ($pages.Count -ne 1) { throw 'Expected one page in the section.' }
29 [IO.File]::WriteAllText((Join-Path $Root 'before.xml'), $content, [Text.Encoding]::UTF8)
30 $targetId = ''
31 $app.CreateNewPage($sectionId, [ref]$targetId, 0)
32 $target = '<one:Page xmlns:one="' + $namespace + '" ID="' + $targetId + '"><one:Title><one:OE><one:T><![CDATA[Link target]]></one:T></one:OE></one:Title><one:Outline><one:Position x="36" y="86"/><one:Size width="300" height="30"/><one:OEChildren><one:OE><one:T><![CDATA[Target paragraph]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>'
33 $app.UpdatePageContent($target, [DateTime]::MinValue, 1, $true)
34 $targetContent = ''
35 $app.GetPageContent($targetId, [ref]$targetContent, 3, 1)
36 [xml]$targetPage = $targetContent
37 $targetParagraph = $targetPage.SelectSingleNode('//*[local-name()="Outline"]/*[local-name()="OEChildren"]/*[local-name()="OE"]').GetAttribute('objectID')
38 $pageLink = ''
39 $app.GetHyperlinkToObject($targetId, '', [ref]$pageLink)
40 $paragraphLink = ''
41 $app.GetHyperlinkToObject($targetId, $targetParagraph, [ref]$paragraphLink)
42 $sectionLink = ''
43 $app.GetHyperlinkToObject($sectionId, '', [ref]$sectionLink)
44 @{ page = $pageLink; paragraph = $paragraphLink; section = $sectionLink; target = $targetId; target_paragraph = $targetParagraph } | ConvertTo-Json | Set-Content (Join-Path $Root 'links.json') -Encoding UTF8
45 $children = $page.SelectSingleNode('//*[local-name()="Outline"]/*[local-name()="OEChildren"]')
46 foreach ($entry in @(@('Page link', $pageLink), @('Paragraph link', $paragraphLink), @('Section link', $sectionLink))) {
47 $oe = $page.CreateElement('one', 'OE', $namespace)
48 $t = $page.CreateElement('one', 'T', $namespace)
49 [void]$t.AppendChild($page.CreateCDataSection('<a href="' + [Security.SecurityElement]::Escape($entry[1]) + '">' + $entry[0] + '</a>'))
50 [void]$oe.AppendChild($t)
51 [void]$children.AppendChild($oe)
52 }
53 [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $page.OuterXml, [Text.Encoding]::UTF8)
54 $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false)
55 $app.SyncHierarchy($notebookId)
56 $app.CloseNotebook($notebookId, $false)
57 $notebookId = ''
58} finally {
59 if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
60 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
61 [GC]::Collect()
62 [GC]::WaitForPendingFinalizers()
63}
tools/test_link_edit.py+26
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import json
2import re
1from pathlib import Path3from pathlib import Path
2import runpy4import runpy
3import shutil5import shutil
...@@ -23,6 +25,30 @@ class LinkEditTest(unittest.TestCase):...@@ -23,6 +25,30 @@ class LinkEditTest(unittest.TestCase):
23 self.assertEqual([' '.join(text.split()) for text in texts],25 self.assertEqual([' '.join(text.split()) for text in texts],
24 ['Read about Rust <a href="https://example.invalid/rust">the Rust site</a>'])26 ['Read about Rust <a href="https://example.invalid/rust">the Rust site</a>'])
2527
28 def test_onenote_links_between_pages_use_stored_identities(self):
29 with TemporaryDirectory() as temporary:
30 read = Path(temporary) / 'read'
31 shutil.copytree(FIXTURE / 'native-links/read', read)
32 compare(FIXTURE / 'native-links/notebook', read)
33 links = json.loads((FIXTURE / 'native-links/links.json').read_text(encoding='utf-8-sig'))
34 page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / 'native-links/read').glob('page-*.xml'))
35 if ET.parse(path).getroot().get('name') == 'Read about Rust the Rust site')
36 hrefs = re.findall(r'href="([^"]*)"', ''.join(oe.find('one:T', ns).text for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:T', ns) is not None))
37 section = re.search(r'section-id=(\{[^}]*\})', links['page']).group(1)
38 page_id = re.search(r'page-id=(\{[^}]*\})', links['page']).group(1)
39 self.assertEqual(hrefs[1], 'onenote:#Link%%20target&amp;section-id=%s&amp;page-id=%s&amp;end&amp;base-path=C:\\one-tests\\runs\\capture\\notebook\\links.one' % (section, page_id))
40 self.assertEqual(hrefs[3], 'onenote:#section-id=%s&amp;end&amp;base-path=C:\\one-tests\\runs\\capture\\notebook\\links.one' % section)
41
42 def test_rust_internal_links_render_natively(self):
43 with TemporaryDirectory() as temporary:
44 read = Path(temporary) / 'read'
45 shutil.copytree(FIXTURE / 'internal/cold/read', read)
46 compare(FIXTURE / 'internal/candidate', read)
47 pages = {ET.parse(path).getroot().get('name'): ET.parse(path).getroot() for path in sorted((FIXTURE / 'internal/cold/read').glob('page-*.xml'))}
48 page = next(page for name, page in pages.items() if name.startswith('Linking page'))
49 text = ' '.join(' '.join(oe.find('one:T', ns).text.split()) for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:T', ns) is not None)
50 self.assertRegex(text, r'^Linking page <a href="onenote:#Link%20target&amp;section-id=\{[0-9A-F-]{36}\}&amp;page-id=\{[0-9A-F-]{36}\}&amp;end&amp;base-path=C:\\one-tests\\runs\\capture\\notebook\\links.one">Link target</a>$')
51
2652
27if __name__ == '__main__':53if __name__ == '__main__':
28 unittest.main()54 unittest.main()
tools/test_tag_edit.py+3-2
...@@ -20,8 +20,9 @@ class TagEditTest(unittest.TestCase):...@@ -20,8 +20,9 @@ class TagEditTest(unittest.TestCase):
20 shutil.copytree(FIXTURE / 'cold/read', read)20 shutil.copytree(FIXTURE / 'cold/read', read)
21 compare(FIXTURE / 'candidate', read)21 compare(FIXTURE / 'candidate', read)
22 page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml')))22 page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml')))
23 definitions = [(d.get('index'), d.get('type'), d.get('symbol'), d.get('name')) for d in page.findall('one:TagDef', ns)]23 definitions = [(d.get('index'), d.get('type'), d.get('symbol'), d.get('name'), d.get('fontColor'), d.get('highlightColor'))
24 self.assertEqual(definitions, [('0', '0', '3', 'Rust task'), ('1', '1', '13', 'Important')])24 for d in page.findall('one:TagDef', ns)]
25 self.assertEqual(definitions, [('0', '0', '3', 'Rust task', 'automatic', 'none'), ('1', '1', '13', 'Important', '#FF0000', '#FFFF00')])
25 tagged = [([(t.get('index'), t.get('completed'), t.get('completionDate') is not None) for t in oe.findall('one:Tag', ns)],26 tagged = [([(t.get('index'), t.get('completed'), t.get('completionDate') is not None) for t in oe.findall('one:Tag', ns)],
26 re.sub(r'<[^>]*>', '', oe.find('one:T', ns).text or ''))27 re.sub(r'<[^>]*>', '', oe.find('one:T', ns).text or ''))
27 for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:T', ns) is not None]28 for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:T', ns) is not None]