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.
1515`ONESTORE_LINK_EXPORT` set to a new absolute directory while running the test,
1616then cold-open it with `tools/native_runner.py OUTPUT COLD --expected-pages 1
1717--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 @@
44`new_definitions_and_tags_publish_on_a_fresh_page` in
55`crates/onestore/tests/page_tags.rs`: on a section created in Rust, two tag
66definitions are added ("Rust task", type 0, symbol 3; "Important", type 1,
7symbol 13), the first paragraph gains an open task, a second paragraph a
7symbol 13, red text on a yellow highlight), the first paragraph gains an open task, a second paragraph a
88completed task with creation and completion dates, a third paragraph both
99tags, and a fourth paragraph none. Tags are property-set arrays on the text
1010object 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
1212requires.
1313
1414`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 and
15type, symbol, name and colours (`fontColor="#FF0000"`,
16`highlightColor="#FFFF00"` for the second), the tags read back with their completion state and
1617dates, and the untagged paragraph has none. `tools/test_tag_edit.py` checks
1718this without a VM. Regenerate with `ONESTORE_TAG_EXPORT` set to a new absolute
1819directory 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 @@
11{"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}
33{"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{"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 @@
11{
22 "powershell": "5.1.14409.1005",
33 "schema": "xs2010",
4 "hostname": "ONE-M6-CFD607C8",
4 "hostname": "ONE-M6-6BD92C98",
55 "cold": true,
66 "onenote": "14.0.4763.1000"
77}
corpus/tag-edit/cold/read/hierarchy.xml+1-1
......@@ -1,2 +1,2 @@
11<?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 @@
11<?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 @@
22 {
33 "path": "Open Notebook.onetoc2",
44 "bytes": 1596,
5 "sha256": "f3ce1472c9be35b1fb52683c5214e62850787e8e13bbd1918fed81fd19a48a65",
6 "mtime_ns": 1789205536390985808
5 "sha256": "4f64a5de0f6a4a1bfc92ec83d50f6cfd986b0c72d803dd9096b277b294b1288f",
6 "mtime_ns": 1789250567099455316
77 },
88 {
99 "path": "tags.one",
10 "bytes": 8352,
11 "sha256": "601a8998b691bab66830196b3b139be6ea58d6f29d5bc69b3efa9892e2fc1550",
12 "mtime_ns": 1789205536390698932
10 "bytes": 8368,
11 "sha256": "bb1715165c52bcc443a012178604d9d1c9b5d4d9fb9c18b6ecb806583db5305d",
12 "mtime_ns": 1789250567099157565
1313 }
1414]
crates/canvas/src/editor.rs+4
......@@ -114,6 +114,7 @@ pub struct CanvasEditor {
114114#[derive(Default)]
115115struct PageHeader {
116116 title: String,
117 identity: Option<[u8; 16]>,
117118 created: Option<u64>,
118119 margin_origin: [f32; 2],
119120 areas: Vec<page::TitleArea>,
......@@ -699,6 +700,7 @@ impl CanvasEditor {
699700 editor.objects = objects;
700701 editor.header = PageHeader {
701702 title: page.title,
703 identity: page.identity,
702704 created: page.created,
703705 margin_origin: page.margin_origin,
704706 areas,
......@@ -767,6 +769,7 @@ impl CanvasEditor {
767769 );
768770 Ok(Page {
769771 title: self.header.title.clone(),
772 identity: self.header.identity,
770773 created: self
771774 .date
772775 .as_ref()
......@@ -4384,6 +4387,7 @@ mod tests {
43844387 let mut editor = CanvasEditor::from_page(
43854388 Page {
43864389 title: "Header".into(),
4390 identity: None,
43874391 created: None,
43884392 margin_origin: [36.0, 14.4],
43894393 definitions: BTreeMap::new(),
crates/canvas/src/gpu/page.rs+7
......@@ -550,6 +550,7 @@ mod tests {
550550 let id = fields[0].id;
551551 let source = fields[0].paragraphs.clone();
552552 let page = Page {
553 identity: None,
553554 created: Some(1),
554555 title: "Header".into(),
555556 margin_origin: [36.0, 14.4],
......@@ -987,6 +988,7 @@ mod tests {
987988 fields[0].min_width = Some(162.0);
988989 let title_id = fields[0].id;
989990 let page = Page {
991 identity: None,
990992 created: None,
991993 title: "Header".into(),
992994 margin_origin: [36.0, 14.4],
......@@ -1060,6 +1062,7 @@ mod tests {
10601062 })
10611063 .collect();
10621064 let page = Page {
1065 identity: None,
10631066 created: None,
10641067 title: String::new(),
10651068 margin_origin: [0.0; 2],
......@@ -1138,6 +1141,7 @@ mod tests {
11381141 PageObject::Outline(tagged)
11391142 };
11401143 let page = Page {
1144 identity: None,
11411145 created: None,
11421146 title: String::new(),
11431147 margin_origin: [36.0, 14.0],
......@@ -1208,6 +1212,7 @@ mod tests {
12081212 layout: Layout::default(),
12091213 };
12101214 let page = Page {
1215 identity: None,
12111216 created: None,
12121217 title: String::new(),
12131218 margin_origin: [0.0; 2],
......@@ -1383,6 +1388,7 @@ mod tests {
13831388 .unwrap();
13841389 }
13851390 let page = || Page {
1391 identity: None,
13861392 created: None,
13871393 title: String::new(),
13881394 margin_origin: [0.0; 2],
......@@ -1504,6 +1510,7 @@ mod tests {
15041510 .unwrap();
15051511 }
15061512 let page = |width| Page {
1513 identity: None,
15071514 created: None,
15081515 title: String::new(),
15091516 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,
1414writes the difference into the working image and queues one
1515`Operation::Page(PageIntent { before, after, author })`; `before` is the page the
1616edit 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,
1818outline layout, insertions and deletions are all differences between `before` and
1919`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
6060identity so the same file reopens the same queue after a relaunch. A section
6161publishes in the background to the file itself under OneNote-compatible
6262exclusion. `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,
6465author)` queues the edited model: `Save::Queued(id)` is durable locally,
6566`Save::Unchanged` means the model equals the stored page, and `Save::Stale`
6667means 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> {
116116 }
117117 Some(Page {
118118 title: theirs.title.clone(),
119 identity: theirs.identity,
119120 created: theirs.created,
120121 margin_origin: theirs.margin_origin,
121122 objects: result,
crates/notebook/src/session.rs+6
......@@ -209,6 +209,12 @@ impl Section {
209209 &self.file
210210 }
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
212218 /// Page spaces and titles in section order, from the local working image.
213219 pub fn pages(&self) -> Result<Vec<(ExGuid, String)>> {
214220 let snapshot = self.replica.snapshot()?;
crates/onestore/README.md+1-1
......@@ -57,7 +57,7 @@ harness also accepts `--client-profile release`.
5757| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
5858| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
5959| `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 |
6161| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |
6262| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
6363| `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::{
1010 sync::Arc,
1111};
1212
13pub mod link;
1314pub mod text;
1415pub(crate) mod write;
1516pub use text::Paragraph;
......@@ -24,6 +25,8 @@ pub enum DateField {
2425#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
2526pub struct Page {
2627 pub title: String,
28 /// The page's notebook-management identity, which internal links name as `page-id`.
29 pub identity: Option<[u8; 16]>,
2730 /// FILETIME ticks from the page's TopologyCreationTimeStamp.
2831 pub created: Option<u64>,
2932 pub margin_origin: [f32; 2],
......@@ -343,14 +346,21 @@ impl Page {
343346 else {
344347 return Err(invalid("Canvas root is not a page"));
345348 };
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());
346355 let mut page = Self {
347356 title: page_title(revision, id).unwrap_or_default().to_owned(),
348 created: revision
349 .roots
350 .get(&2)
351 .and_then(|id| revision.nodes.get(id))
352 .filter(|node| matches!(node.kind, Kind::Metadata { .. }))
353 .and_then(|node| node.extra.first())
357 identity: metadata
358 .and_then(|fields| fields.iter().find(|field| field.id == 0x1c001c30))
359 .and_then(|field| match field.value {
360 FieldValue::Bytes(bytes) => bytes.try_into().ok(),
361 _ => None,
362 }),
363 created: metadata
354364 .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65))
355365 .map(|field| {
356366 let FieldValue::Bytes(bytes) = field.value else {
......@@ -847,7 +857,7 @@ impl PageParagraph {
847857#[cfg(test)]
848858mod tests {
849859 use super::*;
850 use crate::document::{Element, TextRun};
860 use crate::document::{Element, Field, FieldValue, TextRun};
851861
852862 fn id(n: u32) -> ExGuid {
853863 ExGuid {
......@@ -856,6 +866,8 @@ mod tests {
856866 }
857867 }
858868
869 const IDENTITY: [u8; 16] = [7; 16];
870
859871 fn element(kind: Kind<'_>) -> Element<'_> {
860872 Element {
861873 jcid: 0,
......@@ -918,10 +930,14 @@ mod tests {
918930 name: Some("Body".into()),
919931 });
920932 style.format.font_size = Some(12.0);
921 let metadata = element(Kind::Metadata {
933 let mut metadata = element(Kind::Metadata {
922934 title: Some("Page title".into()),
923935 level: None,
924936 });
937 metadata.extra = vec![vec![Field {
938 id: 0x1c001c30,
939 value: FieldValue::Bytes(&IDENTITY),
940 }]];
925941 Revision {
926942 roots: BTreeMap::from([(2, id(6))]),
927943 nodes: BTreeMap::from([
......@@ -1183,14 +1199,19 @@ mod tests {
11831199
11841200 #[test]
11851201 fn creation_time_comes_from_page_metadata_and_date_role_from_the_title_child() {
1186 use crate::document::{Field, FieldValue};
11871202 let mut source = revision();
11881203 source.nodes.get_mut(&id(1)).unwrap().created = Some(7);
11891204 let bytes = 134_333_468_649_123_456_u64.to_le_bytes();
1190 source.nodes.get_mut(&id(6)).unwrap().extra = vec![vec![Field {
1191 id: 0x18001c65,
1192 value: FieldValue::Bytes(&bytes),
1193 }]];
1205 source.nodes.get_mut(&id(6)).unwrap().extra = vec![vec![
1206 Field {
1207 id: 0x18001c65,
1208 value: FieldValue::Bytes(&bytes),
1209 },
1210 Field {
1211 id: 0x1c001c30,
1212 value: FieldValue::Bytes(&IDENTITY),
1213 },
1214 ]];
11941215 source.nodes.get_mut(&id(1)).unwrap().children.clear();
11951216 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7));
11961217 let mut title = element(Kind::Title);
......@@ -1217,12 +1238,12 @@ mod tests {
12171238 source.nodes.get_mut(&id(6)).unwrap().extra[0][0].value = FieldValue::Bytes(&bytes[..7]);
12181239 assert!(Page::from_revision(&source, id(1)).is_err());
12191240 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));
12211243 }
12221244
12231245 #[test]
12241246 fn title_role_and_minimum_width_come_from_root_properties() {
1225 use crate::document::{Field, FieldValue};
12261247 let mut source = revision();
12271248 source.nodes.get_mut(&id(1)).unwrap().children.clear();
12281249 source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7));
crates/onestore/tests/page_links.rs+149-1
......@@ -1,11 +1,18 @@
11use onestore::{
22 ExGuid, PreparedEdit, RevisionIndex, Store,
33 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 },
59};
610
711const OUTLINES: &[u8] =
812 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";
916const AUTHOR: &str = "Link author";
1017const 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() {
191198 .unwrap();
192199 }
193200}
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() {
267267 let important = new_id().unwrap();
268268 after.definitions.insert(task, definition("Rust task", 3));
269269 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 {
271277 panic!()
272278 };
273279 *action_type = Some(1);
280 *color = Some(0x0000_00ff);
281 *highlight = Some(0x0000_ffff);
274282 after.definitions.insert(important, important_definition);
275283 body_paragraphs(&mut after)[0]
276284 .text_mut()
crates/snowbound/src/accessibility.rs+2
......@@ -611,6 +611,7 @@ mod tests {
611611 .snapshot();
612612 title.title = true;
613613 let page = Page {
614 identity: None,
614615 title: "Header".into(),
615616 created: None,
616617 margin_origin: [36.0, 14.4],
......@@ -700,6 +701,7 @@ mod tests {
700701 use canvas::gpu::page::PageScene;
701702 use onestore::page::{Page, PageObject, Unsupported};
702703 let page = Page {
704 identity: None,
703705 created: None,
704706 title: String::new(),
705707 margin_origin: [0.0; 2],
crates/snowbound/src/main.rs+4
......@@ -2126,6 +2126,7 @@ mod tests {
21262126 });
21272127 fields[0].title = true;
21282128 let page = Page {
2129 identity: None,
21292130 title: "Header".into(),
21302131 created: Some(1),
21312132 margin_origin: [36.0, 14.4],
......@@ -2229,6 +2230,7 @@ mod tests {
22292230 );
22302231 }
22312232 let page = onestore::page::Page {
2233 identity: None,
22322234 title: String::new(),
22332235 created: None,
22342236 margin_origin: [0.0; 2],
......@@ -2966,6 +2968,7 @@ mod tests {
29662968 objects.reverse();
29672969 }
29682970 let page = Page {
2971 identity: None,
29692972 created: None,
29702973 title: String::new(),
29712974 margin_origin: [0.0; 2],
......@@ -3143,6 +3146,7 @@ mod tests {
31433146 )
31443147 .unwrap();
31453148 let page = Page {
3149 identity: None,
31463150 created: None,
31473151 title: String::new(),
31483152 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 @@
1import json
2import re
13from pathlib import Path
24import runpy
35import shutil
......@@ -23,6 +25,30 @@ class LinkEditTest(unittest.TestCase):
2325 self.assertEqual([' '.join(text.split()) for text in texts],
2426 ['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
2753if __name__ == '__main__':
2854 unittest.main()
tools/test_tag_edit.py+3-2
......@@ -20,8 +20,9 @@ class TagEditTest(unittest.TestCase):
2020 shutil.copytree(FIXTURE / 'cold/read', read)
2121 compare(FIXTURE / 'candidate', read)
2222 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)]
24 self.assertEqual(definitions, [('0', '0', '3', 'Rust task'), ('1', '1', '13', 'Important')])
23 definitions = [(d.get('index'), d.get('type'), d.get('symbol'), d.get('name'), d.get('fontColor'), d.get('highlightColor'))
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')])
2526 tagged = [([(t.get('index'), t.get('completed'), t.get('completionDate') is not None) for t in oe.findall('one:Tag', ns)],
2627 re.sub(r'<[^>]*>', '', oe.find('one:T', ns).text or ''))
2728 for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:T', ns) is not None]