| author | |
| committer | |
| log | 190fb2d10c46ced562a7f0d96f63aa803f4b47d4 |
| tree | ea89604d82729c5c97cfc818fc01ddae111363b1 |
| parent | 8a86e3ac0f817d8d27104a0a58e865b17e94eb35 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Note tags on paragraphs and text objects, and the tag definitions the page
model carries, are differences the page writer now lowers. A property-set
array writer joins the property patcher: an encoded array of sets is inserted
whole with its nested object references appended to the reference stream in
traversal order, which is where a rewritten tag array sits. The writer
rewrites an element's tag array from the model (definition reference, action
type, status, creation, completion, start and due dates and task id), creates
definition objects for definitions new to the section under writer identities
that squash renames to the model's, and refuses a second tag with the same
action type on one element as OneNote does. The model mutation driver toggles
tags.
corpus/tag-edit is the native gate: a Rust-authored page with two definitions
and open, completed, doubly tagged and untagged paragraphs, cold-read by
OneNote 2010 with the definitions, completion states and dates intact;
tools/test_tag_edit.py checks it without a VM.
Assisted-by: claude-fable-5.124 files changed, 911 insertions(+), 29 deletions(-)
corpus/tag-edit/README.md created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | # Rust note tag authoring | ||
| 2 | |||
| 3 | `candidate/` is the page writer's output for | ||
| 4 | `new_definitions_and_tags_publish_on_a_fresh_page` in | ||
| 5 | `crates/onestore/tests/page_tags.rs`: on a section created in Rust, two tag | ||
| 6 | definitions are added ("Rust task", type 0, symbol 3; "Important", type 1, | ||
| 7 | symbol 13), the first paragraph gains an open task, a second paragraph a | ||
| 8 | completed task with creation and completion dates, a third paragraph both | ||
| 9 | tags, and a fourth paragraph none. Tags are property-set arrays on the text | ||
| 10 | object referencing definition objects (jcid 0x120043) that carry the model's | ||
| 11 | identities after squash; an element holds one tag per action type, as OneNote | ||
| 12 | requires. | ||
| 13 | |||
| 14 | `cold/` is a fresh OneNote 2010 read: both `TagDef` entries appear with their | ||
| 15 | type, symbol and name, the tags read back with their completion state and | ||
| 16 | dates, and the untagged paragraph has none. `tools/test_tag_edit.py` checks | ||
| 17 | this without a VM. Regenerate with `ONESTORE_TAG_EXPORT` set to a new absolute | ||
| 18 | directory while running the test, then cold-open it with | ||
| 19 | `tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook`. | ||
corpus/tag-edit/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/tag-edit/candidate/Open Notebook.onetoc2 differ | |||
corpus/tag-edit/candidate/tags.one created| Binary files /dev/null and b/corpus/tag-edit/candidate/tags.one differ | |||
corpus/tag-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-CFD607C8 -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/tag-edit/cold/machine.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"name": "m6-cfd607c8", "hostname": "ONE-M6-CFD607C8"} | ||
corpus/tag-edit/cold/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/tag-edit/cold/notebook/Open Notebook.onetoc2 differ | |||
corpus/tag-edit/cold/notebook/tags.one created| Binary files /dev/null and b/corpus/tag-edit/cold/notebook/tags.one differ | |||
corpus/tag-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-CFD607C8", | ||
| 5 | "cold": true, | ||
| 6 | "onenote": "14.0.4763.1000" | ||
| 7 | } | ||
corpus/tag-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="{776770BA-9325-43FF-BB17-ADC70305D68D}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T09:33:49.000Z" color="#B49EDE"><one:Section name="tags" ID="{124F54B0-18DB-05C6-2862-7599822E55B2}{1}{B0}" path="C:\one-tests\runs\capture\notebook\tags.one" lastModifiedTime="2026-09-12T09:33:49.000Z" color="#8AA8E4"><one:Page ID="{8B9A6BBA-7DEE-0679-2C42-81467412EBEF}{1}{B0}" name="Open task" dateTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:33:49.000Z" pageLevel="1"/></one:Section></one:Notebook> | ||
corpus/tag-edit/cold/read/page-000.xml created+2| ... | @@ -0,0 +1,2 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{8B9A6BBA-7DEE-0679-2C42-81467412EBEF}{1}{B0}" name="Open task" dateTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:33:49.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="Rust task"/><one:TagDef index="1" type="1" symbol="13" fontColor="automatic" highlightColor="none" name="Important"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-12T09:32:16.000Z" objectID="{124F54B0-18DB-05C6-2862-7599822E55B2}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="158.2036895751953" height="53.71086883544922"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:32:16.000Z" objectID="{124F54B0-18DB-05C6-2862-7599822E55B2}{24}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Open task]]></one:T></one:OE><one:OE author="Tag author" lastModifiedBy="Tag author" creationTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:32:16.000Z" objectID="{13111015-B879-0D3E-10E3-0C769EF30D4A}{0}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2020-01-02T04:05:06.000Z"/><one:T><![CDATA[Completed task]]></one:T></one:OE><one:OE author="Tag author" lastModifiedBy="Tag author" creationTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:32:16.000Z" objectID="{F2B0539D-F9DE-062B-07BC-8EFA0C693E00}{0}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:Tag index="1" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Important open task]]></one:T></one:OE><one:OE author="Tag author" lastModifiedBy="Tag author" creationTime="2026-09-12T09:32:16.000Z" lastModifiedTime="2026-09-12T09:32:16.000Z" objectID="{8F65AAD5-5CD7-0F4E-24EF-4FACE80A618B}{0}{B0}" alignment="left"><one:T><![CDATA[Untagged]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/tag-edit/cold/read/payloads.json created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | [ | ||
| 2 | |||
| 3 | ] | ||
| \ No newline at end of file | |||
corpus/tag-edit/cold/run.json created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | { | ||
| 2 | "notebook": "/private/tmp/m4/tags", | ||
| 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/tag-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/tag-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/tag-edit/cold/source.json created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "path": "Open Notebook.onetoc2", | ||
| 4 | "bytes": 1596, | ||
| 5 | "sha256": "f3ce1472c9be35b1fb52683c5214e62850787e8e13bbd1918fed81fd19a48a65", | ||
| 6 | "mtime_ns": 1789205536390985808 | ||
| 7 | }, | ||
| 8 | { | ||
| 9 | "path": "tags.one", | ||
| 10 | "bytes": 8352, | ||
| 11 | "sha256": "601a8998b691bab66830196b3b139be6ea58d6f29d5bc69b3efa9892e2fc1550", | ||
| 12 | "mtime_ns": 1789205536390698932 | ||
| 13 | } | ||
| 14 | ] | ||
corpus/tag-edit/cold/teardown.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"absent": true} | ||
crates/notebook/README.md+2-2| ... | @@ -14,8 +14,8 @@ diffs the supplied model against the page stored in the supplied local snapshot, | ... | @@ -14,8 +14,8 @@ 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 and numbering, paragraph structure, outline | 17 | paragraph formatting, bullets, numbering and note tags, paragraph structure, |
| 18 | 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 | ||
| 21 | ```no_run | 21 | ```no_run |
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, 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, 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/page/write.rs+198-15| ... | @@ -5,7 +5,7 @@ use super::{Outline, Page, PageObject, PageParagraph, ParagraphContent}; | ... | @@ -5,7 +5,7 @@ use super::{Outline, Page, PageObject, PageParagraph, ParagraphContent}; |
| 5 | use crate::{ | 5 | use crate::{ |
| 6 | Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets, | 6 | Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets, |
| 7 | RevisionIndex, Store, TextAttribute, TreeEdit, Value, | 7 | RevisionIndex, Store, TextAttribute, TreeEdit, Value, |
| 8 | document::{Document, Format, Kind}, | 8 | document::{Document, Format, Kind, Tag}, |
| 9 | write::{PropertyObject, RevisionEdit, write_revisions}, | 9 | write::{PropertyObject, RevisionEdit, write_revisions}, |
| 10 | }; | 10 | }; |
| 11 | use std::{ | 11 | use std::{ |
| ... | @@ -13,6 +13,9 @@ use std::{ | ... | @@ -13,6 +13,9 @@ use std::{ |
| 13 | ops::Range, | 13 | ops::Range, |
| 14 | }; | 14 | }; |
| 15 | 15 | ||
| 16 | /// Property identifiers with their encoded values. | ||
| 17 | type Values = Vec<(u32, Vec<u8>)>; | ||
| 18 | |||
| 16 | fn invalid(message: &'static str) -> Error { | 19 | fn invalid(message: &'static str) -> Error { |
| 17 | Error { offset: 0, message } | 20 | Error { offset: 0, message } |
| 18 | } | 21 | } |
| ... | @@ -174,9 +177,12 @@ impl Lowering<'_> { | ... | @@ -174,9 +177,12 @@ impl Lowering<'_> { |
| 174 | } | 177 | } |
| 175 | for (id, definition) in &after.definitions { | 178 | for (id, definition) in &after.definitions { |
| 176 | if before.definitions.get(id) != Some(definition) | 179 | if before.definitions.get(id) != Some(definition) |
| 177 | && !matches!(definition.kind, Kind::List { .. }) | 180 | && !matches!( |
| 181 | definition.kind, | ||
| 182 | Kind::List { .. } | Kind::TagDefinition { .. } | ||
| 183 | ) | ||
| 178 | { | 184 | { |
| 179 | return Err(invalid("Tag and style definitions cannot be edited")); | 185 | return Err(invalid("Style definitions cannot be edited")); |
| 180 | } | 186 | } |
| 181 | } | 187 | } |
| 182 | let old = View::new(before)?; | 188 | let old = View::new(before)?; |
| ... | @@ -209,6 +215,7 @@ impl Lowering<'_> { | ... | @@ -209,6 +215,7 @@ impl Lowering<'_> { |
| 209 | self.delete(&old, &new, &consumed)?; | 215 | self.delete(&old, &new, &consumed)?; |
| 210 | self.edit_text(&new)?; | 216 | self.edit_text(&new)?; |
| 211 | self.edit_lists(after, &new)?; | 217 | self.edit_lists(after, &new)?; |
| 218 | self.edit_tags(after, &new)?; | ||
| 212 | self.edit_paragraph_formatting(&new)?; | 219 | self.edit_paragraph_formatting(&new)?; |
| 213 | self.edit_formatting(&new)?; | 220 | self.edit_formatting(&new)?; |
| 214 | self.edit_layout(&old, &new)?; | 221 | self.edit_layout(&old, &new)?; |
| ... | @@ -275,18 +282,16 @@ impl Lowering<'_> { | ... | @@ -275,18 +282,16 @@ impl Lowering<'_> { |
| 275 | let Some(previous) = old.paragraphs.get(id) else { | 282 | let Some(previous) = old.paragraphs.get(id) else { |
| 276 | continue; | 283 | continue; |
| 277 | }; | 284 | }; |
| 278 | let same = paragraph.tags == previous.tags | 285 | let same = paragraph.style == previous.style && paragraph.format == previous.format; |
| 279 | && paragraph.style == previous.style | ||
| 280 | && paragraph.format == previous.format; | ||
| 281 | if !same { | 286 | if !same { |
| 282 | return Err(invalid( | 287 | return Err(invalid( |
| 283 | "Paragraph tags, styles and paragraph formatting cannot be edited", | 288 | "Paragraph styles and paragraph formatting cannot be edited", |
| 284 | )); | 289 | )); |
| 285 | } | 290 | } |
| 286 | match (&paragraph.content, &previous.content) { | 291 | match (&paragraph.content, &previous.content) { |
| 287 | (ParagraphContent::Text(text), ParagraphContent::Text(previous)) => { | 292 | (ParagraphContent::Text(text), ParagraphContent::Text(previous)) => { |
| 288 | if text.date_field != previous.date_field || text.tags != previous.tags { | 293 | if text.date_field != previous.date_field { |
| 289 | return Err(invalid("Text fields and tags cannot be edited")); | 294 | return Err(invalid("Text fields cannot be edited")); |
| 290 | } | 295 | } |
| 291 | } | 296 | } |
| 292 | (ParagraphContent::Table(table), ParagraphContent::Table(previous)) => { | 297 | (ParagraphContent::Table(table), ParagraphContent::Table(previous)) => { |
| ... | @@ -558,12 +563,10 @@ impl Lowering<'_> { | ... | @@ -558,12 +563,10 @@ impl Lowering<'_> { |
| 558 | let paragraph = new.paragraphs[id]; | 563 | let paragraph = new.paragraphs[id]; |
| 559 | let text = paragraph | 564 | let text = paragraph |
| 560 | .text() | 565 | .text() |
| 561 | .filter(|text| text.date_field.is_none() && text.tags.is_empty()) | 566 | .filter(|text| text.date_field.is_none()) |
| 562 | .filter(|_| paragraph.tags.is_empty() && paragraph.style.is_none()) | 567 | .filter(|_| paragraph.style.is_none()) |
| 563 | .ok_or_else(|| { | 568 | .ok_or_else(|| { |
| 564 | invalid( | 569 | invalid("New paragraphs contain plain text without fields or styles") |
| 565 | "New paragraphs contain plain text without tags, fields or styles", | ||
| 566 | ) | ||
| 567 | })?; | 570 | })?; |
| 568 | let insertion = Insertion::paragraph( | 571 | let insertion = Insertion::paragraph( |
| 569 | self.id(container), | 572 | self.id(container), |
| ... | @@ -725,7 +728,7 @@ impl Lowering<'_> { | ... | @@ -725,7 +728,7 @@ impl Lowering<'_> { |
| 725 | if bullet.is_some() && (format.is_none() || font.is_none()) { | 728 | if bullet.is_some() && (format.is_none() || font.is_none()) { |
| 726 | return Err(invalid("A bullet definition names its glyph and font")); | 729 | return Err(invalid("A bullet definition names its glyph and font")); |
| 727 | } | 730 | } |
| 728 | let mut values: Vec<(u32, Vec<u8>)> = Vec::new(); | 731 | let mut values: Values = Vec::new(); |
| 729 | if let Some(format) = format { | 732 | if let Some(format) = format { |
| 730 | let units: Vec<u16> = format.encode_utf16().collect(); | 733 | let units: Vec<u16> = format.encode_utf16().collect(); |
| 731 | let count = u16::try_from(units.len()) | 734 | let count = u16::try_from(units.len()) |
| ... | @@ -838,6 +841,186 @@ impl Lowering<'_> { | ... | @@ -838,6 +841,186 @@ impl Lowering<'_> { |
| 838 | Ok(()) | 841 | Ok(()) |
| 839 | } | 842 | } |
| 840 | 843 | ||
| 844 | /// Rewrites the note tags of paragraphs and text objects whose model tags differ from | ||
| 845 | /// the stored ones. A tag definition new to the section becomes a definition object | ||
| 846 | /// carrying the model's identity after squash. | ||
| 847 | fn edit_tags(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> { | ||
| 848 | fn same(a: &[Tag], b: &[Tag]) -> bool { | ||
| 849 | let key = |t: &Tag| { | ||
| 850 | ( | ||
| 851 | t.definition, | ||
| 852 | t.action_type, | ||
| 853 | t.status, | ||
| 854 | t.created, | ||
| 855 | t.completed, | ||
| 856 | t.start, | ||
| 857 | t.due, | ||
| 858 | t.task_id, | ||
| 859 | ) | ||
| 860 | }; | ||
| 861 | a.len() == b.len() && a.iter().zip(b).all(|(x, y)| key(x) == key(y)) | ||
| 862 | } | ||
| 863 | let current = self.current()?; | ||
| 864 | let stored = View::new(&current)?; | ||
| 865 | for (id, paragraph) in &new.paragraphs { | ||
| 866 | let image_id = self.id(*id); | ||
| 867 | let previous = stored | ||
| 868 | .paragraphs | ||
| 869 | .get(&image_id) | ||
| 870 | .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; | ||
| 871 | let mut targets = vec![(image_id, &paragraph.tags, &previous.tags)]; | ||
| 872 | if let (Some(text), Some(before)) = (paragraph.text(), previous.text()) { | ||
| 873 | targets.push((self.id(text.id), &text.tags, &before.tags)); | ||
| 874 | } | ||
| 875 | for (object, tags, stored_tags) in targets { | ||
| 876 | if same(tags, stored_tags) { | ||
| 877 | continue; | ||
| 878 | } | ||
| 879 | let mut definitions: Vec<(ExGuid, Option<Values>)> = Vec::new(); | ||
| 880 | let mut sets: Vec<(usize, Values)> = Vec::new(); | ||
| 881 | let mut action_types = BTreeSet::new(); | ||
| 882 | for tag in tags { | ||
| 883 | let definition = tag | ||
| 884 | .definition | ||
| 885 | .ok_or_else(|| invalid("A note tag names its definition"))?; | ||
| 886 | let action_type = if tag.status & 4 != 0 { | ||
| 887 | tag.action_type | ||
| 888 | } else { | ||
| 889 | match after.definitions.get(&definition).map(|d| &d.kind) { | ||
| 890 | Some(Kind::TagDefinition { action_type, .. }) => *action_type, | ||
| 891 | _ => return Err(invalid("A note tag must reference a tag definition")), | ||
| 892 | } | ||
| 893 | }; | ||
| 894 | if !action_types.insert(action_type.unwrap_or(0)) { | ||
| 895 | return Err(invalid("An element holds one note tag per action type")); | ||
| 896 | } | ||
| 897 | let known = current.definitions.contains_key(&definition) | ||
| 898 | || self.alias.contains_key(&definition); | ||
| 899 | let written = if known { | ||
| 900 | self.id(definition) | ||
| 901 | } else { | ||
| 902 | let allocated = ExGuid { | ||
| 903 | guid: crate::write::fresh_guid()?, | ||
| 904 | n: 1, | ||
| 905 | }; | ||
| 906 | self.alias.insert(definition, allocated); | ||
| 907 | allocated | ||
| 908 | }; | ||
| 909 | let index = match definitions.iter().position(|(id, _)| *id == written) { | ||
| 910 | Some(index) => index, | ||
| 911 | None => { | ||
| 912 | let values = if known { | ||
| 913 | None | ||
| 914 | } else { | ||
| 915 | let Some(model) = after.definitions.get(&definition) else { | ||
| 916 | return Err(invalid( | ||
| 917 | "A note tag references a missing tag definition", | ||
| 918 | )); | ||
| 919 | }; | ||
| 920 | let Kind::TagDefinition { | ||
| 921 | label, | ||
| 922 | action_type, | ||
| 923 | shape, | ||
| 924 | color, | ||
| 925 | highlight, | ||
| 926 | } = &model.kind | ||
| 927 | else { | ||
| 928 | return Err(invalid( | ||
| 929 | "A note tag must reference a tag definition", | ||
| 930 | )); | ||
| 931 | }; | ||
| 932 | let mut values: Values = vec![ | ||
| 933 | (0x0c003473, vec![0]), | ||
| 934 | (0x10003463, action_type.unwrap_or(0).to_le_bytes().to_vec()), | ||
| 935 | (0x10003464, shape.unwrap_or(0).to_le_bytes().to_vec()), | ||
| 936 | (0x14003467, 0u32.to_le_bytes().to_vec()), | ||
| 937 | ]; | ||
| 938 | if let Some(label) = label { | ||
| 939 | values.push((0x1c003468, crate::create::string(label))); | ||
| 940 | } | ||
| 941 | if let Some(color) = color { | ||
| 942 | values.push((0x14003466, color.to_le_bytes().to_vec())); | ||
| 943 | } | ||
| 944 | if let Some(highlight) = highlight { | ||
| 945 | values.push((0x14003465, highlight.to_le_bytes().to_vec())); | ||
| 946 | } | ||
| 947 | Some(values) | ||
| 948 | }; | ||
| 949 | definitions.push((written, values)); | ||
| 950 | definitions.len() - 1 | ||
| 951 | } | ||
| 952 | }; | ||
| 953 | let mut fields: Values = Vec::new(); | ||
| 954 | if let Some(action_type) = tag.action_type { | ||
| 955 | fields.push((0x10003463, action_type.to_le_bytes().to_vec())); | ||
| 956 | } | ||
| 957 | for (id, value) in [ | ||
| 958 | (0x1400346e, tag.created), | ||
| 959 | (0x1400346f, tag.completed), | ||
| 960 | (0x1400346a, tag.start), | ||
| 961 | (0x1400346b, tag.due), | ||
| 962 | ] { | ||
| 963 | if let Some(value) = value { | ||
| 964 | fields.push((id, value.to_le_bytes().to_vec())); | ||
| 965 | } | ||
| 966 | } | ||
| 967 | fields.push((0x10003470, tag.status.to_le_bytes().to_vec())); | ||
| 968 | if let Some(task) = tag.task_id { | ||
| 969 | fields.push((0x1c003469, task.to_vec())); | ||
| 970 | } | ||
| 971 | sets.push((index, fields)); | ||
| 972 | } | ||
| 973 | let space = self.space; | ||
| 974 | self.apply(|image| { | ||
| 975 | let store = Store::parse(image)?; | ||
| 976 | let index = RevisionIndex::parse(&store)?; | ||
| 977 | let document = Document::parse(&index)?; | ||
| 978 | let parents = crate::edit::editable_parents( | ||
| 979 | document.active(space)?, | ||
| 980 | &document.pages_in(space)?, | ||
| 981 | object, | ||
| 982 | )?; | ||
| 983 | let modified = crate::create::current_timestamps()?.0.to_le_bytes(); | ||
| 984 | crate::write::write_revision(image, space, |raw| { | ||
| 985 | let mut changed = BTreeMap::new(); | ||
| 986 | for (id, values) in &definitions { | ||
| 987 | let Some(values) = values else { | ||
| 988 | continue; | ||
| 989 | }; | ||
| 990 | let mut node = PropertyObject { | ||
| 991 | jcid: 0x120043, | ||
| 992 | bytes: crate::create::properties(values)?, | ||
| 993 | global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])), | ||
| 994 | }; | ||
| 995 | node.reference(*id)?; | ||
| 996 | changed.insert(*id, node); | ||
| 997 | } | ||
| 998 | let mut target = PropertyObject::from_object(&raw.objects[&object])?; | ||
| 999 | let mut encoded = Vec::new(); | ||
| 1000 | for (index, fields) in &sets { | ||
| 1001 | let reference = target.reference(definitions[*index].0)?; | ||
| 1002 | let mut set = vec![(0x20003488, reference.to_vec())]; | ||
| 1003 | set.extend(fields.iter().cloned()); | ||
| 1004 | encoded.push(set); | ||
| 1005 | } | ||
| 1006 | target.set_sets(0x40003489, 0x44000811, &encoded)?; | ||
| 1007 | target.set(&[(0x14001d7a, &modified)])?; | ||
| 1008 | changed.insert(object, target); | ||
| 1009 | crate::formatting::touch_ancestors( | ||
| 1010 | raw, | ||
| 1011 | &parents, | ||
| 1012 | object, | ||
| 1013 | &modified, | ||
| 1014 | &mut changed, | ||
| 1015 | )?; | ||
| 1016 | Ok(changed) | ||
| 1017 | }) | ||
| 1018 | })?; | ||
| 1019 | } | ||
| 1020 | } | ||
| 1021 | Ok(()) | ||
| 1022 | } | ||
| 1023 | |||
| 841 | fn edit_paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { | 1024 | fn edit_paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { |
| 842 | let current = self.current()?; | 1025 | let current = self.current()?; |
| 843 | let current = View::new(&current)?; | 1026 | let current = View::new(&current)?; |
crates/onestore/src/write.rs+58-4| ... | @@ -133,6 +133,7 @@ fn patch_properties( | ... | @@ -133,6 +133,7 @@ fn patch_properties( |
| 133 | blob: &[u8], | 133 | blob: &[u8], |
| 134 | updates: &[(u32, &[u8])], | 134 | updates: &[(u32, &[u8])], |
| 135 | inserts: &[(u32, &[u8])], | 135 | inserts: &[(u32, &[u8])], |
| 136 | nested_references: &[u8], | ||
| 136 | ) -> Result<Vec<u8>> { | 137 | ) -> Result<Vec<u8>> { |
| 137 | let properties = PropertySets::parse(blob)?; | 138 | let properties = PropertySets::parse(blob)?; |
| 138 | let root = &properties.sets[0]; | 139 | let root = &properties.sets[0]; |
| ... | @@ -167,6 +168,8 @@ fn patch_properties( | ... | @@ -167,6 +168,8 @@ fn patch_properties( |
| 167 | 7 => value.len() < 0x40000000, | 168 | 7 => value.len() < 0x40000000, |
| 168 | 8 => value.len() == 4, | 169 | 8 => value.len() == 4, |
| 169 | 9 => value.len().is_multiple_of(4) && value.len() / 4 <= 0xffffff, | 170 | 9 => value.len().is_multiple_of(4) && value.len() / 4 <= 0xffffff, |
| 171 | // An encoded property-set array is inserted whole; its references follow. | ||
| 172 | 16 => i >= updates.len() && value.len() >= 4, | ||
| 170 | _ => { | 173 | _ => { |
| 171 | return Err(Error { | 174 | return Err(Error { |
| 172 | offset: 0, | 175 | offset: 0, |
| ... | @@ -198,13 +201,13 @@ fn patch_properties( | ... | @@ -198,13 +201,13 @@ fn patch_properties( |
| 198 | 9 => encoded.extend_from_slice(&(value.len() as u32 / 4).to_le_bytes()), | 201 | 9 => encoded.extend_from_slice(&(value.len() as u32 / 4).to_le_bytes()), |
| 199 | _ => {} | 202 | _ => {} |
| 200 | } | 203 | } |
| 201 | if (3..=7).contains(&kind) { | 204 | if (3..=7).contains(&kind) || kind == 16 { |
| 202 | encoded.extend_from_slice(value); | 205 | encoded.extend_from_slice(value); |
| 203 | } | 206 | } |
| 204 | if adding { | 207 | if adding { |
| 205 | added_ids.extend_from_slice(&property.to_le_bytes()); | 208 | added_ids.extend_from_slice(&property.to_le_bytes()); |
| 206 | added_fields.extend_from_slice(&encoded); | 209 | added_fields.extend_from_slice(&encoded); |
| 207 | if kind >= 8 { | 210 | if (8..=9).contains(&kind) { |
| 208 | object_count += (value.len() / 4) as i64; | 211 | object_count += (value.len() / 4) as i64; |
| 209 | added_references.extend_from_slice(value); | 212 | added_references.extend_from_slice(value); |
| 210 | } | 213 | } |
| ... | @@ -232,6 +235,7 @@ fn patch_properties( | ... | @@ -232,6 +235,7 @@ fn patch_properties( |
| 232 | } | 235 | } |
| 233 | } | 236 | } |
| 234 | } | 237 | } |
| 238 | object_count += (nested_references.len() / 4) as i64; | ||
| 235 | if !(0..=0xffffff).contains(&object_count) { | 239 | if !(0..=0xffffff).contains(&object_count) { |
| 236 | return Err(Error { | 240 | return Err(Error { |
| 237 | offset: 0, | 241 | offset: 0, |
| ... | @@ -242,6 +246,7 @@ fn patch_properties( | ... | @@ -242,6 +246,7 @@ fn patch_properties( |
| 242 | if header != object_header { | 246 | if header != object_header { |
| 243 | patches.push((0, 4, 0, header.to_le_bytes().to_vec())); | 247 | patches.push((0, 4, 0, header.to_le_bytes().to_vec())); |
| 244 | } | 248 | } |
| 249 | added_references.extend_from_slice(nested_references); | ||
| 245 | if !added_references.is_empty() { | 250 | if !added_references.is_empty() { |
| 246 | let end = 4 + (object_header as usize & 0xffffff) * 4; | 251 | let end = 4 + (object_header as usize & 0xffffff) * 4; |
| 247 | patches.push((end, end, usize::MAX, added_references)); | 252 | patches.push((end, end, usize::MAX, added_references)); |
| ... | @@ -339,7 +344,7 @@ pub(crate) fn replace_objects( | ... | @@ -339,7 +344,7 @@ pub(crate) fn replace_objects( |
| 339 | edit.object, | 344 | edit.object, |
| 340 | PropertyObject { | 345 | PropertyObject { |
| 341 | jcid: object.jcid, | 346 | jcid: object.jcid, |
| 342 | bytes: patch_properties(blob, edit.updates, edit.inserts)?, | 347 | bytes: patch_properties(blob, edit.updates, edit.inserts, &[])?, |
| 343 | global_ids: Arc::clone(&object.global_ids), | 348 | global_ids: Arc::clone(&object.global_ids), |
| 344 | }, | 349 | }, |
| 345 | ); | 350 | ); |
| ... | @@ -384,7 +389,56 @@ impl PropertyObject { | ... | @@ -384,7 +389,56 @@ impl PropertyObject { |
| 384 | .iter() | 389 | .iter() |
| 385 | .any(|p| p.id & 0x7fffffff == id & 0x7fffffff) | 390 | .any(|p| p.id & 0x7fffffff == id & 0x7fffffff) |
| 386 | }); | 391 | }); |
| 387 | self.bytes = patch_properties(&self.bytes, &updates, &inserts)?; | 392 | self.bytes = patch_properties(&self.bytes, &updates, &inserts, &[])?; |
| 393 | Ok(()) | ||
| 394 | } | ||
| 395 | |||
| 396 | /// Replaces a property-set array such as note tags: each set lists scalar values | ||
| 397 | /// inline, while an object reference (a kind-8 identity) is supplied as the compact | ||
| 398 | /// identity `reference` produced and joins the object's reference stream in order. | ||
| 399 | pub fn set_sets(&mut self, id: u32, element: u32, sets: &[Vec<(u32, Vec<u8>)>]) -> Result<()> { | ||
| 400 | if (id >> 26) & 31 != 16 || (element >> 26) & 31 != 17 { | ||
| 401 | return Err(Error { | ||
| 402 | offset: 0, | ||
| 403 | message: "Property-set arrays need array and element identifiers", | ||
| 404 | }); | ||
| 405 | } | ||
| 406 | self.remove(&[id])?; | ||
| 407 | if sets.is_empty() { | ||
| 408 | return Ok(()); | ||
| 409 | } | ||
| 410 | let mut encoded = (sets.len() as u32).to_le_bytes().to_vec(); | ||
| 411 | encoded.extend_from_slice(&element.to_le_bytes()); | ||
| 412 | let mut references = Vec::new(); | ||
| 413 | for set in sets { | ||
| 414 | encoded.extend_from_slice(&u16::try_from(set.len()).unwrap().to_le_bytes()); | ||
| 415 | for (field, _) in set { | ||
| 416 | encoded.extend_from_slice(&field.to_le_bytes()); | ||
| 417 | } | ||
| 418 | for (field, value) in set { | ||
| 419 | match (field >> 26) & 31 { | ||
| 420 | 3..=6 => { | ||
| 421 | assert_eq!(value.len(), 1 << (((field >> 26) & 31) - 3)); | ||
| 422 | encoded.extend_from_slice(value); | ||
| 423 | } | ||
| 424 | 7 => { | ||
| 425 | encoded.extend_from_slice(&(value.len() as u32).to_le_bytes()); | ||
| 426 | encoded.extend_from_slice(value); | ||
| 427 | } | ||
| 428 | 8 => { | ||
| 429 | assert_eq!(value.len(), 4); | ||
| 430 | references.extend_from_slice(value); | ||
| 431 | } | ||
| 432 | _ => { | ||
| 433 | return Err(Error { | ||
| 434 | offset: 0, | ||
| 435 | message: "Property-set arrays hold scalars and single references", | ||
| 436 | }); | ||
| 437 | } | ||
| 438 | } | ||
| 439 | } | ||
| 440 | } | ||
| 441 | self.bytes = patch_properties(&self.bytes, &[], &[(id, &encoded)], &references)?; | ||
| 388 | Ok(()) | 442 | Ok(()) |
| 389 | } | 443 | } |
| 390 | 444 |
crates/onestore/src/write/tests.rs+14-6| ... | @@ -221,6 +221,7 @@ fn add_paragraph(source: &[u8], number: u32) -> Vec<u8> { | ... | @@ -221,6 +221,7 @@ fn add_paragraph(source: &[u8], number: u32) -> Vec<u8> { |
| 221 | blob, | 221 | blob, |
| 222 | &[(0x24001c20, &children), (0x14001d7a, &modified)], | 222 | &[(0x24001c20, &children), (0x14001d7a, &modified)], |
| 223 | &[], | 223 | &[], |
| 224 | &[], | ||
| 224 | )?, | 225 | )?, |
| 225 | global_ids: Arc::clone(&table), | 226 | global_ids: Arc::clone(&table), |
| 226 | }, | 227 | }, |
| ... | @@ -302,7 +303,7 @@ fn restyle(source: &[u8]) -> Vec<u8> { | ... | @@ -302,7 +303,7 @@ fn restyle(source: &[u8]) -> Vec<u8> { |
| 302 | style, | 303 | style, |
| 303 | PropertyObject { | 304 | PropertyObject { |
| 304 | jcid: previous.jcid, | 305 | jcid: previous.jcid, |
| 305 | bytes: patch_properties(blob, &[], &[(0x88001c04, &[])])?, | 306 | bytes: patch_properties(blob, &[], &[(0x88001c04, &[])], &[])?, |
| 306 | global_ids: Arc::new(table), | 307 | global_ids: Arc::new(table), |
| 307 | }, | 308 | }, |
| 308 | )]); | 309 | )]); |
| ... | @@ -316,7 +317,12 @@ fn restyle(source: &[u8]) -> Vec<u8> { | ... | @@ -316,7 +317,12 @@ fn restyle(source: &[u8]) -> Vec<u8> { |
| 316 | *id, | 317 | *id, |
| 317 | PropertyObject { | 318 | PropertyObject { |
| 318 | jcid: text.jcid, | 319 | jcid: text.jcid, |
| 319 | bytes: patch_properties(blob, &[(0x24001e13, &compact(style, &table)?)], &[])?, | 320 | bytes: patch_properties( |
| 321 | blob, | ||
| 322 | &[(0x24001e13, &compact(style, &table)?)], | ||
| 323 | &[], | ||
| 324 | &[], | ||
| 325 | )?, | ||
| 320 | global_ids: Arc::new(table), | 326 | global_ids: Arc::new(table), |
| 321 | }, | 327 | }, |
| 322 | ); | 328 | ); |
| ... | @@ -461,7 +467,8 @@ fn changed_graphs_reject_cycles_dangling_and_unreachable_additions() { | ... | @@ -461,7 +467,8 @@ fn changed_graphs_reject_cycles_dangling_and_unreachable_additions() { |
| 461 | bytes: patch_properties( | 467 | bytes: patch_properties( |
| 462 | blob, | 468 | blob, |
| 463 | &[(0x24001c20, &compact(target, &outline.global_ids)?)], | 469 | &[(0x24001c20, &compact(target, &outline.global_ids)?)], |
| 464 | &[] | 470 | &[], |
| 471 | &[], | ||
| 465 | )?, | 472 | )?, |
| 466 | global_ids: Arc::clone(&outline.global_ids), | 473 | global_ids: Arc::clone(&outline.global_ids), |
| 467 | } | 474 | } |
| ... | @@ -559,7 +566,7 @@ fn property_splices_match_independently_encoded_flat_sets() { | ... | @@ -559,7 +566,7 @@ fn property_splices_match_independently_encoded_flat_sets() { |
| 559 | .map(|(id, value)| (*id, value.as_slice())) | 566 | .map(|(id, value)| (*id, value.as_slice())) |
| 560 | .collect(); | 567 | .collect(); |
| 561 | let original = properties(&original).unwrap(); | 568 | let original = properties(&original).unwrap(); |
| 562 | let actual = patch_properties(&original, &updates, &inserts).unwrap(); | 569 | let actual = patch_properties(&original, &updates, &inserts, &[]).unwrap(); |
| 563 | assert_eq!(actual, properties(&expected).unwrap(), "case {case}"); | 570 | assert_eq!(actual, properties(&expected).unwrap(), "case {case}"); |
| 564 | } | 571 | } |
| 565 | } | 572 | } |
| ... | @@ -599,6 +606,7 @@ fn nested_fields_and_other_reference_streams_remain_byte_exact() { | ... | @@ -599,6 +606,7 @@ fn nested_fields_and_other_reference_streams_remain_byte_exact() { |
| 599 | (0x24000001, &[6, 0, 0, 0, 7, 0, 0, 0]), | 606 | (0x24000001, &[6, 0, 0, 0, 7, 0, 0, 0]), |
| 600 | ], | 607 | ], |
| 601 | &[(0x24000005, &[8, 0, 0, 0]), (0x88000006, &[])], | 608 | &[(0x24000005, &[8, 0, 0, 0]), (0x88000006, &[])], |
| 609 | &[], | ||
| 602 | ) | 610 | ) |
| 603 | .unwrap(); | 611 | .unwrap(); |
| 604 | let parsed = PropertySets::parse(&changed).unwrap(); | 612 | let parsed = PropertySets::parse(&changed).unwrap(); |
| ... | @@ -717,7 +725,7 @@ fn deep_property_splices_do_not_use_the_call_stack() { | ... | @@ -717,7 +725,7 @@ fn deep_property_splices_do_not_use_the_call_stack() { |
| 717 | bytes.extend_from_slice(&0x44000001_u32.to_le_bytes()); | 725 | bytes.extend_from_slice(&0x44000001_u32.to_le_bytes()); |
| 718 | } | 726 | } |
| 719 | bytes.extend_from_slice(&0_u16.to_le_bytes()); | 727 | bytes.extend_from_slice(&0_u16.to_le_bytes()); |
| 720 | let changed = patch_properties(&bytes, &[], &[(0x88000002, &[])]).unwrap(); | 728 | let changed = patch_properties(&bytes, &[], &[(0x88000002, &[])], &[]).unwrap(); |
| 721 | let parsed = PropertySets::parse(&changed).unwrap(); | 729 | let parsed = PropertySets::parse(&changed).unwrap(); |
| 722 | assert_eq!(parsed.sets.len(), 100_001); | 730 | assert_eq!(parsed.sets.len(), 100_001); |
| 723 | assert_eq!(parsed.sets[0].len(), 2); | 731 | assert_eq!(parsed.sets[0].len(), 2); |
| ... | @@ -758,7 +766,7 @@ fn invalid_property_splices_are_rejected() { | ... | @@ -758,7 +766,7 @@ fn invalid_property_splices_are_rejected() { |
| 758 | (vec![], vec![(0x88000001, &[][..])]), | 766 | (vec![], vec![(0x88000001, &[][..])]), |
| 759 | (vec![], vec![(0x20000003, &[][..])]), | 767 | (vec![], vec![(0x20000003, &[][..])]), |
| 760 | ] { | 768 | ] { |
| 761 | assert!(patch_properties(&bytes, &updates, &inserts).is_err()); | 769 | assert!(patch_properties(&bytes, &updates, &inserts, &[]).is_err()); |
| 762 | } | 770 | } |
| 763 | } | 771 | } |
| 764 | 772 |
crates/onestore/tests/page_tags.rs created+305| ... | @@ -0,0 +1,305 @@ | ||
| 1 | use onestore::{ | ||
| 2 | ExGuid, PreparedEdit, RevisionIndex, Store, | ||
| 3 | document::{Document, Format, Kind, Tag}, | ||
| 4 | page::{ | ||
| 5 | Definition, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id, | ||
| 6 | }, | ||
| 7 | }; | ||
| 8 | |||
| 9 | const TREES: &[u8] = | ||
| 10 | include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one"); | ||
| 11 | const AUTHOR: &str = "Tag author"; | ||
| 12 | |||
| 13 | fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) { | ||
| 14 | let store = Store::parse(bytes).unwrap(); | ||
| 15 | assert!(store.checksum_mismatches.is_empty()); | ||
| 16 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 17 | let document = Document::parse(&index).unwrap(); | ||
| 18 | document | ||
| 19 | .pages() | ||
| 20 | .unwrap() | ||
| 21 | .into_iter() | ||
| 22 | .find_map(|(space, _)| { | ||
| 23 | let page = Page::from_space(&document, space).unwrap(); | ||
| 24 | (page.title == title).then_some((space, page)) | ||
| 25 | }) | ||
| 26 | .unwrap() | ||
| 27 | } | ||
| 28 | |||
| 29 | fn page_in(bytes: &[u8], space: ExGuid) -> Page { | ||
| 30 | let store = Store::parse(bytes).unwrap(); | ||
| 31 | assert!(store.checksum_mismatches.is_empty()); | ||
| 32 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 33 | Page::from_space(&Document::parse(&index).unwrap(), space).unwrap() | ||
| 34 | } | ||
| 35 | |||
| 36 | fn body_paragraphs(page: &mut Page) -> &mut Vec<PageParagraph> { | ||
| 37 | page.objects | ||
| 38 | .iter_mut() | ||
| 39 | .find_map(|object| match object { | ||
| 40 | PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs), | ||
| 41 | _ => None, | ||
| 42 | }) | ||
| 43 | .unwrap() | ||
| 44 | } | ||
| 45 | |||
| 46 | /// Tag positions in the element's extra arena are assigned on read; equality ignores them. | ||
| 47 | fn normalized(mut page: Page) -> Page { | ||
| 48 | for object in &mut page.objects { | ||
| 49 | if let PageObject::Outline(outline) = object { | ||
| 50 | for paragraph in &mut outline.paragraphs { | ||
| 51 | for tag in &mut paragraph.tags { | ||
| 52 | tag.extra_set = 0; | ||
| 53 | } | ||
| 54 | if let Some(text) = paragraph.text_mut() { | ||
| 55 | for tag in &mut text.tags { | ||
| 56 | tag.extra_set = 0; | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | } | ||
| 62 | page | ||
| 63 | } | ||
| 64 | |||
| 65 | fn assert_same(written: &[u8], space: ExGuid, expected: &Page) -> Page { | ||
| 66 | let stored = page_in(written, space); | ||
| 67 | let mut expected = expected.clone(); | ||
| 68 | expected.title = stored.title.clone(); | ||
| 69 | assert_eq!(normalized(stored.clone()), normalized(expected)); | ||
| 70 | stored | ||
| 71 | } | ||
| 72 | |||
| 73 | fn tagged(page: &Page) -> usize { | ||
| 74 | page.objects | ||
| 75 | .iter() | ||
| 76 | .find_map(|object| match object { | ||
| 77 | PageObject::Outline(outline) if !outline.title => Some( | ||
| 78 | outline | ||
| 79 | .paragraphs | ||
| 80 | .iter() | ||
| 81 | .position(|p| p.text().is_some_and(|t| !t.tags.is_empty())) | ||
| 82 | .unwrap(), | ||
| 83 | ), | ||
| 84 | _ => None, | ||
| 85 | }) | ||
| 86 | .unwrap() | ||
| 87 | } | ||
| 88 | |||
| 89 | fn raw_object(bytes: &[u8], space: ExGuid, id: ExGuid) -> String { | ||
| 90 | let store = Store::parse(bytes).unwrap(); | ||
| 91 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 92 | format!( | ||
| 93 | "{:?}", | ||
| 94 | index.resolve_active(space).unwrap().objects[&id].data | ||
| 95 | ) | ||
| 96 | } | ||
| 97 | |||
| 98 | fn definition(label: &str, shape: u16) -> Definition { | ||
| 99 | Definition { | ||
| 100 | kind: Kind::TagDefinition { | ||
| 101 | label: Some(label.into()), | ||
| 102 | action_type: Some(0), | ||
| 103 | shape: Some(shape), | ||
| 104 | color: None, | ||
| 105 | highlight: None, | ||
| 106 | }, | ||
| 107 | format: Format::default(), | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | fn tag(definition: ExGuid, completed: bool) -> Tag { | ||
| 112 | Tag { | ||
| 113 | definition: Some(definition), | ||
| 114 | action_type: None, | ||
| 115 | status: u16::from(completed), | ||
| 116 | created: Some(1_262_401_445), | ||
| 117 | completed: completed.then_some(1_262_405_106), | ||
| 118 | start: None, | ||
| 119 | due: None, | ||
| 120 | task_id: None, | ||
| 121 | extra_set: 0, | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | fn plain_paragraph(template: &PageParagraph, text: &str) -> PageParagraph { | ||
| 126 | let mut paragraph = template.clone(); | ||
| 127 | paragraph.id = new_id().unwrap(); | ||
| 128 | paragraph.parent = None; | ||
| 129 | paragraph.level = 1; | ||
| 130 | paragraph.lists.clear(); | ||
| 131 | paragraph.tags.clear(); | ||
| 132 | paragraph.style = None; | ||
| 133 | paragraph.collapsed = false; | ||
| 134 | paragraph.content = ParagraphContent::Text(TextObject { | ||
| 135 | id: new_id().unwrap(), | ||
| 136 | date_field: None, | ||
| 137 | text: onestore::page::Paragraph::new( | ||
| 138 | text.into(), | ||
| 139 | Format { | ||
| 140 | font: Some("Calibri".into()), | ||
| 141 | font_size: Some(11.0), | ||
| 142 | language: Some(1033), | ||
| 143 | ..Default::default() | ||
| 144 | }, | ||
| 145 | ), | ||
| 146 | tags: Vec::new(), | ||
| 147 | }); | ||
| 148 | paragraph | ||
| 149 | } | ||
| 150 | |||
| 151 | #[test] | ||
| 152 | fn removing_a_tag_leaves_its_definition_and_the_other_paragraphs_untouched() { | ||
| 153 | let (space, before) = page_by_title(TREES, "Indent first subtree"); | ||
| 154 | let at = tagged(&before); | ||
| 155 | let mut after = before.clone(); | ||
| 156 | let definition = body_paragraphs(&mut after)[at].text().unwrap().tags[0] | ||
| 157 | .definition | ||
| 158 | .unwrap(); | ||
| 159 | body_paragraphs(&mut after)[at] | ||
| 160 | .text_mut() | ||
| 161 | .unwrap() | ||
| 162 | .tags | ||
| 163 | .clear(); | ||
| 164 | let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); | ||
| 165 | let mut expected = after.clone(); | ||
| 166 | expected.definitions.remove(&definition); | ||
| 167 | let stored = assert_same(written.as_bytes(), space, &expected); | ||
| 168 | assert!(!stored.definitions.contains_key(&definition)); | ||
| 169 | assert_eq!( | ||
| 170 | raw_object(written.as_bytes(), space, definition), | ||
| 171 | raw_object(TREES, space, definition) | ||
| 172 | ); | ||
| 173 | let count = body_paragraphs(&mut after).len(); | ||
| 174 | let other = body_paragraphs(&mut after)[(at + 1) % count] | ||
| 175 | .text() | ||
| 176 | .unwrap() | ||
| 177 | .id; | ||
| 178 | assert_eq!( | ||
| 179 | raw_object(written.as_bytes(), space, other), | ||
| 180 | raw_object(TREES, space, other) | ||
| 181 | ); | ||
| 182 | } | ||
| 183 | |||
| 184 | #[test] | ||
| 185 | fn an_existing_definition_tags_another_paragraph_and_a_task_completes_in_place() { | ||
| 186 | let (space, before) = page_by_title(TREES, "Indent first subtree"); | ||
| 187 | let at = tagged(&before); | ||
| 188 | let mut after = before.clone(); | ||
| 189 | let existing = body_paragraphs(&mut after)[at].text().unwrap().tags[0].clone(); | ||
| 190 | let definition = existing.definition.unwrap(); | ||
| 191 | let count = body_paragraphs(&mut after).len(); | ||
| 192 | let other = (at + 1) % count; | ||
| 193 | body_paragraphs(&mut after)[other] | ||
| 194 | .text_mut() | ||
| 195 | .unwrap() | ||
| 196 | .tags | ||
| 197 | .push(tag(definition, false)); | ||
| 198 | let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); | ||
| 199 | let stored = assert_same(written.as_bytes(), space, &after); | ||
| 200 | let mut completed = stored.clone(); | ||
| 201 | let text = body_paragraphs(&mut completed)[other].text_mut().unwrap(); | ||
| 202 | text.tags[0].status = 1; | ||
| 203 | text.tags[0].completed = Some(1_262_405_106); | ||
| 204 | let again = PreparedEdit::page(written.as_bytes(), space, &completed, AUTHOR).unwrap(); | ||
| 205 | assert_same(again.as_bytes(), space, &completed); | ||
| 206 | assert_eq!( | ||
| 207 | PreparedEdit::page( | ||
| 208 | again.as_bytes(), | ||
| 209 | space, | ||
| 210 | &page_in(again.as_bytes(), space), | ||
| 211 | AUTHOR | ||
| 212 | ) | ||
| 213 | .unwrap() | ||
| 214 | .as_bytes(), | ||
| 215 | again.as_bytes() | ||
| 216 | ); | ||
| 217 | } | ||
| 218 | |||
| 219 | #[test] | ||
| 220 | fn tags_without_a_known_definition_are_refused() { | ||
| 221 | let (space, before) = page_by_title(TREES, "Indent first subtree"); | ||
| 222 | let mut missing = before.clone(); | ||
| 223 | body_paragraphs(&mut missing)[0] | ||
| 224 | .text_mut() | ||
| 225 | .unwrap() | ||
| 226 | .tags | ||
| 227 | .push(tag(new_id().unwrap(), false)); | ||
| 228 | assert!(PreparedEdit::page(TREES, space, &missing, AUTHOR).is_err()); | ||
| 229 | let mut repeated = before.clone(); | ||
| 230 | let at = tagged(&repeated); | ||
| 231 | let existing = body_paragraphs(&mut repeated)[at].text().unwrap().tags[0].clone(); | ||
| 232 | body_paragraphs(&mut repeated)[at] | ||
| 233 | .text_mut() | ||
| 234 | .unwrap() | ||
| 235 | .tags | ||
| 236 | .push(tag(existing.definition.unwrap(), false)); | ||
| 237 | assert!(PreparedEdit::page(TREES, space, &repeated, AUTHOR).is_err()); | ||
| 238 | let mut wrong = before.clone(); | ||
| 239 | let list = wrong | ||
| 240 | .definitions | ||
| 241 | .iter() | ||
| 242 | .find(|(_, d)| matches!(d.kind, Kind::List { .. })) | ||
| 243 | .map(|(id, _)| *id); | ||
| 244 | if let Some(list) = list { | ||
| 245 | body_paragraphs(&mut wrong)[0] | ||
| 246 | .text_mut() | ||
| 247 | .unwrap() | ||
| 248 | .tags | ||
| 249 | .push(tag(list, false)); | ||
| 250 | assert!(PreparedEdit::page(TREES, space, &wrong, AUTHOR).is_err()); | ||
| 251 | } | ||
| 252 | } | ||
| 253 | |||
| 254 | /// A fresh page gains two tag definitions and three tagged paragraphs, one completed; | ||
| 255 | /// `ONESTORE_TAG_EXPORT` names a new directory receiving the candidate for a cold reopen. | ||
| 256 | #[test] | ||
| 257 | fn new_definitions_and_tags_publish_on_a_fresh_page() { | ||
| 258 | let source = onestore::create_section("tags.one", "Open task", "Author").unwrap(); | ||
| 259 | let store = Store::parse(&source).unwrap(); | ||
| 260 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 261 | let document = Document::parse(&index).unwrap(); | ||
| 262 | let (space, _) = document.pages().unwrap()[0]; | ||
| 263 | let before = Page::from_space(&document, space).unwrap(); | ||
| 264 | let mut after = before.clone(); | ||
| 265 | let template = body_paragraphs(&mut after)[0].clone(); | ||
| 266 | let task = new_id().unwrap(); | ||
| 267 | let important = new_id().unwrap(); | ||
| 268 | after.definitions.insert(task, definition("Rust task", 3)); | ||
| 269 | let mut important_definition = definition("Important", 13); | ||
| 270 | let Kind::TagDefinition { action_type, .. } = &mut important_definition.kind else { | ||
| 271 | panic!() | ||
| 272 | }; | ||
| 273 | *action_type = Some(1); | ||
| 274 | after.definitions.insert(important, important_definition); | ||
| 275 | body_paragraphs(&mut after)[0] | ||
| 276 | .text_mut() | ||
| 277 | .unwrap() | ||
| 278 | .tags | ||
| 279 | .push(tag(task, false)); | ||
| 280 | let mut done = plain_paragraph(&template, "Completed task"); | ||
| 281 | done.text_mut().unwrap().tags.push(tag(task, true)); | ||
| 282 | body_paragraphs(&mut after).push(done); | ||
| 283 | let mut both = plain_paragraph(&template, "Important open task"); | ||
| 284 | both.text_mut().unwrap().tags.push(tag(important, false)); | ||
| 285 | both.text_mut().unwrap().tags.push(tag(task, false)); | ||
| 286 | body_paragraphs(&mut after).push(both); | ||
| 287 | body_paragraphs(&mut after).push(plain_paragraph(&template, "Untagged")); | ||
| 288 | let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap(); | ||
| 289 | assert_same(written.as_bytes(), space, &after); | ||
| 290 | if let Some(directory) = std::env::var_os("ONESTORE_TAG_EXPORT") { | ||
| 291 | let directory = std::path::PathBuf::from(directory); | ||
| 292 | std::fs::create_dir(&directory).unwrap(); | ||
| 293 | std::fs::write(directory.join("tags.one"), written.as_bytes()).unwrap(); | ||
| 294 | let written_store = Store::parse(written.as_bytes()).unwrap(); | ||
| 295 | std::fs::write( | ||
| 296 | directory.join("Open Notebook.onetoc2"), | ||
| 297 | onestore::create_table_of_contents( | ||
| 298 | "Open Notebook.onetoc2", | ||
| 299 | &[("tags.one", written_store.header.file_id)], | ||
| 300 | ) | ||
| 301 | .unwrap(), | ||
| 302 | ) | ||
| 303 | .unwrap(); | ||
| 304 | } | ||
| 305 | } | ||
crates/onestore/tests/support/page_edits.rs+57-1| ... | @@ -199,7 +199,63 @@ fn mutate(page: &mut Page, bytes: &mut Bytes<'_>) { | ... | @@ -199,7 +199,63 @@ fn mutate(page: &mut Page, bytes: &mut Bytes<'_>) { |
| 199 | .filter(|(_, o)| matches!(o, PageObject::Outline(_))) | 199 | .filter(|(_, o)| matches!(o, PageObject::Outline(_))) |
| 200 | .map(|(i, _)| i) | 200 | .map(|(i, _)| i) |
| 201 | .collect(); | 201 | .collect(); |
| 202 | match kind % 10 { | 202 | match kind % 11 { |
| 203 | 10 => { | ||
| 204 | let Some(outline) = outlines.first().map(|i| match &mut page.objects[*i] { | ||
| 205 | PageObject::Outline(outline) => outline, | ||
| 206 | _ => unreachable!(), | ||
| 207 | }) else { | ||
| 208 | continue; | ||
| 209 | }; | ||
| 210 | let at = usize::from(bytes.next().unwrap_or(0)) % outline.paragraphs.len().max(1); | ||
| 211 | let Some(text) = outline.paragraphs.get_mut(at).and_then(|p| p.text_mut()) else { | ||
| 212 | continue; | ||
| 213 | }; | ||
| 214 | if text.tags.is_empty() { | ||
| 215 | let definition = page | ||
| 216 | .definitions | ||
| 217 | .iter() | ||
| 218 | .find(|(_, d)| { | ||
| 219 | matches!(&d.kind, onestore::document::Kind::TagDefinition { label: Some(label), .. } if label == "Fuzz task") | ||
| 220 | }) | ||
| 221 | .map(|(id, _)| *id) | ||
| 222 | .unwrap_or_else(|| { | ||
| 223 | let id = new_id().unwrap(); | ||
| 224 | page.definitions.insert( | ||
| 225 | id, | ||
| 226 | onestore::page::Definition { | ||
| 227 | kind: onestore::document::Kind::TagDefinition { | ||
| 228 | label: Some("Fuzz task".into()), | ||
| 229 | action_type: Some(0), | ||
| 230 | shape: Some(3), | ||
| 231 | color: None, | ||
| 232 | highlight: None, | ||
| 233 | }, | ||
| 234 | format: Default::default(), | ||
| 235 | }, | ||
| 236 | ); | ||
| 237 | id | ||
| 238 | }); | ||
| 239 | let outline = outlines.first().map(|i| match &mut page.objects[*i] { | ||
| 240 | PageObject::Outline(outline) => outline, | ||
| 241 | _ => unreachable!(), | ||
| 242 | }); | ||
| 243 | let text = outline.unwrap().paragraphs[at].text_mut().unwrap(); | ||
| 244 | text.tags.push(onestore::document::Tag { | ||
| 245 | definition: Some(definition), | ||
| 246 | action_type: None, | ||
| 247 | status: u16::from(bytes.next().unwrap_or(0) % 2), | ||
| 248 | created: Some(1_262_401_445), | ||
| 249 | completed: None, | ||
| 250 | start: None, | ||
| 251 | due: None, | ||
| 252 | task_id: None, | ||
| 253 | extra_set: 0, | ||
| 254 | }); | ||
| 255 | } else { | ||
| 256 | text.tags.clear(); | ||
| 257 | } | ||
| 258 | } | ||
| 203 | 8 => { | 259 | 8 => { |
| 204 | let Some(outline) = outlines.first().map(|i| match &mut page.objects[*i] { | 260 | let Some(outline) = outlines.first().map(|i| match &mut page.objects[*i] { |
| 205 | PageObject::Outline(outline) => outline, | 261 | PageObject::Outline(outline) => outline, |
tools/test_tag_edit.py created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | from pathlib import Path | ||
| 2 | import re | ||
| 3 | import runpy | ||
| 4 | import shutil | ||
| 5 | from tempfile import TemporaryDirectory | ||
| 6 | import unittest | ||
| 7 | import xml.etree.ElementTree as ET | ||
| 8 | |||
| 9 | from native_xml import ns | ||
| 10 | |||
| 11 | ROOT = Path(__file__).resolve().parent.parent | ||
| 12 | FIXTURE = ROOT / 'corpus/tag-edit' | ||
| 13 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | ||
| 14 | |||
| 15 | |||
| 16 | class TagEditTest(unittest.TestCase): | ||
| 17 | def test_rust_tag_definitions_and_task_states_render_natively(self): | ||
| 18 | with TemporaryDirectory() as temporary: | ||
| 19 | read = Path(temporary) / 'read' | ||
| 20 | shutil.copytree(FIXTURE / 'cold/read', read) | ||
| 21 | compare(FIXTURE / 'candidate', read) | ||
| 22 | page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml'))) | ||
| 23 | definitions = [(d.get('index'), d.get('type'), d.get('symbol'), d.get('name')) for d in page.findall('one:TagDef', ns)] | ||
| 24 | self.assertEqual(definitions, [('0', '0', '3', 'Rust task'), ('1', '1', '13', 'Important')]) | ||
| 25 | tagged = [([(t.get('index'), t.get('completed'), t.get('completionDate') is not None) for t in oe.findall('one:Tag', ns)], | ||
| 26 | re.sub(r'<[^>]*>', '', oe.find('one:T', ns).text or '')) | ||
| 27 | for oe in page.iter('{%s}OE' % ns['one']) if oe.find('one:T', ns) is not None] | ||
| 28 | self.assertEqual(tagged, [ | ||
| 29 | ([('0', 'false', False)], 'Open task'), | ||
| 30 | ([('0', 'true', True)], 'Completed task'), | ||
| 31 | ([('0', 'false', False), ('1', 'false', False)], 'Important open task'), | ||
| 32 | ([], 'Untagged'), | ||
| 33 | ]) | ||
| 34 | |||
| 35 | |||
| 36 | if __name__ == '__main__': | ||
| 37 | unittest.main() | ||