From 8a86e3ac0f817d8d27104a0a58e865b17e94eb35 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 12 Sep 2026 02:17:29 -0700 Subject: [PATCH] feat: write bullets and numbering through the page model A paragraph's list references and the list definitions of the page model are differences the page writer now lowers. A definition new to the section becomes a native list node (jcid 0x60012) carrying the format string, list font, restart, bullet index and the definition's text formatting; a changed definition is rewritten in place; a definition another paragraph already owns is copied, since native list nodes belong to one paragraph; a dropped reference leaves the node unreferenced. Bullet definitions name their glyph and font and carry the cleared flag every native bullet node has. List nodes are allocated like other new objects and renamed to the model's identities in squash: a node written directly under a model identity had the null compact identity and failed OneNote's integrity check on open. The three-way merge carries definitions the local side added or changed, so a local bullet merges with a remote edit elsewhere. The model mutation driver toggles bullets, and its round-trip projection coalesces runs that differ only in attributes the writer leaves as stored, such as the language tag. corpus/list-edit is the native gate: a Rust-authored page with a bullet, two numbered paragraphs, a nested numbered child, a restart at three and plain paragraphs, cold-read by OneNote 2010 with the expected glyph and numbering text; tools/test_list_edit.py checks it without a VM. Assisted-by: claude-fable-5.1 --- corpus/list-edit/README.md | 23 ++ .../list-edit/candidate/Open Notebook.onetoc2 | Bin 0 -> 1604 bytes corpus/list-edit/candidate/lists.one | Bin 0 -> 11328 bytes corpus/list-edit/cold/commands.jsonl | 3 + corpus/list-edit/cold/machine.json | 1 + .../cold/notebook/Open Notebook.onetoc2 | Bin 0 -> 3764 bytes corpus/list-edit/cold/notebook/lists.one | Bin 0 -> 11328 bytes corpus/list-edit/cold/read/environment.json | 7 + corpus/list-edit/cold/read/hierarchy.xml | 2 + corpus/list-edit/cold/read/page-000.xml | 2 + corpus/list-edit/cold/read/payloads.json | 3 + corpus/list-edit/cold/run.json | 18 + corpus/list-edit/cold/scripts/cold.ps1 | 27 ++ corpus/list-edit/cold/scripts/read.ps1 | 142 ++++++++ corpus/list-edit/cold/source.json | 14 + corpus/list-edit/cold/teardown.json | 1 + crates/notebook/README.md | 5 +- crates/notebook/src/merge.rs | 19 +- crates/notebook/tests/sync_model.rs | 60 ++++ crates/onestore/README.md | 2 +- crates/onestore/src/page/write.rs | 203 ++++++++++- crates/onestore/tests/page_lists.rs | 322 ++++++++++++++++++ crates/onestore/tests/support/page_edits.rs | 58 +++- tools/test_list_edit.py | 52 +++ 24 files changed, 943 insertions(+), 21 deletions(-) create mode 100644 corpus/list-edit/README.md create mode 100644 corpus/list-edit/candidate/Open Notebook.onetoc2 create mode 100644 corpus/list-edit/candidate/lists.one create mode 100644 corpus/list-edit/cold/commands.jsonl create mode 100644 corpus/list-edit/cold/machine.json create mode 100644 corpus/list-edit/cold/notebook/Open Notebook.onetoc2 create mode 100644 corpus/list-edit/cold/notebook/lists.one create mode 100644 corpus/list-edit/cold/read/environment.json create mode 100644 corpus/list-edit/cold/read/hierarchy.xml create mode 100644 corpus/list-edit/cold/read/page-000.xml create mode 100644 corpus/list-edit/cold/read/payloads.json create mode 100644 corpus/list-edit/cold/run.json create mode 100644 corpus/list-edit/cold/scripts/cold.ps1 create mode 100644 corpus/list-edit/cold/scripts/read.ps1 create mode 100644 corpus/list-edit/cold/source.json create mode 100644 corpus/list-edit/cold/teardown.json create mode 100644 crates/onestore/tests/page_lists.rs create mode 100644 tools/test_list_edit.py diff --git a/corpus/list-edit/README.md b/corpus/list-edit/README.md new file mode 100644 index 0000000000000000000000000000000000000000..842c511a21959f8baf292ab367ec0f0003a3500b --- /dev/null +++ b/corpus/list-edit/README.md @@ -0,0 +1,23 @@ +# Rust list authoring + +`candidate/` is the page writer's output for +`bullets_numbering_nesting_and_restarts_publish_on_a_fresh_page` in +`crates/onestore/tests/page_lists.rs`: on a section created in Rust, the first +paragraph gains a bullet (glyph U+25CB in Courier New, bullet index 4, 11 pt), +then two numbered paragraphs, a nested numbered child, a numbered paragraph +restarting at three, a nested plain paragraph and a trailing plain paragraph. +The numbering definition is the upper-roman `##.` node copied from +`corpus/outline-edit/tree`. Every list node is written with the model's +identity after squash, so the reread model equals the saved one. + +`cold/` is a fresh OneNote 2010 read: the bullet renders (reported as bullet +index 3 at 11 pt), the numbered paragraphs read `I.` and `II.`, the nested +child restarts at `I.`, the restarted paragraph reads `III.`, and the plain +paragraphs carry no list. `tools/test_list_edit.py` checks this without a VM. + +A list node whose compact identity would be the null identity (sequence zero +at table index zero) fails OneNote's integrity check on open; the writer +allocates list nodes like other new objects and renames them to the model's +identities in squash. Regenerate with `ONESTORE_LIST_EXPORT` set to a new +absolute directory while running the test, then cold-open it with +`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook`. diff --git a/corpus/list-edit/candidate/Open Notebook.onetoc2 b/corpus/list-edit/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..e893988f00c2ccf9b7949df6d788ccba7a05b187 GIT binary patch literal 1604 zcmZ3e|KIuM`!b(-j|5(YE0x91d}?xZL;6mq*^yIOrc7)}Wk3V=cV`JmPyFh+^n&=r zw;)KmcMh!UUi+OdL%Z&RtpfsshddG9=g_JRKmlgB8L~fKYJK1*!%n zkaeiP)}^)6Z@y}2CY*3+AxVjc~`1q zC``5V3x$%Wnm>fhmCJB*O6k%v!P)Pt1K!9#MNF1chW|_%t zas6t)tq+7!k|*$8*AQT6(5qnRU|<31VqgdWVu%+&Jdh5MYyebN0wfC-2g!hNqyf;& zP__)vEO2PRLd1KQanoVZL)|d{3jq~^01wbH=ne!KCg8v@Ap>d^*mR&6$Se>5=|eY9 x7$gV;920>0pyeaT1K7-F!C@!FbX4;}@dWY|$h}NJ46+AAgWR}mTm2MdJpf|I$B6&{ literal 0 HcmV?d00001 diff --git a/corpus/list-edit/candidate/lists.one b/corpus/list-edit/candidate/lists.one new file mode 100644 index 0000000000000000000000000000000000000000..dc52b83ff3427b66e1becbc74478f066fc5e6b87 GIT binary patch literal 11328 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 literal 0 HcmV?d00001 diff --git a/corpus/list-edit/cold/commands.jsonl b/corpus/list-edit/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..8c378dee3e8ec4968f046d6958215d6418da7e32 --- /dev/null +++ b/corpus/list-edit/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-17B0A046 -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/list-edit/cold/machine.json b/corpus/list-edit/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..9e20a9c0a32923eebfb5bee45c31bd457048af14 --- /dev/null +++ b/corpus/list-edit/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-17b0a046", "hostname": "ONE-M6-17B0A046"} diff --git a/corpus/list-edit/cold/notebook/Open Notebook.onetoc2 b/corpus/list-edit/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..ab1de4e83f613d517c06f3f412aa67871090671c GIT binary patch literal 3764 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 literal 0 HcmV?d00001 diff --git a/corpus/list-edit/cold/read/environment.json b/corpus/list-edit/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..473105c8c5428be2089b03e1018102b341234b29 --- /dev/null +++ b/corpus/list-edit/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-17B0A046", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/list-edit/cold/read/hierarchy.xml b/corpus/list-edit/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..357ed888a2478ca70d620e2e3068865c22d1c859 --- /dev/null +++ b/corpus/list-edit/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/list-edit/cold/read/page-000.xml b/corpus/list-edit/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..b3d70d1c0f12a58781a996ce5c0ea824123f88a9 --- /dev/null +++ b/corpus/list-edit/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/list-edit/cold/read/payloads.json b/corpus/list-edit/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/list-edit/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/list-edit/cold/run.json b/corpus/list-edit/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..b01c766d642cea229452fba5c07433d8c308c98c --- /dev/null +++ b/corpus/list-edit/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/m4/lists", + "expected_pages": 1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/list-edit/cold/scripts/cold.ps1 b/corpus/list-edit/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/list-edit/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/list-edit/cold/scripts/read.ps1 b/corpus/list-edit/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/list-edit/cold/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/list-edit/cold/source.json b/corpus/list-edit/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..f431b9c2223759b278a6a42d5246847003cd5601 --- /dev/null +++ b/corpus/list-edit/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "25824736beb7e6c953922576b8d8309ec64568d08d1c850e71efa80efe2ee069", + "mtime_ns": 1789204406442521201 + }, + { + "path": "lists.one", + "bytes": 11328, + "sha256": "8ee832fe46e233c22995fd73ed9df9cb9ff345752570a250979aaa26c8f1ef15", + "mtime_ns": 1789204406442128119 + } +] diff --git a/corpus/list-edit/cold/teardown.json b/corpus/list-edit/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/list-edit/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 01cbfca2592e65efc3c102e37665459188671f5a..afca8b056f56a9cb369ad581bb8a5045f0c952b4 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -14,8 +14,9 @@ diffs the supplied model against the page stored in the supplied local snapshot, writes the difference into the working image and queues one `Operation::Page(PageIntent { before, after, author })`; `before` is the page the edit started from and is the precondition reconciliation checks. Text, styles, -paragraph structure, outline layout, insertions and deletions are all differences -between `before` and `after`; the library never sees editor operations. +paragraph formatting, bullets and numbering, paragraph structure, outline +layout, insertions and deletions are all differences between `before` and +`after`; the library never sees editor operations. ```no_run use onestore::{ExGuid, page::Page}; diff --git a/crates/notebook/src/merge.rs b/crates/notebook/src/merge.rs index 8a70862a15ab61e7e373ac2dd7d68f15a431aa02..136bba9d597c4e21637540a796ab15118c8e26a0 100644 --- a/crates/notebook/src/merge.rs +++ b/crates/notebook/src/merge.rs @@ -97,12 +97,29 @@ pub(crate) fn merge(base: &Page, ours: &Page, theirs: &Page) -> Option { } result = reorder(result, &our_order); } + // Definitions we added or changed travel with our paragraphs; theirs win elsewhere. + let mut definitions = theirs.definitions.clone(); + for (id, definition) in &ours.definitions { + match (base.definitions.get(id), theirs.definitions.get(id)) { + (Some(before), Some(after)) if before != definition => { + if after != before && after != definition { + return None; + } + definitions.insert(*id, definition.clone()); + } + (None, None) => { + definitions.insert(*id, definition.clone()); + } + (None, Some(after)) if after != definition => return None, + _ => {} + } + } Some(Page { title: theirs.title.clone(), created: theirs.created, margin_origin: theirs.margin_origin, objects: result, - definitions: theirs.definitions.clone(), + definitions, }) } diff --git a/crates/notebook/tests/sync_model.rs b/crates/notebook/tests/sync_model.rs index f830801de36ec2f510f0714c1cedcfc2a3ba01d2..b22176a9e37953ba0df47945b9557659951ac98d 100644 --- a/crates/notebook/tests/sync_model.rs +++ b/crates/notebook/tests/sync_model.rs @@ -532,3 +532,63 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() { Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) ); } + +#[test] +fn a_local_bullet_merges_with_a_remote_text_edit_and_publishes_its_definition() { + let (space, before) = page_titled(OUTLINES, "Move leaf down"); + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); + let mut after = before.clone(); + let bullet = onestore::page::text::new_id().unwrap(); + after.definitions.insert( + bullet, + onestore::page::Definition { + kind: onestore::document::Kind::List { + font: Some("Courier New".into()), + format: Some("\u{25cb}".into()), + restart: None, + bullet: Some(4), + }, + format: onestore::document::Format { + font_size: Some(11.0), + color: Some(0xff000000), + ..Default::default() + }, + }, + ); + let outline = after + .objects + .iter_mut() + .find_map(|object| match object { + PageObject::Outline(outline) if !outline.title => Some(outline), + _ => None, + }) + .unwrap(); + outline.paragraphs[0].lists = vec![bullet]; + let id = cache + .save(OUTLINES, space, &after, "Model author") + .unwrap() + .unwrap(); + let mut server = remote_with(space, |page| edit_paragraph(page, 1, 0..0, "Remote ")); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) + ); + let published = page_of(&server.durable, space); + let outline = published + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) if !outline.title => Some(outline), + _ => None, + }) + .unwrap(); + assert_eq!(outline.paragraphs[0].lists, vec![bullet]); + assert!(matches!( + published.definitions[&bullet].kind, + onestore::document::Kind::List { + bullet: Some(4), + .. + } + )); + assert!(texts(&published)[1].starts_with("Remote ")); +} diff --git a/crates/onestore/README.md b/crates/onestore/README.md index ca543ea889f24a1c7c5d763f3f8792775dbc9881..e796392b892eec81dbcab9c91ba8897dc2b25912 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -57,7 +57,7 @@ harness also accepts `--client-profile release`. | `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules | | `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | | `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | -| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character formatting, paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities | +| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, 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 | | `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically | | `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter | | `read_file` | Read a snapshot under whole-file exclusion | diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs index d4b5399efa9a1bde1bcae3c9e2c6993ac9ca3d66..a49864f4b3fdf788ad2c86621a22ef06549ea9c2 100644 --- a/crates/onestore/src/page/write.rs +++ b/crates/onestore/src/page/write.rs @@ -5,7 +5,7 @@ use super::{Outline, Page, PageObject, PageParagraph, ParagraphContent}; use crate::{ Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets, RevisionIndex, Store, TextAttribute, TreeEdit, Value, - document::{Document, Format}, + document::{Document, Format, Kind}, write::{PropertyObject, RevisionEdit, write_revisions}, }; use std::{ @@ -173,8 +173,10 @@ impl Lowering<'_> { return Err(invalid("Page creation time and margins cannot be edited")); } for (id, definition) in &after.definitions { - if before.definitions.get(id) != Some(definition) { - return Err(invalid("List, tag and style definitions cannot be edited")); + if before.definitions.get(id) != Some(definition) + && !matches!(definition.kind, Kind::List { .. }) + { + return Err(invalid("Tag and style definitions cannot be edited")); } } let old = View::new(before)?; @@ -206,6 +208,7 @@ impl Lowering<'_> { self.place(&old, &new, &placed, &page_order)?; self.delete(&old, &new, &consumed)?; self.edit_text(&new)?; + self.edit_lists(after, &new)?; self.edit_paragraph_formatting(&new)?; self.edit_formatting(&new)?; self.edit_layout(&old, &new)?; @@ -272,13 +275,12 @@ impl Lowering<'_> { let Some(previous) = old.paragraphs.get(id) else { continue; }; - let same = paragraph.lists == previous.lists - && paragraph.tags == previous.tags + let same = paragraph.tags == previous.tags && paragraph.style == previous.style && paragraph.format == previous.format; if !same { return Err(invalid( - "Paragraph lists, tags, styles and paragraph formatting cannot be edited", + "Paragraph tags, styles and paragraph formatting cannot be edited", )); } match (¶graph.content, &previous.content) { @@ -557,13 +559,11 @@ impl Lowering<'_> { let text = paragraph .text() .filter(|text| text.date_field.is_none() && text.tags.is_empty()) - .filter(|_| { - paragraph.lists.is_empty() - && paragraph.tags.is_empty() - && paragraph.style.is_none() - }) + .filter(|_| paragraph.tags.is_empty() && paragraph.style.is_none()) .ok_or_else(|| { - invalid("New paragraphs contain plain text without lists, tags, fields or styles") + invalid( + "New paragraphs contain plain text without tags, fields or styles", + ) })?; let insertion = Insertion::paragraph( self.id(container), @@ -659,6 +659,185 @@ impl Lowering<'_> { Ok(()) } + /// Gives each paragraph the list nodes its model references: a definition new to the + /// section becomes a list node carrying the model's identity, a changed one is rewritten + /// in place, a definition another paragraph already owns is copied because native list + /// nodes belong to one paragraph, and a dropped reference leaves the node unreferenced. + fn edit_lists(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> { + let current = self.current()?; + let stored = View::new(¤t)?; + let mut owners: BTreeMap = BTreeMap::new(); + for (id, paragraph) in &stored.paragraphs { + for list in ¶graph.lists { + owners.insert(*list, *id); + } + } + for (id, paragraph) in &new.paragraphs { + let image_id = self.id(*id); + let previous = stored + .paragraphs + .get(&image_id) + .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; + let same = paragraph.lists.len() == previous.lists.len() + && paragraph + .lists + .iter() + .zip(&previous.lists) + .all(|(model, node)| { + after.definitions.get(model) == current.definitions.get(node) + }); + if same { + continue; + } + let mut nodes = Vec::new(); + for list in ¶graph.lists { + // Writer-allocated identities keep a nonzero sequence number so the node's + // compact identity is never the null identity; squash renames them to the + // model's. A copy for a second owner keeps its allocated identity. + let node_id = match owners.get(list) { + Some(owner) if *owner == image_id => *list, + Some(_) => ExGuid { + guid: crate::write::fresh_guid()?, + n: 1, + }, + None => { + let written = ExGuid { + guid: crate::write::fresh_guid()?, + n: 1, + }; + self.alias.insert(*list, written); + written + } + }; + let definition = after + .definitions + .get(list) + .ok_or_else(|| invalid("A paragraph references a missing list definition"))?; + let Kind::List { + font, + format, + restart, + bullet, + } = &definition.kind + else { + return Err(invalid("A paragraph list must reference a list definition")); + }; + if bullet.is_some() && (format.is_none() || font.is_none()) { + return Err(invalid("A bullet definition names its glyph and font")); + } + let mut values: Vec<(u32, Vec)> = Vec::new(); + if let Some(format) = format { + let units: Vec = format.encode_utf16().collect(); + let count = u16::try_from(units.len()) + .map_err(|_| invalid("List format exceeds the document range"))?; + let mut bytes = count.to_le_bytes().to_vec(); + bytes.extend(units.iter().flat_map(|unit| unit.to_le_bytes())); + values.push((0x1c001c1a, bytes)); + } + if let Some(font) = font { + values.push((0x1c001c52, crate::create::string(font))); + } + if let Some(restart) = restart { + values.push((0x14001cb7, restart.to_le_bytes().to_vec())); + } + if let Some(bullet) = bullet { + values.push((0x10001d0e, bullet.to_le_bytes().to_vec())); + // Every native bullet node carries this cleared flag alongside its index. + values.push((0x0c001cc0, vec![0])); + } + let style = &definition.format; + if let Some(font) = &style.font { + values.push((0x1c001c0a, crate::create::string(font))); + } + if let Some(size) = style.font_size { + let half = (size * 2.0).round(); + if !(0.0..=f32::from(u16::MAX)).contains(&half) { + return Err(invalid("List font size is outside the document range")); + } + values.push((0x10001c0b, (half as u16).to_le_bytes().to_vec())); + } + if let Some(color) = style.color { + values.push((0x14001c0c, color.to_le_bytes().to_vec())); + } + if let Some(language) = style.language { + values.push((0x14001c3b, language.to_le_bytes().to_vec())); + } + for (flag, id) in [(style.bold, 0x08001c04), (style.italic, 0x08001c05)] { + if let Some(flag) = flag { + values.push((id | (u32::from(flag) << 31), Vec::new())); + } + } + nodes.push((node_id, values)); + } + let (space, object) = (self.space, self.id(*id)); + self.apply(|image| { + let store = Store::parse(image)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + let parents = crate::edit::editable_parents( + document.active(space)?, + &document.pages_in(space)?, + object, + )?; + let modified = crate::create::current_timestamps()?.0.to_le_bytes(); + crate::write::write_revision(image, space, |raw| { + let mut changed = BTreeMap::new(); + let mut target = PropertyObject::from_object(&raw.objects[&object])?; + let mut references = Vec::new(); + for (list, values) in &nodes { + let mut node = match raw.objects.get(list) { + Some(existing) => { + if existing.jcid != 0x60012 { + return Err(invalid( + "A list definition identity belongs to another object", + )); + } + let mut node = PropertyObject::from_object(existing)?; + node.remove(&[ + 0x1c001c1a, 0x1c001c52, 0x14001cb7, 0x10001d0e, 0x0c001cc0, + 0x1c001c0a, 0x10001c0b, 0x14001c0c, 0x14001c3b, 0x08001c04, + 0x08001c05, + ])?; + node + } + None => PropertyObject { + jcid: 0x60012, + bytes: crate::create::properties(&[])?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, list.guid)])), + }, + }; + node.set( + &values + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect::>(), + )?; + node.set(&[(0x14001d7a, &modified)])?; + node.reference(*list)?; + references.extend_from_slice(&target.reference(*list)?); + changed.insert(*list, node); + } + if references.is_empty() { + target.remove(&[0x24001c26])?; + } else { + target.set(&[(0x24001c26, &references)])?; + } + target.set(&[(0x14001d7a, &modified)])?; + changed.insert(object, target); + crate::formatting::touch_ancestors( + raw, + &parents, + object, + &modified, + &mut changed, + )?; + Ok(changed) + }) + })?; + } + Ok(()) + } + fn edit_paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { let current = self.current()?; let current = View::new(¤t)?; diff --git a/crates/onestore/tests/page_lists.rs b/crates/onestore/tests/page_lists.rs new file mode 100644 index 0000000000000000000000000000000000000000..af757f3d277fe932987552b273abdd2c83d08bcb --- /dev/null +++ b/crates/onestore/tests/page_lists.rs @@ -0,0 +1,322 @@ +use onestore::{ + ExGuid, PreparedEdit, RevisionIndex, Store, + document::{Document, Format, Kind}, + page::{ + Definition, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id, + }, +}; +use std::collections::BTreeMap; + +const TREES: &[u8] = + include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one"); +const AUTHOR: &str = "List author"; + +fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + document + .pages() + .unwrap() + .into_iter() + .find_map(|(space, _)| { + let page = Page::from_space(&document, space).unwrap(); + (page.title == title).then_some((space, page)) + }) + .unwrap() +} + +fn page_in(bytes: &[u8], space: ExGuid) -> Page { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + Page::from_space(&Document::parse(&index).unwrap(), space).unwrap() +} + +fn body_paragraphs(page: &mut Page) -> &mut Vec { + page.objects + .iter_mut() + .find_map(|object| match object { + PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs), + _ => None, + }) + .unwrap() +} + +fn list_ids(page: &Page) -> Vec> { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Outline(outline) if !outline.title => { + Some(outline.paragraphs.iter().map(|p| p.lists.clone()).collect()) + } + _ => None, + }) + .next() + .unwrap() +} + +fn raw_object(bytes: &[u8], space: ExGuid, id: ExGuid) -> String { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + format!( + "{:?}", + index.resolve_active(space).unwrap().objects[&id].data + ) +} + +fn bullet_definition() -> Definition { + Definition { + kind: Kind::List { + font: Some("Courier New".into()), + format: Some("\u{25cb}".into()), + restart: None, + bullet: Some(4), + }, + format: Format { + font_size: Some(11.0), + color: Some(0xff000000), + ..Default::default() + }, + } +} + +fn plain_paragraph(template: &PageParagraph, text: &str) -> PageParagraph { + let mut paragraph = template.clone(); + paragraph.id = new_id().unwrap(); + paragraph.parent = None; + paragraph.level = 1; + paragraph.lists.clear(); + paragraph.tags.clear(); + paragraph.style = None; + paragraph.collapsed = false; + paragraph.content = ParagraphContent::Text(TextObject { + id: new_id().unwrap(), + date_field: None, + text: onestore::page::Paragraph::new( + text.into(), + Format { + font: Some("Calibri".into()), + font_size: Some(11.0), + language: Some(1033), + ..Default::default() + }, + ), + tags: Vec::new(), + }); + paragraph +} + +#[test] +fn a_numbered_paragraph_becomes_plain_while_its_neighbours_keep_their_nodes() { + let (space, before) = page_by_title(TREES, "Move numbered subtree down"); + let lists = list_ids(&before); + let numbered: Vec = (0..lists.len()).filter(|i| !lists[*i].is_empty()).collect(); + assert!(numbered.len() >= 2, "{lists:?}"); + let mut after = before.clone(); + body_paragraphs(&mut after)[numbered[0]].lists.clear(); + let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); + let stored = page_in(written.as_bytes(), space); + let mut expected = after.clone(); + expected.title = stored.title.clone(); + expected.definitions.remove(&lists[numbered[0]][0]); + assert_eq!(stored, expected); + for other in &numbered[1..] { + for node in &lists[*other] { + assert_eq!( + raw_object(written.as_bytes(), space, *node), + raw_object(TREES, space, *node) + ); + } + } + assert_eq!( + PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) + .unwrap() + .as_bytes(), + written.as_bytes() + ); +} + +#[test] +fn bullets_and_numbering_write_from_definitions_and_read_back() { + let (space, before) = page_by_title(TREES, "Move numbered subtree down"); + let lists = list_ids(&before); + let numbered = (0..lists.len()).find(|i| !lists[*i].is_empty()).unwrap(); + let plain = (0..lists.len()).find(|i| lists[*i].is_empty()).unwrap(); + let mut after = before.clone(); + let bullet = new_id().unwrap(); + after.definitions.insert(bullet, bullet_definition()); + let template = body_paragraphs(&mut after)[plain].clone(); + body_paragraphs(&mut after)[plain].lists = vec![bullet]; + let number = new_id().unwrap(); + let mut numbering = after.definitions[&lists[numbered][0]].clone(); + let Kind::List { restart, .. } = &mut numbering.kind else { + panic!() + }; + *restart = Some(3); + after.definitions.insert(number, numbering); + let mut fresh = plain_paragraph(&template, "Numbered from three"); + fresh.lists = vec![number]; + body_paragraphs(&mut after).push(fresh); + let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); + let stored = page_in(written.as_bytes(), space); + let mut expected = after.clone(); + expected.title = stored.title.clone(); + assert_eq!(stored, expected); + assert_eq!( + PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) + .unwrap() + .as_bytes(), + written.as_bytes() + ); +} + +#[test] +fn a_list_definition_changes_in_place() { + let (space, before) = page_by_title(TREES, "Move numbered subtree down"); + let lists = list_ids(&before); + let numbered = (0..lists.len()).find(|i| !lists[*i].is_empty()).unwrap(); + let node = lists[numbered][0]; + let mut after = before.clone(); + let definition = after.definitions.get_mut(&node).unwrap(); + let Kind::List { restart, .. } = &mut definition.kind else { + panic!() + }; + *restart = Some(7); + definition.format.bold = Some(true); + let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap(); + let stored = page_in(written.as_bytes(), space); + let mut expected = after.clone(); + expected.title = stored.title.clone(); + assert_eq!(stored, expected); + assert_eq!(list_ids(&stored)[numbered], vec![node]); +} + +#[test] +fn a_definition_shared_between_paragraphs_becomes_a_node_per_paragraph() { + let (space, before) = page_by_title(TREES, "Move numbered subtree down"); + let lists = list_ids(&before); + let numbered = (0..lists.len()).find(|i| !lists[*i].is_empty()).unwrap(); + let plain = (0..lists.len()).find(|i| lists[*i].is_empty()).unwrap(); + let mut shared = before.clone(); + body_paragraphs(&mut shared)[plain].lists = lists[numbered].clone(); + let written = PreparedEdit::page(TREES, space, &shared, AUTHOR).unwrap(); + let stored = page_in(written.as_bytes(), space); + let after = list_ids(&stored); + assert_eq!(after[numbered], lists[numbered]); + assert_eq!(after[plain].len(), 1); + assert_ne!(after[plain], lists[numbered]); + assert_eq!( + stored.definitions[&after[plain][0]], + before.definitions[&lists[numbered][0]] + ); +} + +#[test] +fn missing_and_foreign_list_definitions_are_refused() { + let (space, before) = page_by_title(TREES, "Move numbered subtree down"); + let lists = list_ids(&before); + let plain = (0..lists.len()).find(|i| lists[*i].is_empty()).unwrap(); + let mut missing = before.clone(); + body_paragraphs(&mut missing)[plain].lists = vec![new_id().unwrap()]; + assert!(PreparedEdit::page(TREES, space, &missing, AUTHOR).is_err()); + let mut foreign = before.clone(); + let paragraph = body_paragraphs(&mut foreign)[plain].id; + foreign.definitions.insert(paragraph, bullet_definition()); + body_paragraphs(&mut foreign)[plain].lists = vec![paragraph]; + assert!(PreparedEdit::page(TREES, space, &foreign, AUTHOR).is_err()); + let mut style: BTreeMap = before.definitions.clone(); + if let Some((id, definition)) = style + .iter_mut() + .find(|(_, d)| matches!(d.kind, Kind::Style { .. })) + { + definition.format.bold = Some(true); + let mut edited = before.clone(); + edited.definitions.insert(*id, definition.clone()); + assert!(PreparedEdit::page(TREES, space, &edited, AUTHOR).is_err()); + } +} + +/// A fresh page gains a bullet, two numbered paragraphs with a nested numbered child, a +/// numbering restart and a trailing plain paragraph; `ONESTORE_LIST_EXPORT` names a new +/// directory receiving the candidate for a cold native reopen. +#[test] +fn bullets_numbering_nesting_and_restarts_publish_on_a_fresh_page() { + let source = onestore::create_section("lists.one", "Bullet item", "Author").unwrap(); + let store = Store::parse(&source).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (space, _) = document.pages().unwrap()[0]; + let before = Page::from_space(&document, space).unwrap(); + let (_, trees) = page_by_title(TREES, "Move numbered subtree down"); + let numbering = trees + .definitions + .values() + .find(|d| { + matches!( + d.kind, + Kind::List { + format: Some(_), + .. + } + ) + }) + .unwrap() + .clone(); + let scenario = std::env::var("ONESTORE_LIST_SCENARIO").unwrap_or_else(|_| "all".into()); + let wants = |name: &str| scenario == "all" || scenario == name; + let mut after = before.clone(); + let template = body_paragraphs(&mut after)[0].clone(); + if wants("bullet") { + let bullet = new_id().unwrap(); + after.definitions.insert(bullet, bullet_definition()); + body_paragraphs(&mut after)[0].lists = vec![bullet]; + } + for (text, level, restart, name) in [ + ("First numbered", 1, None, "numbered"), + ("Second numbered", 1, None, "numbered"), + ("Nested numbered", 2, None, "all"), + ("Restarted at three", 1, Some(3), "restart"), + ("Nested plain", 2, None, "nested"), + ] { + if !wants(name) { + continue; + } + let mut paragraph = plain_paragraph(&template, text); + if name != "nested" { + let id = new_id().unwrap(); + let mut definition = numbering.clone(); + let Kind::List { restart: value, .. } = &mut definition.kind else { + panic!() + }; + *value = restart; + after.definitions.insert(id, definition); + paragraph.lists = vec![id]; + } + paragraph.level = level; + if level == 2 { + paragraph.parent = Some(body_paragraphs(&mut after).last().unwrap().id); + } + body_paragraphs(&mut after).push(paragraph); + } + body_paragraphs(&mut after).push(plain_paragraph(&template, "Plain again")); + let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap(); + let stored = page_in(written.as_bytes(), space); + let mut expected = after.clone(); + expected.title = stored.title.clone(); + assert_eq!(stored, expected); + if let Some(directory) = std::env::var_os("ONESTORE_LIST_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("lists.one"), written.as_bytes()).unwrap(); + let written_store = Store::parse(written.as_bytes()).unwrap(); + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents( + "Open Notebook.onetoc2", + &[("lists.one", written_store.header.file_id)], + ) + .unwrap(), + ) + .unwrap(); + } +} diff --git a/crates/onestore/tests/support/page_edits.rs b/crates/onestore/tests/support/page_edits.rs index d7d7a88ed58ababcbdeb6302fb86f92cf17a8e9b..0418b9b3d32bb3fe8ceb65d0e7b0d40619838e53 100644 --- a/crates/onestore/tests/support/page_edits.rs +++ b/crates/onestore/tests/support/page_edits.rs @@ -91,11 +91,13 @@ fn projection(page: &Page) -> String { match ¶graph.content { ParagraphContent::Text(text) => { out.push_str(&format!(" text {} {:?}", text.id, text.text.text())); + // Runs that differ only in attributes the writer leaves as stored, + // such as the language tag, project as one span. + let mut runs: Vec<(usize, String)> = Vec::new(); for span in text.text.spans() { let f = &span.format; - out.push_str(&format!( - " [{} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?}]", - span.end, + let attributes = format!( + "{:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?}", f.bold.unwrap_or(false), f.italic.unwrap_or(false), f.underline.unwrap_or(false), @@ -106,7 +108,16 @@ fn projection(page: &Page) -> String { f.font_size, f.color, f.highlight - )); + ); + match runs.last_mut() { + Some((end, previous)) if *previous == attributes => { + *end = span.end + } + _ => runs.push((span.end, attributes)), + } + } + for (end, attributes) in runs { + out.push_str(&format!(" [{end} {attributes}]")); } } ParagraphContent::Table(table) => { @@ -188,7 +199,44 @@ fn mutate(page: &mut Page, bytes: &mut Bytes<'_>) { .filter(|(_, o)| matches!(o, PageObject::Outline(_))) .map(|(i, _)| i) .collect(); - match kind % 9 { + match kind % 10 { + 8 => { + let Some(outline) = outlines.first().map(|i| match &mut page.objects[*i] { + PageObject::Outline(outline) => outline, + _ => unreachable!(), + }) else { + continue; + }; + let at = usize::from(bytes.next().unwrap_or(0)) % outline.paragraphs.len().max(1); + let Some(paragraph) = outline.paragraphs.get_mut(at) else { + continue; + }; + if paragraph.text().is_none() { + continue; + } + if paragraph.lists.is_empty() { + let id = new_id().unwrap(); + page.definitions.insert( + id, + onestore::page::Definition { + kind: onestore::document::Kind::List { + font: Some("Courier New".into()), + format: Some("\u{25cb}".into()), + restart: None, + bullet: Some(4), + }, + format: onestore::document::Format { + font_size: Some(11.0), + color: Some(0xff000000), + ..Default::default() + }, + }, + ); + paragraph.lists = vec![id]; + } else { + paragraph.lists.clear(); + } + } 0 | 1 => { let Some(o) = bytes.pick(outlines.len()) else { continue; diff --git a/tools/test_list_edit.py b/tools/test_list_edit.py new file mode 100644 index 0000000000000000000000000000000000000000..660eb076c7ca02c0ba8adbd57bdf780d7f5cc0ac --- /dev/null +++ b/tools/test_list_edit.py @@ -0,0 +1,52 @@ +from pathlib import Path +import re +import runpy +import shutil +from tempfile import TemporaryDirectory +import unittest +import xml.etree.ElementTree as ET + +from native_xml import ns + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / 'corpus/list-edit' +compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] + + +def items(read): + page, = (ET.parse(path).getroot() for path in sorted(read.glob('page-*.xml'))) + outline, = page.findall('one:Outline', ns) + + def walk(element, depth): + marker = element.find('one:List', ns) + kind = None + if marker is not None: + child, = list(marker) + kind = (child.tag.split('}')[1], child.get('text')) + text = re.sub(r'<[^>]*>', '', element.find('one:T', ns).text or '') + yield depth, kind, text + for nested in element.findall('one:OEChildren/one:OE', ns): + yield from walk(nested, depth + 1) + + return [item for element in outline.findall('one:OEChildren/one:OE', ns) for item in walk(element, 0)] + + +class ListEditTest(unittest.TestCase): + def test_rust_bullets_numbering_and_restarts_render_natively(self): + with TemporaryDirectory() as temporary: + read = Path(temporary) / 'read' + shutil.copytree(FIXTURE / 'cold/read', read) + compare(FIXTURE / 'candidate', read) + self.assertEqual(items(FIXTURE / 'cold/read'), [ + (0, ('Bullet', None), 'Bullet item'), + (0, ('Number', 'I.'), 'First numbered'), + (0, ('Number', 'II.'), 'Second numbered'), + (1, ('Number', 'I.'), 'Nested numbered'), + (0, ('Number', 'III.'), 'Restarted at three'), + (1, None, 'Nested plain'), + (0, None, 'Plain again'), + ]) + + +if __name__ == '__main__': + unittest.main() -- 2.54.0