| author | |
| committer | |
| log | 8b4f45b5b7a6d9b8555e7234a123c9887495d265 |
| tree | eb27d721da9a0b2911611aa13b5a46ead0696b50 |
| parent | 190fb2d10c46ced562a7f0d96f63aa803f4b47d4 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
A hyperlink in the page model is what OneNote stores: a hidden field-code
run (U+FDDF HYPERLINK "url") followed by the visible label, both flagged as
hyperlink runs. The text and formatting writers now treat hidden and
hyperlink runs as ordinary text with flags instead of refusing them, and the
page writer lowers the hidden, hyperlink and hyperlink-label flags as run
attributes; equations, embedded objects and runs with associated data stay
refused. Boolean run flags compare as absent-equals-false, so clearing a flag
to an absent value writes false once and a rewritten run does not churn.
corpus/link-edit is the native gate: a Rust-authored page whose paragraph
gained a link, cold-read by OneNote 2010 as an anchor with the URL and label;
tools/test_link_edit.py checks it without a VM.
Assisted-by: claude-fable-5.127 files changed, 491 insertions(+), 75 deletions(-)
corpus/link-edit/README.md created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | # Rust hyperlink authoring | ||
| 2 | |||
| 3 | `candidate/` is the page writer's output for | ||
| 4 | `a_link_is_added_to_a_fresh_page_and_reads_back` in | ||
| 5 | `crates/onestore/tests/page_links.rs`: on a section created in Rust, the | ||
| 6 | paragraph "Read about Rust" gains a hyperlink the way OneNote stores one, a | ||
| 7 | hidden field-code run `U+FDDF HYPERLINK "https://example.invalid/rust"` and | ||
| 8 | the visible label "the Rust site", both flagged as hyperlink runs. The text | ||
| 9 | and formatting writers treat such runs as ordinary text with flags; equations, | ||
| 10 | embedded objects and runs with associated data stay refused. | ||
| 11 | |||
| 12 | `cold/` is a fresh OneNote 2010 read: the paragraph text is | ||
| 13 | `Read about Rust <a href="https://example.invalid/rust">the Rust site</a>`. | ||
| 14 | `tools/test_link_edit.py` checks this without a VM. Regenerate with | ||
| 15 | `ONESTORE_LINK_EXPORT` set to a new absolute directory while running the test, | ||
| 16 | then cold-open it with `tools/native_runner.py OUTPUT COLD --expected-pages 1 | ||
| 17 | --collect-notebook`. | ||
corpus/link-edit/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/link-edit/candidate/Open Notebook.onetoc2 differ | |||
corpus/link-edit/candidate/links.one created| Binary files /dev/null and b/corpus/link-edit/candidate/links.one differ | |||
corpus/link-edit/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-D4BD156B -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} | ||
| 3 | {"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} | ||
corpus/link-edit/cold/machine.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"name": "m6-d4bd156b", "hostname": "ONE-M6-D4BD156B"} | ||
corpus/link-edit/cold/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/link-edit/cold/notebook/Open Notebook.onetoc2 differ | |||
corpus/link-edit/cold/notebook/links.one created| Binary files /dev/null and b/corpus/link-edit/cold/notebook/links.one differ | |||
corpus/link-edit/cold/read/environment.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "powershell": "5.1.14409.1005", | ||
| 3 | "schema": "xs2010", | ||
| 4 | "hostname": "ONE-M6-D4BD156B", | ||
| 5 | "cold": true, | ||
| 6 | "onenote": "14.0.4763.1000" | ||
| 7 | } | ||
corpus/link-edit/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="{6DA07119-26C3-4DA6-A65C-1F33B76209F1}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T09:50:50.000Z" color="#B49EDE"><one:Section name="links" ID="{BD233792-FF1C-04B6-06F5-BAA64CF04510}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-12T09:50:50.000Z" color="#8AA8E4"><one:Page ID="{1C2EF11B-970B-0B8B-105E-9AC6B413CCEB}{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-12T09:50:50.000Z" pageLevel="1"/></one:Section></one:Notebook> | ||
corpus/link-edit/cold/read/page-000.xml created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{1C2EF11B-970B-0B8B-105E-9AC6B413CCEB}{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-12T09:50:50.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="{BD233792-FF1C-04B6-06F5-BAA64CF04510}{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="{BD233792-FF1C-04B6-06F5-BAA64CF04510}{24}{B0}" alignment="left"><one:T><![CDATA[Read about Rust <a | ||
| 3 | href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/link-edit/cold/read/payloads.json created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | [ | ||
| 2 | |||
| 3 | ] | ||
| \ No newline at end of file | |||
corpus/link-edit/cold/run.json created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | { | ||
| 2 | "notebook": "/private/tmp/m4/links", | ||
| 3 | "expected_pages": 1, | ||
| 4 | "author": null, | ||
| 5 | "author_timeout_seconds": 600, | ||
| 6 | "inspect": false, | ||
| 7 | "collect_notebook": true, | ||
| 8 | "base": { | ||
| 9 | "file": "win7-office-base.qcow2", | ||
| 10 | "format": "qcow2", | ||
| 11 | "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", | ||
| 12 | "virtual_size": 68719476736 | ||
| 13 | }, | ||
| 14 | "scripts": { | ||
| 15 | "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", | ||
| 16 | "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" | ||
| 17 | } | ||
| 18 | } | ||
corpus/link-edit/cold/scripts/cold.ps1 created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | ||
| 2 | Set-StrictMode -Version Latest | ||
| 3 | $ErrorActionPreference = 'Stop' | ||
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | ||
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | ||
| 7 | } | ||
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | ||
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | ||
| 10 | if ($CloneHost) { | ||
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | ||
| 12 | throw 'The disposable clone hostname does not match this machine.' | ||
| 13 | } | ||
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | ||
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | ||
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | ||
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | ||
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | ||
| 19 | } | ||
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | ||
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | ||
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | ||
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | ||
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | ||
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | ||
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | ||
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null | ||
corpus/link-edit/cold/scripts/read.ps1 created+142| ... | @@ -0,0 +1,142 @@ | ||
| 1 | param( | ||
| 2 | [Parameter(Mandatory=$true)][string]$Root, | ||
| 3 | [int]$ExpectedPages = -1, | ||
| 4 | [switch]$UseCurrentCache, | ||
| 5 | [switch]$Pdf, | ||
| 6 | [switch]$KeepOpen, | ||
| 7 | [string]$CloneHost = '' | ||
| 8 | ) | ||
| 9 | Set-StrictMode -Version Latest | ||
| 10 | $ErrorActionPreference = 'Stop' | ||
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | ||
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | ||
| 14 | } | ||
| 15 | $notebook = Join-Path $root 'notebook' | ||
| 16 | $output = Join-Path $root 'read' | ||
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | ||
| 18 | if ($UseCurrentCache) { | ||
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | ||
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | ||
| 21 | } | ||
| 22 | } else { | ||
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | ||
| 24 | } | ||
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | ||
| 26 | $app = New-Object -ComObject OneNote.Application | ||
| 27 | $notebookId = '' | ||
| 28 | $failure = $null | ||
| 29 | try { | ||
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | ||
| 31 | $process = Get-Process ONENOTE | ||
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | ||
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | ||
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | ||
| 35 | $sections = @() | ||
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | ||
| 37 | $id = '' | ||
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | ||
| 39 | $sections += $id | ||
| 40 | } | ||
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | ||
| 42 | $previous = '' | ||
| 43 | $lastChange = '' | ||
| 44 | $stableSince = [DateTime]::UtcNow | ||
| 45 | $settled = $false | ||
| 46 | do { | ||
| 47 | $pages = @{} | ||
| 48 | foreach ($section in $sections) { | ||
| 49 | $hierarchy = '' | ||
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | ||
| 51 | [xml]$xml = $hierarchy | ||
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | ||
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | ||
| 54 | throw 'OneNote opened a section outside the copied notebook.' | ||
| 55 | } | ||
| 56 | } | ||
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | ||
| 58 | $id = $node.GetAttribute('ID') | ||
| 59 | $content = '' | ||
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | ||
| 61 | $pages[$id] = $content | ||
| 62 | } | ||
| 63 | } | ||
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | ||
| 65 | if ($signature -ne $previous) { | ||
| 66 | $lastChange = $previous | ||
| 67 | $previous = $signature | ||
| 68 | $stableSince = [DateTime]::UtcNow | ||
| 69 | } | ||
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | ||
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | ||
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | ||
| 73 | Start-Sleep -Milliseconds 250 | ||
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | ||
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | ||
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | ||
| 77 | $index = 0 | ||
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | ||
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | ||
| 80 | $index++ | ||
| 81 | } | ||
| 82 | $hierarchy = '' | ||
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | ||
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | ||
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | ||
| 86 | } | ||
| 87 | $index = 0 | ||
| 88 | $payloads = @() | ||
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | ||
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | ||
| 91 | if ($Pdf) { | ||
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | ||
| 93 | $app.NavigateTo($id, '', $false) | ||
| 94 | $app.Publish($id, $pdfPath, 3, '') | ||
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | ||
| 96 | } | ||
| 97 | [xml]$page = $pages[$id] | ||
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | ||
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | ||
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | ||
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | ||
| 102 | $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); | ||
| 103 | kind = $file.LocalName; name = $file.GetAttribute('preferredName'); | ||
| 104 | sha256 = $hash; bytes = $bytes.Length } | ||
| 105 | } | ||
| 106 | $index++ | ||
| 107 | } | ||
| 108 | [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) | ||
| 109 | $all = '' | ||
| 110 | $app.GetHierarchy($notebookId, 4, [ref]$all, 1) | ||
| 111 | [xml]$finalTree = $all | ||
| 112 | $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) | ||
| 113 | if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { | ||
| 114 | throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' | ||
| 115 | } | ||
| 116 | [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) | ||
| 117 | Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." | ||
| 118 | } catch { | ||
| 119 | $failure = $_ | ||
| 120 | [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | ||
| 121 | throw | ||
| 122 | } finally { | ||
| 123 | try { | ||
| 124 | try { | ||
| 125 | if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } | ||
| 126 | if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { | ||
| 127 | $app.CloseNotebook($notebookId, $false) | ||
| 128 | } | ||
| 129 | } catch { | ||
| 130 | if ($null -eq $failure) { throw } | ||
| 131 | [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) | ||
| 132 | } | ||
| 133 | } finally { | ||
| 134 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | ||
| 135 | } | ||
| 136 | $app = $null | ||
| 137 | [GC]::Collect() | ||
| 138 | [GC]::WaitForPendingFinalizers() | ||
| 139 | if (-not $UseCurrentCache -and -not $CloneHost) { | ||
| 140 | Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 | ||
| 141 | } | ||
| 142 | } | ||
corpus/link-edit/cold/source.json created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "path": "Open Notebook.onetoc2", | ||
| 4 | "bytes": 1604, | ||
| 5 | "sha256": "6731c4214437fe1cb6aa347b0004542b7f7e4260f47773bf9ec07c4e894de47c", | ||
| 6 | "mtime_ns": 1789206556176938556 | ||
| 7 | }, | ||
| 8 | { | ||
| 9 | "path": "links.one", | ||
| 10 | "bytes": 6128, | ||
| 11 | "sha256": "0cfb356ed374fe919f73fb73fdbe5d66e0baa484d4119f70074d9f869d6db78a", | ||
| 12 | "mtime_ns": 1789206556176648597 | ||
| 13 | } | ||
| 14 | ] | ||
corpus/link-edit/cold/teardown.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"absent": true} | ||
crates/notebook/README.md+1-1| ... | @@ -14,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot, | ... | @@ -14,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot, |
| 14 | writes the difference into the working image and queues one | 14 | writes the difference into the working image and queues one |
| 15 | `Operation::Page(PageIntent { before, after, author })`; `before` is the page the | 15 | `Operation::Page(PageIntent { before, after, author })`; `before` is the page the |
| 16 | edit started from and is the precondition reconciliation checks. Text, styles, | 16 | edit started from and is the precondition reconciliation checks. Text, styles, |
| 17 | paragraph formatting, bullets, numbering and note tags, paragraph structure, | 17 | paragraph formatting, hyperlinks, bullets, numbering and note tags, paragraph structure, |
| 18 | outline layout, insertions and deletions are all differences between `before` and | 18 | outline layout, insertions and deletions are all differences between `before` and |
| 19 | `after`; the library never sees editor operations. | 19 | `after`; the library never sees editor operations. |
| 20 | 20 |
crates/onestore/README.md+1-1| ... | @@ -57,7 +57,7 @@ harness also accepts `--client-profile release`. | ... | @@ -57,7 +57,7 @@ harness also accepts `--client-profile release`. |
| 57 | | `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules | | 57 | | `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules | |
| 58 | | `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | | 58 | | `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | |
| 59 | | `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | | 59 | | `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | |
| 60 | | `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, bullets and numbering, note tags, 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, bullets and numbering, note tags, paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities | |
| 61 | | `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically | | 61 | | `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically | |
| 62 | | `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter | | 62 | | `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter | |
| 63 | | `read_file` | Read a snapshot under whole-file exclusion | | 63 | | `read_file` | Read a snapshot under whole-file exclusion | |
crates/onestore/src/edit.rs+5-9| ... | @@ -36,7 +36,7 @@ pub fn replace_text( | ... | @@ -36,7 +36,7 @@ pub fn replace_text( |
| 36 | replacement: &str, | 36 | replacement: &str, |
| 37 | ) -> Result<Vec<u8>, Error> { | 37 | ) -> Result<Vec<u8>, Error> { |
| 38 | let invalid = |message| Error { offset: 0, message }; | 38 | let invalid = |message| Error { offset: 0, message }; |
| 39 | if range.start > range.end || replacement.contains(['\0', '\n', '\r', '\u{fffc}', '\u{fddf}']) { | 39 | if range.start > range.end || replacement.contains(['\0', '\n', '\r', '\u{fffc}']) { |
| 40 | return Err(invalid( | 40 | return Err(invalid( |
| 41 | "Use a valid text range and ordinary paragraph text", | 41 | "Use a valid text range and ordinary paragraph text", |
| 42 | )); | 42 | )); |
| ... | @@ -82,15 +82,11 @@ pub fn replace_text( | ... | @@ -82,15 +82,11 @@ pub fn replace_text( |
| 82 | let resolved = revision.text_runs(object)?; | 82 | let resolved = revision.text_runs(object)?; |
| 83 | for (i, run) in runs.iter().enumerate() { | 83 | for (i, run) in runs.iter().enumerate() { |
| 84 | if i == selected || (run.start < range.end && range.start < run.end) { | 84 | if i == selected || (run.start < range.end && range.start < run.end) { |
| 85 | // Hyperlink field codes and their hidden runs are ordinary text with flags; | ||
| 86 | // embedded objects and equations are not. | ||
| 85 | let format = &resolved[i].format; | 87 | let format = &resolved[i].format; |
| 86 | if [ | 88 | if [format.math, format.embedded_object].contains(&Some(true)) |
| 87 | format.hidden, | 89 | || resolved[i].text.contains('\u{fffc}') |
| 88 | format.hyperlink, | ||
| 89 | format.math, | ||
| 90 | format.embedded_object, | ||
| 91 | ] | ||
| 92 | .contains(&Some(true)) | ||
| 93 | || resolved[i].text.contains(['\u{fffc}', '\u{fddf}']) | ||
| 94 | || run.extra_set.is_some_and(|set| !node.extra[set].is_empty()) | 90 | || run.extra_set.is_some_and(|set| !node.extra[set].is_empty()) |
| 95 | { | 91 | { |
| 96 | return Err(invalid("This text run contains a field or embedded data")); | 92 | return Err(invalid("This text run contains a field or embedded data")); |
crates/onestore/src/formatting.rs+11-8| ... | @@ -35,6 +35,12 @@ pub enum TextAttribute { | ... | @@ -35,6 +35,12 @@ pub enum TextAttribute { |
| 35 | Color(Option<[u8; 3]>), | 35 | Color(Option<[u8; 3]>), |
| 36 | /// RGB, or None to clear highlighting. | 36 | /// RGB, or None to clear highlighting. |
| 37 | Highlight(Option<[u8; 3]>), | 37 | Highlight(Option<[u8; 3]>), |
| 38 | /// Hidden text, such as a hyperlink's field code. | ||
| 39 | Hidden(bool), | ||
| 40 | /// The run belongs to a hyperlink field. | ||
| 41 | Hyperlink(bool), | ||
| 42 | /// The run is a hyperlink's visible label or field code rather than surrounding text. | ||
| 43 | HyperlinkLabel(bool), | ||
| 38 | } | 44 | } |
| 39 | 45 | ||
| 40 | impl TextAttribute { | 46 | impl TextAttribute { |
| ... | @@ -47,6 +53,9 @@ impl TextAttribute { | ... | @@ -47,6 +53,9 @@ impl TextAttribute { |
| 47 | Self::Strike(value) => boolean(0x08001c07, *value), | 53 | Self::Strike(value) => boolean(0x08001c07, *value), |
| 48 | Self::Superscript(value) => boolean(0x08001c08, *value), | 54 | Self::Superscript(value) => boolean(0x08001c08, *value), |
| 49 | Self::Subscript(value) => boolean(0x08001c09, *value), | 55 | Self::Subscript(value) => boolean(0x08001c09, *value), |
| 56 | Self::Hidden(value) => boolean(0x08001e16, *value), | ||
| 57 | Self::Hyperlink(value) => boolean(0x08001e14, *value), | ||
| 58 | Self::HyperlinkLabel(value) => boolean(0x08001e19, *value), | ||
| 50 | Self::Font(font) => { | 59 | Self::Font(font) => { |
| 51 | if font.is_empty() || font.contains('\0') { | 60 | if font.is_empty() || font.contains('\0') { |
| 52 | return Err(invalid("Font names must be nonempty and contain no NUL")); | 61 | return Err(invalid("Font names must be nonempty and contain no NUL")); |
| ... | @@ -141,14 +150,8 @@ pub(crate) fn format_text( | ... | @@ -141,14 +150,8 @@ pub(crate) fn format_text( |
| 141 | }; | 150 | }; |
| 142 | if selected { | 151 | if selected { |
| 143 | let format = &resolved[i].format; | 152 | let format = &resolved[i].format; |
| 144 | if [ | 153 | if [format.math, format.embedded_object].contains(&Some(true)) |
| 145 | format.hidden, | 154 | || resolved[i].text.contains('\u{fffc}') |
| 146 | format.hyperlink, | ||
| 147 | format.math, | ||
| 148 | format.embedded_object, | ||
| 149 | ] | ||
| 150 | .contains(&Some(true)) | ||
| 151 | || resolved[i].text.contains(['\u{fffc}', '\u{fddf}']) | ||
| 152 | { | 155 | { |
| 153 | return Err(invalid( | 156 | return Err(invalid( |
| 154 | "This format range contains a field or embedded data", | 157 | "This format range contains a field or embedded data", |
crates/onestore/src/insertion.rs+1-1| ... | @@ -143,7 +143,7 @@ impl Insertion { | ... | @@ -143,7 +143,7 @@ impl Insertion { |
| 143 | fn validate(&self) -> Result<(), Error> { | 143 | fn validate(&self) -> Result<(), Error> { |
| 144 | if self.guid == [0; 16] | 144 | if self.guid == [0; 16] |
| 145 | || self.parent.guid == [0; 16] | 145 | || self.parent.guid == [0; 16] |
| 146 | || self.text.contains(['\0', '\n', '\u{fffc}', '\u{fddf}']) | 146 | || self.text.contains(['\0', '\n', '\u{fffc}']) |
| 147 | || self.author.contains('\0') | 147 | || self.author.contains('\0') |
| 148 | { | 148 | { |
| 149 | return Err(invalid( | 149 | return Err(invalid( |
crates/onestore/src/page/write.rs+8-11| ... | @@ -1388,14 +1388,11 @@ fn text_edit(before: &str, after: &str) -> Result<(Range<u32>, String), Error> { | ... | @@ -1388,14 +1388,11 @@ fn text_edit(before: &str, after: &str) -> Result<(Range<u32>, String), Error> { |
| 1388 | fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<TextAttribute>, Error> { | 1388 | fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<TextAttribute>, Error> { |
| 1389 | let mut out = Vec::new(); | 1389 | let mut out = Vec::new(); |
| 1390 | let inherited = || invalid("Inherited character formatting cannot be restored"); | 1390 | let inherited = || invalid("Inherited character formatting cannot be restored"); |
| 1391 | // An absent flag and an explicit false are the same formatting. | ||
| 1391 | macro_rules! boolean { | 1392 | macro_rules! boolean { |
| 1392 | ($field:ident, $variant:ident) => { | 1393 | ($field:ident, $variant:ident) => { |
| 1393 | if current.$field != target.$field { | 1394 | if current.$field.unwrap_or(false) != target.$field.unwrap_or(false) { |
| 1394 | match target.$field { | 1395 | out.push(TextAttribute::$variant(target.$field.unwrap_or(false))); |
| 1395 | Some(value) => out.push(TextAttribute::$variant(value)), | ||
| 1396 | None if fresh => {} | ||
| 1397 | None => return Err(inherited()), | ||
| 1398 | } | ||
| 1399 | } | 1396 | } |
| 1400 | }; | 1397 | }; |
| 1401 | } | 1398 | } |
| ... | @@ -1405,6 +1402,9 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text | ... | @@ -1405,6 +1402,9 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text |
| 1405 | boolean!(strike, Strike); | 1402 | boolean!(strike, Strike); |
| 1406 | boolean!(superscript, Superscript); | 1403 | boolean!(superscript, Superscript); |
| 1407 | boolean!(subscript, Subscript); | 1404 | boolean!(subscript, Subscript); |
| 1405 | boolean!(hidden, Hidden); | ||
| 1406 | boolean!(hyperlink, Hyperlink); | ||
| 1407 | boolean!(hyperlink_label, HyperlinkLabel); | ||
| 1408 | if current.font != target.font { | 1408 | if current.font != target.font { |
| 1409 | match &target.font { | 1409 | match &target.font { |
| 1410 | Some(font) => out.push(TextAttribute::Font(font.clone())), | 1410 | Some(font) => out.push(TextAttribute::Font(font.clone())), |
| ... | @@ -1438,10 +1438,7 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text | ... | @@ -1438,10 +1438,7 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text |
| 1438 | // An absent value and its stored default are the same formatting. | 1438 | // An absent value and its stored default are the same formatting. |
| 1439 | let flag = |a: Option<bool>, b: Option<bool>| a.unwrap_or(false) == b.unwrap_or(false); | 1439 | let flag = |a: Option<bool>, b: Option<bool>| a.unwrap_or(false) == b.unwrap_or(false); |
| 1440 | let points = |a: Option<f32>, b: Option<f32>| a.unwrap_or(0.0) == b.unwrap_or(0.0); | 1440 | let points = |a: Option<f32>, b: Option<f32>| a.unwrap_or(0.0) == b.unwrap_or(0.0); |
| 1441 | let same_rest = flag(current.hidden, target.hidden) | 1441 | let same_rest = flag(current.math, target.math) |
| 1442 | && flag(current.hyperlink, target.hyperlink) | ||
| 1443 | && flag(current.hyperlink_label, target.hyperlink_label) | ||
| 1444 | && flag(current.math, target.math) | ||
| 1445 | && flag(current.embedded_object, target.embedded_object) | 1442 | && flag(current.embedded_object, target.embedded_object) |
| 1446 | && current.alignment.unwrap_or(0) == target.alignment.unwrap_or(0) | 1443 | && current.alignment.unwrap_or(0) == target.alignment.unwrap_or(0) |
| 1447 | && flag(current.rtl, target.rtl) | 1444 | && flag(current.rtl, target.rtl) |
| ... | @@ -1451,7 +1448,7 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text | ... | @@ -1451,7 +1448,7 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text |
| 1451 | && points(current.list_spacing, target.list_spacing); | 1448 | && points(current.list_spacing, target.list_spacing); |
| 1452 | if !same_rest { | 1449 | if !same_rest { |
| 1453 | return Err(invalid( | 1450 | return Err(invalid( |
| 1454 | "Fields, links, language and paragraph spacing cannot be edited through the page model", | 1451 | "Fields, language and paragraph spacing cannot be edited through the page model", |
| 1455 | )); | 1452 | )); |
| 1456 | } | 1453 | } |
| 1457 | Ok(out) | 1454 | Ok(out) |
crates/onestore/src/write/tests.rs+4-2| ... | @@ -884,10 +884,12 @@ fn character_formatting_preserves_inheritance_and_associated_data() { | ... | @@ -884,10 +884,12 @@ fn character_formatting_preserves_inheritance_and_associated_data() { |
| 884 | ); | 884 | ); |
| 885 | continue; | 885 | continue; |
| 886 | } | 886 | } |
| 887 | // Hidden and hyperlink runs are ordinary text with flags; associated data, | ||
| 888 | // equations and embedded objects are not. | ||
| 887 | let text_edit = PreparedEdit::text(&fixture, sid, text, 1..5, "x"); | 889 | let text_edit = PreparedEdit::text(&fixture, sid, text, 1..5, "x"); |
| 888 | assert_eq!(text_edit.is_ok(), variant == 0); | 890 | assert_eq!(text_edit.is_ok(), matches!(variant, 0 | 1 | 4)); |
| 889 | let edit = PreparedEdit::format(&fixture, sid, text, 0..6, &[TextAttribute::Bold(true)]); | 891 | let edit = PreparedEdit::format(&fixture, sid, text, 0..6, &[TextAttribute::Bold(true)]); |
| 890 | if matches!(variant, 1 | 2 | 4 | 5 | 6) { | 892 | if matches!(variant, 2 | 5 | 6) { |
| 891 | assert!(edit.is_err()); | 893 | assert!(edit.is_err()); |
| 892 | continue; | 894 | continue; |
| 893 | } | 895 | } |
crates/onestore/tests/edit.rs-41| ... | @@ -371,7 +371,6 @@ fn invalid_text_edits_never_touch_storage() { | ... | @@ -371,7 +371,6 @@ fn invalid_text_edits_never_touch_storage() { |
| 371 | (0..0, "\n"), | 371 | (0..0, "\n"), |
| 372 | (0..0, "\0"), | 372 | (0..0, "\0"), |
| 373 | (0..0, "\u{fffc}"), | 373 | (0..0, "\u{fffc}"), |
| 374 | (0..0, "\u{fddf}"), | ||
| 375 | ] { | 374 | ] { |
| 376 | let mut disk = Disk { | 375 | let mut disk = Disk { |
| 377 | visible: source.clone(), | 376 | visible: source.clone(), |
| ... | @@ -389,46 +388,6 @@ fn invalid_text_edits_never_touch_storage() { | ... | @@ -389,46 +388,6 @@ fn invalid_text_edits_never_touch_storage() { |
| 389 | } | 388 | } |
| 390 | } | 389 | } |
| 391 | 390 | ||
| 392 | #[test] | ||
| 393 | fn cross_run_edits_reject_native_fields_before_storage_io() { | ||
| 394 | let source = include_bytes!("../../../corpus/m6/native-probes-01/notebook/synthetic.one"); | ||
| 395 | let store = Store::parse(source).unwrap(); | ||
| 396 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 397 | let document = Document::parse(&index).unwrap(); | ||
| 398 | let mut fields = 0; | ||
| 399 | for (sid, space) in &document.spaces { | ||
| 400 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 401 | for (oid, node) in &revision.nodes { | ||
| 402 | let Kind::RichText { text, runs, .. } = &node.kind else { | ||
| 403 | continue; | ||
| 404 | }; | ||
| 405 | let resolved = revision.text_runs(*oid).unwrap(); | ||
| 406 | for (run, resolved) in runs.iter().zip(resolved) { | ||
| 407 | if resolved.format.hyperlink != Some(true) { | ||
| 408 | continue; | ||
| 409 | } | ||
| 410 | fields += 1; | ||
| 411 | for range in [0..run.end, run.start..text.encode_utf16().count() as u32] { | ||
| 412 | let mut disk = Disk { | ||
| 413 | visible: source.to_vec(), | ||
| 414 | durable: source.to_vec(), | ||
| 415 | operation: 0, | ||
| 416 | fail_at: None, | ||
| 417 | write_limit: 17, | ||
| 418 | random: 9, | ||
| 419 | }; | ||
| 420 | let error = onestore::commit_text(&mut disk, source, *sid, *oid, range, "x") | ||
| 421 | .unwrap_err(); | ||
| 422 | assert_eq!(error.state, CommitState::NotCommitted); | ||
| 423 | assert_eq!(disk.operation, 0); | ||
| 424 | assert_eq!(disk.durable, source); | ||
| 425 | } | ||
| 426 | } | ||
| 427 | } | ||
| 428 | } | ||
| 429 | assert!(fields > 0); | ||
| 430 | } | ||
| 431 | |||
| 432 | #[test] | 391 | #[test] |
| 433 | fn cross_run_splices_match_a_character_model_and_preserve_history() { | 392 | fn cross_run_splices_match_a_character_model_and_preserve_history() { |
| 434 | let (sid, oid) = target(SOURCE); | 393 | let (sid, oid) = target(SOURCE); |
crates/onestore/tests/insertion.rs+1-1| ... | @@ -616,7 +616,7 @@ fn anchors_targets_serialized_identities_and_text_are_validated_before_publicati | ... | @@ -616,7 +616,7 @@ fn anchors_targets_serialized_identities_and_text_are_validated_before_publicati |
| 616 | let (sid, page, outline, paragraph, text) = targets(&source); | 616 | let (sid, page, outline, paragraph, text) = targets(&source); |
| 617 | assert!(Insertion::outline(page, f32::NAN, 0.0, "Text", "Author").is_err()); | 617 | assert!(Insertion::outline(page, f32::NAN, 0.0, "Text", "Author").is_err()); |
| 618 | assert!(Insertion::outline(page, 0.0, f32::INFINITY, "Text", "Author").is_err()); | 618 | assert!(Insertion::outline(page, 0.0, f32::INFINITY, "Text", "Author").is_err()); |
| 619 | for content in ["a\0b", "a\nb", "a\u{fffc}b", "a\u{fddf}b"] { | 619 | for content in ["a\0b", "a\nb", "a\u{fffc}b"] { |
| 620 | assert!(Insertion::paragraph(outline, None, content, "Author").is_err()); | 620 | assert!(Insertion::paragraph(outline, None, content, "Author").is_err()); |
| 621 | } | 621 | } |
| 622 | assert!(Insertion::paragraph(outline, None, "Text", "a\0b").is_err()); | 622 | assert!(Insertion::paragraph(outline, None, "Text", "a\0b").is_err()); |
crates/onestore/tests/page_links.rs created+193| ... | @@ -0,0 +1,193 @@ | ||
| 1 | use onestore::{ | ||
| 2 | ExGuid, PreparedEdit, RevisionIndex, Store, | ||
| 3 | document::Document, | ||
| 4 | page::{Page, PageObject, PageParagraph, Paragraph, text::Edit}, | ||
| 5 | }; | ||
| 6 | |||
| 7 | const OUTLINES: &[u8] = | ||
| 8 | include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); | ||
| 9 | const AUTHOR: &str = "Link author"; | ||
| 10 | const CODE: &str = "\u{fddf}HYPERLINK \"https://example.invalid/rust\""; | ||
| 11 | |||
| 12 | fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) { | ||
| 13 | let store = Store::parse(bytes).unwrap(); | ||
| 14 | assert!(store.checksum_mismatches.is_empty()); | ||
| 15 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 16 | let document = Document::parse(&index).unwrap(); | ||
| 17 | document | ||
| 18 | .pages() | ||
| 19 | .unwrap() | ||
| 20 | .into_iter() | ||
| 21 | .find_map(|(space, _)| { | ||
| 22 | let page = Page::from_space(&document, space).unwrap(); | ||
| 23 | (page.title == title).then_some((space, page)) | ||
| 24 | }) | ||
| 25 | .unwrap() | ||
| 26 | } | ||
| 27 | |||
| 28 | fn page_in(bytes: &[u8], space: ExGuid) -> Page { | ||
| 29 | let store = Store::parse(bytes).unwrap(); | ||
| 30 | assert!(store.checksum_mismatches.is_empty()); | ||
| 31 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 32 | Page::from_space(&Document::parse(&index).unwrap(), space).unwrap() | ||
| 33 | } | ||
| 34 | |||
| 35 | fn body_paragraphs(page: &mut Page) -> &mut Vec<PageParagraph> { | ||
| 36 | page.objects | ||
| 37 | .iter_mut() | ||
| 38 | .find_map(|object| match object { | ||
| 39 | PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs), | ||
| 40 | _ => None, | ||
| 41 | }) | ||
| 42 | .unwrap() | ||
| 43 | } | ||
| 44 | |||
| 45 | /// Language tags stay as stored, so the comparison leaves them out. | ||
| 46 | fn without_language(mut page: Page) -> Page { | ||
| 47 | for object in &mut page.objects { | ||
| 48 | if let PageObject::Outline(outline) = object { | ||
| 49 | for paragraph in &mut outline.paragraphs { | ||
| 50 | if let Some(text) = paragraph.text_mut() { | ||
| 51 | let mut start = 0; | ||
| 52 | text.text = Paragraph::from_runs(text.text.spans().iter().map(|span| { | ||
| 53 | let value = text.text.text()[start..span.end].to_owned(); | ||
| 54 | start = span.end; | ||
| 55 | let mut format = span.format.clone(); | ||
| 56 | format.language = None; | ||
| 57 | (value, format) | ||
| 58 | })); | ||
| 59 | } | ||
| 60 | } | ||
| 61 | } | ||
| 62 | } | ||
| 63 | page | ||
| 64 | } | ||
| 65 | |||
| 66 | fn assert_same(written: &[u8], space: ExGuid, expected: &Page) -> Page { | ||
| 67 | let stored = page_in(written, space); | ||
| 68 | let mut expected = expected.clone(); | ||
| 69 | expected.title = stored.title.clone(); | ||
| 70 | assert_eq!(without_language(stored.clone()), without_language(expected)); | ||
| 71 | stored | ||
| 72 | } | ||
| 73 | |||
| 74 | fn linked(page: &mut Page) -> usize { | ||
| 75 | body_paragraphs(page) | ||
| 76 | .iter() | ||
| 77 | .position(|p| { | ||
| 78 | p.text() | ||
| 79 | .is_some_and(|t| t.text.text().contains("HYPERLINK")) | ||
| 80 | }) | ||
| 81 | .unwrap() | ||
| 82 | } | ||
| 83 | |||
| 84 | /// Appends a hyperlink: a hidden field code run followed by the visible label, both | ||
| 85 | /// flagged as link runs like OneNote writes them. | ||
| 86 | fn append_link(text: &mut Paragraph, label: &str) { | ||
| 87 | let end = text.utf16_offset(text.text().len()).unwrap(); | ||
| 88 | let base = text.format_at(end).unwrap().clone(); | ||
| 89 | let mut code = base.clone(); | ||
| 90 | code.hyperlink = Some(true); | ||
| 91 | code.hyperlink_label = Some(true); | ||
| 92 | code.hidden = Some(true); | ||
| 93 | let mut visible = base.clone(); | ||
| 94 | visible.hyperlink = Some(true); | ||
| 95 | visible.hyperlink_label = Some(true); | ||
| 96 | text.apply(Edit { | ||
| 97 | range: end..end, | ||
| 98 | replacement: Paragraph::from_runs([ | ||
| 99 | (" ".to_owned(), base), | ||
| 100 | (CODE.to_owned(), code), | ||
| 101 | (label.to_owned(), visible), | ||
| 102 | ]), | ||
| 103 | }) | ||
| 104 | .unwrap(); | ||
| 105 | } | ||
| 106 | |||
| 107 | #[test] | ||
| 108 | fn a_native_link_is_removed_leaving_plain_label_text() { | ||
| 109 | let (space, before) = page_by_title(OUTLINES, "Move leaf down"); | ||
| 110 | let mut after = before.clone(); | ||
| 111 | let at = linked(&mut after); | ||
| 112 | let text = &mut body_paragraphs(&mut after)[at].text_mut().unwrap().text; | ||
| 113 | let content = text.text().to_owned(); | ||
| 114 | let start = content.find('\u{fddf}').unwrap(); | ||
| 115 | let end = content.find("Link label").unwrap(); | ||
| 116 | let mut plain = text.format_at(0).unwrap().clone(); | ||
| 117 | plain.hyperlink = Some(false); | ||
| 118 | plain.hyperlink_label = Some(false); | ||
| 119 | plain.hidden = Some(false); | ||
| 120 | let (from, to) = ( | ||
| 121 | text.utf16_offset(start).unwrap(), | ||
| 122 | text.utf16_offset(content.len()).unwrap(), | ||
| 123 | ); | ||
| 124 | text.apply(Edit { | ||
| 125 | range: from..to, | ||
| 126 | replacement: Paragraph::new(content[end..].to_owned(), plain), | ||
| 127 | }) | ||
| 128 | .unwrap(); | ||
| 129 | let written = PreparedEdit::page(OUTLINES, space, &after, AUTHOR).unwrap(); | ||
| 130 | let stored = assert_same(written.as_bytes(), space, &after); | ||
| 131 | let mut stored = stored; | ||
| 132 | let text = &body_paragraphs(&mut stored)[at].text().unwrap().text; | ||
| 133 | assert!(text.text().ends_with(" Link label")); | ||
| 134 | assert!( | ||
| 135 | text.spans() | ||
| 136 | .iter() | ||
| 137 | .all(|span| span.format.hyperlink != Some(true) && span.format.hidden != Some(true)) | ||
| 138 | ); | ||
| 139 | } | ||
| 140 | |||
| 141 | /// `ONESTORE_LINK_EXPORT` names a new directory receiving the candidate for a cold reopen. | ||
| 142 | #[test] | ||
| 143 | fn a_link_is_added_to_a_fresh_page_and_reads_back() { | ||
| 144 | let source = onestore::create_section("links.one", "Read about Rust", "Author").unwrap(); | ||
| 145 | let store = Store::parse(&source).unwrap(); | ||
| 146 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 147 | let document = Document::parse(&index).unwrap(); | ||
| 148 | let (space, _) = document.pages().unwrap()[0]; | ||
| 149 | let before = Page::from_space(&document, space).unwrap(); | ||
| 150 | let mut after = before.clone(); | ||
| 151 | append_link( | ||
| 152 | &mut body_paragraphs(&mut after)[0].text_mut().unwrap().text, | ||
| 153 | "the Rust site", | ||
| 154 | ); | ||
| 155 | let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap(); | ||
| 156 | let stored = assert_same(written.as_bytes(), space, &after); | ||
| 157 | let mut stored = stored; | ||
| 158 | let text = &body_paragraphs(&mut stored)[0].text().unwrap().text; | ||
| 159 | let flags: Vec<(Option<bool>, Option<bool>, Option<bool>)> = text | ||
| 160 | .spans() | ||
| 161 | .iter() | ||
| 162 | .map(|s| { | ||
| 163 | ( | ||
| 164 | s.format.hyperlink, | ||
| 165 | s.format.hyperlink_label, | ||
| 166 | s.format.hidden, | ||
| 167 | ) | ||
| 168 | }) | ||
| 169 | .collect(); | ||
| 170 | assert_eq!( | ||
| 171 | flags, | ||
| 172 | [ | ||
| 173 | (None, None, None), | ||
| 174 | (Some(true), Some(true), Some(true)), | ||
| 175 | (Some(true), Some(true), None), | ||
| 176 | ] | ||
| 177 | ); | ||
| 178 | if let Some(directory) = std::env::var_os("ONESTORE_LINK_EXPORT") { | ||
| 179 | let directory = std::path::PathBuf::from(directory); | ||
| 180 | std::fs::create_dir(&directory).unwrap(); | ||
| 181 | std::fs::write(directory.join("links.one"), written.as_bytes()).unwrap(); | ||
| 182 | let written_store = Store::parse(written.as_bytes()).unwrap(); | ||
| 183 | std::fs::write( | ||
| 184 | directory.join("Open Notebook.onetoc2"), | ||
| 185 | onestore::create_table_of_contents( | ||
| 186 | "Open Notebook.onetoc2", | ||
| 187 | &[("links.one", written_store.header.file_id)], | ||
| 188 | ) | ||
| 189 | .unwrap(), | ||
| 190 | ) | ||
| 191 | .unwrap(); | ||
| 192 | } | ||
| 193 | } | ||
tools/test_link_edit.py created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | from pathlib import Path | ||
| 2 | import runpy | ||
| 3 | import shutil | ||
| 4 | from tempfile import TemporaryDirectory | ||
| 5 | import unittest | ||
| 6 | import xml.etree.ElementTree as ET | ||
| 7 | |||
| 8 | from native_xml import ns | ||
| 9 | |||
| 10 | ROOT = Path(__file__).resolve().parent.parent | ||
| 11 | FIXTURE = ROOT / 'corpus/link-edit' | ||
| 12 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | ||
| 13 | |||
| 14 | |||
| 15 | class LinkEditTest(unittest.TestCase): | ||
| 16 | def test_a_rust_hyperlink_renders_natively(self): | ||
| 17 | with TemporaryDirectory() as temporary: | ||
| 18 | read = Path(temporary) / 'read' | ||
| 19 | shutil.copytree(FIXTURE / 'cold/read', read) | ||
| 20 | compare(FIXTURE / 'candidate', read) | ||
| 21 | page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml'))) | ||
| 22 | texts = [oe.find('one:T', ns).text for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:T', ns) is not None] | ||
| 23 | self.assertEqual([' '.join(text.split()) for text in texts], | ||
| 24 | ['Read about Rust <a href="https://example.invalid/rust">the Rust site</a>']) | ||
| 25 | |||
| 26 | |||
| 27 | if __name__ == '__main__': | ||
| 28 | unittest.main() | ||