diff --git a/corpus/link-edit/README.md b/corpus/link-edit/README.md index 8523c8e2e4e58af232847a390d61d65e1e365cbb..bb18bc40a59103567d2afa9ae5cfd08f99e1af51 100644 --- a/corpus/link-edit/README.md +++ b/corpus/link-edit/README.md @@ -15,3 +15,25 @@ embedded objects and runs with associated data stay refused. `ONESTORE_LINK_EXPORT` set to a new absolute directory while running the test, then cold-open it with `tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook`. + +## Internal links + +`native-links/` is OneNote 2010 adding links to a page, to a paragraph on it +and to the section on the Rust-authored page above through the COM API +(`tools/native/page-link.ps1`; `links.json` holds the `onenote:///…` URLs +`GetHyperlinkToObject` returned, `update.xml` the submitted page). In +`notebook/`, OneNote stored each as a `HYPERLINK` field code with the +relative form `onenote:#Link%20target§ion-id={section file identity} +&page-id={page notebook-management identity}&end&base-path=
`; +a paragraph link ends with `&object-id={paragraph identity}&n` instead of +`&end`, and a section link has neither title nor page. OneNote rewrote the +target outline after linking, so its own paragraph link names an identity +(`n` 28) the current outline no longer holds. + +`internal/candidate` is the writer's output for +`a_page_links_to_another_page_and_its_paragraph` in +`crates/onestore/tests/page_links.rs`: a section created in Rust with a +second page, whose first page links to that page and to its first paragraph +with URLs built by `onestore::page::link::internal_link`. `internal/cold` is +its cold read with both links. Regenerate with +`ONESTORE_INTERNAL_LINK_EXPORT` and cold-open with `--expected-pages 2`. diff --git a/corpus/link-edit/internal/candidate/Open Notebook.onetoc2 b/corpus/link-edit/internal/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..706b7e2eb20b7d88a33f1a772e96c0301474b991 Binary files /dev/null and b/corpus/link-edit/internal/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/link-edit/internal/candidate/links.one b/corpus/link-edit/internal/candidate/links.one new file mode 100644 index 0000000000000000000000000000000000000000..9cc5dedcc8cdea93cd116113909fa5aeeca20230 Binary files /dev/null and b/corpus/link-edit/internal/candidate/links.one differ diff --git a/corpus/link-edit/internal/cold/commands.jsonl b/corpus/link-edit/internal/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..99f483459bd62f15a2bf14f495f998abcfa2e1ef --- /dev/null +++ b/corpus/link-edit/internal/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"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} +{"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} +{"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} diff --git a/corpus/link-edit/internal/cold/machine.json b/corpus/link-edit/internal/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..bbf8801f4361b349e73998e36f121ac37e8aacfb --- /dev/null +++ b/corpus/link-edit/internal/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-a4418b81", "hostname": "ONE-M6-A4418B81"} diff --git a/corpus/link-edit/internal/cold/notebook/Open Notebook.onetoc2 b/corpus/link-edit/internal/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..dfdfc3f4756aa719a5e4e6b4c8d7c39bca922112 Binary files /dev/null and b/corpus/link-edit/internal/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/link-edit/internal/cold/notebook/links.one b/corpus/link-edit/internal/cold/notebook/links.one new file mode 100644 index 0000000000000000000000000000000000000000..c2daf85762bee9b487a48667250454d1faa5a9ed Binary files /dev/null and b/corpus/link-edit/internal/cold/notebook/links.one differ diff --git a/corpus/link-edit/internal/cold/read/environment.json b/corpus/link-edit/internal/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..86799bad7ebf19b99589758ceb9baa77b641c4d5 --- /dev/null +++ b/corpus/link-edit/internal/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-A4418B81", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/link-edit/internal/cold/read/hierarchy.xml b/corpus/link-edit/internal/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..a386bc9495f54320ec819f931e8894bec190d797 --- /dev/null +++ b/corpus/link-edit/internal/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/link-edit/internal/cold/read/page-000.xml b/corpus/link-edit/internal/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..350680605480840543665e7b1768927b126eaa20 --- /dev/null +++ b/corpus/link-edit/internal/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/link-edit/internal/cold/read/page-001.xml b/corpus/link-edit/internal/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..932908066288f0d2537155a759c57ccf92856540 --- /dev/null +++ b/corpus/link-edit/internal/cold/read/page-001.xml @@ -0,0 +1,3 @@ + +Link target]]> diff --git a/corpus/link-edit/internal/cold/read/payloads.json b/corpus/link-edit/internal/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/link-edit/internal/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/link-edit/internal/cold/run.json b/corpus/link-edit/internal/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..46d54750380e3863ca25f56c1b44e8dba711073d --- /dev/null +++ b/corpus/link-edit/internal/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/m4/ilink", + "expected_pages": 2, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/link-edit/internal/cold/scripts/cold.ps1 b/corpus/link-edit/internal/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/link-edit/internal/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/link-edit/internal/cold/scripts/read.ps1 b/corpus/link-edit/internal/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/link-edit/internal/cold/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/link-edit/internal/cold/source.json b/corpus/link-edit/internal/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..439edd6ce12404fddb1b88a971314a02bc4b5d5e --- /dev/null +++ b/corpus/link-edit/internal/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "fdee47831417ef5d21624890fe8d9f3d73a4ded56cacbbe2ebb978788786681b", + "mtime_ns": 1789251129798178239 + }, + { + "path": "links.one", + "bytes": 9200, + "sha256": "16b69f8178ce8a23e37ca844b89ae736f4e2c57827205fcfc1e51501b2865d17", + "mtime_ns": 1789251129797893821 + } +] diff --git a/corpus/link-edit/internal/cold/teardown.json b/corpus/link-edit/internal/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/link-edit/internal/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/link-edit/native-links/before.xml b/corpus/link-edit/native-links/before.xml new file mode 100644 index 0000000000000000000000000000000000000000..44442d5bd4b87e8ca11c9dc928e73730bf9a5610 --- /dev/null +++ b/corpus/link-edit/native-links/before.xml @@ -0,0 +1,3 @@ + +the Rust site]]> diff --git a/corpus/link-edit/native-links/commands.jsonl b/corpus/link-edit/native-links/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..8df3611a2444f79f1ff28f677761e8a95975d982 --- /dev/null +++ b/corpus/link-edit/native-links/commands.jsonl @@ -0,0 +1,4 @@ +{"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} +{"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} +{"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} +{"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} diff --git a/corpus/link-edit/native-links/links.json b/corpus/link-edit/native-links/links.json new file mode 100644 index 0000000000000000000000000000000000000000..bafe9c5da87bf6b9a2d0660f6da5433391a7c2f4 --- /dev/null +++ b/corpus/link-edit/native-links/links.json @@ -0,0 +1,7 @@ +{ + "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", + "section": "onenote:///C:\\one-tests\\runs\\capture\\notebook\\links.one#section-id={F8C0DD10-38DD-49C1-B6E4-B882569883D1}\u0026end", + "target_paragraph": "{1DAB917D-E947-4865-BCEA-2612693D9939}{40}{B0}", + "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", + "target": "{38458021-4A30-4602-B127-7D34E7287AC7}{1}{B0}" +} diff --git a/corpus/link-edit/native-links/machine.json b/corpus/link-edit/native-links/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..fe651dffd9cd953cc29209c679bbbb5f6fb68adc --- /dev/null +++ b/corpus/link-edit/native-links/machine.json @@ -0,0 +1 @@ +{"name": "m6-66b8f3a0", "hostname": "ONE-M6-66B8F3A0"} diff --git a/corpus/link-edit/native-links/notebook/Open Notebook.onetoc2 b/corpus/link-edit/native-links/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..d540de51d26d094b0c6f9e89976991f519245a74 Binary files /dev/null and b/corpus/link-edit/native-links/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/link-edit/native-links/notebook/links.one b/corpus/link-edit/native-links/notebook/links.one new file mode 100644 index 0000000000000000000000000000000000000000..7402d0aeb72ccefac32abc23ed45a77e407b6d07 Binary files /dev/null and b/corpus/link-edit/native-links/notebook/links.one differ diff --git a/corpus/link-edit/native-links/read/environment.json b/corpus/link-edit/native-links/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..edaabd17a681dc24965ecefaa363c9ef7e6200b1 --- /dev/null +++ b/corpus/link-edit/native-links/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-66B8F3A0", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/link-edit/native-links/read/hierarchy.xml b/corpus/link-edit/native-links/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..db716e4b37b4897d6a82df408756ac77eefbde87 --- /dev/null +++ b/corpus/link-edit/native-links/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/link-edit/native-links/read/page-000.xml b/corpus/link-edit/native-links/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..3d49c38a70d4dc7adfe1f2940276ed64b8f1853b --- /dev/null +++ b/corpus/link-edit/native-links/read/page-000.xml @@ -0,0 +1,6 @@ + +the Rust site]]>Page link]]>Paragraph link]]>Section link]]> diff --git a/corpus/link-edit/native-links/read/page-001.xml b/corpus/link-edit/native-links/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..cc8e3206c43b4798a6192be2950c370fda6a9b3a --- /dev/null +++ b/corpus/link-edit/native-links/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/link-edit/native-links/read/payloads.json b/corpus/link-edit/native-links/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/link-edit/native-links/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/link-edit/native-links/run.json b/corpus/link-edit/native-links/run.json new file mode 100644 index 0000000000000000000000000000000000000000..272cd81addb626bc2ce1dd63dea2e27914e20406 --- /dev/null +++ b/corpus/link-edit/native-links/run.json @@ -0,0 +1,19 @@ +{ + "notebook": "/Users/clo/dev/one/corpus/link-edit/candidate", + "expected_pages": 2, + "author": "tools/native/page-link.ps1", + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "author.ps1": "a67883aa9e5ffd80b9709d8558f37184cf05d79b28b35ce0db3b2f8a16b57225", + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/link-edit/native-links/scripts/author.ps1 b/corpus/link-edit/native-links/scripts/author.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..8b4ab67977373c26c922fc7d13c01bb98939624e --- /dev/null +++ b/corpus/link-edit/native-links/scripts/author.ps1 @@ -0,0 +1,63 @@ +param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost +$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +try { + $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0) + $sectionId = '' + $app.OpenHierarchy('links.one', $notebookId, [ref]$sectionId, 0) + $deadline = [DateTime]::UtcNow.AddSeconds(30) + $page = $null + $pages = @() + do { + $hierarchy = '' + $app.GetHierarchy($sectionId, 4, [ref]$hierarchy, 1) + [xml]$tree = $hierarchy + $pages = @($tree.SelectNodes('//*[local-name()="Page"]')) + if ($pages.Count -eq 1) { + $content = '' + $app.GetPageContent($pages[0].GetAttribute('ID'), [ref]$content, 3, 1) + [xml]$page = $content + if (@($page.SelectNodes('//*[local-name()="OE"]')).Count -ge 1) { break } + } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if ($pages.Count -ne 1) { throw 'Expected one page in the section.' } + [IO.File]::WriteAllText((Join-Path $Root 'before.xml'), $content, [Text.Encoding]::UTF8) + $targetId = '' + $app.CreateNewPage($sectionId, [ref]$targetId, 0) + $target = '' + $app.UpdatePageContent($target, [DateTime]::MinValue, 1, $true) + $targetContent = '' + $app.GetPageContent($targetId, [ref]$targetContent, 3, 1) + [xml]$targetPage = $targetContent + $targetParagraph = $targetPage.SelectSingleNode('//*[local-name()="Outline"]/*[local-name()="OEChildren"]/*[local-name()="OE"]').GetAttribute('objectID') + $pageLink = '' + $app.GetHyperlinkToObject($targetId, '', [ref]$pageLink) + $paragraphLink = '' + $app.GetHyperlinkToObject($targetId, $targetParagraph, [ref]$paragraphLink) + $sectionLink = '' + $app.GetHyperlinkToObject($sectionId, '', [ref]$sectionLink) + @{ page = $pageLink; paragraph = $paragraphLink; section = $sectionLink; target = $targetId; target_paragraph = $targetParagraph } | ConvertTo-Json | Set-Content (Join-Path $Root 'links.json') -Encoding UTF8 + $children = $page.SelectSingleNode('//*[local-name()="Outline"]/*[local-name()="OEChildren"]') + foreach ($entry in @(@('Page link', $pageLink), @('Paragraph link', $paragraphLink), @('Section link', $sectionLink))) { + $oe = $page.CreateElement('one', 'OE', $namespace) + $t = $page.CreateElement('one', 'T', $namespace) + [void]$t.AppendChild($page.CreateCDataSection('' + $entry[0] + '')) + [void]$oe.AppendChild($t) + [void]$children.AppendChild($oe) + } + [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $page.OuterXml, [Text.Encoding]::UTF8) + $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false) + $app.SyncHierarchy($notebookId) + $app.CloseNotebook($notebookId, $false) + $notebookId = '' +} finally { + if ($notebookId) { $app.CloseNotebook($notebookId, $false) } + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + [GC]::Collect() + [GC]::WaitForPendingFinalizers() +} diff --git a/corpus/link-edit/native-links/scripts/cold.ps1 b/corpus/link-edit/native-links/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/link-edit/native-links/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/link-edit/native-links/scripts/read.ps1 b/corpus/link-edit/native-links/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/link-edit/native-links/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/link-edit/native-links/source.json b/corpus/link-edit/native-links/source.json new file mode 100644 index 0000000000000000000000000000000000000000..14786084a474f1cc0a6f522bd49c8104ad3627f0 --- /dev/null +++ b/corpus/link-edit/native-links/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "6731c4214437fe1cb6aa347b0004542b7f7e4260f47773bf9ec07c4e894de47c", + "mtime_ns": 1789206821144296891 + }, + { + "path": "links.one", + "bytes": 6128, + "sha256": "0cfb356ed374fe919f73fb73fdbe5d66e0baa484d4119f70074d9f869d6db78a", + "mtime_ns": 1789206821116200533 + } +] diff --git a/corpus/link-edit/native-links/teardown.json b/corpus/link-edit/native-links/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/link-edit/native-links/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/link-edit/native-links/update.xml b/corpus/link-edit/native-links/update.xml new file mode 100644 index 0000000000000000000000000000000000000000..193166474c08eadd81a7e598f5cde65d17128140 --- /dev/null +++ b/corpus/link-edit/native-links/update.xml @@ -0,0 +1,2 @@ +the Rust site]]>Page link]]>Paragraph link]]>Section link]]> \ No newline at end of file diff --git a/corpus/tag-edit/README.md b/corpus/tag-edit/README.md index 7f32604fb23240e27c6ee877292d57fdaf9a8784..f4c030930366abe1bb1aca8c1bd944494caac230 100644 --- a/corpus/tag-edit/README.md +++ b/corpus/tag-edit/README.md @@ -4,7 +4,7 @@ `new_definitions_and_tags_publish_on_a_fresh_page` in `crates/onestore/tests/page_tags.rs`: on a section created in Rust, two tag definitions are added ("Rust task", type 0, symbol 3; "Important", type 1, -symbol 13), the first paragraph gains an open task, a second paragraph a +symbol 13, red text on a yellow highlight), the first paragraph gains an open task, a second paragraph a completed task with creation and completion dates, a third paragraph both tags, and a fourth paragraph none. Tags are property-set arrays on the text object 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 requires. `cold/` is a fresh OneNote 2010 read: both `TagDef` entries appear with their -type, symbol and name, the tags read back with their completion state and +type, symbol, name and colours (`fontColor="#FF0000"`, +`highlightColor="#FFFF00"` for the second), the tags read back with their completion state and dates, and the untagged paragraph has none. `tools/test_tag_edit.py` checks this without a VM. Regenerate with `ONESTORE_TAG_EXPORT` set to a new absolute directory while running the test, then cold-open it with diff --git a/corpus/tag-edit/candidate/Open Notebook.onetoc2 b/corpus/tag-edit/candidate/Open Notebook.onetoc2 index e8389f6e1db021270da0b9edd70425d940dbf64e..2c0cd173a96929da9867a717651a72b0ec319c25 100644 Binary files a/corpus/tag-edit/candidate/Open Notebook.onetoc2 and b/corpus/tag-edit/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/tag-edit/candidate/tags.one b/corpus/tag-edit/candidate/tags.one index 197cc2ccf1965bfa156fa3d4acdb6ec7ca9362f1..94380e8449ce6987c7013ea9a5c233d9e22ee760 100644 Binary files a/corpus/tag-edit/candidate/tags.one and b/corpus/tag-edit/candidate/tags.one differ diff --git a/corpus/tag-edit/cold/commands.jsonl b/corpus/tag-edit/cold/commands.jsonl index 0b40bbdbcf63b5876f4029d6235462575970eb6f..a44e0ffa5750ead62217c3115770a1ea314cd0e1 100644 --- a/corpus/tag-edit/cold/commands.jsonl +++ b/corpus/tag-edit/cold/commands.jsonl @@ -1,3 +1,3 @@ {"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} -{"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} +{"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} {"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} diff --git a/corpus/tag-edit/cold/machine.json b/corpus/tag-edit/cold/machine.json index 789e3c21c336c77a53b96bbcb7c2a35cc90ae9ea..cfe68a1084b29f714ce2f6bae198ed5d6c4c2ab3 100644 --- a/corpus/tag-edit/cold/machine.json +++ b/corpus/tag-edit/cold/machine.json @@ -1 +1 @@ -{"name": "m6-cfd607c8", "hostname": "ONE-M6-CFD607C8"} +{"name": "m6-6bd92c98", "hostname": "ONE-M6-6BD92C98"} diff --git a/corpus/tag-edit/cold/notebook/Open Notebook.onetoc2 b/corpus/tag-edit/cold/notebook/Open Notebook.onetoc2 index 24da7710436174fcca0465b4fb65084df42d28a6..fa6f9629a966b7fb791ad01ed69b0c4a9166d4b1 100644 Binary files a/corpus/tag-edit/cold/notebook/Open Notebook.onetoc2 and b/corpus/tag-edit/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/tag-edit/cold/notebook/tags.one b/corpus/tag-edit/cold/notebook/tags.one index d05139e43a9a9b70061e7b44d1feeabdc4c3d978..e06c4884d2d9f368bb651de2c345df771ec5a628 100644 Binary files a/corpus/tag-edit/cold/notebook/tags.one and b/corpus/tag-edit/cold/notebook/tags.one differ diff --git a/corpus/tag-edit/cold/read/environment.json b/corpus/tag-edit/cold/read/environment.json index 594f24f943243c04b579251adfdf3fe8478f9a37..177ce2ecb560bd15f8f883b06aac7ca99a02fa9f 100644 --- a/corpus/tag-edit/cold/read/environment.json +++ b/corpus/tag-edit/cold/read/environment.json @@ -1,7 +1,7 @@ { "powershell": "5.1.14409.1005", "schema": "xs2010", - "hostname": "ONE-M6-CFD607C8", + "hostname": "ONE-M6-6BD92C98", "cold": true, "onenote": "14.0.4763.1000" } diff --git a/corpus/tag-edit/cold/read/hierarchy.xml b/corpus/tag-edit/cold/read/hierarchy.xml index 841314eff1eb2271d34eaff44d07b1c362e7cf4b..12c91df906cb56159555baf4ea8f48cab4c72e78 100644 --- a/corpus/tag-edit/cold/read/hierarchy.xml +++ b/corpus/tag-edit/cold/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/tag-edit/cold/read/page-000.xml b/corpus/tag-edit/cold/read/page-000.xml index 85c699c5520bbfcefe08a894fbf2573a613f1b62..f302dd3c89dfd62306ae6f89806b36e3191b64d5 100644 --- a/corpus/tag-edit/cold/read/page-000.xml +++ b/corpus/tag-edit/cold/read/page-000.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/tag-edit/cold/source.json b/corpus/tag-edit/cold/source.json index bf8d669db188f0769bb053778a7153c3516f39e4..2c55890abe6d87c5632ff8d714fdbfd461662eb8 100644 --- a/corpus/tag-edit/cold/source.json +++ b/corpus/tag-edit/cold/source.json @@ -2,13 +2,13 @@ { "path": "Open Notebook.onetoc2", "bytes": 1596, - "sha256": "f3ce1472c9be35b1fb52683c5214e62850787e8e13bbd1918fed81fd19a48a65", - "mtime_ns": 1789205536390985808 + "sha256": "4f64a5de0f6a4a1bfc92ec83d50f6cfd986b0c72d803dd9096b277b294b1288f", + "mtime_ns": 1789250567099455316 }, { "path": "tags.one", - "bytes": 8352, - "sha256": "601a8998b691bab66830196b3b139be6ea58d6f29d5bc69b3efa9892e2fc1550", - "mtime_ns": 1789205536390698932 + "bytes": 8368, + "sha256": "bb1715165c52bcc443a012178604d9d1c9b5d4d9fb9c18b6ecb806583db5305d", + "mtime_ns": 1789250567099157565 } ] diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index d783b2c35fd8d4f7daa9b53025f73e5b2774cd3c..c5d23e9af6f7d47f4ec3e8718815f0018093e6d2 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -114,6 +114,7 @@ pub struct CanvasEditor { #[derive(Default)] struct PageHeader { title: String, + identity: Option<[u8; 16]>, created: Option, margin_origin: [f32; 2], areas: Vec, @@ -699,6 +700,7 @@ impl CanvasEditor { editor.objects = objects; editor.header = PageHeader { title: page.title, + identity: page.identity, created: page.created, margin_origin: page.margin_origin, areas, @@ -767,6 +769,7 @@ impl CanvasEditor { ); Ok(Page { title: self.header.title.clone(), + identity: self.header.identity, created: self .date .as_ref() @@ -4384,6 +4387,7 @@ mod tests { let mut editor = CanvasEditor::from_page( Page { title: "Header".into(), + identity: None, created: None, margin_origin: [36.0, 14.4], definitions: BTreeMap::new(), diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index 3067f71a5fe3bc961d2ca6f91f66c745203a5733..8fdbbfb399444f14535a9e98f84ce41a1d45df44 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -550,6 +550,7 @@ mod tests { let id = fields[0].id; let source = fields[0].paragraphs.clone(); let page = Page { + identity: None, created: Some(1), title: "Header".into(), margin_origin: [36.0, 14.4], @@ -987,6 +988,7 @@ mod tests { fields[0].min_width = Some(162.0); let title_id = fields[0].id; let page = Page { + identity: None, created: None, title: "Header".into(), margin_origin: [36.0, 14.4], @@ -1060,6 +1062,7 @@ mod tests { }) .collect(); let page = Page { + identity: None, created: None, title: String::new(), margin_origin: [0.0; 2], @@ -1138,6 +1141,7 @@ mod tests { PageObject::Outline(tagged) }; let page = Page { + identity: None, created: None, title: String::new(), margin_origin: [36.0, 14.0], @@ -1208,6 +1212,7 @@ mod tests { layout: Layout::default(), }; let page = Page { + identity: None, created: None, title: String::new(), margin_origin: [0.0; 2], @@ -1383,6 +1388,7 @@ mod tests { .unwrap(); } let page = || Page { + identity: None, created: None, title: String::new(), margin_origin: [0.0; 2], @@ -1504,6 +1510,7 @@ mod tests { .unwrap(); } let page = |width| Page { + identity: None, created: None, title: String::new(), margin_origin: [0.0; 2], diff --git a/crates/notebook/README.md b/crates/notebook/README.md index aaffc0e1b263a86614981ebc249d2c33fd3b278e..cd1073986e9ef784ae93d96983a051ab006d9636 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -14,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot, writes the difference into the working image and queues one `Operation::Page(PageIntent { before, after, author })`; `before` is the page the edit started from and is the precondition reconciliation checks. Text, styles, -paragraph 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, +paragraph 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, outline layout, insertions and deletions are all differences between `before` and `after`; the library never sees editor operations. @@ -60,7 +60,8 @@ a replica stored under the cache directory, named by the section's document identity so the same file reopens the same queue after a relaunch. A section publishes in the background to the file itself under OneNote-compatible exclusion. `pages()` lists page spaces and titles from the local image, -`page(space)` returns the model to edit, and `save(space, before, after, +`page(space)` returns the model to edit (its `identity` and the section's +`identity()` feed `onestore::page::link::internal_link`), and `save(space, before, after, author)` queues the edited model: `Save::Queued(id)` is durable locally, `Save::Unchanged` means the model equals the stored page, and `Save::Stale` means the stored page no longer matches `before` because the section changed diff --git a/crates/notebook/src/merge.rs b/crates/notebook/src/merge.rs index 136bba9d597c4e21637540a796ab15118c8e26a0..92298e776aac729ba9bc46c0649fa9a113b887a2 100644 --- a/crates/notebook/src/merge.rs +++ b/crates/notebook/src/merge.rs @@ -116,6 +116,7 @@ pub(crate) fn merge(base: &Page, ours: &Page, theirs: &Page) -> Option { } Some(Page { title: theirs.title.clone(), + identity: theirs.identity, created: theirs.created, margin_origin: theirs.margin_origin, objects: result, diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 86f5d0c257d03e68266369aff1ac65cf7adc850d..9bc1b894349395de053b30128038e3487378717f 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -209,6 +209,12 @@ impl Section { &self.file } + /// The section file identity, which internal links name as `section-id` + /// (`onestore::page::link::internal_link`). + pub fn identity(&self) -> Result<[u8; 16]> { + Ok(Store::parse(&self.replica.snapshot()?)?.header.file_id) + } + /// Page spaces and titles in section order, from the local working image. pub fn pages(&self) -> Result> { let snapshot = self.replica.snapshot()?; diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 3d1e9943f9e4e6cd00c6325acaaf8d5d436ea004..c49355f2254a6d4a3ace5b93fd4254053f99e139 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -57,7 +57,7 @@ harness also accepts `--client-profile release`. | `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules | | `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | | `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | -| `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 | +| `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 | | `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically | | `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter | | `read_file` | Read a snapshot under whole-file exclusion | diff --git a/crates/onestore/src/page/link.rs b/crates/onestore/src/page/link.rs new file mode 100644 index 0000000000000000000000000000000000000000..c2a84e773fdbdd031e1701d869c3cc17e78eeb3f --- /dev/null +++ b/crates/onestore/src/page/link.rs @@ -0,0 +1,77 @@ +//! Links between pages, paragraphs and sections of the same notebook, in the form OneNote +//! stores them: a `HYPERLINK` field code whose URL names the section by its file identity +//! and the page by its notebook-management identity, with the section path as `base-path`. + +use crate::ExGuid; + +/// What an internal link opens. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LinkTarget<'a> { + Section, + Page { + identity: [u8; 16], + title: &'a str, + }, + /// A paragraph or other object on a page, named by its stored identity. + Object { + identity: [u8; 16], + title: &'a str, + object: ExGuid, + }, +} + +/// The URL OneNote 2010 stores for a link into the section whose file identity is `section` +/// and whose file is at `base_path` (the path as the linking client sees it): +/// `onenote:#Title§ion-id={…}&page-id={…}&end&base-path=…`. +pub fn internal_link(section: [u8; 16], base_path: &str, target: LinkTarget<'_>) -> String { + let guid = |bytes: [u8; 16]| { + ExGuid { guid: bytes, n: 0 } + .to_string() + .split(',') + .next() + .unwrap() + .to_owned() + }; + let mut url = String::from("onenote:#"); + match target { + LinkTarget::Section => {} + LinkTarget::Page { title, .. } | LinkTarget::Object { title, .. } => { + url.push_str(&encoded(title)); + url.push('&'); + } + } + url.push_str(&format!("section-id={}", guid(section))); + match target { + LinkTarget::Section => url.push_str("&end"), + LinkTarget::Page { identity, .. } => { + url.push_str(&format!("&page-id={}&end", guid(identity))); + } + LinkTarget::Object { + identity, object, .. + } => { + url.push_str(&format!( + "&page-id={}&object-id={}&{}", + guid(identity), + guid(object.guid), + object.n + )); + } + } + url.push_str("&base-path="); + url.push_str(base_path); + url +} + +/// Percent-encodes a page title the way OneNote does in a link fragment. +fn encoded(title: &str) -> String { + let mut out = String::new(); + for byte in title.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char); + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index 8322927c7389448b2edc12a9272822548c5a7fef..789da80149c8ed6d4967acc1095a850ca7d43ae3 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -10,6 +10,7 @@ use std::{ sync::Arc, }; +pub mod link; pub mod text; pub(crate) mod write; pub use text::Paragraph; @@ -24,6 +25,8 @@ pub enum DateField { #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Page { pub title: String, + /// The page's notebook-management identity, which internal links name as `page-id`. + pub identity: Option<[u8; 16]>, /// FILETIME ticks from the page's TopologyCreationTimeStamp. pub created: Option, pub margin_origin: [f32; 2], @@ -343,14 +346,21 @@ impl Page { else { return Err(invalid("Canvas root is not a page")); }; + let metadata = revision + .roots + .get(&2) + .and_then(|id| revision.nodes.get(id)) + .filter(|node| matches!(node.kind, Kind::Metadata { .. })) + .and_then(|node| node.extra.first()); let mut page = Self { title: page_title(revision, id).unwrap_or_default().to_owned(), - created: revision - .roots - .get(&2) - .and_then(|id| revision.nodes.get(id)) - .filter(|node| matches!(node.kind, Kind::Metadata { .. })) - .and_then(|node| node.extra.first()) + identity: metadata + .and_then(|fields| fields.iter().find(|field| field.id == 0x1c001c30)) + .and_then(|field| match field.value { + FieldValue::Bytes(bytes) => bytes.try_into().ok(), + _ => None, + }), + created: metadata .and_then(|fields| fields.iter().find(|field| field.id == 0x18001c65)) .map(|field| { let FieldValue::Bytes(bytes) = field.value else { @@ -847,7 +857,7 @@ impl PageParagraph { #[cfg(test)] mod tests { use super::*; - use crate::document::{Element, TextRun}; + use crate::document::{Element, Field, FieldValue, TextRun}; fn id(n: u32) -> ExGuid { ExGuid { @@ -856,6 +866,8 @@ mod tests { } } + const IDENTITY: [u8; 16] = [7; 16]; + fn element(kind: Kind<'_>) -> Element<'_> { Element { jcid: 0, @@ -918,10 +930,14 @@ mod tests { name: Some("Body".into()), }); style.format.font_size = Some(12.0); - let metadata = element(Kind::Metadata { + let mut metadata = element(Kind::Metadata { title: Some("Page title".into()), level: None, }); + metadata.extra = vec![vec![Field { + id: 0x1c001c30, + value: FieldValue::Bytes(&IDENTITY), + }]]; Revision { roots: BTreeMap::from([(2, id(6))]), nodes: BTreeMap::from([ @@ -1183,14 +1199,19 @@ mod tests { #[test] fn creation_time_comes_from_page_metadata_and_date_role_from_the_title_child() { - use crate::document::{Field, FieldValue}; let mut source = revision(); source.nodes.get_mut(&id(1)).unwrap().created = Some(7); let bytes = 134_333_468_649_123_456_u64.to_le_bytes(); - source.nodes.get_mut(&id(6)).unwrap().extra = vec![vec![Field { - id: 0x18001c65, - value: FieldValue::Bytes(&bytes), - }]]; + source.nodes.get_mut(&id(6)).unwrap().extra = vec![vec![ + Field { + id: 0x18001c65, + value: FieldValue::Bytes(&bytes), + }, + Field { + id: 0x1c001c30, + value: FieldValue::Bytes(&IDENTITY), + }, + ]]; source.nodes.get_mut(&id(1)).unwrap().children.clear(); source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7)); let mut title = element(Kind::Title); @@ -1217,12 +1238,12 @@ mod tests { source.nodes.get_mut(&id(6)).unwrap().extra[0][0].value = FieldValue::Bytes(&bytes[..7]); assert!(Page::from_revision(&source, id(1)).is_err()); source.nodes.get_mut(&id(6)).unwrap().extra.clear(); - assert_eq!(Page::from_revision(&source, id(1)).unwrap().created, None); + let bare = Page::from_revision(&source, id(1)).unwrap(); + assert_eq!((bare.created, bare.identity), (None, None)); } #[test] fn title_role_and_minimum_width_come_from_root_properties() { - use crate::document::{Field, FieldValue}; let mut source = revision(); source.nodes.get_mut(&id(1)).unwrap().children.clear(); source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7)); diff --git a/crates/onestore/tests/page_links.rs b/crates/onestore/tests/page_links.rs index 0aac0f7b91b586bf2a8d1c2b85a8bc2af6577afa..a0c3be26bfd9c6f0e66005ea13fd66c8d50358af 100644 --- a/crates/onestore/tests/page_links.rs +++ b/crates/onestore/tests/page_links.rs @@ -1,11 +1,18 @@ use onestore::{ ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, - page::{Page, PageObject, PageParagraph, Paragraph, text::Edit}, + page::{ + Page, PageObject, PageParagraph, Paragraph, + link::{LinkTarget, internal_link}, + text::Edit, + }, }; const OUTLINES: &[u8] = include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); +const NATIVE_LINKS: &[u8] = + include_bytes!("../../../corpus/link-edit/native-links/notebook/links.one"); +const NATIVE_BASE_PATH: &str = r"C:\one-tests\runs\capture\notebook\links.one"; const AUTHOR: &str = "Link author"; const CODE: &str = "\u{fddf}HYPERLINK \"https://example.invalid/rust\""; @@ -191,3 +198,144 @@ fn a_link_is_added_to_a_fresh_page_and_reads_back() { .unwrap(); } } + +fn field_codes(page: &mut Page) -> Vec { + body_paragraphs(page) + .iter() + .filter_map(|p| p.text()) + .filter_map(|t| { + let text = t.text.text(); + let start = text.find("\u{fddf}HYPERLINK \"")? + "\u{fddf}HYPERLINK \"".len(); + let end = start + text[start..].find('"')?; + Some(text[start..end].to_owned()) + }) + .collect() +} + +/// The URLs OneNote 2010 wrote for links to a page, a paragraph and the section +/// (`corpus/link-edit/native-links`) are what `internal_link` produces from the stored +/// identities: the section file identity, the target page's notebook-management identity +/// and the paragraph's stored identity. +#[test] +fn internal_links_match_what_onenote_stores() { + let section = Store::parse(NATIVE_LINKS).unwrap().header.file_id; + let (_, target) = page_by_title(NATIVE_LINKS, "Link target"); + let mut target_paragraphs = target.clone(); + // OneNote rewrote the target outline after linking; the link keeps the paragraph + // identity it saw then (n 28), whose page half is the current paragraph's. + let paragraph = ExGuid { + guid: body_paragraphs(&mut target_paragraphs)[0].id.guid, + n: 28, + }; + let (_, mut source) = page_by_title(NATIVE_LINKS, "Read about Rust the Rust site"); + let stored = field_codes(&mut source); + let page = LinkTarget::Page { + identity: target.identity.unwrap(), + title: &target.title, + }; + let object = LinkTarget::Object { + identity: target.identity.unwrap(), + title: &target.title, + object: paragraph, + }; + assert_eq!( + stored, + [ + "https://example.invalid/rust".to_owned(), + internal_link(section, NATIVE_BASE_PATH, page), + internal_link(section, NATIVE_BASE_PATH, object), + internal_link(section, NATIVE_BASE_PATH, LinkTarget::Section), + ] + ); + assert_eq!( + internal_link( + [0; 16], + "p", + LinkTarget::Page { + identity: [0; 16], + title: "A b/c" + } + ), + "onenote:#A%20b%2Fc§ion-id={00000000-0000-0000-0000-000000000000}&page-id={00000000-0000-0000-0000-000000000000}&end&base-path=p" + ); +} + +/// `ONESTORE_INTERNAL_LINK_EXPORT` names a new directory receiving the candidate for a cold +/// reopen: a section whose first page links to its second page and to a paragraph on it. +#[test] +fn a_page_links_to_another_page_and_its_paragraph() { + let source = onestore::create_section("links.one", "Linking page", "Author").unwrap(); + let creation = onestore::PageCreation::new(None, Some("Link target"), "Author").unwrap(); + let with_target = onestore::PreparedEdit::create_page(&source, &creation) + .unwrap() + .as_bytes() + .to_vec(); + let section = Store::parse(&with_target).unwrap().header.file_id; + let (_, target) = page_by_title(&with_target, "Link target"); + let (space, before) = page_by_title(&with_target, "Linking page"); + let paragraph = target + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) if !outline.title => outline.paragraphs.first(), + _ => None, + }) + .map(|p| p.id); + let base_path = r"C:\one-tests\runs\capture\notebook\links.one"; + let page_url = internal_link( + section, + base_path, + LinkTarget::Page { + identity: target.identity.unwrap(), + title: &target.title, + }, + ); + let mut after = before.clone(); + let text = &mut body_paragraphs(&mut after)[0].text_mut().unwrap().text; + let end = text.utf16_offset(text.text().len()).unwrap(); + let base = text.format_at(end).unwrap().clone(); + let mut code = base.clone(); + code.hyperlink = Some(true); + code.hyperlink_label = Some(true); + code.hidden = Some(true); + let mut visible = base.clone(); + visible.hyperlink = Some(true); + visible.hyperlink_label = Some(true); + let mut runs = vec![ + (" ".to_owned(), base.clone()), + (format!("\u{fddf}HYPERLINK \"{page_url}\""), code.clone()), + ("Link target".to_owned(), visible.clone()), + ]; + if let Some(paragraph) = paragraph { + let object_url = internal_link( + section, + base_path, + LinkTarget::Object { + identity: target.identity.unwrap(), + title: &target.title, + object: paragraph, + }, + ); + runs.push((" ".to_owned(), base.clone())); + runs.push((format!("\u{fddf}HYPERLINK \"{object_url}\""), code)); + runs.push(("its paragraph".to_owned(), visible)); + } + text.apply(Edit { + range: end..end, + replacement: Paragraph::from_runs(runs), + }) + .unwrap(); + let written = PreparedEdit::page(&with_target, space, &after, AUTHOR).unwrap(); + assert_same(written.as_bytes(), space, &after); + if let Some(directory) = std::env::var_os("ONESTORE_INTERNAL_LINK_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("links.one"), written.as_bytes()).unwrap(); + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents("Open Notebook.onetoc2", &[("links.one", section)]) + .unwrap(), + ) + .unwrap(); + } +} diff --git a/crates/onestore/tests/page_tags.rs b/crates/onestore/tests/page_tags.rs index 9a31b1503e6c6cf64dc7763f07e0f45d7d42ec1b..16bd65cbd386c77452913718e578ed4a179a37d4 100644 --- a/crates/onestore/tests/page_tags.rs +++ b/crates/onestore/tests/page_tags.rs @@ -267,10 +267,18 @@ fn new_definitions_and_tags_publish_on_a_fresh_page() { let important = new_id().unwrap(); after.definitions.insert(task, definition("Rust task", 3)); let mut important_definition = definition("Important", 13); - let Kind::TagDefinition { action_type, .. } = &mut important_definition.kind else { + let Kind::TagDefinition { + action_type, + color, + highlight, + .. + } = &mut important_definition.kind + else { panic!() }; *action_type = Some(1); + *color = Some(0x0000_00ff); + *highlight = Some(0x0000_ffff); after.definitions.insert(important, important_definition); body_paragraphs(&mut after)[0] .text_mut() diff --git a/crates/snowbound/src/accessibility.rs b/crates/snowbound/src/accessibility.rs index 6185b2f576be29180026d3557eae913b1d18fd51..15d769b41cadcd7399c6528c44a83addba9632e2 100644 --- a/crates/snowbound/src/accessibility.rs +++ b/crates/snowbound/src/accessibility.rs @@ -611,6 +611,7 @@ mod tests { .snapshot(); title.title = true; let page = Page { + identity: None, title: "Header".into(), created: None, margin_origin: [36.0, 14.4], @@ -700,6 +701,7 @@ mod tests { use canvas::gpu::page::PageScene; use onestore::page::{Page, PageObject, Unsupported}; let page = Page { + identity: None, created: None, title: String::new(), margin_origin: [0.0; 2], diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 7fd0920b7c91164c8ed28f071db70061d595c98d..1a5f81427e6d84d93609cb6878ddfb95b3fb333d 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -2126,6 +2126,7 @@ mod tests { }); fields[0].title = true; let page = Page { + identity: None, title: "Header".into(), created: Some(1), margin_origin: [36.0, 14.4], @@ -2229,6 +2230,7 @@ mod tests { ); } let page = onestore::page::Page { + identity: None, title: String::new(), created: None, margin_origin: [0.0; 2], @@ -2966,6 +2968,7 @@ mod tests { objects.reverse(); } let page = Page { + identity: None, created: None, title: String::new(), margin_origin: [0.0; 2], @@ -3143,6 +3146,7 @@ mod tests { ) .unwrap(); let page = Page { + identity: None, created: None, title: String::new(), margin_origin: [0.0; 2], diff --git a/tools/native/page-link.ps1 b/tools/native/page-link.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..8b4ab67977373c26c922fc7d13c01bb98939624e --- /dev/null +++ b/tools/native/page-link.ps1 @@ -0,0 +1,63 @@ +param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost +$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +try { + $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0) + $sectionId = '' + $app.OpenHierarchy('links.one', $notebookId, [ref]$sectionId, 0) + $deadline = [DateTime]::UtcNow.AddSeconds(30) + $page = $null + $pages = @() + do { + $hierarchy = '' + $app.GetHierarchy($sectionId, 4, [ref]$hierarchy, 1) + [xml]$tree = $hierarchy + $pages = @($tree.SelectNodes('//*[local-name()="Page"]')) + if ($pages.Count -eq 1) { + $content = '' + $app.GetPageContent($pages[0].GetAttribute('ID'), [ref]$content, 3, 1) + [xml]$page = $content + if (@($page.SelectNodes('//*[local-name()="OE"]')).Count -ge 1) { break } + } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if ($pages.Count -ne 1) { throw 'Expected one page in the section.' } + [IO.File]::WriteAllText((Join-Path $Root 'before.xml'), $content, [Text.Encoding]::UTF8) + $targetId = '' + $app.CreateNewPage($sectionId, [ref]$targetId, 0) + $target = '' + $app.UpdatePageContent($target, [DateTime]::MinValue, 1, $true) + $targetContent = '' + $app.GetPageContent($targetId, [ref]$targetContent, 3, 1) + [xml]$targetPage = $targetContent + $targetParagraph = $targetPage.SelectSingleNode('//*[local-name()="Outline"]/*[local-name()="OEChildren"]/*[local-name()="OE"]').GetAttribute('objectID') + $pageLink = '' + $app.GetHyperlinkToObject($targetId, '', [ref]$pageLink) + $paragraphLink = '' + $app.GetHyperlinkToObject($targetId, $targetParagraph, [ref]$paragraphLink) + $sectionLink = '' + $app.GetHyperlinkToObject($sectionId, '', [ref]$sectionLink) + @{ page = $pageLink; paragraph = $paragraphLink; section = $sectionLink; target = $targetId; target_paragraph = $targetParagraph } | ConvertTo-Json | Set-Content (Join-Path $Root 'links.json') -Encoding UTF8 + $children = $page.SelectSingleNode('//*[local-name()="Outline"]/*[local-name()="OEChildren"]') + foreach ($entry in @(@('Page link', $pageLink), @('Paragraph link', $paragraphLink), @('Section link', $sectionLink))) { + $oe = $page.CreateElement('one', 'OE', $namespace) + $t = $page.CreateElement('one', 'T', $namespace) + [void]$t.AppendChild($page.CreateCDataSection('' + $entry[0] + '')) + [void]$oe.AppendChild($t) + [void]$children.AppendChild($oe) + } + [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $page.OuterXml, [Text.Encoding]::UTF8) + $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false) + $app.SyncHierarchy($notebookId) + $app.CloseNotebook($notebookId, $false) + $notebookId = '' +} finally { + if ($notebookId) { $app.CloseNotebook($notebookId, $false) } + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + [GC]::Collect() + [GC]::WaitForPendingFinalizers() +} diff --git a/tools/test_link_edit.py b/tools/test_link_edit.py index 25fe7c74d3b716abc3688e1ef14f8a19ab86793a..1c4408d8d18d9201d6ae92db29f5156d9e1d1d7f 100644 --- a/tools/test_link_edit.py +++ b/tools/test_link_edit.py @@ -1,3 +1,5 @@ +import json +import re from pathlib import Path import runpy import shutil @@ -23,6 +25,30 @@ class LinkEditTest(unittest.TestCase): self.assertEqual([' '.join(text.split()) for text in texts], ['Read about Rust the Rust site']) + def test_onenote_links_between_pages_use_stored_identities(self): + with TemporaryDirectory() as temporary: + read = Path(temporary) / 'read' + shutil.copytree(FIXTURE / 'native-links/read', read) + compare(FIXTURE / 'native-links/notebook', read) + links = json.loads((FIXTURE / 'native-links/links.json').read_text(encoding='utf-8-sig')) + page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / 'native-links/read').glob('page-*.xml')) + if ET.parse(path).getroot().get('name') == 'Read about Rust the Rust site') + 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)) + section = re.search(r'section-id=(\{[^}]*\})', links['page']).group(1) + page_id = re.search(r'page-id=(\{[^}]*\})', links['page']).group(1) + self.assertEqual(hrefs[1], 'onenote:#Link%%20target&section-id=%s&page-id=%s&end&base-path=C:\\one-tests\\runs\\capture\\notebook\\links.one' % (section, page_id)) + self.assertEqual(hrefs[3], 'onenote:#section-id=%s&end&base-path=C:\\one-tests\\runs\\capture\\notebook\\links.one' % section) + + def test_rust_internal_links_render_natively(self): + with TemporaryDirectory() as temporary: + read = Path(temporary) / 'read' + shutil.copytree(FIXTURE / 'internal/cold/read', read) + compare(FIXTURE / 'internal/candidate', read) + pages = {ET.parse(path).getroot().get('name'): ET.parse(path).getroot() for path in sorted((FIXTURE / 'internal/cold/read').glob('page-*.xml'))} + page = next(page for name, page in pages.items() if name.startswith('Linking page')) + 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) + self.assertRegex(text, r'^Linking page Link target$') + if __name__ == '__main__': unittest.main() diff --git a/tools/test_tag_edit.py b/tools/test_tag_edit.py index 9645227074f0d5e6f8a95906d2981cfa853ab83b..8ddb527d38ceefa8964330477356d0fbb30b51c0 100644 --- a/tools/test_tag_edit.py +++ b/tools/test_tag_edit.py @@ -20,8 +20,9 @@ class TagEditTest(unittest.TestCase): shutil.copytree(FIXTURE / 'cold/read', read) compare(FIXTURE / 'candidate', read) page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml'))) - definitions = [(d.get('index'), d.get('type'), d.get('symbol'), d.get('name')) for d in page.findall('one:TagDef', ns)] - self.assertEqual(definitions, [('0', '0', '3', 'Rust task'), ('1', '1', '13', 'Important')]) + definitions = [(d.get('index'), d.get('type'), d.get('symbol'), d.get('name'), d.get('fontColor'), d.get('highlightColor')) + for d in page.findall('one:TagDef', ns)] + self.assertEqual(definitions, [('0', '0', '3', 'Rust task', 'automatic', 'none'), ('1', '1', '13', 'Important', '#FF0000', '#FFFF00')]) tagged = [([(t.get('index'), t.get('completed'), t.get('completionDate') is not None) for t in oe.findall('one:Tag', ns)], re.sub(r'<[^>]*>', '', oe.find('one:T', ns).text or '')) for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:T', ns) is not None]